microsandbox_image/
digest.rs1use std::{fmt, str::FromStr};
4
5use crate::error::ImageError;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13pub struct Digest {
14 algorithm: String,
16 hex: String,
18}
19
20impl Digest {
25 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 pub fn algorithm(&self) -> &str {
35 &self.algorithm
36 }
37
38 pub fn hex(&self) -> &str {
40 &self.hex
41 }
42
43 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
55impl 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#[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}