Skip to main content

microsandbox_image/
digest.rs

1//! OCI content-addressable digest type.
2
3use std::{fmt, str::FromStr};
4
5use crate::error::ImageError;
6
7//--------------------------------------------------------------------------------------------------
8// Types
9//--------------------------------------------------------------------------------------------------
10
11/// OCI content-addressable digest (e.g., `sha256:e3b0c44298fc1c14...`).
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13pub struct Digest {
14    /// Hash algorithm (e.g., `sha256`).
15    algorithm: String,
16    /// Hex-encoded hash value.
17    hex: String,
18}
19
20//--------------------------------------------------------------------------------------------------
21// Methods
22//--------------------------------------------------------------------------------------------------
23
24impl Digest {
25    /// Create a new digest from algorithm and hex components.
26    pub fn new(algorithm: impl Into<String>, hex: impl Into<String>) -> Self {
27        Self {
28            algorithm: algorithm.into(),
29            hex: hex.into(),
30        }
31    }
32
33    /// Hash algorithm (e.g., `sha256`).
34    pub fn algorithm(&self) -> &str {
35        &self.algorithm
36    }
37
38    /// Hex-encoded hash value.
39    pub fn hex(&self) -> &str {
40        &self.hex
41    }
42
43    /// Filesystem-safe representation for use in paths.
44    ///
45    /// Replaces `:` with `_` (e.g., `sha256_abc123...`).
46    pub fn to_path_safe(&self) -> String {
47        format!(
48            "{}_{}",
49            path_safe_component(&self.algorithm),
50            path_safe_component(&self.hex)
51        )
52    }
53}
54
55//--------------------------------------------------------------------------------------------------
56// Trait Implementations
57//--------------------------------------------------------------------------------------------------
58
59impl FromStr for Digest {
60    type Err = ImageError;
61
62    fn from_str(s: &str) -> Result<Self, Self::Err> {
63        let (algo, hex) = s.split_once(':').ok_or_else(|| {
64            ImageError::ManifestParse(format!("invalid digest (missing ':'): {s}"))
65        })?;
66
67        if algo.is_empty() || hex.is_empty() {
68            return Err(ImageError::ManifestParse(format!(
69                "invalid digest (empty component): {s}"
70            )));
71        }
72
73        if !is_valid_algorithm(algo) {
74            return Err(ImageError::ManifestParse(format!(
75                "invalid digest algorithm: {s}"
76            )));
77        }
78
79        if !is_valid_encoded(hex) {
80            return Err(ImageError::ManifestParse(format!(
81                "invalid digest encoded value: {s}"
82            )));
83        }
84
85        Ok(Self {
86            algorithm: algo.to_string(),
87            hex: hex.to_string(),
88        })
89    }
90}
91
92fn is_valid_algorithm(algo: &str) -> bool {
93    let mut previous_was_separator = false;
94
95    for b in algo.bytes() {
96        if b.is_ascii_lowercase() || b.is_ascii_digit() {
97            previous_was_separator = false;
98        } else if matches!(b, b'+' | b'.' | b'_' | b'-') {
99            if previous_was_separator {
100                return false;
101            }
102            previous_was_separator = true;
103        } else {
104            return false;
105        }
106    }
107
108    !previous_was_separator
109}
110
111fn is_valid_encoded(encoded: &str) -> bool {
112    encoded
113        .bytes()
114        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'=' | b'_' | b'-'))
115}
116
117fn path_safe_component(component: &str) -> String {
118    component
119        .bytes()
120        .map(|b| {
121            if b.is_ascii_alphanumeric() || matches!(b, b'+' | b'.' | b'=' | b'_' | b'-') {
122                b as char
123            } else {
124                '_'
125            }
126        })
127        .collect()
128}
129
130impl fmt::Display for Digest {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        write!(f, "{}:{}", self.algorithm, self.hex)
133    }
134}
135
136//--------------------------------------------------------------------------------------------------
137// Tests
138//--------------------------------------------------------------------------------------------------
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_parse_valid_digest() {
146        let d: Digest = "sha256:abc123".parse().unwrap();
147        assert_eq!(d.algorithm(), "sha256");
148        assert_eq!(d.hex(), "abc123");
149    }
150
151    #[test]
152    fn test_display() {
153        let d = Digest::new("sha256", "abc123");
154        assert_eq!(d.to_string(), "sha256:abc123");
155    }
156
157    #[test]
158    fn test_path_safe() {
159        let d = Digest::new("sha256", "abc123");
160        assert_eq!(d.to_path_safe(), "sha256_abc123");
161    }
162
163    #[test]
164    fn test_path_safe_sanitizes_constructed_digest() {
165        let d = Digest::new("sha/256", "../../escape");
166        assert_eq!(d.to_path_safe(), "sha_256_.._.._escape");
167    }
168
169    #[test]
170    fn test_parse_missing_colon() {
171        assert!("sha256abc123".parse::<Digest>().is_err());
172    }
173
174    #[test]
175    fn test_parse_empty_components() {
176        assert!(":abc123".parse::<Digest>().is_err());
177        assert!("sha256:".parse::<Digest>().is_err());
178    }
179
180    #[test]
181    fn test_parse_rejects_invalid_algorithm() {
182        assert!("SHA256:abc123".parse::<Digest>().is_err());
183        assert!("sha256.:abc123".parse::<Digest>().is_err());
184        assert!("sha256..v2:abc123".parse::<Digest>().is_err());
185    }
186
187    #[test]
188    fn test_parse_allows_valid_extension_digest() {
189        let d: Digest = "sha256+b64u:LCa0a2j_xo_5m0U8HTBBNBNCLXBkg7-g-YpeiGJm564"
190            .parse()
191            .unwrap();
192        assert_eq!(d.algorithm(), "sha256+b64u");
193        assert_eq!(d.hex(), "LCa0a2j_xo_5m0U8HTBBNBNCLXBkg7-g-YpeiGJm564");
194    }
195
196    #[test]
197    fn test_parse_rejects_invalid_encoded_digest() {
198        assert!("sha256:has.dot".parse::<Digest>().is_err());
199        assert!("sha256:../../escape".parse::<Digest>().is_err());
200    }
201}