1use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::install::VerifyError;
18use crate::layer::{LayerId, Line};
19use crate::verify::{dsse_sign_typed, dsse_verify_typed};
20
21pub const LINE_STATUS_PAYLOAD_TYPE: &str = "application/vnd.pulseengine.varve.line-status.v1+json";
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct KnownProblem {
28 pub id: String,
29 pub title: String,
30 pub severity: String,
31 pub affected: Vec<String>,
33 #[serde(skip_serializing_if = "Option::is_none", default)]
34 pub workaround: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none", default)]
36 pub detection: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none", default)]
38 pub mitigation: Option<String>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct LineStatus {
44 pub line: String,
46 pub counter: u64,
49 #[serde(rename = "issued-at")]
51 pub issued_at: String,
52 #[serde(
54 rename = "support-until",
55 skip_serializing_if = "Option::is_none",
56 default
57 )]
58 pub support_until: Option<String>,
59 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
61 pub yanked: BTreeMap<String, String>,
62 #[serde(
63 rename = "known-problems",
64 skip_serializing_if = "Vec::is_empty",
65 default
66 )]
67 pub known_problems: Vec<KnownProblem>,
68}
69
70#[derive(Debug, thiserror::Error)]
71pub enum LineStatusError {
72 #[error("line-status envelope rejected: {0}")]
78 Envelope(String),
79 #[error("cannot sign the line-status document: {0}")]
80 Sign(String),
81 #[error("line-status payload is not valid: {0}")]
82 Payload(String),
83 #[error("line-status covers line {got} but line {expected} was requested")]
84 LineMismatch { expected: String, got: String },
85 #[error(
86 "refusing stale line-status document for {line}: presented counter {presented}, cached {cached}"
87 )]
88 Stale {
89 line: String,
90 presented: u64,
91 cached: u64,
92 },
93 #[error(
98 "{what} names layer '{id}', which is not a layer of line {line} ({reason}) — `varve \
99 status` matches layer ids exactly, so this entry would never fire for any installed \
100 layer; fix the id and re-sign the document"
101 )]
102 DeadReference {
103 what: String,
104 id: String,
105 line: String,
106 reason: String,
107 },
108 #[error(
115 "{what} names layer '{id}', which line {line} does not contain — it exposes: {existing}. \
116 `varve status` matches layer ids EXACTLY, so this entry would fire for nobody: you \
117 would see success, every consumer would see nothing, and the advisory would silently \
118 not exist. Fix the id, or pass --force to pre-sign an advisory for a layer that is not \
119 deposited yet."
120 )]
121 UnknownLayer {
122 what: String,
123 id: String,
124 line: String,
125 existing: String,
126 },
127 #[error(
128 "{layout} is not an OCI image layout (it has no index.json) — point --layout at the \
129 directory `varve deposit --out` produced"
130 )]
131 NotALayout { layout: String },
132 #[error("io error at {path}")]
136 Io {
137 path: String,
138 #[source]
139 source: std::io::Error,
140 },
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum KnownLayers {
155 Known {
157 source: String,
159 line: Option<String>,
162 layers: Vec<String>,
163 },
164 Unknown { why: String },
166}
167
168impl KnownLayers {
169 pub fn from_index(index: &crate::lineindex::LineIndex) -> Self {
172 KnownLayers::Known {
173 source: format!(
174 "the signed line-index for {} (counter {})",
175 index.line, index.counter
176 ),
177 line: Some(index.line.clone()),
178 layers: index.layers.iter().map(|e| e.layer.clone()).collect(),
179 }
180 }
181
182 pub fn unknown(why: impl Into<String>) -> Self {
183 KnownLayers::Unknown { why: why.into() }
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct RefCheck {
191 pub existence_checked: bool,
194 pub note: String,
196}
197
198pub fn known_layers_from_index(
205 envelope: &[u8],
206 root_public_key: &[u8],
207) -> Result<KnownLayers, crate::lineindex::IndexError> {
208 let doc = crate::lineindex::LineIndex::verify_and_parse(envelope, root_public_key)?;
209 Ok(KnownLayers::from_index(&doc))
210}
211
212pub fn known_layers_in_layout_dirs(dirs: &[std::path::PathBuf], line: &str) -> KnownLayers {
227 let mut layers: Vec<String> = Vec::new();
228 let mut scanned = 0usize;
229 let visit = |dir: &Path, layers: &mut Vec<String>| {
230 let mut candidates: Vec<Vec<u8>> = Vec::new();
236 if let Ok(index) = std::fs::read(dir.join("index.json"))
237 && let Ok(idx) = serde_json::from_slice::<serde_json::Value>(&index)
238 {
239 for m in idx
240 .get("manifests")
241 .and_then(|m| m.as_array())
242 .into_iter()
243 .flatten()
244 {
245 if let Some(d) = m.get("digest").and_then(|d| d.as_str())
246 && let Some((_, hex)) = d.split_once(':')
247 && let Ok(b) = std::fs::read(dir.join("blobs").join("sha256").join(hex))
248 {
249 candidates.push(b);
250 }
251 }
252 }
253 if let Ok(entries) = std::fs::read_dir(dir.join("manifests")) {
254 for e in entries.filter_map(|e| e.ok()) {
255 if let Ok(b) = std::fs::read(e.path()) {
256 candidates.push(b);
257 }
258 }
259 }
260 if candidates.is_empty() {
261 return false;
262 }
263 for bytes in candidates {
264 let payload = std::str::from_utf8(&bytes)
269 .ok()
270 .and_then(|t| wsc::dsse::DsseEnvelope::from_json(t).ok())
271 .and_then(|env| env.payload_bytes().ok())
272 .unwrap_or_else(|| bytes.clone());
273 if let Ok(m) = crate::manifest::LayerManifest::parse(&payload) {
274 let id = m.layer.to_string();
275 if id.starts_with(&format!("{line}.")) && !layers.contains(&id) {
276 layers.push(id);
277 }
278 }
279 }
280 true
281 };
282 for dir in dirs {
283 if visit(dir, &mut layers) {
284 scanned += 1;
285 continue;
286 }
287 if let Ok(children) = std::fs::read_dir(dir) {
289 for c in children.filter_map(|e| e.ok()) {
290 if c.path().is_dir() && visit(&c.path(), &mut layers) {
291 scanned += 1;
292 }
293 }
294 }
295 }
296 if scanned == 0 {
297 return KnownLayers::unknown(format!(
298 "no oci-layout was found under {} — pass the directory `varve deposit --out` \
299 wrote, or one holding several of them",
300 dirs.iter()
301 .map(|d| d.display().to_string())
302 .collect::<Vec<_>>()
303 .join(", ")
304 ));
305 }
306 layers.sort();
307 KnownLayers::Known {
308 source: format!("{scanned} local layout(s) the producer holds"),
309 line: Some(line.to_string()),
310 layers,
311 }
312}
313
314pub fn known_layers_in_layout(layout: &Path, line: &str) -> KnownLayers {
321 match crate::lineindex::read_from_layout(layout, line) {
322 Ok(Some(envelope)) => match crate::lineindex::parse_unverified(&envelope) {
323 Ok(doc) if doc.line == line => KnownLayers::from_index(&doc),
324 Ok(doc) => KnownLayers::unknown(format!(
325 "the line-index this layout carries is for line {}, not {line}",
326 doc.line
327 )),
328 Err(e) => KnownLayers::unknown(format!(
329 "the line-index this layout carries could not be read ({e})"
330 )),
331 },
332 Ok(None) => KnownLayers::unknown(format!(
333 "this layout carries no signed line-index for {line}, and a layout holds ONE layer \
334 — it is not a listing of the line. Attach the index first (`varve attach-index`), \
335 or sign against it (`varve sign-status --index <envelope>`)"
336 )),
337 Err(e) => KnownLayers::unknown(format!("the layout's index.json could not be read ({e})")),
338 }
339}
340
341impl LineStatus {
342 pub fn verify_and_parse(
344 envelope: &[u8],
345 root_public_key: &[u8],
346 ) -> Result<Self, LineStatusError> {
347 if let Ok(text) = std::str::from_utf8(envelope)
352 && wsc::dsse::DsseEnvelope::from_json(text).is_err()
353 {
354 return Err(not_an_envelope(text));
355 }
356 let payload = dsse_verify_typed(envelope, LINE_STATUS_PAYLOAD_TYPE, root_public_key)
357 .map_err(|VerifyError(msg)| {
358 let hint = if msg.contains("does not verify") {
362 " (is the document signed by THIS realm's root? `varve pubkey <key>` \
363 prints the public half a signature verifies against)"
364 } else {
365 ""
366 };
367 LineStatusError::Envelope(format!("{msg}{hint}"))
368 })?;
369 serde_json::from_slice(&payload).map_err(|e| LineStatusError::Payload(e.to_string()))
370 }
371
372 pub fn sign(&self, secret_key: &[u8], key_id: &str) -> Result<String, LineStatusError> {
377 self.check_layer_refs()?;
378 let payload = serde_json::to_vec_pretty(self).expect("status serializes");
379 dsse_sign_typed(&payload, LINE_STATUS_PAYLOAD_TYPE, secret_key, key_id)
380 .map_err(|VerifyError(msg)| LineStatusError::Sign(msg))
381 }
382
383 pub fn sign_against(
388 &self,
389 known: &KnownLayers,
390 force: bool,
391 secret_key: &[u8],
392 key_id: &str,
393 ) -> Result<(String, RefCheck), LineStatusError> {
394 let check = self.check_layer_refs_against(known, force)?;
395 let payload = serde_json::to_vec_pretty(self).expect("status serializes");
396 let envelope = dsse_sign_typed(&payload, LINE_STATUS_PAYLOAD_TYPE, secret_key, key_id)
397 .map_err(|VerifyError(msg)| LineStatusError::Sign(msg))?;
398 Ok((envelope, check))
399 }
400
401 pub fn check_layer_refs_against(
414 &self,
415 known: &KnownLayers,
416 force: bool,
417 ) -> Result<RefCheck, LineStatusError> {
418 self.check_layer_refs()?;
419 let referenced = self.yanked.len()
428 + self
429 .known_problems
430 .iter()
431 .map(|p| p.affected.len())
432 .sum::<usize>();
433 let (source, layers) = match known {
434 KnownLayers::Unknown { .. } if referenced == 0 => {
435 return Ok(RefCheck {
436 existence_checked: true,
437 note: "this document names no layer — nothing to check against the line"
438 .to_string(),
439 });
440 }
441 KnownLayers::Unknown { why } => {
442 return Ok(RefCheck {
443 existence_checked: false,
444 note: format!(
445 "advisory references were checked for SHAPE only — NOT against the \
446 layers line {} actually has: {why}. An id naming a layer that does not \
447 exist still signs cleanly here and fires for nobody.",
448 self.line
449 ),
450 });
451 }
452 KnownLayers::Known {
453 source,
454 line,
455 layers,
456 } => {
457 if let Some(listing_line) = line
462 && listing_line != &self.line
463 {
464 return Err(LineStatusError::LineMismatch {
465 expected: self.line.clone(),
466 got: listing_line.clone(),
467 });
468 }
469 (source, layers)
470 }
471 };
472 if force {
473 return Ok(RefCheck {
474 existence_checked: false,
475 note: format!(
476 "--force: advisory references were NOT checked against the layers line {} \
477 has. An entry naming a layer that has not been deposited yet fires only \
478 once it is.",
479 self.line
480 ),
481 });
482 }
483 let existing = if layers.is_empty() {
484 "no layers at all — this line has none yet".to_string()
485 } else {
486 layers.join(", ")
487 };
488 let mut refs = 0usize;
489 let mut check = |what: String, id: &str| -> Result<(), LineStatusError> {
490 refs += 1;
491 if layers.iter().any(|l| l == id) {
492 return Ok(());
493 }
494 Err(LineStatusError::UnknownLayer {
495 what,
496 id: id.to_string(),
497 line: self.line.clone(),
498 existing: existing.clone(),
499 })
500 };
501 for id in self.yanked.keys() {
502 check("the yank entry".to_string(), id)?;
503 }
504 for kp in &self.known_problems {
505 for id in &kp.affected {
506 check(format!("known problem '{}'", kp.id), id)?;
507 }
508 }
509 Ok(RefCheck {
510 existence_checked: true,
511 note: format!(
512 "{refs} advisory reference{} checked against the {} layer{} {source} lists for \
513 line {}",
514 if refs == 1 { "" } else { "s" },
515 layers.len(),
516 if layers.len() == 1 { "" } else { "s" },
517 self.line
518 ),
519 })
520 }
521
522 pub fn check_layer_refs(&self) -> Result<(), LineStatusError> {
529 let line: Line = self.line.parse().map_err(|e| {
530 LineStatusError::Payload(format!("'{}' is not a YYYY.MM line: {e}", self.line))
531 })?;
532 let check = |what: String, id: &str| -> Result<(), LineStatusError> {
533 let dead = |reason: String| LineStatusError::DeadReference {
534 what: what.clone(),
535 id: id.to_string(),
536 line: self.line.clone(),
537 reason,
538 };
539 match id.parse::<LayerId>() {
540 Ok(layer) if layer.line() == &line => Ok(()),
541 Ok(layer) => Err(dead(format!("it belongs to line {}", layer.line()))),
542 Err(e) => Err(dead(e.to_string())),
543 }
544 };
545 for id in self.yanked.keys() {
546 check("the yank entry".to_string(), id)?;
547 }
548 for kp in &self.known_problems {
549 for id in &kp.affected {
550 check(format!("known problem '{}'", kp.id), id)?;
551 }
552 }
553 Ok(())
554 }
555
556 pub fn report_for(&self, layer: &LayerId) -> LayerStatusReport {
558 let name = layer.to_string();
559 let problems: Vec<&KnownProblem> = self
560 .known_problems
561 .iter()
562 .filter(|kp| kp.affected.iter().any(|a| a == &name))
563 .collect();
564 LayerStatusReport {
565 yanked_reason: self.yanked.get(&name).cloned(),
566 support_until: self.support_until.clone(),
567 problems_total: problems.len(),
568 problems_with_workaround: problems.iter().filter(|kp| kp.workaround.is_some()).count(),
569 }
570 }
571}
572
573#[derive(Debug, Clone, PartialEq, Eq)]
574pub struct LayerStatusReport {
575 pub yanked_reason: Option<String>,
576 pub support_until: Option<String>,
577 pub problems_total: usize,
578 pub problems_with_workaround: usize,
579}
580
581#[derive(Debug)]
583pub struct StatusCache {
584 dir: PathBuf,
585}
586
587impl StatusCache {
588 pub fn at_root(root: &Path) -> Self {
589 StatusCache {
590 dir: root.join("state").join("line-status"),
591 }
592 }
593
594 pub fn update(
596 &self,
597 line: &Line,
598 envelope: &[u8],
599 parsed: &LineStatus,
600 ) -> Result<(), LineStatusError> {
601 if let Some(cached) = self.load_parsed(line)?
602 && parsed.counter < cached.counter
603 {
604 return Err(LineStatusError::Stale {
605 line: line.to_string(),
606 presented: parsed.counter,
607 cached: cached.counter,
608 });
609 }
610 let io = |path: &Path, source: std::io::Error| LineStatusError::Io {
611 path: path.display().to_string(),
612 source,
613 };
614 std::fs::create_dir_all(&self.dir).map_err(|e| io(&self.dir, e))?;
615 let path = self.envelope_path(line);
616 std::fs::write(&path, envelope).map_err(|e| io(&path, e))?;
617 Ok(())
618 }
619
620 pub fn envelope_bytes(&self, line: &Line) -> Result<Option<Vec<u8>>, LineStatusError> {
627 let path = self.envelope_path(line);
628 match std::fs::read(&path) {
629 Ok(bytes) => Ok(Some(bytes)),
630 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
631 Err(source) => Err(LineStatusError::Io {
632 path: path.display().to_string(),
633 source,
634 }),
635 }
636 }
637
638 pub fn load(
640 &self,
641 line: &Line,
642 root_public_key: &[u8],
643 ) -> Result<Option<LineStatus>, LineStatusError> {
644 let path = self.envelope_path(line);
645 match std::fs::read(&path) {
646 Ok(bytes) => Ok(Some(LineStatus::verify_and_parse(&bytes, root_public_key)?)),
647 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
648 Err(source) => Err(LineStatusError::Io {
649 path: path.display().to_string(),
650 source,
651 }),
652 }
653 }
654
655 fn load_parsed(&self, line: &Line) -> Result<Option<LineStatus>, LineStatusError> {
656 let path = self.envelope_path(line);
657 match std::fs::read(&path) {
658 Ok(bytes) => {
659 let text = std::str::from_utf8(&bytes)
662 .map_err(|_| LineStatusError::Payload("cache is not UTF-8".into()))?;
663 let env = wsc::dsse::DsseEnvelope::from_json(text)
664 .map_err(|e| LineStatusError::Payload(e.to_string()))?;
665 let payload = env
666 .payload_bytes()
667 .map_err(|e| LineStatusError::Payload(e.to_string()))?;
668 Ok(Some(
669 serde_json::from_slice(&payload)
670 .map_err(|e| LineStatusError::Payload(e.to_string()))?,
671 ))
672 }
673 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
674 Err(source) => Err(LineStatusError::Io {
675 path: path.display().to_string(),
676 source,
677 }),
678 }
679 }
680
681 fn envelope_path(&self, line: &Line) -> PathBuf {
682 self.dir.join(format!("{line}.dsse.json"))
683 }
684}
685
686pub const LINE_STATUS_ARTIFACT_TYPE: &str = LINE_STATUS_PAYLOAD_TYPE;
688pub const ANN_LINE: &str = "eu.pulseengine.varve.status-line";
690
691pub fn attach_to_layout(
695 layout: &Path,
696 line: &Line,
697 envelope: &[u8],
698) -> Result<(), LineStatusError> {
699 let io = |path: &Path, source: std::io::Error| LineStatusError::Io {
700 path: path.display().to_string(),
701 source,
702 };
703 let digest = crate::store::manifest_digest(envelope);
704 let hex = digest.strip_prefix("sha256:").expect("digest shape");
705 let blob_dir = layout.join("blobs").join("sha256");
706 std::fs::create_dir_all(&blob_dir).map_err(|e| io(&blob_dir, e))?;
707 let blob_path = blob_dir.join(hex);
708 std::fs::write(&blob_path, envelope).map_err(|e| io(&blob_path, e))?;
709
710 let index_path = layout.join("index.json");
711 let mut index: serde_json::Value =
712 serde_json::from_slice(&std::fs::read(&index_path).map_err(|e| io(&index_path, e))?)
713 .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
714 let entries = index["manifests"]
715 .as_array_mut()
716 .ok_or_else(|| LineStatusError::Payload("index.json has no manifests array".into()))?;
717 let line_name = line.to_string();
718 entries.retain(|e| {
719 !(e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE
720 && e["annotations"][ANN_LINE] == *line_name)
721 });
722 entries.push(serde_json::json!({
723 "mediaType": "application/json",
724 "artifactType": LINE_STATUS_ARTIFACT_TYPE,
725 "digest": digest,
726 "size": envelope.len(),
727 "annotations": { ANN_LINE: line_name }
728 }));
729 std::fs::write(
730 &index_path,
731 serde_json::to_vec_pretty(&index).expect("index serializes"),
732 )
733 .map_err(|e| io(&index_path, e))?;
734 Ok(())
735}
736
737pub fn cache_baseline_from_source(
746 source: &dyn crate::source::LayerSource,
747 layer: &crate::source::LayerRef,
748 line: &Line,
749 root_pk: &[u8],
750 store_root: &Path,
751) -> Result<Option<u64>, LineStatusError> {
752 let envelope = match source
753 .fetch_line_status(layer)
754 .map_err(|e| LineStatusError::Payload(format!("fetching baseline line-status: {e}")))?
755 {
756 Some(bytes) => bytes,
757 None => return Ok(None),
758 };
759 let doc = LineStatus::verify_and_parse(&envelope, root_pk)?;
760 if doc.line != line.to_string() {
763 return Err(LineStatusError::LineMismatch {
764 expected: line.to_string(),
765 got: doc.line,
766 });
767 }
768 let counter = doc.counter;
769 StatusCache::at_root(store_root).update(line, &envelope, &doc)?;
770 Ok(Some(counter))
771}
772
773pub fn attach_envelope_to_layout(
779 layout: &Path,
780 envelope: &[u8],
781) -> Result<(Line, u64), LineStatusError> {
782 let (line, counter, _) = attach_envelope_to_layout_checked(layout, envelope, false)?;
783 Ok((line, counter))
784}
785
786pub fn attach_envelope_to_layout_checked(
789 layout: &Path,
790 envelope: &[u8],
791 force: bool,
792) -> Result<(Line, u64, RefCheck), LineStatusError> {
793 if !layout.join("index.json").is_file() {
797 return Err(LineStatusError::NotALayout {
798 layout: layout.display().to_string(),
799 });
800 }
801 let text = std::str::from_utf8(envelope)
802 .map_err(|e| LineStatusError::Payload(format!("envelope is not utf-8: {e}")))?;
803 let env = wsc::dsse::DsseEnvelope::from_json(text).map_err(|_| not_an_envelope(text))?;
804 let payload = env
805 .payload_bytes()
806 .map_err(|e| LineStatusError::Payload(format!("envelope payload: {e}")))?;
807 let doc: LineStatus = serde_json::from_slice(&payload)
808 .map_err(|e| LineStatusError::Payload(format!("status document: {e}")))?;
809 let line: Line = doc
810 .line
811 .parse()
812 .map_err(|e| LineStatusError::Payload(format!("status line '{}': {e}", doc.line)))?;
813 let check = doc.check_layer_refs_against(&known_layers_in_layout(layout, &doc.line), force)?;
820 if let Some(existing) = read_any_from_layout(layout)?
827 && let Ok(prev) = parse_unverified(&existing)
828 && prev.line == doc.line
829 && doc.counter < prev.counter
830 {
831 return Err(LineStatusError::Stale {
832 line: doc.line.clone(),
833 presented: doc.counter,
834 cached: prev.counter,
835 });
836 }
837 if let Some(layout_line) = layout_line(layout)
841 && layout_line != line.to_string()
842 {
843 return Err(LineStatusError::LineMismatch {
844 expected: layout_line,
845 got: line.to_string(),
846 });
847 }
848 attach_to_layout(layout, &line, envelope)?;
849 Ok((line, doc.counter, check))
850}
851
852fn not_an_envelope(text: &str) -> LineStatusError {
858 if serde_json::from_str::<LineStatus>(text).is_ok() {
859 LineStatusError::Payload(
860 "this is the UNSIGNED status document, not a signed envelope — sign it first \
861 (`varve sign-status --file <doc> --key <key> --out <envelope>`) and pass the \
862 envelope"
863 .into(),
864 )
865 } else {
866 LineStatusError::Payload(
867 "not a DSSE envelope — expected the signed output of `varve sign-status`".into(),
868 )
869 }
870}
871
872fn parse_unverified(envelope: &[u8]) -> Result<LineStatus, LineStatusError> {
876 let text = std::str::from_utf8(envelope)
877 .map_err(|e| LineStatusError::Payload(format!("envelope is not utf-8: {e}")))?;
878 let env = wsc::dsse::DsseEnvelope::from_json(text).map_err(|_| not_an_envelope(text))?;
879 let payload = env
880 .payload_bytes()
881 .map_err(|e| LineStatusError::Payload(format!("envelope payload: {e}")))?;
882 serde_json::from_slice(&payload)
883 .map_err(|e| LineStatusError::Payload(format!("status document: {e}")))
884}
885
886pub(crate) fn layout_line(layout: &Path) -> Option<String> {
890 let index: serde_json::Value =
891 serde_json::from_slice(&std::fs::read(layout.join("index.json")).ok()?).ok()?;
892 for m in index["manifests"].as_array()? {
893 let digest = m["digest"].as_str()?.replace(':', "-");
894 let blob = layout
895 .join("blobs")
896 .join("sha256")
897 .join(digest.trim_start_matches("sha256-"));
898 let Ok(bytes) = std::fs::read(&blob) else {
899 continue;
900 };
901 let Ok(text) = std::str::from_utf8(&bytes) else {
904 continue;
905 };
906 let Ok(env) = wsc::dsse::DsseEnvelope::from_json(text) else {
907 continue;
908 };
909 let Ok(payload) = env.payload_bytes() else {
910 continue;
911 };
912 let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&payload) else {
913 continue;
914 };
915 if let Some(line) = doc["annotations"]["eu.pulseengine.varve.line"].as_str() {
916 return Some(line.to_string());
917 }
918 }
919 None
920}
921
922pub fn read_any_from_layout(layout: &Path) -> Result<Option<Vec<u8>>, LineStatusError> {
927 let index_path = layout.join("index.json");
928 let bytes = match std::fs::read(&index_path) {
929 Ok(bytes) => bytes,
930 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
931 Err(source) => {
932 return Err(LineStatusError::Io {
933 path: index_path.display().to_string(),
934 source,
935 });
936 }
937 };
938 let index: serde_json::Value = serde_json::from_slice(&bytes)
939 .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
940 let Some(entry) = index["manifests"].as_array().and_then(|entries| {
941 entries
942 .iter()
943 .find(|e| e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE)
944 }) else {
945 return Ok(None);
946 };
947 let digest = entry["digest"]
948 .as_str()
949 .ok_or_else(|| LineStatusError::Payload("status entry has no digest".into()))?;
950 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
951 let blob_path = layout.join("blobs").join("sha256").join(hex);
952 std::fs::read(&blob_path)
953 .map(Some)
954 .map_err(|source| LineStatusError::Io {
955 path: blob_path.display().to_string(),
956 source,
957 })
958}
959
960pub fn read_from_layout(layout: &Path, line: &Line) -> Result<Option<Vec<u8>>, LineStatusError> {
962 let index_path = layout.join("index.json");
963 let bytes = match std::fs::read(&index_path) {
964 Ok(bytes) => bytes,
965 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
966 Err(source) => {
967 return Err(LineStatusError::Io {
968 path: index_path.display().to_string(),
969 source,
970 });
971 }
972 };
973 let index: serde_json::Value = serde_json::from_slice(&bytes)
974 .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
975 let line_name = line.to_string();
976 let Some(entry) = index["manifests"].as_array().and_then(|entries| {
977 entries.iter().find(|e| {
978 e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE
979 && e["annotations"][ANN_LINE] == *line_name
980 })
981 }) else {
982 return Ok(None);
983 };
984 let digest = entry["digest"]
985 .as_str()
986 .ok_or_else(|| LineStatusError::Payload("status entry has no digest".into()))?;
987 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
988 let blob_path = layout.join("blobs").join("sha256").join(hex);
989 std::fs::read(&blob_path)
990 .map(Some)
991 .map_err(|source| LineStatusError::Io {
992 path: blob_path.display().to_string(),
993 source,
994 })
995}
996
997#[cfg(test)]
998mod tests {
999 use super::*;
1000 use crate::verify::generate_root_keypair;
1001
1002 fn status(counter: u64) -> LineStatus {
1003 LineStatus {
1004 line: "2026.07".into(),
1005 counter,
1006 issued_at: "2026-08-07T00:00:00Z".into(),
1007 support_until: Some("2028-07-31".into()),
1008 yanked: BTreeMap::from([(
1009 "2026.07.0".to_string(),
1010 "CVE-2026-0001 in synth".to_string(),
1011 )]),
1012 known_problems: vec![
1013 KnownProblem {
1014 id: "KP-1".into(),
1015 title: "synth mla fusion regresses flat_flight".into(),
1016 severity: "medium".into(),
1017 affected: vec!["2026.07.0".into()],
1018 workaround: Some("disable mla fusion".into()),
1019 detection: None,
1020 mitigation: None,
1021 },
1022 KnownProblem {
1023 id: "KP-2".into(),
1024 title: "witness truth-table gap on nested variants".into(),
1025 severity: "high".into(),
1026 affected: vec!["2026.07.0".into(), "2026.07.1".into()],
1027 workaround: None,
1028 detection: Some("witness gap rows non-empty".into()),
1029 mitigation: None,
1030 },
1031 ],
1032 }
1033 }
1034
1035 #[test]
1037 fn a_signed_status_document_round_trips() {
1038 let (sk, pk) = generate_root_keypair();
1039 let envelope = status(1).sign(&sk, "varve-root-1").unwrap();
1040 let parsed = LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap();
1041 assert_eq!(parsed, status(1));
1042 }
1043
1044 #[test]
1046 fn a_layer_manifest_envelope_cannot_pose_as_a_status_document() {
1047 let (sk, pk) = generate_root_keypair();
1048 let manifest = crate::manifest::fixtures::manifest(
1050 "2026.07.0",
1051 "qualified",
1052 1,
1053 "2026-08-07T00:00:00Z",
1054 );
1055 let envelope = crate::verify::sign_layer_manifest(&manifest, &sk, "varve-root-1").unwrap();
1056 let err = LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap_err();
1057 assert!(err.to_string().contains("payload type"), "got: {err}");
1058 }
1059
1060 #[test]
1062 fn the_report_names_yank_support_window_and_problem_counts() {
1063 let doc = status(1);
1064 let report = doc.report_for(&"2026.07.0".parse().unwrap());
1065 assert_eq!(
1066 report.yanked_reason.as_deref(),
1067 Some("CVE-2026-0001 in synth")
1068 );
1069 assert_eq!(report.support_until.as_deref(), Some("2028-07-31"));
1070 assert_eq!(report.problems_total, 2);
1071 assert_eq!(report.problems_with_workaround, 1);
1072 let clean = doc.report_for(&"2026.07.2".parse().unwrap());
1073 assert_eq!(clean.yanked_reason, None);
1074 assert_eq!(clean.problems_total, 0);
1075 }
1076
1077 #[test]
1079 fn attaching_status_to_a_layout_leaves_every_layer_blob_untouched() {
1080 use crate::deposit::{DepositSpec, DepositTool, deposit};
1081 let (sk, pk) = generate_root_keypair();
1082 let tmp = tempfile::tempdir().unwrap();
1083 let dest = tmp.path().join("layout");
1084 let spec = DepositSpec {
1085 includes: Vec::new(),
1086 layer: "2026.07.0".parse().unwrap(),
1087 channel: "qualified".into(),
1088 counter: 1,
1089 issued_at: "2026-08-07T00:00:00Z".into(),
1090 tools: vec![DepositTool {
1091 name: "synth".into(),
1092 version: "1".into(),
1093 platform: None,
1094 bytes: b"t".to_vec(),
1095 source: None,
1096 runner: None,
1097 kind: None,
1098 sdk_prefix: None,
1099 }],
1100 };
1101 let outcome = deposit(&spec, &sk, "k", &dest).unwrap();
1102
1103 let blob_dir = dest.join("blobs/sha256");
1105 let before: std::collections::BTreeMap<String, Vec<u8>> = std::fs::read_dir(&blob_dir)
1106 .unwrap()
1107 .map(|e| {
1108 let p = e.unwrap().path();
1109 (
1110 p.file_name().unwrap().to_string_lossy().into_owned(),
1111 std::fs::read(&p).unwrap(),
1112 )
1113 })
1114 .collect();
1115
1116 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1117 let envelope = status(1).sign(&sk, "k").unwrap();
1118 attach_to_layout(&dest, &line, envelope.as_bytes()).unwrap();
1119
1120 for (name, bytes) in &before {
1123 assert_eq!(&std::fs::read(blob_dir.join(name)).unwrap(), bytes);
1124 }
1125 let carried = read_from_layout(&dest, &line).unwrap().unwrap();
1126 let parsed = LineStatus::verify_and_parse(&carried, &pk).unwrap();
1127 assert_eq!(parsed.counter, 1);
1128 let hex = outcome.digest.strip_prefix("sha256:").unwrap();
1129 assert!(
1130 blob_dir.join(hex).is_file(),
1131 "layer manifest blob still present"
1132 );
1133
1134 let envelope2 = status(2).sign(&sk, "k").unwrap();
1136 attach_to_layout(&dest, &line, envelope2.as_bytes()).unwrap();
1137 let index: serde_json::Value =
1138 serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
1139 let count = index["manifests"]
1140 .as_array()
1141 .unwrap()
1142 .iter()
1143 .filter(|e| e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE)
1144 .count();
1145 assert_eq!(count, 1);
1146 }
1147
1148 #[test]
1150 fn the_cache_refuses_a_counter_regression() {
1151 let (sk, pk) = generate_root_keypair();
1152 let tmp = tempfile::tempdir().unwrap();
1153 let cache = StatusCache::at_root(tmp.path());
1154 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1155
1156 let newer = status(2);
1157 let env2 = newer.sign(&sk, "k").unwrap();
1158 cache.update(&line, env2.as_bytes(), &newer).unwrap();
1159
1160 let older = status(1);
1161 let env1 = older.sign(&sk, "k").unwrap();
1162 let err = cache.update(&line, env1.as_bytes(), &older).unwrap_err();
1163 assert!(matches!(
1164 err,
1165 LineStatusError::Stale {
1166 presented: 1,
1167 cached: 2,
1168 ..
1169 }
1170 ));
1171
1172 let loaded = cache.load(&line, &pk).unwrap().unwrap();
1174 assert_eq!(loaded.counter, 2);
1175 }
1176
1177 #[test]
1179 fn a_source_baseline_is_verified_and_cached_so_status_works_offline() {
1180 use crate::source::{LayerRef, MemorySource};
1181 let (sk, pk) = generate_root_keypair();
1182 let tmp = tempfile::tempdir().unwrap();
1183 let store_root = tmp.path();
1184 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1185 let doc = status(5);
1186 let envelope = doc.sign(&sk, "k").unwrap();
1187 let source = MemorySource::new().with_line_status(envelope.as_bytes());
1188 let layer = LayerRef::Name("2026.07.0".parse().unwrap());
1189
1190 let cached = cache_baseline_from_source(&source, &layer, &line, &pk, store_root).unwrap();
1191 assert_eq!(
1192 cached,
1193 Some(5),
1194 "a carried baseline is cached at its counter"
1195 );
1196
1197 let loaded = StatusCache::at_root(store_root)
1199 .load(&line, &pk)
1200 .unwrap()
1201 .unwrap();
1202 assert_eq!(loaded.counter, 5);
1203 }
1204
1205 #[test]
1207 fn a_baseline_for_the_wrong_line_is_refused_not_miscached() {
1208 use crate::source::{LayerRef, MemorySource};
1213 let (sk, pk) = generate_root_keypair();
1214 let tmp = tempfile::tempdir().unwrap();
1215 let requested: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1216 let doc = LineStatus {
1219 line: "2026.08".into(),
1220 counter: 5,
1221 issued_at: "2026-08-07T00:00:00Z".into(),
1222 support_until: None,
1223 yanked: BTreeMap::new(),
1224 known_problems: Vec::new(),
1225 };
1226 let envelope = doc.sign(&sk, "k").unwrap();
1227 let source = MemorySource::new().with_line_status(envelope.as_bytes());
1228 let err = cache_baseline_from_source(
1229 &source,
1230 &LayerRef::Name("2026.07.0".parse().unwrap()),
1231 &requested,
1232 &pk,
1233 tmp.path(),
1234 )
1235 .unwrap_err();
1236 assert!(
1237 matches!(err, LineStatusError::LineMismatch { .. }),
1238 "a baseline for the wrong line must be refused: {err}"
1239 );
1240 assert!(
1241 StatusCache::at_root(tmp.path())
1242 .load(&requested, &pk)
1243 .unwrap()
1244 .is_none(),
1245 "nothing is cached under the requested line"
1246 );
1247 }
1248
1249 #[test]
1251 fn a_source_with_no_baseline_caches_nothing_and_does_not_error() {
1252 use crate::source::{LayerRef, MemorySource};
1253 let (_sk, pk) = generate_root_keypair();
1254 let tmp = tempfile::tempdir().unwrap();
1255 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1256 let source = MemorySource::new();
1257 let cached = cache_baseline_from_source(
1258 &source,
1259 &LayerRef::Name("2026.07.0".parse().unwrap()),
1260 &line,
1261 &pk,
1262 tmp.path(),
1263 )
1264 .unwrap();
1265 assert_eq!(cached, None);
1266 }
1267
1268 #[test]
1270 fn a_baseline_signed_by_an_impostor_is_refused_not_cached() {
1271 use crate::source::{LayerRef, MemorySource};
1272 let (attacker_sk, _) = generate_root_keypair();
1273 let (_real_sk, real_pk) = generate_root_keypair();
1274 let tmp = tempfile::tempdir().unwrap();
1275 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1276 let envelope = status(5).sign(&attacker_sk, "k").unwrap();
1277 let source = MemorySource::new().with_line_status(envelope.as_bytes());
1278 let err = cache_baseline_from_source(
1279 &source,
1280 &LayerRef::Name("2026.07.0".parse().unwrap()),
1281 &line,
1282 &real_pk,
1283 tmp.path(),
1284 )
1285 .unwrap_err();
1286 assert!(
1288 StatusCache::at_root(tmp.path())
1289 .load(&line, &real_pk)
1290 .unwrap()
1291 .is_none(),
1292 "a baseline that fails verification must not be cached: {err}"
1293 );
1294 }
1295
1296 #[test]
1298 fn attaching_by_envelope_derives_the_line_from_the_document() {
1299 use crate::deposit::{DepositSpec, DepositTool, deposit};
1300 let (sk, pk) = generate_root_keypair();
1301 let tmp = tempfile::tempdir().unwrap();
1302 let dest = tmp.path().join("layout");
1303 deposit(
1304 &DepositSpec {
1305 includes: Vec::new(),
1306 layer: "2026.07.0".parse().unwrap(),
1307 channel: "qualified".into(),
1308 counter: 1,
1309 issued_at: "2026-08-07T00:00:00Z".into(),
1310 tools: vec![DepositTool {
1311 name: "synth".into(),
1312 version: "1".into(),
1313 platform: None,
1314 bytes: b"t".to_vec(),
1315 source: None,
1316 runner: None,
1317 kind: None,
1318 sdk_prefix: None,
1319 }],
1320 },
1321 &sk,
1322 "k",
1323 &dest,
1324 )
1325 .unwrap();
1326
1327 let envelope = status(4).sign(&sk, "k").unwrap();
1328 let (line, counter) = attach_envelope_to_layout(&dest, envelope.as_bytes()).unwrap();
1329 assert_eq!(line.to_string(), "2026.07");
1330 assert_eq!(counter, 4);
1331 let carried = read_any_from_layout(&dest).unwrap().unwrap();
1333 assert_eq!(
1334 LineStatus::verify_and_parse(&carried, &pk).unwrap().counter,
1335 4
1336 );
1337 }
1338
1339 #[test]
1341 fn attaching_a_stale_document_over_a_newer_one_is_refused() {
1342 use crate::deposit::{DepositSpec, DepositTool, deposit};
1350 let (sk, _pk) = generate_root_keypair();
1351 let tmp = tempfile::tempdir().unwrap();
1352 let dest = tmp.path().join("layout");
1353 deposit(
1354 &DepositSpec {
1355 includes: Vec::new(),
1356 layer: "2026.07.0".parse().unwrap(),
1357 channel: "qualified".into(),
1358 counter: 1,
1359 issued_at: "2026-08-07T00:00:00Z".into(),
1360 tools: vec![DepositTool {
1361 name: "synth".into(),
1362 version: "1".into(),
1363 platform: None,
1364 bytes: b"t".to_vec(),
1365 source: None,
1366 runner: None,
1367 kind: None,
1368 sdk_prefix: None,
1369 }],
1370 },
1371 &sk,
1372 "k",
1373 &dest,
1374 )
1375 .unwrap();
1376
1377 attach_envelope_to_layout(&dest, status(7).sign(&sk, "k").unwrap().as_bytes()).unwrap();
1379 let err = attach_envelope_to_layout(&dest, status(3).sign(&sk, "k").unwrap().as_bytes())
1381 .unwrap_err();
1382 assert!(
1383 matches!(
1384 err,
1385 LineStatusError::Stale {
1386 presented: 3,
1387 cached: 7,
1388 ..
1389 }
1390 ),
1391 "a lower counter must be refused, got {err}"
1392 );
1393 let msg = err.to_string();
1394 assert!(msg.contains('3') && msg.contains('7'), "names both: {msg}");
1395 let carried = parse_unverified(&read_any_from_layout(&dest).unwrap().unwrap()).unwrap();
1397 assert_eq!(
1398 carried.counter, 7,
1399 "the newer baseline survives the attempt"
1400 );
1401 attach_envelope_to_layout(&dest, status(7).sign(&sk, "k").unwrap().as_bytes()).unwrap();
1404 }
1405
1406 #[test]
1408 fn an_advisory_that_could_never_fire_is_refused_at_sign_time() {
1409 let (sk, _pk) = generate_root_keypair();
1414 let cases: &[(&str, &str)] = &[
1415 ("2026.7.0", "not a valid YYYY.MM.P id"), ("2026.07", "missing its patch component"), ("2026.08.0", "belongs to another line"), ("2026.07.O", "letter O for zero"),
1419 ];
1420 for (bad, why) in cases {
1421 let mut doc = status(1);
1422 doc.known_problems[0].affected = vec![bad.to_string()];
1423 let err = doc.sign(&sk, "k").unwrap_err();
1424 assert!(
1425 matches!(err, LineStatusError::DeadReference { .. }),
1426 "{why}: affected id {bad:?} must be refused, got: {err}"
1427 );
1428 let msg = err.to_string();
1429 assert!(
1430 msg.contains(bad) && msg.contains("2026.07") && msg.contains("re-sign"),
1431 "the error must name the id, the line, and the fix: {msg}"
1432 );
1433 }
1434 let mut doc = status(1);
1436 doc.yanked = BTreeMap::from([("2026.8.0".to_string(), "CVE".to_string())]);
1437 assert!(matches!(
1438 doc.sign(&sk, "k").unwrap_err(),
1439 LineStatusError::DeadReference { .. }
1440 ));
1441 status(1).sign(&sk, "k").unwrap();
1444 }
1445
1446 #[test]
1448 fn attach_refuses_a_pre_signed_advisory_that_could_never_fire() {
1449 use crate::deposit::{DepositSpec, DepositTool, deposit};
1454 let (sk, _pk) = generate_root_keypair();
1455 let tmp = tempfile::tempdir().unwrap();
1456 let dest = tmp.path().join("layout");
1457 deposit(
1458 &DepositSpec {
1459 includes: Vec::new(),
1460 layer: "2026.07.0".parse().unwrap(),
1461 channel: "qualified".into(),
1462 counter: 1,
1463 issued_at: "2026-08-07T00:00:00Z".into(),
1464 tools: vec![DepositTool {
1465 name: "synth".into(),
1466 version: "1".into(),
1467 platform: None,
1468 bytes: b"t".to_vec(),
1469 source: None,
1470 runner: None,
1471 kind: None,
1472 sdk_prefix: None,
1473 }],
1474 },
1475 &sk,
1476 "k",
1477 &dest,
1478 )
1479 .unwrap();
1480 let mut doc = status(1);
1481 doc.known_problems[0].affected = vec!["2026.7.0".to_string()];
1482 let payload = serde_json::to_vec_pretty(&doc).unwrap();
1483 let envelope = dsse_sign_typed(&payload, LINE_STATUS_PAYLOAD_TYPE, &sk, "k").unwrap();
1484 let err = attach_envelope_to_layout(&dest, envelope.as_bytes()).unwrap_err();
1485 assert!(
1486 matches!(err, LineStatusError::DeadReference { .. }),
1487 "got: {err}"
1488 );
1489 assert!(
1490 read_any_from_layout(&dest).unwrap().is_none(),
1491 "the dead advisory must not land in the layout"
1492 );
1493 }
1494
1495 #[test]
1497 fn attaching_to_a_directory_that_is_not_a_layout_is_refused_before_writing() {
1498 let (sk, _pk) = generate_root_keypair();
1503 let tmp = tempfile::tempdir().unwrap();
1504 let not_a_layout = tmp.path().join("somedir");
1505 std::fs::create_dir_all(¬_a_layout).unwrap();
1506 let envelope = status(1).sign(&sk, "k").unwrap();
1507 let err = attach_envelope_to_layout(¬_a_layout, envelope.as_bytes()).unwrap_err();
1508 assert!(
1509 matches!(err, LineStatusError::NotALayout { .. }),
1510 "got: {err}"
1511 );
1512 assert!(
1513 err.to_string().contains("varve deposit"),
1514 "the error must carry its fix: {err}"
1515 );
1516 assert!(
1517 !not_a_layout.join("blobs").exists(),
1518 "nothing may be written into a directory that is not a layout"
1519 );
1520 }
1521
1522 #[test]
1524 fn the_unsigned_document_mistake_is_named_not_wrapped() {
1525 let raw = serde_json::to_string_pretty(&status(1)).unwrap();
1529 let err = not_an_envelope(&raw);
1530 let msg = err.to_string();
1531 assert!(
1532 msg.contains("UNSIGNED") && msg.contains("varve sign-status"),
1533 "raw document must be diagnosed with its fix: {msg}"
1534 );
1535 let msg = not_an_envelope("garbage").to_string();
1537 assert!(
1538 msg.contains("not a DSSE envelope") && msg.contains("varve sign-status"),
1539 "got: {msg}"
1540 );
1541 let (_sk, pk) = generate_root_keypair();
1543 let err = LineStatus::verify_and_parse(raw.as_bytes(), &pk).unwrap_err();
1544 assert!(err.to_string().contains("UNSIGNED"), "got: {err}");
1545 }
1546
1547 #[test]
1549 fn a_deposit_layouts_baseline_is_readable_without_naming_the_line() {
1550 use crate::deposit::{DepositSpec, DepositTool, deposit};
1554 let (sk, pk) = generate_root_keypair();
1555 let tmp = tempfile::tempdir().unwrap();
1556 let dest = tmp.path().join("layout");
1557 let spec = DepositSpec {
1558 includes: Vec::new(),
1559 layer: "2026.07.0".parse().unwrap(),
1560 channel: "qualified".into(),
1561 counter: 1,
1562 issued_at: "2026-08-07T00:00:00Z".into(),
1563 tools: vec![DepositTool {
1564 name: "synth".into(),
1565 version: "1".into(),
1566 platform: None,
1567 bytes: b"t".to_vec(),
1568 source: None,
1569 runner: None,
1570 kind: None,
1571 sdk_prefix: None,
1572 }],
1573 };
1574 deposit(&spec, &sk, "k", &dest).unwrap();
1575
1576 assert!(read_any_from_layout(&dest).unwrap().is_none());
1578
1579 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1580 let envelope = status(3).sign(&sk, "k").unwrap();
1581 attach_to_layout(&dest, &line, envelope.as_bytes()).unwrap();
1582
1583 let carried = read_any_from_layout(&dest).unwrap().unwrap();
1584 let parsed = LineStatus::verify_and_parse(&carried, &pk).unwrap();
1585 assert_eq!(parsed.counter, 3);
1586 }
1587
1588 fn listing(layers: &[&str]) -> KnownLayers {
1593 KnownLayers::Known {
1594 source: "the signed line-index for 2026.07 (counter 1)".into(),
1595 line: Some("2026.07".into()),
1596 layers: layers.iter().map(|s| s.to_string()).collect(),
1597 }
1598 }
1599
1600 #[test]
1602 fn an_affected_id_naming_no_existing_layer_is_refused_and_the_verdict_lists_what_does_exist() {
1603 let mut doc = status(1);
1608 doc.yanked.clear();
1609 doc.known_problems = vec![KnownProblem {
1610 id: "KP-1".into(),
1611 title: "t".into(),
1612 severity: "high".into(),
1613 affected: vec!["2026.07.10".into()],
1614 workaround: None,
1615 detection: None,
1616 mitigation: None,
1617 }];
1618 let err = doc
1619 .check_layer_refs_against(&listing(&["2026.07.0", "2026.07.1"]), false)
1620 .expect_err("an advisory that can never fire must be refused");
1621 assert!(
1622 matches!(&err, LineStatusError::UnknownLayer { id, .. } if id == "2026.07.10"),
1623 "got: {err}"
1624 );
1625 let msg = err.to_string();
1626 assert!(msg.contains("KP-1"), "name the entry at fault: {msg}");
1627 assert!(
1631 msg.contains("2026.07.0") && msg.contains("2026.07.1"),
1632 "the refusal must list the ids that exist: {msg}"
1633 );
1634 assert!(msg.contains("--force"), "{msg}");
1635 }
1636
1637 #[test]
1639 fn a_yank_key_is_checked_against_existing_layers_too_not_only_affected() {
1640 let mut doc = status(1);
1644 doc.known_problems.clear();
1645 doc.yanked = BTreeMap::from([("2026.07.9".to_string(), "CVE".to_string())]);
1646 let err = doc
1647 .check_layer_refs_against(&listing(&["2026.07.0"]), false)
1648 .expect_err("a yank naming no layer must be refused");
1649 assert!(
1650 matches!(&err, LineStatusError::UnknownLayer { what, id, .. }
1651 if what.contains("yank") && id == "2026.07.9"),
1652 "got: {err}"
1653 );
1654 }
1655
1656 #[test]
1658 fn a_document_whose_ids_all_exist_passes_and_says_what_was_checked() {
1659 let check = status(1)
1662 .check_layer_refs_against(&listing(&["2026.07.0", "2026.07.1"]), false)
1663 .expect("every id in the fixture exists on the line");
1664 assert!(check.existence_checked);
1665 assert!(
1666 check.note.contains("checked against") && check.note.contains("2 layers"),
1667 "the note must state the check that RAN: {}",
1668 check.note
1669 );
1670 }
1671
1672 #[test]
1674 fn where_the_line_is_not_visible_the_answer_says_which_check_was_not_run() {
1675 let check = status(1)
1680 .check_layer_refs_against(&KnownLayers::unknown("no line-index was supplied"), false)
1681 .unwrap();
1682 assert!(
1683 !check.existence_checked,
1684 "an unchecked document must not report itself as checked"
1685 );
1686 assert!(
1687 check.note.contains("NOT") && check.note.contains("no line-index was supplied"),
1688 "the note must name the check that did NOT run, and why: {}",
1689 check.note
1690 );
1691 }
1692
1693 #[test]
1695 fn force_allows_a_layer_not_deposited_yet_but_never_a_malformed_id() {
1696 let mut doc = status(1);
1701 doc.yanked.clear();
1702 doc.known_problems = vec![KnownProblem {
1703 id: "KP-1".into(),
1704 title: "t".into(),
1705 severity: "high".into(),
1706 affected: vec!["2026.07.9".into()],
1707 workaround: None,
1708 detection: None,
1709 mitigation: None,
1710 }];
1711 let check = doc
1712 .check_layer_refs_against(&listing(&["2026.07.0"]), true)
1713 .expect("--force pre-signs for a layer not deposited yet");
1714 assert!(
1715 !check.existence_checked,
1716 "forcing must not report the check as having run"
1717 );
1718 assert!(check.note.contains("--force"), "{}", check.note);
1719
1720 for id in ["2026.07", "twenty-twenty-six", "2026.08.0"] {
1723 doc.known_problems[0].affected = vec![id.to_string()];
1724 match doc.check_layer_refs_against(&listing(&["2026.07.0"]), true) {
1725 Err(LineStatusError::DeadReference { .. }) => {}
1726 other => panic!("'{id}' must be refused even under --force, got: {other:?}"),
1727 }
1728 }
1729 }
1730
1731 #[test]
1733 fn a_listing_for_another_line_is_refused_rather_than_used() {
1734 let wrong = KnownLayers::Known {
1738 source: "the signed line-index for 2026.08".into(),
1739 line: Some("2026.08".into()),
1740 layers: vec!["2026.08.0".into()],
1741 };
1742 let err = status(1)
1743 .check_layer_refs_against(&wrong, false)
1744 .expect_err("a listing for another line must not be used as this line's");
1745 assert!(
1746 matches!(&err, LineStatusError::LineMismatch { expected, got }
1747 if expected == "2026.07" && got == "2026.08"),
1748 "got: {err}"
1749 );
1750 }
1751
1752 #[test]
1754 fn a_layout_becomes_a_listing_only_once_the_signed_index_is_attached() {
1755 use crate::deposit::{DepositSpec, DepositTool, deposit};
1760 let (sk, _pk) = generate_root_keypair();
1761 let tmp = tempfile::tempdir().unwrap();
1762 let dest = tmp.path().join("layout");
1763 deposit(
1764 &DepositSpec {
1765 includes: Vec::new(),
1766 layer: "2026.07.0".parse().unwrap(),
1767 channel: "qualified".into(),
1768 counter: 1,
1769 issued_at: "2026-08-07T00:00:00Z".into(),
1770 tools: vec![DepositTool {
1771 name: "synth".into(),
1772 version: "1".into(),
1773 platform: None,
1774 bytes: b"t".to_vec(),
1775 source: None,
1776 runner: None,
1777 kind: None,
1778 sdk_prefix: None,
1779 }],
1780 },
1781 &sk,
1782 "k",
1783 &dest,
1784 )
1785 .unwrap();
1786
1787 let known = known_layers_in_layout(&dest, "2026.07");
1789 assert!(
1790 matches!(&known, KnownLayers::Unknown { why } if why.contains("not a listing")),
1791 "got: {known:?}"
1792 );
1793
1794 let index = crate::lineindex::LineIndex {
1795 line: "2026.07".into(),
1796 counter: 1,
1797 issued_at: "2026-08-07T00:00:00Z".into(),
1798 layers: vec![crate::lineindex::IndexedLayer {
1799 layer: "2026.07.0".into(),
1800 digest: "sha256:aa".into(),
1801 channel: "qualified".into(),
1802 counter: 1,
1803 }],
1804 };
1805 crate::lineindex::attach_to_layout(
1806 &dest,
1807 "2026.07",
1808 index.sign(&sk, "k").unwrap().as_bytes(),
1809 )
1810 .unwrap();
1811
1812 let known = known_layers_in_layout(&dest, "2026.07");
1813 assert_eq!(
1814 known,
1815 KnownLayers::Known {
1816 source: "the signed line-index for 2026.07 (counter 1)".into(),
1817 line: Some("2026.07".into()),
1818 layers: vec!["2026.07.0".into()],
1819 }
1820 );
1821
1822 let mut doc = status(2);
1826 doc.yanked.clear();
1827 doc.known_problems = vec![KnownProblem {
1828 id: "KP-1".into(),
1829 title: "t".into(),
1830 severity: "high".into(),
1831 affected: vec!["2026.07.1".into()],
1832 workaround: None,
1833 detection: None,
1834 mitigation: None,
1835 }];
1836 let err = attach_envelope_to_layout(&dest, doc.sign(&sk, "k").unwrap().as_bytes())
1837 .expect_err("2026.07.1 is not on this line's index");
1838 assert!(
1839 matches!(&err, LineStatusError::UnknownLayer { .. }),
1840 "got: {err}"
1841 );
1842
1843 doc.known_problems[0].affected = vec!["2026.07.0".into()];
1844 let (_line, counter, check) =
1845 attach_envelope_to_layout_checked(&dest, doc.sign(&sk, "k").unwrap().as_bytes(), false)
1846 .unwrap();
1847 assert_eq!(counter, 2);
1848 assert!(check.existence_checked, "{}", check.note);
1849 }
1850
1851 #[test]
1853 fn signing_reports_the_check_it_ran_alongside_the_envelope() {
1854 let (sk, pk) = generate_root_keypair();
1858 let (envelope, check) = status(1)
1859 .sign_against(&listing(&["2026.07.0", "2026.07.1"]), false, &sk, "k")
1860 .unwrap();
1861 assert!(check.existence_checked);
1862 assert_eq!(
1863 LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap(),
1864 status(1),
1865 "the checked path must sign the same document the plain path does"
1866 );
1867
1868 let mut doc = status(1);
1871 doc.yanked.clear();
1872 doc.known_problems[0].affected = vec!["2026.07.7".into()];
1873 doc.known_problems[1].affected = vec!["2026.07.0".into()];
1874 assert!(
1875 doc.sign_against(&listing(&["2026.07.0"]), false, &sk, "k")
1876 .is_err()
1877 );
1878 }
1879
1880 #[test]
1882 fn a_producer_can_list_their_own_line_without_a_network_or_an_index() {
1883 let tmp = tempfile::tempdir().unwrap();
1888 let (sk, _pk) = crate::generate_root_keypair();
1889 for (id, counter, dir) in [
1896 ("2026.08.0", 1u64, "out-a"),
1897 ("2026.08.1", 2, "out-b"),
1898 ("2026.09.0", 1, "out-other"),
1900 ] {
1901 let spec = crate::deposit::DepositSpec {
1902 layer: id.parse().unwrap(),
1903 channel: "rolling".into(),
1904 counter,
1905 issued_at: "2026-08-07T00:00:00Z".into(),
1906 tools: vec![crate::DepositTool {
1907 name: "t".into(),
1908 version: "1.0".into(),
1909 platform: None,
1910 bytes: b"x".to_vec(),
1911 source: None,
1912 runner: None,
1913 kind: None,
1914 sdk_prefix: None,
1915 }],
1916 includes: Vec::new(),
1917 };
1918 crate::deposit(&spec, &sk, "k", &tmp.path().join(dir)).unwrap();
1919 }
1920
1921 let known = known_layers_in_layout_dirs(&[tmp.path().to_path_buf()], "2026.08");
1923 match &known {
1924 KnownLayers::Known { layers, line, .. } => {
1925 assert_eq!(layers, &["2026.08.0", "2026.08.1"], "got {layers:?}");
1926 assert_eq!(line.as_deref(), Some("2026.08"));
1927 }
1928 KnownLayers::Unknown { why } => panic!("expected a listing, got: {why}"),
1929 }
1930
1931 let mut doc = status(2);
1933 doc.line = "2026.08".into();
1934 doc.yanked = BTreeMap::from([(
1935 "2026.08.10".to_string(),
1936 "typo — never deposited".to_string(),
1937 )]);
1938 doc.known_problems.clear();
1939 let err = doc
1940 .check_layer_refs_against(&known, false)
1941 .expect_err("a yank naming a layer this line does not have must be refused");
1942 let msg = err.to_string();
1943 assert!(
1944 msg.contains("2026.08.10") && msg.contains("2026.08.0"),
1945 "the refusal must name the bad id AND the ids that exist: {msg}"
1946 );
1947
1948 let empty = tempfile::tempdir().unwrap();
1950 assert!(matches!(
1951 known_layers_in_layout_dirs(&[empty.path().to_path_buf()], "2026.08"),
1952 KnownLayers::Unknown { .. }
1953 ));
1954 }
1955}