From cbeb52a9cb5995984b620f3e433d4f837df4ca11 Mon Sep 17 00:00:00 2001 From: bad Date: Thu, 4 Aug 2022 15:00:24 +0200 Subject: [PATCH] Initial commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thanks @riley for the help with code review. Ily ♥️♥️♥️♥️♥️♥️♥️ --- .gitignore | 2 + Cargo.toml | 8 +++ nix-cache-info.sample | 3 + rustfmt.toml | 2 + sample.narinfo | 10 ++++ src/error.rs | 26 ++++++++ src/lib.rs | 15 +++++ src/narinfo/from_str.rs | 129 ++++++++++++++++++++++++++++++++++++++++ src/narinfo/mod.rs | 67 +++++++++++++++++++++ src/narinfo/to_str.rs | 59 ++++++++++++++++++ src/nix_cache_info.rs | 117 ++++++++++++++++++++++++++++++++++++ src/sig.rs | 55 +++++++++++++++++ 12 files changed, 493 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 nix-cache-info.sample create mode 100644 rustfmt.toml create mode 100644 sample.narinfo create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/narinfo/from_str.rs create mode 100644 src/narinfo/mod.rs create mode 100644 src/narinfo/to_str.rs create mode 100644 src/nix_cache_info.rs create mode 100644 src/sig.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d05de2c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "narinfo" +version = "0.1.0" +edition = "2021" +description = "A parser for the narinfo file format" + +[dependencies] +derive_builder = { version = "0.11.2", default-features = false } diff --git a/nix-cache-info.sample b/nix-cache-info.sample new file mode 100644 index 0000000..7d239de --- /dev/null +++ b/nix-cache-info.sample @@ -0,0 +1,3 @@ +StoreDir: /nix/store +WantMassQuery: 1 +Priority: 40 diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..896c47e --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +hard_tabs = true +normalize_comments = true diff --git a/sample.narinfo b/sample.narinfo new file mode 100644 index 0000000..b7c0044 --- /dev/null +++ b/sample.narinfo @@ -0,0 +1,10 @@ +StorePath: /nix/store/zzxrhj9056vjlanfjkinvhd7458yc2z8-liblouis-3.22.0 +URL: nar/0ccqg4il1m9qqh8b6x0x8nn7pjcphr82h2qdfc5gqq8dy7h2kp9x.nar.xz +Compression: xz +FileHash: sha256:0ccqg4il1m9qqh8b6x0x8nn7pjcphr82h2qdfc5gqq8dy7h2kp9x +FileSize: 1914556 +NarHash: sha256:0c8ld5yxcr6a6j63mvrqbqiy08q6f85wd74817ai7pvd5nkidcqw +NarSize: 11374872 +References: mhhlymrg2m70r8h94cwhv2d7a0c8l7g6-glibc-2.34-210 ppn8983d9b5r6k7mnhkbg6rqw7vgl1ij-libyaml-0.2.5 qm2lv1gpbyn0rsfai40cbvj3h4gz69yc-bash-5.1-p16 sn0w3f12547crckss4ybmnxmi29gpgq7-perl-5.34.1 zzxrhj9056vjlanfjkinvhd7458yc2z8-liblouis-3.22.0 +Deriver: dlxmsgfc0am35fh0kiy88zqr91x2dn5j-liblouis-3.22.0.drv +Sig: cache.nixos.org-1:BJ5QGcOta2s76XC6sep9DbAv0x3TILh3hHSKyR+9rFWYuBDTWdHs1KHeUEpw2espE/zPPBp2yURO6/J4Dhf9DQ== diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..7da5a8e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,26 @@ +#[derive(Debug)] +/// The error type returned by all the parsing functions in this crate. +pub enum ParsingError<'a> { + InvalidIntValue { line: &'a str }, + UnknownKey { key: &'a str }, + InvalidLine { line: &'a str }, + InvalidSignature(&'a str), + MissingField(&'static str), +} + +impl From for ParsingError<'static> { + fn from(e: derive_builder::UninitializedFieldError) -> Self { + Self::MissingField(e.field_name()) + } +} + +impl<'a> ParsingError<'a> { + pub(crate) fn try_parse_int<'b>(value: &'b str, line: &'a str) -> ParsingResult<'a, usize> { + value + .parse() + .map_err(|_| ParsingError::InvalidIntValue { line }) + } +} + +/// The result type returned by all the parsing functions in this crate. +pub type ParsingResult<'a, T> = core::result::Result>; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..852a60e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,15 @@ +#![no_std] +#![deny(clippy::all)] +//! Parse the nix substituter(cache) related files. + +extern crate alloc; + +pub mod error; + +mod narinfo; +mod nix_cache_info; +mod sig; + +pub use crate::narinfo::NarInfo; +pub use nix_cache_info::NixCacheInfo; +pub use sig::Sig; diff --git a/src/narinfo/from_str.rs b/src/narinfo/from_str.rs new file mode 100644 index 0000000..c7ebc5f --- /dev/null +++ b/src/narinfo/from_str.rs @@ -0,0 +1,129 @@ +use super::{NarInfo, NarInfoBuilder}; +use crate::{ + error::{ParsingError, ParsingResult}, + sig::Sig, +}; +use alloc::{borrow::Cow, vec::Vec}; + +impl<'a> NarInfo<'a> { + /// Parses the contents of a narinfo file from str. + /// This function is also used to implement the [TryFrom] trait for &'a str. + /// + /// For duplicate keys the last value is used(same behaviour as libstore). + /// Unknown keys return an error(unlike libstore where they're simply ignored). + /// + /// ``` + /// # fn http_get_str(_url: &str) -> &'static str { include_str!("../../sample.narinfo") } + /// use narinfo::NarInfo; + /// let data: &str = + /// http_get_str("https://cache.nixos.org/zn2h0kln2b02x4x6jqxymd4sg9cwvdsx.narinfo"); + /// + /// let parsed = NarInfo::parse(data).unwrap(); + /// assert_eq!(parsed.store_path, "/nix/store/zzxrhj9056vjlanfjkinvhd7458yc2z8-liblouis-3.22.0"); + /// ``` + pub fn parse(value: &'a str) -> ParsingResult { + let mut builder = NarInfoBuilder::default(); + for line in value.lines() { + let (key, value) = line + .split_once(':') + .map(|(k, v)| (k, v.trim())) + .ok_or(ParsingError::InvalidLine { line })?; + + match key { + "StorePath" => builder.store_path(Cow::from(value)), + "URL" => builder.url(value), + "Compression" => builder.compression(Some(Cow::from(value))), + "FileHash" => builder.file_hash(Some(value)), + "NarHash" => builder.nar_hash(Cow::from(value)), + "NarSize" => { + let size: usize = ParsingError::try_parse_int(value, line)?; + builder.nar_size(size) + } + "FileSize" => { + let size: usize = ParsingError::try_parse_int(value, line)?; + builder.file_size(Some(size)) + } + "Deriver" => builder.deriver(Some(Cow::from(value))), + "System" => builder.system(Some(Cow::from(value))), + "References" => builder.references(value.split(' ').map(Cow::from).collect()), + // TODO: replace with try_collect once that gets stabilized + "Sig" => builder.sigs(value.split(' ').map(Sig::try_from).try_fold( + Vec::new(), + |mut a, c| { + c.map(|c| { + a.push(c); + a + }) + }, + )?), + _ => return Err(ParsingError::UnknownKey { key }), + }; + } + + builder.build() + } +} + +impl<'a> TryFrom<&'a str> for NarInfo<'a> { + type Error = crate::error::ParsingError<'a>; + + fn try_from(value: &'a str) -> ParsingResult { + NarInfo::parse(value) + } +} + +#[cfg(test)] +mod tests { + use alloc::borrow::Cow; + + use super::*; + + #[test] + fn parses_sample_narinfo() { + let sample = include_str!("../../sample.narinfo"); + let info = NarInfo::try_from(sample).unwrap(); + assert_eq!( + info.store_path, + "/nix/store/zzxrhj9056vjlanfjkinvhd7458yc2z8-liblouis-3.22.0" + ); + assert_eq!( + info.url, + "nar/0ccqg4il1m9qqh8b6x0x8nn7pjcphr82h2qdfc5gqq8dy7h2kp9x.nar.xz" + ); + assert_eq!(info.compression, Some(Cow::Borrowed("xz"))); + assert_eq!( + info.file_hash, + Some("sha256:0ccqg4il1m9qqh8b6x0x8nn7pjcphr82h2qdfc5gqq8dy7h2kp9x") + ); + assert_eq!(info.file_size, Some(1914556)); + assert_eq!( + info.nar_hash, + "sha256:0c8ld5yxcr6a6j63mvrqbqiy08q6f85wd74817ai7pvd5nkidcqw" + ); + assert_eq!(info.nar_size, 11374872); + + let expected_refs = [ + "mhhlymrg2m70r8h94cwhv2d7a0c8l7g6-glibc-2.34-210", + "ppn8983d9b5r6k7mnhkbg6rqw7vgl1ij-libyaml-0.2.5", + "qm2lv1gpbyn0rsfai40cbvj3h4gz69yc-bash-5.1-p16", + "sn0w3f12547crckss4ybmnxmi29gpgq7-perl-5.34.1", + "zzxrhj9056vjlanfjkinvhd7458yc2z8-liblouis-3.22.0", + ]; + for (a, b) in info.references.iter().zip(expected_refs) { + assert_eq!(a, b); + } + + assert_eq!( + info.deriver, + Some(Cow::Borrowed( + "dlxmsgfc0am35fh0kiy88zqr91x2dn5j-liblouis-3.22.0.drv" + )) + ); + + let sig = &info.sigs[0]; + assert_eq!(sig.key_name, "cache.nixos.org-1"); + assert_eq!(sig.sig, "BJ5QGcOta2s76XC6sep9DbAv0x3TILh3hHSKyR+9rFWYuBDTWdHs1KHeUEpw2espE/zPPBp2yURO6/J4Dhf9DQ=="); + + assert!(info.sigs.len() == 1); + } +} diff --git a/src/narinfo/mod.rs b/src/narinfo/mod.rs new file mode 100644 index 0000000..ef60c9c --- /dev/null +++ b/src/narinfo/mod.rs @@ -0,0 +1,67 @@ +mod from_str; +mod to_str; + +use crate::sig::Sig; +use alloc::{borrow::Cow, vec::Vec}; +pub use from_str::*; +pub use to_str::*; + +use derive_builder::Builder; + +type FileSize = usize; + +/// Struct representing the narinfo file fetched from a nix substituter +/// Based on the [unofficial spec](https://fzakaria.github.io/nix-http-binary-cache-api-spec) +/// and the [libstore narinfo parsing code.](https://github.com/NixOS/nix/blob/6776e65fd960e25b55d11a03324f9007b6dc2a0b/src/libstore/nar-info.cc) +#[derive(Builder, Eq, PartialEq, Debug)] +#[builder(no_std)] +#[builder(build_fn(error = "crate::error::ParsingError<'static>"))] +pub struct NarInfo<'a> { + /// The full store path, including the name part (e.g., glibc-2.7). It must match the requested store path. + pub store_path: Cow<'a, str>, + + /// The URL of the NAR, relative to the binary cache URL. + pub url: &'a str, + + /// The compression method; Usuall xz or bzip2. + #[builder(default)] + pub compression: Option>, + + /// The cryptographic hash of the NAR (decompressed) in base 32. + pub nar_hash: Cow<'a, str>, + /// The size of the decompressed NAR file. + pub nar_size: FileSize, + + /// The cryptographic hash of the file to download in base32. + #[builder(default)] + pub file_hash: Option<&'a str>, + /// The size of the downloaded(compressed) NAR file. + #[builder(default)] + pub file_size: Option, + + /// The deriver of the store path, without the Nix store prefix. This field is optional. + #[builder(default)] + pub deriver: Option>, + + /// The Nix platform type of this binary(eg. x86_64-linux). + #[builder(default)] + pub system: Option>, + + /// Store paths for direct runtime dependencies. + #[builder(default)] + pub references: Vec>, + + /// A collection of the signatures signing this package. + #[builder(default)] + pub sigs: Vec>, +} + +impl<'a> NarInfo<'a> { + /// Get the builder for this struct. + /// The builder is generated by [derive_builder](https://lib.rs/crates/derive_builder) + // The builder is is used internally by the parsing code so we might as well expose it. + // If we ever decide to not use derive_builder we can simply hide it behind an optional feature flag + pub fn builder() -> NarInfoBuilder<'a> { + NarInfoBuilder::default() + } +} diff --git a/src/narinfo/to_str.rs b/src/narinfo/to_str.rs new file mode 100644 index 0000000..edea976 --- /dev/null +++ b/src/narinfo/to_str.rs @@ -0,0 +1,59 @@ +use super::NarInfo; +use core::fmt::{self, Write}; + +impl<'a> NarInfo<'a> { + /// Serializes the narinfo struct into the text format + /// ``` + /// use narinfo::NarInfo; + /// + /// let info = NarInfo::parse(include_str!("../../sample.narinfo")).unwrap(); + /// let mut serialized = String::new(); + /// info.serialize_into(&mut serialized).unwrap(); + /// ``` + pub fn serialize_into(&self, w: &mut T) -> fmt::Result { + write!(w, "StorePath: {}", self.store_path)?; + write!(w, "\nURL: {}", self.url)?; + write!(w, "\nNarHash: {}", self.nar_hash)?; + write!(w, "\nNarSize: {}", self.nar_size)?; + write!(w, "\nReferences: {}", self.references.join(" "))?; + + write!(w, "\nSig: ")?; + for sig in self.sigs.iter() { + sig.serialize_into(w)?; + } + + if let Some(compression) = &self.compression { + write!(w, "\nCompression: {}", compression)?; + }; + if let Some(file_hash) = self.file_hash { + write!(w, "\nFileHash: {}", file_hash)?; + }; + if let Some(file_size) = self.file_size { + write!(w, "\nFileSize: {}", file_size)?; + }; + if let Some(deriver) = &self.deriver { + write!(w, "\nDeriver: {}", deriver)?; + }; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use alloc::string::String; + + use super::*; + + #[test] + fn serialize_deserialize_eq() { + let sample = include_str!("../../sample.narinfo"); + + let info = NarInfo::parse(sample).unwrap(); + let mut serialized = String::new(); + info.serialize_into(&mut serialized).unwrap(); + let info2 = NarInfo::parse(&serialized).unwrap(); + + assert_eq!(info, info2); + } +} diff --git a/src/nix_cache_info.rs b/src/nix_cache_info.rs new file mode 100644 index 0000000..9d8fc6c --- /dev/null +++ b/src/nix_cache_info.rs @@ -0,0 +1,117 @@ +use crate::error::{ParsingError, ParsingResult}; +use alloc::borrow::Cow; +use core::fmt; +use core::fmt::Write; +use derive_builder::Builder; + +/// Struct representing the nix-cache-info file fetched from a nix substituter, describing the +/// substituter +/// Based on the [unofficial spec](https://fzakaria.github.io/nix-http-binary-cache-api-spec) +/// and the [libstore narinfo parsing code.](https://github.com/NixOS/nix/blob/af4e8b00fb986acf32d7e4cd4fff7218b38958df/src/libstore/binary-cache-store.cc#L37) +#[derive(Builder, Eq, PartialEq, Debug)] +#[builder(no_std)] +#[builder(build_fn(error = "crate::error::ParsingError<'static>"))] +#[builder(setter(into))] +pub struct NixCacheInfo<'a> { + /// The path of the Nix store to which this binary cache applies. Binaries are not relocatable — a binary built for `/nix/store` won’t generally work in `/home/alice/store` — so to prevent binaries from being used in a wrong store, a binary cache is only used if its StoreDir matches the local Nix configuration. The default path on nixos is `/nix/store`. + pub store_dir: Cow<'a, str>, + /// Query operations such as `nix-env -qas` can cause thousands of cache queries, and thus thousands of HTTP requests, to determine which packages are available in binary form. While these requests are small, not every server may appreciate a potential onslaught of queries. If WantMassQuery is set to 0 (default), “mass queries” such as `nix-env -qas` will skip this cache. Thus a package may appear not to have a binary substitute. However, the binary will still be used when you actually install the package. If WantMassQuery is set to 1, mass queries will use this cache. + #[builder(default)] + pub wants_mass_query: bool, + /// Each binary cache has a priority (defaulting to 50). Binary caches are checked for binaries in order of ascending priority; thus a higher number denotes a lower priority. The binary cache cache.nixos.org has priority 40. + #[builder(default = "50")] + pub priority: usize, +} + +impl<'a> NixCacheInfo<'a> { + /// Parses the contents of a narinfo file from str. + /// This function is also used to implement the [TryFrom] trait for &'a str. + /// + /// For duplicate keys the last value is used(same behaviour as libstore). + /// Unknown keys return an error(unlike libstore where they're simply ignored). + /// + /// ``` + /// # fn http_get_str(_url: &str) -> &'static str { include_str!("../nix-cache-info.sample") } + /// use narinfo::NixCacheInfo; + /// let data: &str = + /// http_get_str("https://cache.nixos.org/nix-cache-info"); + /// + /// let parsed = NixCacheInfo::parse(data).unwrap(); + /// assert_eq!(parsed.store_dir, "/nix/store"); + /// ``` + pub fn parse(value: &'a str) -> ParsingResult { + let mut builder = NixCacheInfoBuilder::default(); + for line in value.lines() { + let (key, value) = line + .split_once(':') + .map(|(k, v)| (k, v.trim())) + .ok_or(ParsingError::InvalidLine { line })?; + match key { + "StoreDir" => builder.store_dir(value), + "WantMassQuery" => { + let wants_mass_query: usize = ParsingError::try_parse_int(value, line)?; + builder.wants_mass_query(wants_mass_query >= 1) + } + "Priority" => builder.priority(ParsingError::try_parse_int(value, line)?), + _ => return Err(ParsingError::UnknownKey { key }), + }; + } + builder.build() + } + + /// Serializes the narinfo struct into the text format + /// ``` + /// use narinfo::NixCacheInfo; + /// + /// let info = NixCacheInfo::parse(include_str!("../nix-cache-info.sample")).unwrap(); + /// let mut serialized = String::new(); + /// info.serialize_into(&mut serialized).unwrap(); + /// ``` + pub fn serialize_into(&self, w: &mut T) -> fmt::Result { + write!(w, "StoreDir: {}", self.store_dir)?; + write!(w, "\nPriority: {}", self.priority)?; + let mass_query_int = if self.wants_mass_query { 1 } else { 0 }; + write!(w, "\nWantMassQuery: {}", mass_query_int)?; + Ok(()) + } + + /// Get the builder for this struct. + /// The builder is generated by [derive_builder](https://lib.rs/crates/derive_builder) + // The builder is is used internally by the parsing code so we might as well expose it. + // If we ever decide to not use derive_builder we can simply hide it behind an optional feature flag + pub fn builder() -> NixCacheInfoBuilder<'a> { + NixCacheInfoBuilder::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::String; + + static SAMPLE_NIX_CACHE_INFO: &str = include_str!("../nix-cache-info.sample"); + #[test] + fn parses_sample_nix_cache_info() { + let info = NixCacheInfo::parse(SAMPLE_NIX_CACHE_INFO).unwrap(); + assert_eq!(info.store_dir, "/nix/store"); + assert_eq!(info.priority, 40); + assert!(info.wants_mass_query); + } + + #[test] + fn serializes_then_deserializes_into_same_result() { + let info = NixCacheInfoBuilder::default() + .store_dir("/home/alice/nix") + .wants_mass_query(true) + .priority(69_usize) + .build() + .unwrap(); + let mut serialized = String::new(); + info.serialize_into(&mut serialized).unwrap(); + + let deserialized = NixCacheInfo::parse(&serialized).unwrap(); + assert_eq!(deserialized.store_dir, "/home/alice/nix"); + assert_eq!(deserialized.priority, 69); + assert!(deserialized.wants_mass_query); + } +} diff --git a/src/sig.rs b/src/sig.rs new file mode 100644 index 0000000..8e9f992 --- /dev/null +++ b/src/sig.rs @@ -0,0 +1,55 @@ +use alloc::borrow::Cow; + +use crate::error::{ParsingError, ParsingResult}; +use core::fmt::{self, Write}; + +/// A signature of a nix narinfo file. Computed over the StorePath, NarHash, NarSize and References fields using the Ed25519 public-key signature system. +#[derive(Clone, Eq, PartialEq, Debug)] +pub struct Sig<'a> { + /// The name of the key, eg `cache.example.org-1` for cache.nixos.org + pub key_name: Cow<'a, str>, + /// The actual signature + pub sig: Cow<'a, str>, +} + +impl<'a> TryFrom<&'a str> for Sig<'a> { + type Error = ParsingError<'a>; + + fn try_from(value: &'a str) -> ParsingResult { + Sig::parse(value) + } +} +// Neither the parse nor the serializa method is public since +// it doesn't really make sense to de/serialize the +// sig into the narinfo format outside of de/serializing a whole narinfo +impl<'a> Sig<'a> { + pub(crate) fn parse(value: &'a str) -> ParsingResult { + match value.split_once(':') { + Some((key, sig)) => Ok(Sig { + key_name: key.into(), + sig: sig.into(), + }), + None => Err(ParsingError::InvalidSignature(value)), + } + } + pub(crate) fn serialize_into(&self, w: &mut T) -> fmt::Result { + write!(w, "{}:{}", self.key_name, self.sig) + } +} + +#[cfg(test)] +mod tests { + use alloc::string::String; + + use super::*; + + const SAMPLE_SIG: &str = "cache.nixos.org-1:BJ5QGcOta2s76XC6sep9DbAv0x3TILh3hHSKyR+9rFWYuBDTWdHs1KHeUEpw2espE/zPPBp2yURO6/J4Dhf9DQ=="; + + #[test] + fn serialize_deserialize_eq() { + let sig = Sig::parse(SAMPLE_SIG).unwrap(); + let mut serialized = String::new(); + sig.serialize_into(&mut serialized).unwrap(); + assert_eq!(serialized, SAMPLE_SIG); + } +}