1use std::collections::HashMap;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum Source {
9 Oci { reference: String },
11 Http {
14 url: String,
15 etag: Option<String>,
16 last_modified: Option<String>,
17 },
18 Local { path: String },
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Provenance {
25 pub source: Source,
26 pub digest: String,
28 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#[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
53fn 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}