1use std::collections::{BTreeMap, BTreeSet};
21
22#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct LayerView {
30 pub includes: Vec<Include>,
31 pub tools: Vec<String>,
33}
34
35pub fn view(bytes: &[u8]) -> Result<LayerView, ComposeError> {
39 let json: serde_json::Value =
40 serde_json::from_slice(bytes).map_err(|e| ComposeError::Unreadable(e.to_string()))?;
41 let mut v = LayerView::default();
42 let Some(entries) = json["manifests"].as_array() else {
43 return Ok(v);
44 };
45 for e in entries {
46 let ann = &e["annotations"];
47 let digest = e["digest"].as_str().unwrap_or_default().to_string();
48 match ann[crate::kind::ANN_KIND].as_str() {
49 Some("layer") => v.includes.push(Include {
50 digest,
51 realm: ann[ANN_INCLUDE_REALM].as_str().map(|s| s.to_string()),
52 layer: ann[ANN_INCLUDE_LAYER].as_str().map(|s| s.to_string()),
53 }),
54 None => {
56 if let Some(t) = ann["eu.pulseengine.tool"].as_str() {
57 v.tools.push(t.to_string());
58 }
59 }
60 Some(_) => {}
62 }
63 }
64 Ok(v)
65}
66
67pub const ANN_INCLUDE_REALM: &str = "eu.pulseengine.varve.include.realm";
70pub const ANN_INCLUDE_LAYER: &str = "eu.pulseengine.varve.include.layer";
73
74pub const MAX_DEPTH: usize = 8;
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Include {
82 pub digest: String,
84 pub realm: Option<String>,
86 pub layer: Option<String>,
88}
89
90#[derive(Debug, thiserror::Error)]
91pub enum ComposeError {
92 #[error(
93 "composition cycle: layer {digest} includes itself, directly or through \
94 {via} — refusing to follow it"
95 )]
96 Cycle { digest: String, via: String },
97 #[error(
98 "composition is more than {MAX_DEPTH} layers deep — refusing to walk further \
99 (a layer graph this deep is a mistake, not a design)"
100 )]
101 TooDeep,
102 #[error("layer manifest could not be read for composition: {0}")]
103 Unreadable(String),
104 #[error(
105 "tool '{tool}' is exposed by more than one layer in this composition \
106 ({first} and {second}) — refusing to choose. Restrict the pin's `tools`, \
107 or remove the duplicate from one layer."
108 )]
109 AmbiguousTool {
110 tool: String,
111 first: String,
112 second: String,
113 },
114 #[error(transparent)]
117 ConflictingPayload(#[from] Box<PayloadConflict>),
118}
119
120#[derive(Debug, thiserror::Error, PartialEq, Eq)]
123#[error(
124 "{name} {version} is offered by two layers in this composition with DIFFERENT bytes: \
125 {first} has {first_digest}, {second} has {second_digest} — refusing to choose. \
126 Two realms disagreeing about what one name-and-version IS cannot both be exported; \
127 a name at different VERSIONS is legal and both export, but one (name, version) must \
128 be one artifact. Re-deposit one of the layers against the other's bytes, or drop the \
129 duplicate from the composition."
130)]
131pub struct PayloadConflict {
132 pub name: String,
133 pub version: String,
134 pub first: String,
135 pub first_digest: String,
136 pub second: String,
137 pub second_digest: String,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct PayloadOrigin {
144 pub name: String,
145 pub version: String,
146 pub digest: String,
148 pub realm: String,
152 pub layer: String,
153}
154
155impl PayloadOrigin {
156 fn describe(&self) -> String {
159 format!("realm '{}' layer {}", self.realm, self.layer)
160 }
161}
162
163pub fn union_payloads<T>(
181 items: Vec<(PayloadOrigin, T)>,
182) -> Result<Vec<(PayloadOrigin, T)>, ComposeError> {
183 let mut first_seen: BTreeMap<(String, String), PayloadOrigin> = BTreeMap::new();
184 let mut out = Vec::new();
185 for (origin, payload) in items {
186 let key = (origin.name.clone(), origin.version.clone());
187 match first_seen.get(&key) {
188 Some(first) if first.digest != origin.digest => {
189 return Err(ComposeError::ConflictingPayload(Box::new(
190 PayloadConflict {
191 name: origin.name.clone(),
192 version: origin.version.clone(),
193 first: first.describe(),
194 first_digest: first.digest.clone(),
195 second: origin.describe(),
196 second_digest: origin.digest,
197 },
198 )));
199 }
200 Some(_) => continue,
202 None => {
203 first_seen.insert(key, origin.clone());
204 out.push((origin, payload));
205 }
206 }
207 }
208 Ok(out)
209}
210
211pub fn includes(v: &LayerView) -> Vec<Include> {
213 v.includes.clone()
214}
215
216pub fn walk<F>(
223 root_digest: &str,
224 root: &LayerView,
225 mut fetch: F,
226) -> Result<Vec<(String, LayerView)>, ComposeError>
227where
228 F: FnMut(&str) -> Option<LayerView>,
229{
230 let mut out = vec![(root_digest.to_string(), root.clone())];
231 let mut emitted: BTreeSet<String> = BTreeSet::new();
232 emitted.insert(root_digest.to_string());
233 let mut stack: Vec<(String, LayerView, BTreeSet<String>)> = vec![(
239 root_digest.to_string(),
240 root.clone(),
241 BTreeSet::from([root_digest.to_string()]),
242 )];
243 while let Some((from, view, path)) = stack.pop() {
244 if path.len() > MAX_DEPTH {
245 return Err(ComposeError::TooDeep);
246 }
247 for inc in includes(&view) {
248 if path.contains(&inc.digest) {
249 return Err(ComposeError::Cycle {
250 digest: inc.digest.clone(),
251 via: from.clone(),
252 });
253 }
254 let Some(child) = fetch(&inc.digest) else {
255 continue;
257 };
258 if emitted.insert(inc.digest.clone()) {
260 out.push((inc.digest.clone(), child.clone()));
261 }
262 let mut child_path = path.clone();
263 child_path.insert(inc.digest.clone());
264 stack.push((inc.digest.clone(), child, child_path));
265 }
266 }
267 Ok(out)
268}
269
270pub fn union_tools(
273 layers: &[(String, LayerView)],
274) -> Result<BTreeMap<String, String>, ComposeError> {
275 let mut owner: BTreeMap<String, String> = BTreeMap::new();
276 for (digest, v) in layers {
277 for tool in &v.tools {
278 if let Some(first) = owner.get(tool)
279 && first != digest
280 {
281 return Err(ComposeError::AmbiguousTool {
282 tool: tool.clone(),
283 first: first.clone(),
284 second: digest.clone(),
285 });
286 }
287 owner.insert(tool.clone(), digest.clone());
288 }
289 }
290 Ok(owner)
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 fn manifest(layer: &str, tools: &[&str], includes: &[(&str, &str)]) -> LayerView {
299 let mut entries: Vec<String> = tools
300 .iter()
301 .map(|t| {
302 format!(
303 r#"{{"digest":"sha256:{t}","annotations":{{"eu.pulseengine.tool":"{t}"}}}}"#
304 )
305 })
306 .collect();
307 for (digest, realm) in includes {
308 entries.push(format!(
309 r#"{{"digest":"{digest}","annotations":{{"eu.pulseengine.varve.kind":"layer","{ANN_INCLUDE_REALM}":"{realm}"}}}}"#
310 ));
311 }
312 let json = format!(
313 r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json",
314"artifactType":"application/vnd.pulseengine.varve.layer.v1+json",
315"annotations":{{"eu.pulseengine.varve.layer":"{layer}","eu.pulseengine.varve.channel":"qualified",
316"eu.pulseengine.varve.counter":"1","org.opencontainers.image.created":"2026-08-01T00:00:00Z"}},
317"manifests":[{}]}}"#,
318 entries.join(",")
319 );
320 let _ = layer;
321 view(json.as_bytes()).unwrap()
322 }
323
324 #[test]
326 fn a_composition_exposes_both_layers_tools() {
327 let upstream = manifest("2026.08.0", &["wasm-tools", "cargo-component"], &[]);
328 let root = manifest(
329 "2026.08.0",
330 &["rivet", "meld"],
331 &[("sha256:up", "bytecodealliance")],
332 );
333 let inc = includes(&root);
334 assert_eq!(inc.len(), 1);
335 assert_eq!(inc[0].digest, "sha256:up");
336 assert_eq!(inc[0].realm.as_deref(), Some("bytecodealliance"));
337
338 let layers = walk("sha256:root", &root, |d| {
339 (d == "sha256:up").then(|| upstream.clone())
340 })
341 .unwrap();
342 assert_eq!(layers.len(), 2, "root plus the included layer");
343 let tools = union_tools(&layers).unwrap();
344 for t in ["rivet", "meld", "wasm-tools", "cargo-component"] {
346 assert!(tools.contains_key(t), "{t} missing from the composition");
347 }
348 assert_eq!(tools["wasm-tools"], "sha256:up");
349 assert_eq!(tools["rivet"], "sha256:root");
350 }
351
352 #[test]
354 fn a_tool_in_two_layers_is_an_error_not_a_silent_choice() {
355 let upstream = manifest("2026.08.0", &["wasm-tools"], &[]);
357 let root = manifest(
358 "2026.08.0",
359 &["wasm-tools"],
360 &[("sha256:up", "bytecodealliance")],
361 );
362 let layers = walk("sha256:root", &root, |d| {
363 (d == "sha256:up").then(|| upstream.clone())
364 })
365 .unwrap();
366 match union_tools(&layers) {
367 Err(ComposeError::AmbiguousTool { tool, .. }) => assert_eq!(tool, "wasm-tools"),
368 other => panic!("expected AmbiguousTool, got {other:?}"),
369 }
370 }
371
372 #[test]
374 fn depth_is_bounded_so_a_long_chain_cannot_exhaust_the_walker() {
375 let leaf = manifest("2026.08.0", &["leaf"], &[]);
380 let chain: Vec<LayerView> = (0..=MAX_DEPTH + 2)
382 .map(|i| manifest("2026.08.0", &["t"], &[(&format!("sha256:{}", i + 1), "r")]))
383 .collect();
384 let err = walk("sha256:0", &chain[0], |d| {
385 let n: usize = d.trim_start_matches("sha256:").parse().ok()?;
386 chain.get(n).cloned().or_else(|| Some(leaf.clone()))
387 })
388 .unwrap_err();
389 assert!(matches!(err, ComposeError::TooDeep), "got {err:?}");
390 }
391
392 #[test]
394 fn a_diamond_is_walked_once_not_refused_as_a_cycle() {
395 let d = manifest("2026.08.0", &["base"], &[]);
400 let b = manifest("2026.08.0", &["b"], &[("sha256:d", "r")]);
401 let c = manifest("2026.08.0", &["c"], &[("sha256:d", "r")]);
402 let a = manifest("2026.08.0", &["a"], &[("sha256:b", "r"), ("sha256:c", "r")]);
403 let walked = walk("sha256:a", &a, |q| match q {
404 "sha256:b" => Some(b.clone()),
405 "sha256:c" => Some(c.clone()),
406 "sha256:d" => Some(d.clone()),
407 _ => None,
408 })
409 .unwrap();
410 assert_eq!(walked.len(), 4, "A, B, C and D each once: {walked:?}");
411 let tools = union_tools(&walked).unwrap();
413 assert_eq!(tools["base"], "sha256:d");
414 }
415
416 #[test]
418 fn a_cycle_is_refused_not_followed() {
419 let a = manifest("2026.08.0", &["x"], &[("sha256:b", "r")]);
421 let b = manifest("2026.08.0", &["y"], &[("sha256:a", "r")]);
422 let (ac, bc) = (a.clone(), b.clone());
423 let err = walk("sha256:a", &a, move |d| match d {
424 "sha256:b" => Some(bc.clone()),
425 "sha256:a" => Some(ac.clone()),
426 _ => None,
427 })
428 .unwrap_err();
429 assert!(matches!(err, ComposeError::Cycle { .. }), "got {err:?}");
430 }
431
432 #[test]
434 fn an_uninstalled_include_is_skipped_for_the_caller_to_report() {
435 let root = manifest("2026.08.0", &["rivet"], &[("sha256:missing", "other")]);
438 let layers = walk("sha256:root", &root, |_| None).unwrap();
439 assert_eq!(layers.len(), 1, "only the root resolved");
440 assert_eq!(
441 includes(&root).len(),
442 1,
443 "but the include is still declared"
444 );
445 }
446
447 fn offered(realm: &str, name: &str, version: &str, digest: &str) -> (PayloadOrigin, ()) {
449 (
450 PayloadOrigin {
451 name: name.into(),
452 version: version.into(),
453 digest: format!("sha256:{digest}"),
454 realm: realm.into(),
455 layer: "2026.08.0".into(),
456 },
457 (),
458 )
459 }
460
461 #[test]
463 fn two_versions_of_one_crate_both_export() {
464 let kept = union_payloads(vec![
469 offered("pulseengine", "serde", "1.0.200", "aa"),
470 offered("bytecodealliance", "serde", "1.0.210", "bb"),
471 ])
472 .unwrap();
473 assert_eq!(kept.len(), 2);
474 let mut vers: Vec<&str> = kept.iter().map(|(o, _)| o.version.as_str()).collect();
475 vers.sort();
476 assert_eq!(vers, ["1.0.200", "1.0.210"]);
477 }
478
479 #[test]
481 fn the_same_name_and_version_with_the_same_bytes_exports_once() {
482 let kept = union_payloads(vec![
486 offered("pulseengine", "cfg-if", "1.0.0", "aa"),
487 offered("bytecodealliance", "cfg-if", "1.0.0", "aa"),
488 ])
489 .unwrap();
490 assert_eq!(kept.len(), 1, "one copy of agreed bytes: {kept:?}");
491 assert_eq!(kept[0].0.realm, "pulseengine", "the first offer wins");
492 }
493
494 #[test]
496 fn the_same_name_and_version_with_different_bytes_names_both_realms() {
497 let err = union_payloads(vec![
502 offered("pulseengine", "cfg-if", "1.0.0", "aa"),
503 offered("bytecodealliance", "cfg-if", "1.0.0", "bb"),
504 ])
505 .unwrap_err();
506 let msg = err.to_string();
507 assert!(matches!(err, ComposeError::ConflictingPayload(_)), "{msg}");
508 assert!(msg.contains("cfg-if") && msg.contains("1.0.0"), "{msg}");
509 assert!(
510 msg.contains("pulseengine") && msg.contains("bytecodealliance"),
511 "both realms must be named: {msg}"
512 );
513 assert!(
514 msg.contains("sha256:aa") && msg.contains("sha256:bb"),
515 "both digests must be named: {msg}"
516 );
517 }
518
519 #[test]
521 fn a_layer_without_includes_composes_to_itself() {
522 let plain = manifest("2026.08.0", &["rivet", "meld"], &[]);
525 assert!(includes(&plain).is_empty());
526 let layers = walk("sha256:root", &plain, |_| None).unwrap();
527 assert_eq!(layers.len(), 1);
528 assert_eq!(union_tools(&layers).unwrap().len(), 2);
529 }
530}