From 764c0eb73878aafb5df4617866b322102e662edf Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Tue, 27 Aug 2024 17:37:49 -0400 Subject: [PATCH] Add Digest and SHA256Digest This addresses https://github.com/containers/oci-spec-rs/issues/201 without breaking semver by adding opt-in methods on Descriptor to access the digest as a parsed, validated content (according to the grammar) as well as directly as a validated SHA-256. For example in some cases (e.g. ocidir-rs) I want to write a descriptor to the filesystem, and I don't want any possibility of path traversal attacks from someone including a `/` in a descriptor. Signed-off-by: Colin Walters --- src/image/descriptor.rs | 6 ++ src/image/digest.rs | 167 ++++++++++++++++++++++++++++++++++++++++ src/image/mod.rs | 2 + 3 files changed, 175 insertions(+) create mode 100644 src/image/digest.rs diff --git a/src/image/descriptor.rs b/src/image/descriptor.rs index 5e36e372cc..0152f4586f 100644 --- a/src/image/descriptor.rs +++ b/src/image/descriptor.rs @@ -145,6 +145,12 @@ impl Descriptor { data: Default::default(), } } + + /// Return a view of [`Self::digest()`] that has been parsed as a valid SHA-256 digest. + pub fn digest_sha256(&self) -> crate::Result { + let digest = super::Digest::new(&self.digest)?; + digest.try_into() + } } #[cfg(test)] diff --git a/src/image/digest.rs b/src/image/digest.rs new file mode 100644 index 0000000000..c799d583d4 --- /dev/null +++ b/src/image/digest.rs @@ -0,0 +1,167 @@ +//! Functionality corresponding to . + +/// The well-known identifier for a SHA-256 digest. +/// At this point, no one is really using alternative digests like sha512, so we +/// don't yet try to expose them here in a higher level way. +const ALG_SHA256: &str = "sha256"; + +fn char_is_lowercase_ascii_hex(c: char) -> bool { + matches!(c, '0'..='9' | 'a'..='f') +} + +/// algorithm-component ::= [a-z0-9]+ +fn char_is_algorithm_component(c: char) -> bool { + matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9') +} + +/// encoded ::= [a-zA-Z0-9=_-]+ +fn char_is_encoded(c: char) -> bool { + char_is_algorithm_component(c) || matches!(c, '=' | '_' | '-') +} + +/// A parsed pair of algorithm:digest as defined +/// by +pub struct Digest<'a> { + /// The algorithm name (e.g. sha256, sha512) + pub algorithm: &'a str, + /// The algorithm component (lowercase hexadecimal) + pub value: &'a str, +} + +impl<'a> Digest<'a> { + const ALGORITHM_SEPARATOR: &'static [char] = &['+', '.', '_', '-']; + /// Parse a digest instance. + pub fn new(s: &'a str) -> crate::Result { + let (algorithm, value) = s + .split_once(':') + .ok_or_else(|| crate::OciSpecError::Other("Missing : in digest".into()))?; + // algorithm ::= algorithm-component (algorithm-separator algorithm-component)* + let algorithm_parts = algorithm.split(Self::ALGORITHM_SEPARATOR); + for part in algorithm_parts { + if part.is_empty() { + return Err(crate::OciSpecError::Other( + "Empty algorithm component".into(), + )); + } + if !part.chars().all(char_is_algorithm_component) { + return Err(crate::OciSpecError::Other(format!( + "Invalid algorithm component: {part}" + ))); + } + } + + if value.is_empty() { + return Err(crate::OciSpecError::Other("Empty algorithm value".into())); + } + if !value.chars().all(char_is_encoded) { + return Err(crate::OciSpecError::Other(format!( + "Invalid encoded value {value}" + ))); + } + + Ok(Self { algorithm, value }) + } + + /// View this instance as a validated SHA256 digest. + pub fn to_sha256(self) -> crate::Result> { + self.try_into() + } +} + +/// A SHA-256 digest. +pub struct Sha256Digest<'a> { + /// The underlying SHA-256 digest, guaranteed to be 64 lowercase hexadecimal ASCII characters. + pub value: &'a str, +} + +impl<'a> TryFrom> for Sha256Digest<'a> { + type Error = crate::OciSpecError; + + fn try_from(algdigest: Digest<'a>) -> Result { + match algdigest.algorithm { + ALG_SHA256 => {} + o => { + return Err(crate::OciSpecError::Other(format!( + "Expected algorithm {ALG_SHA256} but found {o}" + ))) + } + } + + let digest = algdigest.value; + let is_all_hex = digest.chars().all(char_is_lowercase_ascii_hex); + if !(algdigest.value.len() == 64 && is_all_hex) { + return Err(crate::OciSpecError::Other(format!( + "Invalid SHA-256 digest: {digest}" + ))); + } + Ok(Self { + value: algdigest.value, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_digest_invalid() { + let invalid = [ + "", + "foo", + ":", + "blah+", + "_digest:somevalue", + ":blah", + "blah:", + "^:foo", + "bar^baz:blah", + "sha256:123456*78", + ]; + for case in invalid { + assert!( + Digest::new(case).is_err(), + "Should have failed to parse: {case}" + ) + } + } + + const VALID_DIGEST_SHA256: &str = + "sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b"; + + #[test] + fn test_digest_valid() { + let cases = ["foo:bar", "sha256:blah", "sha512:12345"]; + for case in cases { + Digest::new(case).unwrap(); + } + + let d = + Digest::new("multihash+base58:QmRZxt2b1FVZPNqd8hsiykDL3TdBDeTSPX9Kv46HmX4Gx8").unwrap(); + assert_eq!(d.algorithm, "multihash+base58"); + assert_eq!(d.value, "QmRZxt2b1FVZPNqd8hsiykDL3TdBDeTSPX9Kv46HmX4Gx8"); + } + + #[test] + fn test_sha256_invalid() { + let invalid = [ + "sha256:123456=78", + "foo:bar", + "sha256+blah:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b", + ]; + for case in invalid { + let d = Digest::new(case).unwrap(); + assert!( + Sha256Digest::try_from(d).is_err(), + "Should have failed to parse: {case}" + ) + } + } + + #[test] + fn test_sha256_valid() { + let d = Digest::new(VALID_DIGEST_SHA256).unwrap(); + let d = d.to_sha256().unwrap(); + assert_eq!(d.value, VALID_DIGEST_SHA256.split_once(':').unwrap().1); + } +} diff --git a/src/image/mod.rs b/src/image/mod.rs index 334dd99e25..f260cb1e4e 100644 --- a/src/image/mod.rs +++ b/src/image/mod.rs @@ -4,6 +4,7 @@ mod annotations; mod artifact; mod config; mod descriptor; +mod digest; mod index; mod manifest; mod oci_layout; @@ -17,6 +18,7 @@ pub use annotations::*; pub use artifact::*; pub use config::*; pub use descriptor::*; +pub use digest::*; pub use index::*; pub use manifest::*; pub use oci_layout::*;