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
58fn non_empty(s: &str) -> Option<String> {
59    (!s.is_empty()).then(|| s.to_string())
60}
61
62/// Name implied by a file name: its stem, with a `.wasm` extension removed.
63fn name_from_file_name(file_name: &str) -> Option<String> {
64    non_empty(file_name.strip_suffix(".wasm").unwrap_or(file_name))
65}
66
67/// Name and version implied by an OCI reference: the repository's last path
68/// segment and the tag. A digest-pinned reference states no version.
69fn oci_name_version(reference: &str) -> (Option<String>, Option<String>) {
70    let bare = oci_ref_name(reference);
71    // The digest never contains '/', so splitting it off first leaves the
72    // repository path intact.
73    let (path, digest_pinned) = match bare.split_once('@') {
74        Some((path, _digest)) => (path, true),
75        None => (bare, false),
76    };
77    // Drop the registry before looking for a tag: the ':' in `localhost:5000`
78    // is a port, and only a ':' in a later segment separates a tag.
79    let Some((_registry, repository)) = path.split_once('/') else {
80        return (None, None);
81    };
82    let last = repository.rsplit('/').next().unwrap_or(repository);
83    let (name, tag) = match last.split_once(':') {
84        Some((name, tag)) => (name, Some(tag)),
85        None => (last, None),
86    };
87    let version = if digest_pinned {
88        None
89    } else {
90        tag.and_then(non_empty)
91    };
92    (non_empty(name), version)
93}
94
95/// Name and version *as the source implies them*.
96///
97/// The store deliberately never parses the component it holds, so this is not
98/// the component's declared identity from its `act:component` section — it is
99/// what the reference itself says. Callers use it to fill provenance at pull
100/// time, and to label entries stored before the pull path recorded either
101/// field.
102pub fn implied_name_version(source: &Source) -> (Option<String>, Option<String>) {
103    match source {
104        Source::Oci { reference } => oci_name_version(reference),
105        Source::Http { url, .. } => {
106            let name = url::Url::parse(url)
107                .ok()
108                .and_then(|u| {
109                    u.path_segments()
110                        .and_then(|mut s| s.next_back().map(str::to_string))
111                })
112                .as_deref()
113                .and_then(name_from_file_name);
114            (name, None)
115        }
116        Source::Local { path } => {
117            let last = path.rsplit(['/', '\\']).next().unwrap_or(path);
118            (name_from_file_name(last), None)
119        }
120    }
121}
122
123impl Provenance {
124    pub fn to_annotations(&self) -> HashMap<String, String> {
125        let mut a = HashMap::new();
126        a.insert(K_DIGEST.into(), self.digest.clone());
127        a.insert(K_FETCHED.into(), self.fetched_at.clone());
128        if let Some(n) = &self.name {
129            a.insert(K_NAME.into(), n.clone());
130        }
131        if let Some(v) = &self.version {
132            a.insert(K_VERSION.into(), v.clone());
133        }
134        match &self.source {
135            Source::Oci { reference } => {
136                a.insert(K_KIND.into(), "oci".into());
137                a.insert(K_REF.into(), reference.clone());
138                a.insert(K_REF_NAME.into(), oci_ref_name(reference).to_string());
139            }
140            Source::Http {
141                url,
142                etag,
143                last_modified,
144            } => {
145                a.insert(K_KIND.into(), "http".into());
146                a.insert(K_REF.into(), url.clone());
147                if let Some(e) = etag {
148                    a.insert(K_ETAG.into(), e.clone());
149                }
150                if let Some(lm) = last_modified {
151                    a.insert(K_LAST_MOD.into(), lm.clone());
152                }
153            }
154            Source::Local { path } => {
155                a.insert(K_KIND.into(), "local".into());
156                a.insert(K_REF.into(), path.clone());
157            }
158        }
159        a
160    }
161
162    pub fn from_annotations(a: &HashMap<String, String>) -> Result<Self, ProvenanceError> {
163        let get = |k: &'static str| a.get(k).cloned().ok_or(ProvenanceError::Missing(k));
164        let kind = get(K_KIND)?;
165        let reference = get(K_REF)?;
166        let source = match kind.as_str() {
167            "oci" => Source::Oci { reference },
168            "http" => Source::Http {
169                url: reference,
170                etag: a.get(K_ETAG).cloned(),
171                last_modified: a.get(K_LAST_MOD).cloned(),
172            },
173            "local" => Source::Local { path: reference },
174            other => return Err(ProvenanceError::UnknownKind(other.to_string())),
175        };
176        Ok(Self {
177            source,
178            digest: get(K_DIGEST)?,
179            fetched_at: get(K_FETCHED)?,
180            name: a.get(K_NAME).cloned(),
181            version: a.get(K_VERSION).cloned(),
182        })
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn oci_source_round_trips_through_annotations() {
192        let prov = Provenance {
193            source: Source::Oci {
194                reference: "oci://ghcr.io/actpkg/sqlite:0.1.0".into(),
195            },
196            digest: "sha256:9f86d08".into(),
197            fetched_at: "2026-05-26T14:03:21Z".into(),
198            name: Some("sqlite".into()),
199            version: Some("0.1.0".into()),
200        };
201        let ann = prov.to_annotations();
202        assert_eq!(
203            ann.get("dev.actcore.source.kind").map(String::as_str),
204            Some("oci")
205        );
206        assert_eq!(
207            ann.get("org.opencontainers.image.ref.name")
208                .map(String::as_str),
209            Some("ghcr.io/actpkg/sqlite:0.1.0"),
210        );
211        let back = Provenance::from_annotations(&ann).unwrap();
212        assert_eq!(back, prov);
213    }
214
215    #[test]
216    fn http_source_round_trips_with_optional_fields() {
217        let prov = Provenance {
218            source: Source::Http {
219                url: "https://cdn.example.com/x.wasm".into(),
220                etag: Some("\"abc\"".into()),
221                last_modified: None,
222            },
223            digest: "sha256:b1946ac".into(),
224            fetched_at: "2026-05-26T14:05:00Z".into(),
225            name: None,
226            version: None,
227        };
228        let back = Provenance::from_annotations(&prov.to_annotations()).unwrap();
229        assert_eq!(back, prov);
230    }
231
232    #[test]
233    fn missing_kind_is_an_error() {
234        let ann = std::collections::HashMap::new();
235        assert!(Provenance::from_annotations(&ann).is_err());
236    }
237}