Skip to main content

hyperlight_host/sandbox/snapshot/file/
reference.rs

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