Skip to main content

act_store/
index.rs

1//! `index.json` (OCI image index) load/save plus pure descriptor helpers.
2
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5use std::str::FromStr;
6
7use oci_spec::image::{
8    Descriptor, DescriptorBuilder, ImageIndex, ImageIndexBuilder, ImageManifest, MediaType,
9    SCHEMA_VERSION, Sha256Digest,
10};
11
12use crate::layout;
13use crate::referrer::K_SUBJECT;
14
15const K_REF: &str = "dev.actcore.source.ref";
16
17/// Errors from index manipulation.
18#[derive(Debug, thiserror::Error)]
19pub enum IndexError {
20    #[error("oci-spec error: {0}")]
21    Oci(#[from] oci_spec::OciSpecError),
22    #[error("io error: {0}")]
23    Io(#[from] std::io::Error),
24    #[error("invalid digest `{0}`")]
25    Digest(String),
26}
27
28/// Load the index, or an empty index if `index.json` is absent.
29pub fn load(root: &Path) -> Result<ImageIndex, IndexError> {
30    let path = layout::index_path(root);
31    if !path.exists() {
32        return Ok(build_index(Vec::new()));
33    }
34    Ok(ImageIndex::from_file(&path)?)
35}
36
37/// Write the index atomically (temp + rename) to `index.json`.
38pub fn save(root: &Path, index: &ImageIndex) -> Result<(), IndexError> {
39    let dest = layout::index_path(root);
40    let tmp = root.join(format!(".index.json.{}.tmp", std::process::id()));
41    index.to_file_pretty(&tmp)?;
42    std::fs::rename(&tmp, &dest)?;
43    Ok(())
44}
45
46/// Build an image index over the given manifest descriptors.
47pub fn build_index(manifests: Vec<Descriptor>) -> ImageIndex {
48    ImageIndexBuilder::default()
49        .schema_version(SCHEMA_VERSION)
50        .media_type(MediaType::ImageIndex)
51        .manifests(manifests)
52        .build()
53        .expect("image index with valid fields always builds")
54}
55
56/// Build a manifest descriptor for `index.json.manifests[]`.
57pub fn manifest_descriptor(
58    hex: &str,
59    size: u64,
60    annotations: HashMap<String, String>,
61) -> Result<Descriptor, IndexError> {
62    let digest = Sha256Digest::from_str(hex).map_err(|_| IndexError::Digest(hex.to_string()))?;
63    Ok(DescriptorBuilder::default()
64        .media_type(MediaType::ImageManifest)
65        .digest(digest)
66        .size(size)
67        .annotations(annotations)
68        .build()?)
69}
70
71/// Hex digest (no `sha256:` prefix) of a descriptor's target blob.
72pub fn digest_hex(d: &Descriptor) -> String {
73    let s = d.digest().to_string();
74    s.rsplit(':').next().unwrap_or(&s).to_string()
75}
76
77fn ref_of(d: &Descriptor) -> Option<&str> {
78    d.annotations().as_ref()?.get(K_REF).map(String::as_str)
79}
80
81/// Insert `desc`, replacing any existing descriptor whose
82/// `dev.actcore.source.ref` matches (i.e. the same logical ref / tag).
83pub fn upsert(manifests: &mut Vec<Descriptor>, desc: Descriptor) {
84    let new_ref = ref_of(&desc).map(str::to_string);
85    if let Some(r) = &new_ref {
86        manifests.retain(|d| ref_of(d) != Some(r.as_str()));
87    }
88    manifests.push(desc);
89}
90
91/// Insert `desc`, replacing any existing descriptor with the same manifest
92/// digest (referrers have no `source.ref`, so they dedupe by digest).
93pub fn upsert_by_digest(manifests: &mut Vec<Descriptor>, desc: Descriptor) {
94    let new = digest_hex(&desc);
95    manifests.retain(|d| digest_hex(d) != new);
96    manifests.push(desc);
97}
98
99/// The subject component manifest hex (no prefix) a referrer descriptor points
100/// at, if it is a referrer (`dev.actcore.referrer.subject` annotation present).
101fn subject_of(d: &Descriptor) -> Option<String> {
102    d.annotations()
103        .as_ref()?
104        .get(K_SUBJECT)
105        .map(|s| s.rsplit(':').next().unwrap_or(s).to_string())
106}
107
108/// Find a stored descriptor by its source ref (as typed).
109pub fn find_by_ref<'a>(manifests: &'a [Descriptor], reference: &str) -> Option<&'a Descriptor> {
110    manifests.iter().find(|d| ref_of(d) == Some(reference))
111}
112
113/// Every blob hex digest reachable from the index. A *primary* descriptor (no
114/// `dev.actcore.referrer.subject` annotation) is always a root. A *referrer*
115/// descriptor is reachable only while the manifest it is a subject of is
116/// reachable — transitively (a referrer of a referrer survives via its chain).
117/// For every reachable manifest, its own digest plus its config + layer digests
118/// are collected. `read_manifest` fetches manifest bytes by hex digest.
119pub fn reachable_digests(
120    index: &ImageIndex,
121    read_manifest: impl Fn(&str) -> Result<Vec<u8>, IndexError>,
122) -> Result<HashSet<String>, IndexError> {
123    // 1. Reachable manifest hexes: primaries, then fixpoint-add referrers whose
124    //    subject is already reachable.
125    let mut reachable_manifests: HashSet<String> = index
126        .manifests()
127        .iter()
128        .filter(|d| subject_of(d).is_none())
129        .map(digest_hex)
130        .collect();
131
132    loop {
133        let mut added = false;
134        for d in index.manifests() {
135            let Some(subject) = subject_of(d) else {
136                continue;
137            };
138            let hex = digest_hex(d);
139            if reachable_manifests.contains(&subject) && reachable_manifests.insert(hex) {
140                added = true;
141            }
142        }
143        if !added {
144            break;
145        }
146    }
147
148    // 2. For each reachable manifest, collect its blob + config + layers.
149    let mut set: HashSet<String> = HashSet::new();
150    for hex in &reachable_manifests {
151        set.insert(hex.clone());
152        if let Ok(manifest) = serde_json::from_slice::<ImageManifest>(&read_manifest(hex)?) {
153            set.insert(strip_algo(manifest.config().digest().as_ref()));
154            for layer in manifest.layers() {
155                set.insert(strip_algo(layer.digest().as_ref()));
156            }
157        }
158    }
159    Ok(set)
160}
161
162fn strip_algo(digest: &str) -> String {
163    digest.rsplit(':').next().unwrap_or(digest).to_string()
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::layout;
170    use crate::provenance::{Provenance, Source};
171    use crate::referrer::K_SUBJECT;
172    use tempfile::TempDir;
173
174    /// Build a referrer descriptor pointing at `subject_hex`.
175    fn referrer_desc(hex: &str, subject_hex: &str) -> Descriptor {
176        let mut ann = std::collections::HashMap::new();
177        ann.insert(K_SUBJECT.to_string(), format!("sha256:{subject_hex}"));
178        manifest_descriptor(hex, 10, ann).unwrap()
179    }
180
181    #[test]
182    fn upsert_by_digest_dedupes() {
183        let a = referrer_desc(
184            "1111111111111111111111111111111111111111111111111111111111111111",
185            "9999999999999999999999999999999999999999999999999999999999999999",
186        );
187        let a2 = a.clone();
188        let mut v = vec![a];
189        upsert_by_digest(&mut v, a2);
190        assert_eq!(v.len(), 1, "same digest dedupes");
191    }
192
193    #[test]
194    fn referrer_unreachable_when_subject_absent() {
195        let c_hex = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
196        let r_hex = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
197        let o_hex = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
198        let c = manifest_descriptor(c_hex, 1, std::collections::HashMap::new()).unwrap();
199        let r = referrer_desc(r_hex, c_hex);
200        let o = referrer_desc(
201            o_hex,
202            "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
203        );
204        let idx = build_index(vec![c, r, o]);
205
206        let empty_manifest = br#"{"schemaVersion":2,"config":{"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[]}"#.to_vec();
207        let reachable = reachable_digests(&idx, |_hex| Ok(empty_manifest.clone())).unwrap();
208
209        assert!(reachable.contains(c_hex), "component reachable");
210        assert!(
211            reachable.contains(r_hex),
212            "referrer of present component reachable"
213        );
214        assert!(
215            !reachable.contains(o_hex),
216            "referrer of absent subject NOT reachable"
217        );
218    }
219
220    #[test]
221    fn missing_index_loads_as_empty() {
222        let dir = TempDir::new().unwrap();
223        layout::init(dir.path()).unwrap();
224        let idx = load(dir.path()).unwrap();
225        assert!(idx.manifests().is_empty());
226    }
227
228    #[test]
229    fn save_then_load_roundtrips_a_descriptor() {
230        let dir = TempDir::new().unwrap();
231        layout::init(dir.path()).unwrap();
232        let desc = manifest_descriptor(
233            "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
234            123,
235            std::collections::HashMap::new(),
236        )
237        .unwrap();
238        let idx = build_index(vec![desc]);
239        save(dir.path(), &idx).unwrap();
240        let back = load(dir.path()).unwrap();
241        assert_eq!(back.manifests().len(), 1);
242    }
243
244    fn prov(reference: &str, digest_hex: &str) -> std::collections::HashMap<String, String> {
245        Provenance {
246            source: Source::Oci {
247                reference: format!("oci://{reference}"),
248            },
249            digest: format!("sha256:{digest_hex}"),
250            fetched_at: "2026-05-26T00:00:00Z".into(),
251            name: None,
252            version: None,
253        }
254        .to_annotations()
255    }
256
257    #[test]
258    fn upsert_inserts_then_replaces_same_ref_name() {
259        let a = manifest_descriptor(
260            "1111111111111111111111111111111111111111111111111111111111111111",
261            1,
262            prov(
263                "ghcr.io/x/c:0.1",
264                "1111111111111111111111111111111111111111111111111111111111111111",
265            ),
266        )
267        .unwrap();
268        let b = manifest_descriptor(
269            "2222222222222222222222222222222222222222222222222222222222222222",
270            2,
271            prov(
272                "ghcr.io/x/c:0.1",
273                "2222222222222222222222222222222222222222222222222222222222222222",
274            ),
275        )
276        .unwrap();
277
278        let mut manifests = vec![a];
279        upsert(&mut manifests, b);
280        assert_eq!(manifests.len(), 1, "same ref.name replaces, not appends");
281        assert_eq!(
282            digest_hex(&manifests[0]),
283            "2222222222222222222222222222222222222222222222222222222222222222"
284        );
285    }
286
287    #[test]
288    fn find_by_ref_works() {
289        let a = manifest_descriptor(
290            "1111111111111111111111111111111111111111111111111111111111111111",
291            1,
292            prov(
293                "ghcr.io/x/c:0.1",
294                "1111111111111111111111111111111111111111111111111111111111111111",
295            ),
296        )
297        .unwrap();
298        let manifests = vec![a];
299        assert!(find_by_ref(&manifests, "oci://ghcr.io/x/c:0.1").is_some());
300        assert!(find_by_ref(&manifests, "oci://ghcr.io/x/nope:0.1").is_none());
301    }
302}