Skip to main content

hyperlight_host/sandbox/snapshot/file/
reference.rs

1/*
2Copyright 2025 The Hyperlight Authors.
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8    http://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15*/
16
17//! Validated identifiers for snapshots stored in an OCI Image Layout:
18//! a human-readable tag, a content digest, and the reference enum that
19//! selects a manifest by either one.
20
21use std::fmt;
22use std::str::FromStr;
23
24use oci_spec::image::{Digest as OciSpecDigest, DigestAlgorithm};
25
26/// A tag naming one snapshot inside an OCI Image Layout directory.
27/// Used to save a snapshot under a name and to load it back by that
28/// same name.
29#[derive(Clone, Debug, PartialEq, Eq, Hash)]
30pub struct OciTag(String);
31
32impl OciTag {
33    /// Construct a tag, validating it against the OCI Distribution
34    /// grammar `[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}`. Returns an error
35    /// if the input does not match.
36    pub fn new(tag: impl Into<String>) -> crate::Result<Self> {
37        Self::try_from(tag.into())
38    }
39
40    /// The tag as a string slice.
41    pub fn as_str(&self) -> &str {
42        &self.0
43    }
44}
45
46fn validate_tag(tag: &str) -> crate::Result<()> {
47    let bytes = tag.as_bytes();
48    if bytes.is_empty() || bytes.len() > 128 {
49        return Err(crate::new_error!(
50            "tag {:?} is invalid: must be 1..=128 bytes",
51            tag
52        ));
53    }
54    let first = bytes[0];
55    if !(first.is_ascii_alphanumeric() || first == b'_') {
56        return Err(crate::new_error!(
57            "tag {:?} is invalid: first character must be alphanumeric or '_'",
58            tag
59        ));
60    }
61    for &b in &bytes[1..] {
62        if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'-') {
63            return Err(crate::new_error!(
64                "tag {:?} is invalid: characters after the first must be \
65                 alphanumeric or one of '_', '.', '-'",
66                tag
67            ));
68        }
69    }
70    Ok(())
71}
72
73impl FromStr for OciTag {
74    type Err = crate::HyperlightError;
75
76    fn from_str(s: &str) -> crate::Result<Self> {
77        validate_tag(s)?;
78        Ok(Self(s.to_string()))
79    }
80}
81
82impl TryFrom<&str> for OciTag {
83    type Error = crate::HyperlightError;
84
85    fn try_from(s: &str) -> crate::Result<Self> {
86        s.parse()
87    }
88}
89
90impl TryFrom<String> for OciTag {
91    type Error = crate::HyperlightError;
92
93    fn try_from(s: String) -> crate::Result<Self> {
94        validate_tag(&s)?;
95        Ok(Self(s))
96    }
97}
98
99impl AsRef<str> for OciTag {
100    fn as_ref(&self) -> &str {
101        &self.0
102    }
103}
104
105impl fmt::Display for OciTag {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.write_str(&self.0)
108    }
109}
110
111/// A sha256 content digest in canonical `sha256:<64 lowercase hex>`
112/// form, identifying one snapshot by the bytes of its manifest.
113/// Names that snapshot for loading even when no tag points at it.
114#[derive(Clone, Debug, PartialEq, Eq, Hash)]
115pub struct OciDigest(String);
116
117impl OciDigest {
118    /// The digest as a `sha256:<hex>` string slice.
119    pub fn as_str(&self) -> &str {
120        &self.0
121    }
122
123    /// Wrap a validated `oci-spec` digest. The caller guarantees it
124    /// uses the sha256 algorithm.
125    pub(super) fn from_oci_spec_digest(digest: &OciSpecDigest) -> Self {
126        Self(digest.to_string())
127    }
128}
129
130fn validate_digest(s: &str) -> crate::Result<String> {
131    let digest = OciSpecDigest::from_str(s)
132        .map_err(|e| crate::new_error!("invalid OCI digest {:?}: {}", s, e))?;
133    if digest.algorithm() != &DigestAlgorithm::Sha256 {
134        return Err(crate::new_error!(
135            "OCI digest {:?} must use the sha256 algorithm, found {}",
136            s,
137            digest.algorithm()
138        ));
139    }
140    Ok(digest.to_string())
141}
142
143impl FromStr for OciDigest {
144    type Err = crate::HyperlightError;
145
146    fn from_str(s: &str) -> crate::Result<Self> {
147        Ok(Self(validate_digest(s)?))
148    }
149}
150
151impl TryFrom<&str> for OciDigest {
152    type Error = crate::HyperlightError;
153
154    fn try_from(s: &str) -> crate::Result<Self> {
155        s.parse()
156    }
157}
158
159impl TryFrom<String> for OciDigest {
160    type Error = crate::HyperlightError;
161
162    fn try_from(s: String) -> crate::Result<Self> {
163        Ok(Self(validate_digest(&s)?))
164    }
165}
166
167impl AsRef<str> for OciDigest {
168    fn as_ref(&self) -> &str {
169        &self.0
170    }
171}
172
173impl fmt::Display for OciDigest {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        f.write_str(&self.0)
176    }
177}
178
179/// Names one snapshot in an OCI Image Layout, either by tag or by
180/// content digest.
181#[derive(Clone, Debug, PartialEq, Eq, Hash)]
182pub enum OciReference {
183    /// A snapshot named by its tag.
184    Tag(OciTag),
185    /// A snapshot named by its content digest.
186    Digest(OciDigest),
187}
188
189impl From<OciTag> for OciReference {
190    fn from(tag: OciTag) -> Self {
191        OciReference::Tag(tag)
192    }
193}
194
195impl From<OciDigest> for OciReference {
196    fn from(digest: OciDigest) -> Self {
197        OciReference::Digest(digest)
198    }
199}
200
201impl FromStr for OciReference {
202    type Err = crate::HyperlightError;
203
204    /// Parse a tag or a digest. A `:` marks a digest, since the tag
205    /// grammar forbids that character.
206    fn from_str(s: &str) -> crate::Result<Self> {
207        if s.contains(':') {
208            Ok(OciReference::Digest(s.parse()?))
209        } else {
210            Ok(OciReference::Tag(s.parse()?))
211        }
212    }
213}
214
215impl fmt::Display for OciReference {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        match self {
218            OciReference::Tag(t) => fmt::Display::fmt(t, f),
219            OciReference::Digest(d) => fmt::Display::fmt(d, f),
220        }
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    /// A 64-character lowercase hex string, the body of a canonical
229    /// sha256 digest.
230    const HEX64: &str = "0000000000000000000000000000000000000000000000000000000000000000";
231
232    #[test]
233    fn tag_accepts_grammar_and_length_bounds() {
234        // First character may be alphanumeric or underscore.
235        assert!(OciTag::new("a").is_ok());
236        assert!(OciTag::new("Z").is_ok());
237        assert!(OciTag::new("9").is_ok());
238        assert!(OciTag::new("_").is_ok());
239        // Later characters add '.', '-'.
240        assert!(OciTag::new("v1.0_release-2").is_ok());
241        // 128 bytes is the maximum.
242        assert!(OciTag::new("a".repeat(128)).is_ok());
243    }
244
245    #[test]
246    fn tag_rejects_out_of_grammar_input() {
247        // Empty and over-length.
248        assert!(OciTag::new("").is_err());
249        assert!(OciTag::new("a".repeat(129)).is_err());
250        // First character cannot be '.', '-', or punctuation.
251        assert!(OciTag::new(".tag").is_err());
252        assert!(OciTag::new("-tag").is_err());
253        // Later characters cannot include these.
254        assert!(OciTag::new("a/b").is_err());
255        assert!(OciTag::new("a b").is_err());
256    }
257
258    #[test]
259    fn tag_never_contains_colon() {
260        // The reference parser routes on ':'. A valid tag must never
261        // carry one, otherwise a tag would be parsed as a digest.
262        assert!(OciTag::new("sha256:abc").is_err());
263    }
264
265    #[test]
266    fn digest_accepts_canonical_sha256() {
267        let s = format!("sha256:{HEX64}");
268        let d = OciDigest::try_from(s.as_str()).unwrap();
269        assert_eq!(d.as_str(), s);
270    }
271
272    #[test]
273    fn digest_rejects_non_sha256_algorithm() {
274        let s = format!("sha512:{}", "0".repeat(128));
275        assert!(OciDigest::try_from(s.as_str()).is_err());
276    }
277
278    #[test]
279    fn digest_rejects_malformed_input() {
280        // Missing algorithm prefix.
281        assert!(OciDigest::try_from(HEX64).is_err());
282        // Hex body of the wrong length.
283        assert!(OciDigest::try_from("sha256:abcd").is_err());
284    }
285
286    #[test]
287    fn reference_parses_tag_when_no_colon() {
288        let r: OciReference = "latest".parse().unwrap();
289        assert_eq!(r, OciReference::Tag(OciTag::new("latest").unwrap()));
290    }
291
292    #[test]
293    fn reference_parses_digest_when_colon_present() {
294        let s = format!("sha256:{HEX64}");
295        let r: OciReference = s.parse().unwrap();
296        assert_eq!(
297            r,
298            OciReference::Digest(OciDigest::try_from(s.as_str()).unwrap())
299        );
300    }
301
302    #[test]
303    fn tag_display_round_trips() {
304        let tag = OciTag::new("v1.2-rc1").unwrap();
305        assert_eq!(OciTag::new(tag.to_string()).unwrap(), tag);
306    }
307
308    #[test]
309    fn digest_display_round_trips() {
310        let d = OciDigest::try_from(format!("sha256:{HEX64}").as_str()).unwrap();
311        assert_eq!(OciDigest::try_from(d.to_string().as_str()).unwrap(), d);
312    }
313
314    #[test]
315    fn reference_display_round_trips() {
316        let tag_ref: OciReference = "latest".parse().unwrap();
317        assert_eq!(
318            tag_ref.to_string().parse::<OciReference>().unwrap(),
319            tag_ref
320        );
321
322        let digest_ref: OciReference = format!("sha256:{HEX64}").parse().unwrap();
323        assert_eq!(
324            digest_ref.to_string().parse::<OciReference>().unwrap(),
325            digest_ref
326        );
327    }
328}