1use 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#[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
28pub 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
37pub 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
46pub 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
56pub 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
71pub 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
81pub 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
91pub 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
99fn 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
108pub 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
113pub fn reachable_digests(
120 index: &ImageIndex,
121 read_manifest: impl Fn(&str) -> Result<Vec<u8>, IndexError>,
122) -> Result<HashSet<String>, IndexError> {
123 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 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 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}