Skip to main content

act_store/
provenance.rs

1//! Provenance of a stored component, carried as OCI annotations.
2//! See spec ยง3.
3
4use std::collections::HashMap;
5
6/// Where a stored component came from.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum Source {
9    /// Pulled from an OCI registry. `reference` is the ref exactly as typed.
10    Oci { reference: String },
11    /// Fetched from an HTTP(S) URL (synthesized manifest). Optional caching
12    /// headers support cheap update checks later.
13    Http {
14        url: String,
15        etag: Option<String>,
16        last_modified: Option<String>,
17    },
18    /// Installed from a local file (pinned snapshot). `path` is the file URI.
19    Local { path: String },
20}
21
22/// Full provenance for one stored component.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Provenance {
25    pub source: Source,
26    /// Resolved content digest the source pulled (`sha256:...`).
27    pub digest: String,
28    /// RFC 3339 timestamp. Supplied by the caller (the pull layer).
29    pub fetched_at: String,
30    pub name: Option<String>,
31    pub version: Option<String>,
32}
33
34const K_REF_NAME: &str = "org.opencontainers.image.ref.name";
35const K_KIND: &str = "dev.actcore.source.kind";
36const K_REF: &str = "dev.actcore.source.ref";
37const K_DIGEST: &str = "dev.actcore.source.digest";
38const K_ETAG: &str = "dev.actcore.source.etag";
39const K_LAST_MOD: &str = "dev.actcore.source.last-modified";
40const K_FETCHED: &str = "dev.actcore.fetched-at";
41const K_NAME: &str = "dev.actcore.name";
42const K_VERSION: &str = "dev.actcore.version";
43
44/// Error parsing provenance back from annotations.
45#[derive(Debug, thiserror::Error)]
46pub enum ProvenanceError {
47    #[error("annotation `{0}` is missing")]
48    Missing(&'static str),
49    #[error("unknown source kind `{0}`")]
50    UnknownKind(String),
51}
52
53/// Strip the `oci://` scheme from an OCI ref for the standard ref.name slot.
54fn oci_ref_name(reference: &str) -> &str {
55    reference.strip_prefix("oci://").unwrap_or(reference)
56}
57
58impl Provenance {
59    pub fn to_annotations(&self) -> HashMap<String, String> {
60        let mut a = HashMap::new();
61        a.insert(K_DIGEST.into(), self.digest.clone());
62        a.insert(K_FETCHED.into(), self.fetched_at.clone());
63        if let Some(n) = &self.name {
64            a.insert(K_NAME.into(), n.clone());
65        }
66        if let Some(v) = &self.version {
67            a.insert(K_VERSION.into(), v.clone());
68        }
69        match &self.source {
70            Source::Oci { reference } => {
71                a.insert(K_KIND.into(), "oci".into());
72                a.insert(K_REF.into(), reference.clone());
73                a.insert(K_REF_NAME.into(), oci_ref_name(reference).to_string());
74            }
75            Source::Http {
76                url,
77                etag,
78                last_modified,
79            } => {
80                a.insert(K_KIND.into(), "http".into());
81                a.insert(K_REF.into(), url.clone());
82                if let Some(e) = etag {
83                    a.insert(K_ETAG.into(), e.clone());
84                }
85                if let Some(lm) = last_modified {
86                    a.insert(K_LAST_MOD.into(), lm.clone());
87                }
88            }
89            Source::Local { path } => {
90                a.insert(K_KIND.into(), "local".into());
91                a.insert(K_REF.into(), path.clone());
92            }
93        }
94        a
95    }
96
97    pub fn from_annotations(a: &HashMap<String, String>) -> Result<Self, ProvenanceError> {
98        let get = |k: &'static str| a.get(k).cloned().ok_or(ProvenanceError::Missing(k));
99        let kind = get(K_KIND)?;
100        let reference = get(K_REF)?;
101        let source = match kind.as_str() {
102            "oci" => Source::Oci { reference },
103            "http" => Source::Http {
104                url: reference,
105                etag: a.get(K_ETAG).cloned(),
106                last_modified: a.get(K_LAST_MOD).cloned(),
107            },
108            "local" => Source::Local { path: reference },
109            other => return Err(ProvenanceError::UnknownKind(other.to_string())),
110        };
111        Ok(Self {
112            source,
113            digest: get(K_DIGEST)?,
114            fetched_at: get(K_FETCHED)?,
115            name: a.get(K_NAME).cloned(),
116            version: a.get(K_VERSION).cloned(),
117        })
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn oci_source_round_trips_through_annotations() {
127        let prov = Provenance {
128            source: Source::Oci {
129                reference: "oci://ghcr.io/actpkg/sqlite:0.1.0".into(),
130            },
131            digest: "sha256:9f86d08".into(),
132            fetched_at: "2026-05-26T14:03:21Z".into(),
133            name: Some("sqlite".into()),
134            version: Some("0.1.0".into()),
135        };
136        let ann = prov.to_annotations();
137        assert_eq!(
138            ann.get("dev.actcore.source.kind").map(String::as_str),
139            Some("oci")
140        );
141        assert_eq!(
142            ann.get("org.opencontainers.image.ref.name")
143                .map(String::as_str),
144            Some("ghcr.io/actpkg/sqlite:0.1.0"),
145        );
146        let back = Provenance::from_annotations(&ann).unwrap();
147        assert_eq!(back, prov);
148    }
149
150    #[test]
151    fn http_source_round_trips_with_optional_fields() {
152        let prov = Provenance {
153            source: Source::Http {
154                url: "https://cdn.example.com/x.wasm".into(),
155                etag: Some("\"abc\"".into()),
156                last_modified: None,
157            },
158            digest: "sha256:b1946ac".into(),
159            fetched_at: "2026-05-26T14:05:00Z".into(),
160            name: None,
161            version: None,
162        };
163        let back = Provenance::from_annotations(&prov.to_annotations()).unwrap();
164        assert_eq!(back, prov);
165    }
166
167    #[test]
168    fn missing_kind_is_an_error() {
169        let ann = std::collections::HashMap::new();
170        assert!(Provenance::from_annotations(&ann).is_err());
171    }
172}