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
58fn non_empty(s: &str) -> Option<String> {
59 (!s.is_empty()).then(|| s.to_string())
60}
61
62fn name_from_file_name(file_name: &str) -> Option<String> {
64 non_empty(file_name.strip_suffix(".wasm").unwrap_or(file_name))
65}
66
67fn oci_name_version(reference: &str) -> (Option<String>, Option<String>) {
70 let bare = oci_ref_name(reference);
71 let (path, digest_pinned) = match bare.split_once('@') {
74 Some((path, _digest)) => (path, true),
75 None => (bare, false),
76 };
77 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
95pub 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}