Skip to content

Commit

Permalink
Add Digest and SHA256Digest
Browse files Browse the repository at this point in the history
This addresses #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 <[email protected]>
  • Loading branch information
cgwalters committed Aug 27, 2024
1 parent 9e9d3fc commit 764c0eb
Show file tree
Hide file tree
Showing 3 changed files with 175 additions and 0 deletions.
6 changes: 6 additions & 0 deletions src/image/descriptor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<super::Sha256Digest> {
let digest = super::Digest::new(&self.digest)?;
digest.try_into()
}
}

#[cfg(test)]
Expand Down
167 changes: 167 additions & 0 deletions src/image/digest.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
//! Functionality corresponding to <https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests>.

/// 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 <https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests>
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<Self> {
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<Sha256Digest<'a>> {
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<Digest<'a>> for Sha256Digest<'a> {
type Error = crate::OciSpecError;

fn try_from(algdigest: Digest<'a>) -> Result<Self, Self::Error> {
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);
}
}
2 changes: 2 additions & 0 deletions src/image/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod annotations;
mod artifact;
mod config;
mod descriptor;
mod digest;
mod index;
mod manifest;
mod oci_layout;
Expand All @@ -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::*;
Expand Down

0 comments on commit 764c0eb

Please sign in to comment.