use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
Oci { reference: String },
Http {
url: String,
etag: Option<String>,
last_modified: Option<String>,
},
Local { path: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Provenance {
pub source: Source,
pub digest: String,
pub fetched_at: String,
pub name: Option<String>,
pub version: Option<String>,
}
const K_REF_NAME: &str = "org.opencontainers.image.ref.name";
const K_KIND: &str = "dev.actcore.source.kind";
const K_REF: &str = "dev.actcore.source.ref";
const K_DIGEST: &str = "dev.actcore.source.digest";
const K_ETAG: &str = "dev.actcore.source.etag";
const K_LAST_MOD: &str = "dev.actcore.source.last-modified";
const K_FETCHED: &str = "dev.actcore.fetched-at";
const K_NAME: &str = "dev.actcore.name";
const K_VERSION: &str = "dev.actcore.version";
#[derive(Debug, thiserror::Error)]
pub enum ProvenanceError {
#[error("annotation `{0}` is missing")]
Missing(&'static str),
#[error("unknown source kind `{0}`")]
UnknownKind(String),
}
fn oci_ref_name(reference: &str) -> &str {
reference.strip_prefix("oci://").unwrap_or(reference)
}
impl Provenance {
pub fn to_annotations(&self) -> HashMap<String, String> {
let mut a = HashMap::new();
a.insert(K_DIGEST.into(), self.digest.clone());
a.insert(K_FETCHED.into(), self.fetched_at.clone());
if let Some(n) = &self.name {
a.insert(K_NAME.into(), n.clone());
}
if let Some(v) = &self.version {
a.insert(K_VERSION.into(), v.clone());
}
match &self.source {
Source::Oci { reference } => {
a.insert(K_KIND.into(), "oci".into());
a.insert(K_REF.into(), reference.clone());
a.insert(K_REF_NAME.into(), oci_ref_name(reference).to_string());
}
Source::Http {
url,
etag,
last_modified,
} => {
a.insert(K_KIND.into(), "http".into());
a.insert(K_REF.into(), url.clone());
if let Some(e) = etag {
a.insert(K_ETAG.into(), e.clone());
}
if let Some(lm) = last_modified {
a.insert(K_LAST_MOD.into(), lm.clone());
}
}
Source::Local { path } => {
a.insert(K_KIND.into(), "local".into());
a.insert(K_REF.into(), path.clone());
}
}
a
}
pub fn from_annotations(a: &HashMap<String, String>) -> Result<Self, ProvenanceError> {
let get = |k: &'static str| a.get(k).cloned().ok_or(ProvenanceError::Missing(k));
let kind = get(K_KIND)?;
let reference = get(K_REF)?;
let source = match kind.as_str() {
"oci" => Source::Oci { reference },
"http" => Source::Http {
url: reference,
etag: a.get(K_ETAG).cloned(),
last_modified: a.get(K_LAST_MOD).cloned(),
},
"local" => Source::Local { path: reference },
other => return Err(ProvenanceError::UnknownKind(other.to_string())),
};
Ok(Self {
source,
digest: get(K_DIGEST)?,
fetched_at: get(K_FETCHED)?,
name: a.get(K_NAME).cloned(),
version: a.get(K_VERSION).cloned(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn oci_source_round_trips_through_annotations() {
let prov = Provenance {
source: Source::Oci {
reference: "oci://ghcr.io/actpkg/sqlite:0.1.0".into(),
},
digest: "sha256:9f86d08".into(),
fetched_at: "2026-05-26T14:03:21Z".into(),
name: Some("sqlite".into()),
version: Some("0.1.0".into()),
};
let ann = prov.to_annotations();
assert_eq!(
ann.get("dev.actcore.source.kind").map(String::as_str),
Some("oci")
);
assert_eq!(
ann.get("org.opencontainers.image.ref.name")
.map(String::as_str),
Some("ghcr.io/actpkg/sqlite:0.1.0"),
);
let back = Provenance::from_annotations(&ann).unwrap();
assert_eq!(back, prov);
}
#[test]
fn http_source_round_trips_with_optional_fields() {
let prov = Provenance {
source: Source::Http {
url: "https://cdn.example.com/x.wasm".into(),
etag: Some("\"abc\"".into()),
last_modified: None,
},
digest: "sha256:b1946ac".into(),
fetched_at: "2026-05-26T14:05:00Z".into(),
name: None,
version: None,
};
let back = Provenance::from_annotations(&prov.to_annotations()).unwrap();
assert_eq!(back, prov);
}
#[test]
fn missing_kind_is_an_error() {
let ann = std::collections::HashMap::new();
assert!(Provenance::from_annotations(&ann).is_err());
}
}