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(
70 rename = "min-counter",
71 skip_serializing_if = "Option::is_none",
72 default
73 )]
74 pub min_counter: Option<u64>,
75 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
77 pub yanked: BTreeMap<String, String>,
78 #[serde(
79 rename = "known-problems",
80 skip_serializing_if = "Vec::is_empty",
81 default
82 )]
83 pub known_problems: Vec<KnownProblem>,
84}
85
86#[derive(Debug, thiserror::Error)]
87pub enum LineStatusError {
88 #[error("line-status envelope rejected: {0}")]
94 Envelope(String),
95 #[error("cannot sign the line-status document: {0}")]
96 Sign(String),
97 #[error("line-status payload is not valid: {0}")]
98 Payload(String),
99 #[error("line-status covers line {got} but line {expected} was requested")]
100 LineMismatch { expected: String, got: String },
101 #[error(
102 "refusing stale line-status document for {line}: presented counter {presented}, cached {cached}"
103 )]
104 Stale {
105 line: String,
106 presented: u64,
107 cached: u64,
108 },
109 #[error(
114 "{what} names layer '{id}', which is not a layer of line {line} ({reason}) — `varve \
115 status` matches layer ids exactly, so this entry would never fire for any installed \
116 layer; fix the id and re-sign the document"
117 )]
118 DeadReference {
119 what: String,
120 id: String,
121 line: String,
122 reason: String,
123 },
124 #[error(
131 "{what} names layer '{id}', which line {line} does not contain — it exposes: {existing}. \
132 `varve status` matches layer ids EXACTLY, so this entry would fire for nobody: you \
133 would see success, every consumer would see nothing, and the advisory would silently \
134 not exist. Fix the id, or pass --force to pre-sign an advisory for a layer that is not \
135 deposited yet."
136 )]
137 UnknownLayer {
138 what: String,
139 id: String,
140 line: String,
141 existing: String,
142 },
143 #[error(
144 "{layout} is not an OCI image layout (it has no index.json) — point --layout at the \
145 directory `varve deposit --out` produced"
146 )]
147 NotALayout { layout: String },
148 #[error("io error at {path}")]
152 Io {
153 path: String,
154 #[source]
155 source: std::io::Error,
156 },
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum KnownLayers {
171 Known {
173 source: String,
175 line: Option<String>,
178 layers: Vec<String>,
179 },
180 Unknown { why: String },
182}
183
184impl KnownLayers {
185 pub fn from_index(index: &crate::lineindex::LineIndex) -> Self {
188 KnownLayers::Known {
189 source: format!(
190 "the signed line-index for {} (counter {})",
191 index.line, index.counter
192 ),
193 line: Some(index.line.clone()),
194 layers: index.layers.iter().map(|e| e.layer.clone()).collect(),
195 }
196 }
197
198 pub fn unknown(why: impl Into<String>) -> Self {
199 KnownLayers::Unknown { why: why.into() }
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct RefCheck {
207 pub existence_checked: bool,
210 pub note: String,
212}
213
214pub fn known_layers_from_index(
221 envelope: &[u8],
222 root_public_key: &[u8],
223) -> Result<KnownLayers, crate::lineindex::IndexError> {
224 let doc = crate::lineindex::LineIndex::verify_and_parse(envelope, root_public_key)?;
225 Ok(KnownLayers::from_index(&doc))
226}
227
228pub fn known_layers_in_layout_dirs(dirs: &[std::path::PathBuf], line: &str) -> KnownLayers {
243 let mut layers: Vec<String> = Vec::new();
244 let mut scanned = 0usize;
245 let visit = |dir: &Path, layers: &mut Vec<String>| {
246 let mut candidates: Vec<Vec<u8>> = Vec::new();
252 if let Ok(index) = std::fs::read(dir.join("index.json"))
253 && let Ok(idx) = serde_json::from_slice::<serde_json::Value>(&index)
254 {
255 for m in idx
256 .get("manifests")
257 .and_then(|m| m.as_array())
258 .into_iter()
259 .flatten()
260 {
261 if let Some(d) = m.get("digest").and_then(|d| d.as_str())
262 && let Some((_, hex)) = d.split_once(':')
263 && let Ok(b) = std::fs::read(dir.join("blobs").join("sha256").join(hex))
264 {
265 candidates.push(b);
266 }
267 }
268 }
269 if let Ok(entries) = std::fs::read_dir(dir.join("manifests")) {
270 for e in entries.filter_map(|e| e.ok()) {
271 if let Ok(b) = std::fs::read(e.path()) {
272 candidates.push(b);
273 }
274 }
275 }
276 if candidates.is_empty() {
277 return false;
278 }
279 for bytes in candidates {
280 let payload = std::str::from_utf8(&bytes)
285 .ok()
286 .and_then(|t| wsc::dsse::DsseEnvelope::from_json(t).ok())
287 .and_then(|env| env.payload_bytes().ok())
288 .unwrap_or_else(|| bytes.clone());
289 if let Ok(m) = crate::manifest::LayerManifest::parse(&payload) {
290 let id = m.layer.to_string();
291 if id.starts_with(&format!("{line}.")) && !layers.contains(&id) {
292 layers.push(id);
293 }
294 }
295 }
296 true
297 };
298 for dir in dirs {
299 if visit(dir, &mut layers) {
300 scanned += 1;
301 continue;
302 }
303 if let Ok(children) = std::fs::read_dir(dir) {
305 for c in children.filter_map(|e| e.ok()) {
306 if c.path().is_dir() && visit(&c.path(), &mut layers) {
307 scanned += 1;
308 }
309 }
310 }
311 }
312 if scanned == 0 {
313 return KnownLayers::unknown(format!(
314 "no oci-layout was found under {} — pass the directory `varve deposit --out` \
315 wrote, or one holding several of them",
316 dirs.iter()
317 .map(|d| d.display().to_string())
318 .collect::<Vec<_>>()
319 .join(", ")
320 ));
321 }
322 layers.sort();
323 KnownLayers::Known {
324 source: format!("{scanned} local layout(s) the producer holds"),
325 line: Some(line.to_string()),
326 layers,
327 }
328}
329
330pub fn known_layers_in_layout(layout: &Path, line: &str) -> KnownLayers {
337 match crate::lineindex::read_from_layout(layout, line) {
338 Ok(Some(envelope)) => match crate::lineindex::parse_unverified(&envelope) {
339 Ok(doc) if doc.line == line => KnownLayers::from_index(&doc),
340 Ok(doc) => KnownLayers::unknown(format!(
341 "the line-index this layout carries is for line {}, not {line}",
342 doc.line
343 )),
344 Err(e) => KnownLayers::unknown(format!(
345 "the line-index this layout carries could not be read ({e})"
346 )),
347 },
348 Ok(None) => KnownLayers::unknown(format!(
349 "this layout carries no signed line-index for {line}, and a layout holds ONE layer \
350 — it is not a listing of the line. Attach the index first (`varve attach-index`), \
351 or sign against it (`varve sign-status --index <envelope>`)"
352 )),
353 Err(e) => KnownLayers::unknown(format!("the layout's index.json could not be read ({e})")),
354 }
355}
356
357impl LineStatus {
358 pub fn verify_and_parse(
360 envelope: &[u8],
361 root_public_key: &[u8],
362 ) -> Result<Self, LineStatusError> {
363 if let Ok(text) = std::str::from_utf8(envelope)
368 && wsc::dsse::DsseEnvelope::from_json(text).is_err()
369 {
370 return Err(not_an_envelope(text));
371 }
372 let payload = dsse_verify_typed(envelope, LINE_STATUS_PAYLOAD_TYPE, root_public_key)
373 .map_err(|VerifyError(msg)| {
374 let hint = if msg.contains("does not verify") {
378 " (is the document signed by THIS realm's root? `varve pubkey <key>` \
379 prints the public half a signature verifies against)"
380 } else {
381 ""
382 };
383 LineStatusError::Envelope(format!("{msg}{hint}"))
384 })?;
385 serde_json::from_slice(&payload).map_err(|e| LineStatusError::Payload(e.to_string()))
386 }
387
388 pub fn sign(&self, secret_key: &[u8], key_id: &str) -> Result<String, LineStatusError> {
393 self.check_layer_refs()?;
394 let payload = serde_json::to_vec_pretty(self).expect("status serializes");
395 dsse_sign_typed(&payload, LINE_STATUS_PAYLOAD_TYPE, secret_key, key_id)
396 .map_err(|VerifyError(msg)| LineStatusError::Sign(msg))
397 }
398
399 pub fn sign_against(
404 &self,
405 known: &KnownLayers,
406 force: bool,
407 secret_key: &[u8],
408 key_id: &str,
409 ) -> Result<(String, RefCheck), LineStatusError> {
410 let check = self.check_layer_refs_against(known, force)?;
411 let payload = serde_json::to_vec_pretty(self).expect("status serializes");
412 let envelope = dsse_sign_typed(&payload, LINE_STATUS_PAYLOAD_TYPE, secret_key, key_id)
413 .map_err(|VerifyError(msg)| LineStatusError::Sign(msg))?;
414 Ok((envelope, check))
415 }
416
417 pub fn check_layer_refs_against(
430 &self,
431 known: &KnownLayers,
432 force: bool,
433 ) -> Result<RefCheck, LineStatusError> {
434 self.check_layer_refs()?;
435 let referenced = self.yanked.len()
444 + self
445 .known_problems
446 .iter()
447 .map(|p| p.affected.len())
448 .sum::<usize>();
449 let (source, layers) = match known {
450 KnownLayers::Unknown { .. } if referenced == 0 => {
451 return Ok(RefCheck {
452 existence_checked: true,
453 note: "this document names no layer — nothing to check against the line"
454 .to_string(),
455 });
456 }
457 KnownLayers::Unknown { why } => {
458 return Ok(RefCheck {
459 existence_checked: false,
460 note: format!(
461 "advisory references were checked for SHAPE only — NOT against the \
462 layers line {} actually has: {why}. An id naming a layer that does not \
463 exist still signs cleanly here and fires for nobody.",
464 self.line
465 ),
466 });
467 }
468 KnownLayers::Known {
469 source,
470 line,
471 layers,
472 } => {
473 if let Some(listing_line) = line
478 && listing_line != &self.line
479 {
480 return Err(LineStatusError::LineMismatch {
481 expected: self.line.clone(),
482 got: listing_line.clone(),
483 });
484 }
485 (source, layers)
486 }
487 };
488 if force {
489 return Ok(RefCheck {
490 existence_checked: false,
491 note: format!(
492 "--force: advisory references were NOT checked against the layers line {} \
493 has. An entry naming a layer that has not been deposited yet fires only \
494 once it is.",
495 self.line
496 ),
497 });
498 }
499 let existing = if layers.is_empty() {
500 "no layers at all — this line has none yet".to_string()
501 } else {
502 layers.join(", ")
503 };
504 let mut refs = 0usize;
505 let mut check = |what: String, id: &str| -> Result<(), LineStatusError> {
506 refs += 1;
507 if layers.iter().any(|l| l == id) {
508 return Ok(());
509 }
510 Err(LineStatusError::UnknownLayer {
511 what,
512 id: id.to_string(),
513 line: self.line.clone(),
514 existing: existing.clone(),
515 })
516 };
517 for id in self.yanked.keys() {
518 check("the yank entry".to_string(), id)?;
519 }
520 for kp in &self.known_problems {
521 for id in &kp.affected {
522 check(format!("known problem '{}'", kp.id), id)?;
523 }
524 }
525 Ok(RefCheck {
526 existence_checked: true,
527 note: format!(
528 "{refs} advisory reference{} checked against the {} layer{} {source} lists for \
529 line {}",
530 if refs == 1 { "" } else { "s" },
531 layers.len(),
532 if layers.len() == 1 { "" } else { "s" },
533 self.line
534 ),
535 })
536 }
537
538 pub fn check_layer_refs(&self) -> Result<(), LineStatusError> {
545 let line: Line = self.line.parse().map_err(|e| {
546 LineStatusError::Payload(format!("'{}' is not a YYYY.MM line: {e}", self.line))
547 })?;
548 let check = |what: String, id: &str| -> Result<(), LineStatusError> {
549 let dead = |reason: String| LineStatusError::DeadReference {
550 what: what.clone(),
551 id: id.to_string(),
552 line: self.line.clone(),
553 reason,
554 };
555 match id.parse::<LayerId>() {
556 Ok(layer) if layer.line() == &line => Ok(()),
557 Ok(layer) => Err(dead(format!("it belongs to line {}", layer.line()))),
558 Err(e) => Err(dead(e.to_string())),
559 }
560 };
561 for id in self.yanked.keys() {
562 check("the yank entry".to_string(), id)?;
563 }
564 for kp in &self.known_problems {
565 for id in &kp.affected {
566 check(format!("known problem '{}'", kp.id), id)?;
567 }
568 }
569 Ok(())
570 }
571
572 pub fn report_for(&self, layer: &LayerId) -> LayerStatusReport {
574 let name = layer.to_string();
575 let problems: Vec<&KnownProblem> = self
576 .known_problems
577 .iter()
578 .filter(|kp| kp.affected.iter().any(|a| a == &name))
579 .collect();
580 LayerStatusReport {
581 yanked_reason: self.yanked.get(&name).cloned(),
582 support_until: self.support_until.clone(),
583 problems_total: problems.len(),
584 problems_with_workaround: problems.iter().filter(|kp| kp.workaround.is_some()).count(),
585 }
586 }
587}
588
589#[derive(Debug, Clone, PartialEq, Eq)]
590pub struct LayerStatusReport {
591 pub yanked_reason: Option<String>,
592 pub support_until: Option<String>,
593 pub problems_total: usize,
594 pub problems_with_workaround: usize,
595}
596
597#[derive(Debug)]
599pub struct StatusCache {
600 dir: PathBuf,
601}
602
603impl StatusCache {
604 pub fn at_root(root: &Path) -> Self {
605 StatusCache {
606 dir: root.join("state").join("line-status"),
607 }
608 }
609
610 pub fn update(
612 &self,
613 line: &Line,
614 envelope: &[u8],
615 parsed: &LineStatus,
616 ) -> Result<(), LineStatusError> {
617 if let Some(cached) = self.load_parsed(line)?
618 && parsed.counter < cached.counter
619 {
620 return Err(LineStatusError::Stale {
621 line: line.to_string(),
622 presented: parsed.counter,
623 cached: cached.counter,
624 });
625 }
626 let io = |path: &Path, source: std::io::Error| LineStatusError::Io {
627 path: path.display().to_string(),
628 source,
629 };
630 std::fs::create_dir_all(&self.dir).map_err(|e| io(&self.dir, e))?;
631 let path = self.envelope_path(line);
632 std::fs::write(&path, envelope).map_err(|e| io(&path, e))?;
633 Ok(())
634 }
635
636 pub fn envelope_bytes(&self, line: &Line) -> Result<Option<Vec<u8>>, LineStatusError> {
643 let path = self.envelope_path(line);
644 match std::fs::read(&path) {
645 Ok(bytes) => Ok(Some(bytes)),
646 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
647 Err(source) => Err(LineStatusError::Io {
648 path: path.display().to_string(),
649 source,
650 }),
651 }
652 }
653
654 pub fn load(
656 &self,
657 line: &Line,
658 root_public_key: &[u8],
659 ) -> Result<Option<LineStatus>, LineStatusError> {
660 let path = self.envelope_path(line);
661 match std::fs::read(&path) {
662 Ok(bytes) => Ok(Some(LineStatus::verify_and_parse(&bytes, root_public_key)?)),
663 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
664 Err(source) => Err(LineStatusError::Io {
665 path: path.display().to_string(),
666 source,
667 }),
668 }
669 }
670
671 fn load_parsed(&self, line: &Line) -> Result<Option<LineStatus>, LineStatusError> {
672 let path = self.envelope_path(line);
673 match std::fs::read(&path) {
674 Ok(bytes) => {
675 let text = std::str::from_utf8(&bytes)
678 .map_err(|_| LineStatusError::Payload("cache is not UTF-8".into()))?;
679 let env = wsc::dsse::DsseEnvelope::from_json(text)
680 .map_err(|e| LineStatusError::Payload(e.to_string()))?;
681 let payload = env
682 .payload_bytes()
683 .map_err(|e| LineStatusError::Payload(e.to_string()))?;
684 Ok(Some(
685 serde_json::from_slice(&payload)
686 .map_err(|e| LineStatusError::Payload(e.to_string()))?,
687 ))
688 }
689 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
690 Err(source) => Err(LineStatusError::Io {
691 path: path.display().to_string(),
692 source,
693 }),
694 }
695 }
696
697 fn envelope_path(&self, line: &Line) -> PathBuf {
698 self.dir.join(format!("{line}.dsse.json"))
699 }
700}
701
702pub const LINE_STATUS_ARTIFACT_TYPE: &str = LINE_STATUS_PAYLOAD_TYPE;
704pub const ANN_LINE: &str = "eu.pulseengine.varve.status-line";
706
707pub fn attach_to_layout(
711 layout: &Path,
712 line: &Line,
713 envelope: &[u8],
714) -> Result<(), LineStatusError> {
715 let io = |path: &Path, source: std::io::Error| LineStatusError::Io {
716 path: path.display().to_string(),
717 source,
718 };
719 let digest = crate::store::manifest_digest(envelope);
720 let hex = digest.strip_prefix("sha256:").expect("digest shape");
721 let blob_dir = layout.join("blobs").join("sha256");
722 std::fs::create_dir_all(&blob_dir).map_err(|e| io(&blob_dir, e))?;
723 let blob_path = blob_dir.join(hex);
724 std::fs::write(&blob_path, envelope).map_err(|e| io(&blob_path, e))?;
725
726 let index_path = layout.join("index.json");
727 let mut index: serde_json::Value =
728 serde_json::from_slice(&std::fs::read(&index_path).map_err(|e| io(&index_path, e))?)
729 .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
730 let entries = index["manifests"]
731 .as_array_mut()
732 .ok_or_else(|| LineStatusError::Payload("index.json has no manifests array".into()))?;
733 let line_name = line.to_string();
734 entries.retain(|e| {
735 !(e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE
736 && e["annotations"][ANN_LINE] == *line_name)
737 });
738 entries.push(serde_json::json!({
739 "mediaType": "application/json",
740 "artifactType": LINE_STATUS_ARTIFACT_TYPE,
741 "digest": digest,
742 "size": envelope.len(),
743 "annotations": { ANN_LINE: line_name }
744 }));
745 std::fs::write(
746 &index_path,
747 serde_json::to_vec_pretty(&index).expect("index serializes"),
748 )
749 .map_err(|e| io(&index_path, e))?;
750 Ok(())
751}
752
753pub fn cache_baseline_from_source(
762 source: &dyn crate::source::LayerSource,
763 layer: &crate::source::LayerRef,
764 line: &Line,
765 root_pk: &[u8],
766 store_root: &Path,
767) -> Result<Option<u64>, LineStatusError> {
768 let envelope = match source
769 .fetch_line_status(layer)
770 .map_err(|e| LineStatusError::Payload(format!("fetching baseline line-status: {e}")))?
771 {
772 Some(bytes) => bytes,
773 None => return Ok(None),
774 };
775 let doc = LineStatus::verify_and_parse(&envelope, root_pk)?;
776 if doc.line != line.to_string() {
779 return Err(LineStatusError::LineMismatch {
780 expected: line.to_string(),
781 got: doc.line,
782 });
783 }
784 let counter = doc.counter;
785 StatusCache::at_root(store_root).update(line, &envelope, &doc)?;
786 Ok(Some(counter))
787}
788
789pub fn attach_envelope_to_layout(
795 layout: &Path,
796 envelope: &[u8],
797) -> Result<(Line, u64), LineStatusError> {
798 let (line, counter, _) = attach_envelope_to_layout_checked(layout, envelope, false)?;
799 Ok((line, counter))
800}
801
802pub fn attach_envelope_to_layout_checked(
805 layout: &Path,
806 envelope: &[u8],
807 force: bool,
808) -> Result<(Line, u64, RefCheck), LineStatusError> {
809 if !layout.join("index.json").is_file() {
813 return Err(LineStatusError::NotALayout {
814 layout: layout.display().to_string(),
815 });
816 }
817 let text = std::str::from_utf8(envelope)
818 .map_err(|e| LineStatusError::Payload(format!("envelope is not utf-8: {e}")))?;
819 let env = wsc::dsse::DsseEnvelope::from_json(text).map_err(|_| not_an_envelope(text))?;
820 let payload = env
821 .payload_bytes()
822 .map_err(|e| LineStatusError::Payload(format!("envelope payload: {e}")))?;
823 let doc: LineStatus = serde_json::from_slice(&payload)
824 .map_err(|e| LineStatusError::Payload(format!("status document: {e}")))?;
825 let line: Line = doc
826 .line
827 .parse()
828 .map_err(|e| LineStatusError::Payload(format!("status line '{}': {e}", doc.line)))?;
829 let check = doc.check_layer_refs_against(&known_layers_in_layout(layout, &doc.line), force)?;
836 if let Some(existing) = read_any_from_layout(layout)?
843 && let Ok(prev) = parse_unverified(&existing)
844 && prev.line == doc.line
845 && doc.counter < prev.counter
846 {
847 return Err(LineStatusError::Stale {
848 line: doc.line.clone(),
849 presented: doc.counter,
850 cached: prev.counter,
851 });
852 }
853 if let Some(layout_line) = layout_line(layout)
857 && layout_line != line.to_string()
858 {
859 return Err(LineStatusError::LineMismatch {
860 expected: layout_line,
861 got: line.to_string(),
862 });
863 }
864 attach_to_layout(layout, &line, envelope)?;
865 Ok((line, doc.counter, check))
866}
867
868fn not_an_envelope(text: &str) -> LineStatusError {
874 if serde_json::from_str::<LineStatus>(text).is_ok() {
875 LineStatusError::Payload(
876 "this is the UNSIGNED status document, not a signed envelope — sign it first \
877 (`varve sign-status --file <doc> --key <key> --out <envelope>`) and pass the \
878 envelope"
879 .into(),
880 )
881 } else {
882 LineStatusError::Payload(
883 "not a DSSE envelope — expected the signed output of `varve sign-status`".into(),
884 )
885 }
886}
887
888fn parse_unverified(envelope: &[u8]) -> Result<LineStatus, LineStatusError> {
892 let text = std::str::from_utf8(envelope)
893 .map_err(|e| LineStatusError::Payload(format!("envelope is not utf-8: {e}")))?;
894 let env = wsc::dsse::DsseEnvelope::from_json(text).map_err(|_| not_an_envelope(text))?;
895 let payload = env
896 .payload_bytes()
897 .map_err(|e| LineStatusError::Payload(format!("envelope payload: {e}")))?;
898 serde_json::from_slice(&payload)
899 .map_err(|e| LineStatusError::Payload(format!("status document: {e}")))
900}
901
902pub(crate) fn layout_line(layout: &Path) -> Option<String> {
906 let index: serde_json::Value =
907 serde_json::from_slice(&std::fs::read(layout.join("index.json")).ok()?).ok()?;
908 for m in index["manifests"].as_array()? {
909 let digest = m["digest"].as_str()?.replace(':', "-");
910 let blob = layout
911 .join("blobs")
912 .join("sha256")
913 .join(digest.trim_start_matches("sha256-"));
914 let Ok(bytes) = std::fs::read(&blob) else {
915 continue;
916 };
917 let Ok(text) = std::str::from_utf8(&bytes) else {
920 continue;
921 };
922 let Ok(env) = wsc::dsse::DsseEnvelope::from_json(text) else {
923 continue;
924 };
925 let Ok(payload) = env.payload_bytes() else {
926 continue;
927 };
928 let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&payload) else {
929 continue;
930 };
931 if let Some(line) = doc["annotations"]["eu.pulseengine.varve.line"].as_str() {
932 return Some(line.to_string());
933 }
934 }
935 None
936}
937
938pub fn read_any_from_layout(layout: &Path) -> Result<Option<Vec<u8>>, LineStatusError> {
943 let index_path = layout.join("index.json");
944 let bytes = match std::fs::read(&index_path) {
945 Ok(bytes) => bytes,
946 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
947 Err(source) => {
948 return Err(LineStatusError::Io {
949 path: index_path.display().to_string(),
950 source,
951 });
952 }
953 };
954 let index: serde_json::Value = serde_json::from_slice(&bytes)
955 .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
956 let Some(entry) = index["manifests"].as_array().and_then(|entries| {
957 entries
958 .iter()
959 .find(|e| e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE)
960 }) else {
961 return Ok(None);
962 };
963 let digest = entry["digest"]
964 .as_str()
965 .ok_or_else(|| LineStatusError::Payload("status entry has no digest".into()))?;
966 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
967 let blob_path = layout.join("blobs").join("sha256").join(hex);
968 std::fs::read(&blob_path)
969 .map(Some)
970 .map_err(|source| LineStatusError::Io {
971 path: blob_path.display().to_string(),
972 source,
973 })
974}
975
976pub fn read_from_layout(layout: &Path, line: &Line) -> Result<Option<Vec<u8>>, LineStatusError> {
978 let index_path = layout.join("index.json");
979 let bytes = match std::fs::read(&index_path) {
980 Ok(bytes) => bytes,
981 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
982 Err(source) => {
983 return Err(LineStatusError::Io {
984 path: index_path.display().to_string(),
985 source,
986 });
987 }
988 };
989 let index: serde_json::Value = serde_json::from_slice(&bytes)
990 .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
991 let line_name = line.to_string();
992 let Some(entry) = index["manifests"].as_array().and_then(|entries| {
993 entries.iter().find(|e| {
994 e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE
995 && e["annotations"][ANN_LINE] == *line_name
996 })
997 }) else {
998 return Ok(None);
999 };
1000 let digest = entry["digest"]
1001 .as_str()
1002 .ok_or_else(|| LineStatusError::Payload("status entry has no digest".into()))?;
1003 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
1004 let blob_path = layout.join("blobs").join("sha256").join(hex);
1005 std::fs::read(&blob_path)
1006 .map(Some)
1007 .map_err(|source| LineStatusError::Io {
1008 path: blob_path.display().to_string(),
1009 source,
1010 })
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015 use super::*;
1016 use crate::verify::generate_root_keypair;
1017
1018 fn status(counter: u64) -> LineStatus {
1019 LineStatus {
1020 min_counter: None,
1021 line: "2026.07".into(),
1022 counter,
1023 issued_at: "2026-08-07T00:00:00Z".into(),
1024 support_until: Some("2028-07-31".into()),
1025 yanked: BTreeMap::from([(
1026 "2026.07.0".to_string(),
1027 "CVE-2026-0001 in synth".to_string(),
1028 )]),
1029 known_problems: vec![
1030 KnownProblem {
1031 id: "KP-1".into(),
1032 title: "synth mla fusion regresses flat_flight".into(),
1033 severity: "medium".into(),
1034 affected: vec!["2026.07.0".into()],
1035 workaround: Some("disable mla fusion".into()),
1036 detection: None,
1037 mitigation: None,
1038 },
1039 KnownProblem {
1040 id: "KP-2".into(),
1041 title: "witness truth-table gap on nested variants".into(),
1042 severity: "high".into(),
1043 affected: vec!["2026.07.0".into(), "2026.07.1".into()],
1044 workaround: None,
1045 detection: Some("witness gap rows non-empty".into()),
1046 mitigation: None,
1047 },
1048 ],
1049 }
1050 }
1051
1052 #[test]
1054 fn a_signed_status_document_round_trips() {
1055 let (sk, pk) = generate_root_keypair();
1056 let envelope = status(1).sign(&sk, "varve-root-1").unwrap();
1057 let parsed = LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap();
1058 assert_eq!(parsed, status(1));
1059 }
1060
1061 #[test]
1063 fn a_layer_manifest_envelope_cannot_pose_as_a_status_document() {
1064 let (sk, pk) = generate_root_keypair();
1065 let manifest = crate::manifest::fixtures::manifest(
1067 "2026.07.0",
1068 "qualified",
1069 1,
1070 "2026-08-07T00:00:00Z",
1071 );
1072 let envelope = crate::verify::sign_layer_manifest(&manifest, &sk, "varve-root-1").unwrap();
1073 let err = LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap_err();
1074 assert!(err.to_string().contains("payload type"), "got: {err}");
1075 }
1076
1077 #[test]
1079 fn the_report_names_yank_support_window_and_problem_counts() {
1080 let doc = status(1);
1081 let report = doc.report_for(&"2026.07.0".parse().unwrap());
1082 assert_eq!(
1083 report.yanked_reason.as_deref(),
1084 Some("CVE-2026-0001 in synth")
1085 );
1086 assert_eq!(report.support_until.as_deref(), Some("2028-07-31"));
1087 assert_eq!(report.problems_total, 2);
1088 assert_eq!(report.problems_with_workaround, 1);
1089 let clean = doc.report_for(&"2026.07.2".parse().unwrap());
1090 assert_eq!(clean.yanked_reason, None);
1091 assert_eq!(clean.problems_total, 0);
1092 }
1093
1094 #[test]
1096 fn attaching_status_to_a_layout_leaves_every_layer_blob_untouched() {
1097 use crate::deposit::{DepositSpec, DepositTool, deposit};
1098 let (sk, pk) = generate_root_keypair();
1099 let tmp = tempfile::tempdir().unwrap();
1100 let dest = tmp.path().join("layout");
1101 let spec = DepositSpec {
1102 includes: Vec::new(),
1103 layer: "2026.07.0".parse().unwrap(),
1104 channel: "qualified".into(),
1105 counter: 1,
1106 issued_at: "2026-08-07T00:00:00Z".into(),
1107 tools: vec![DepositTool {
1108 name: "synth".into(),
1109 version: "1".into(),
1110 platform: None,
1111 bytes: b"t".to_vec(),
1112 source: None,
1113 runner: None,
1114 kind: None,
1115 sdk_prefix: None,
1116 }],
1117 };
1118 let outcome = deposit(&spec, &sk, "k", &dest).unwrap();
1119
1120 let blob_dir = dest.join("blobs/sha256");
1122 let before: std::collections::BTreeMap<String, Vec<u8>> = std::fs::read_dir(&blob_dir)
1123 .unwrap()
1124 .map(|e| {
1125 let p = e.unwrap().path();
1126 (
1127 p.file_name().unwrap().to_string_lossy().into_owned(),
1128 std::fs::read(&p).unwrap(),
1129 )
1130 })
1131 .collect();
1132
1133 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1134 let envelope = status(1).sign(&sk, "k").unwrap();
1135 attach_to_layout(&dest, &line, envelope.as_bytes()).unwrap();
1136
1137 for (name, bytes) in &before {
1140 assert_eq!(&std::fs::read(blob_dir.join(name)).unwrap(), bytes);
1141 }
1142 let carried = read_from_layout(&dest, &line).unwrap().unwrap();
1143 let parsed = LineStatus::verify_and_parse(&carried, &pk).unwrap();
1144 assert_eq!(parsed.counter, 1);
1145 let hex = outcome.digest.strip_prefix("sha256:").unwrap();
1146 assert!(
1147 blob_dir.join(hex).is_file(),
1148 "layer manifest blob still present"
1149 );
1150
1151 let envelope2 = status(2).sign(&sk, "k").unwrap();
1153 attach_to_layout(&dest, &line, envelope2.as_bytes()).unwrap();
1154 let index: serde_json::Value =
1155 serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
1156 let count = index["manifests"]
1157 .as_array()
1158 .unwrap()
1159 .iter()
1160 .filter(|e| e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE)
1161 .count();
1162 assert_eq!(count, 1);
1163 }
1164
1165 #[test]
1167 fn the_cache_refuses_a_counter_regression() {
1168 let (sk, pk) = generate_root_keypair();
1169 let tmp = tempfile::tempdir().unwrap();
1170 let cache = StatusCache::at_root(tmp.path());
1171 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1172
1173 let newer = status(2);
1174 let env2 = newer.sign(&sk, "k").unwrap();
1175 cache.update(&line, env2.as_bytes(), &newer).unwrap();
1176
1177 let older = status(1);
1178 let env1 = older.sign(&sk, "k").unwrap();
1179 let err = cache.update(&line, env1.as_bytes(), &older).unwrap_err();
1180 assert!(matches!(
1181 err,
1182 LineStatusError::Stale {
1183 presented: 1,
1184 cached: 2,
1185 ..
1186 }
1187 ));
1188
1189 let loaded = cache.load(&line, &pk).unwrap().unwrap();
1191 assert_eq!(loaded.counter, 2);
1192 }
1193
1194 #[test]
1196 fn a_source_baseline_is_verified_and_cached_so_status_works_offline() {
1197 use crate::source::{LayerRef, MemorySource};
1198 let (sk, pk) = generate_root_keypair();
1199 let tmp = tempfile::tempdir().unwrap();
1200 let store_root = tmp.path();
1201 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1202 let doc = status(5);
1203 let envelope = doc.sign(&sk, "k").unwrap();
1204 let source = MemorySource::new().with_line_status(envelope.as_bytes());
1205 let layer = LayerRef::Name("2026.07.0".parse().unwrap());
1206
1207 let cached = cache_baseline_from_source(&source, &layer, &line, &pk, store_root).unwrap();
1208 assert_eq!(
1209 cached,
1210 Some(5),
1211 "a carried baseline is cached at its counter"
1212 );
1213
1214 let loaded = StatusCache::at_root(store_root)
1216 .load(&line, &pk)
1217 .unwrap()
1218 .unwrap();
1219 assert_eq!(loaded.counter, 5);
1220 }
1221
1222 #[test]
1224 fn a_baseline_for_the_wrong_line_is_refused_not_miscached() {
1225 use crate::source::{LayerRef, MemorySource};
1230 let (sk, pk) = generate_root_keypair();
1231 let tmp = tempfile::tempdir().unwrap();
1232 let requested: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1233 let doc = LineStatus {
1236 min_counter: None,
1237 line: "2026.08".into(),
1238 counter: 5,
1239 issued_at: "2026-08-07T00:00:00Z".into(),
1240 support_until: None,
1241 yanked: BTreeMap::new(),
1242 known_problems: Vec::new(),
1243 };
1244 let envelope = doc.sign(&sk, "k").unwrap();
1245 let source = MemorySource::new().with_line_status(envelope.as_bytes());
1246 let err = cache_baseline_from_source(
1247 &source,
1248 &LayerRef::Name("2026.07.0".parse().unwrap()),
1249 &requested,
1250 &pk,
1251 tmp.path(),
1252 )
1253 .unwrap_err();
1254 assert!(
1255 matches!(err, LineStatusError::LineMismatch { .. }),
1256 "a baseline for the wrong line must be refused: {err}"
1257 );
1258 assert!(
1259 StatusCache::at_root(tmp.path())
1260 .load(&requested, &pk)
1261 .unwrap()
1262 .is_none(),
1263 "nothing is cached under the requested line"
1264 );
1265 }
1266
1267 #[test]
1269 fn a_source_with_no_baseline_caches_nothing_and_does_not_error() {
1270 use crate::source::{LayerRef, MemorySource};
1271 let (_sk, pk) = generate_root_keypair();
1272 let tmp = tempfile::tempdir().unwrap();
1273 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1274 let source = MemorySource::new();
1275 let cached = cache_baseline_from_source(
1276 &source,
1277 &LayerRef::Name("2026.07.0".parse().unwrap()),
1278 &line,
1279 &pk,
1280 tmp.path(),
1281 )
1282 .unwrap();
1283 assert_eq!(cached, None);
1284 }
1285
1286 #[test]
1288 fn a_baseline_signed_by_an_impostor_is_refused_not_cached() {
1289 use crate::source::{LayerRef, MemorySource};
1290 let (attacker_sk, _) = generate_root_keypair();
1291 let (_real_sk, real_pk) = generate_root_keypair();
1292 let tmp = tempfile::tempdir().unwrap();
1293 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1294 let envelope = status(5).sign(&attacker_sk, "k").unwrap();
1295 let source = MemorySource::new().with_line_status(envelope.as_bytes());
1296 let err = cache_baseline_from_source(
1297 &source,
1298 &LayerRef::Name("2026.07.0".parse().unwrap()),
1299 &line,
1300 &real_pk,
1301 tmp.path(),
1302 )
1303 .unwrap_err();
1304 assert!(
1306 StatusCache::at_root(tmp.path())
1307 .load(&line, &real_pk)
1308 .unwrap()
1309 .is_none(),
1310 "a baseline that fails verification must not be cached: {err}"
1311 );
1312 }
1313
1314 #[test]
1316 fn attaching_by_envelope_derives_the_line_from_the_document() {
1317 use crate::deposit::{DepositSpec, DepositTool, deposit};
1318 let (sk, pk) = generate_root_keypair();
1319 let tmp = tempfile::tempdir().unwrap();
1320 let dest = tmp.path().join("layout");
1321 deposit(
1322 &DepositSpec {
1323 includes: Vec::new(),
1324 layer: "2026.07.0".parse().unwrap(),
1325 channel: "qualified".into(),
1326 counter: 1,
1327 issued_at: "2026-08-07T00:00:00Z".into(),
1328 tools: vec![DepositTool {
1329 name: "synth".into(),
1330 version: "1".into(),
1331 platform: None,
1332 bytes: b"t".to_vec(),
1333 source: None,
1334 runner: None,
1335 kind: None,
1336 sdk_prefix: None,
1337 }],
1338 },
1339 &sk,
1340 "k",
1341 &dest,
1342 )
1343 .unwrap();
1344
1345 let envelope = status(4).sign(&sk, "k").unwrap();
1346 let (line, counter) = attach_envelope_to_layout(&dest, envelope.as_bytes()).unwrap();
1347 assert_eq!(line.to_string(), "2026.07");
1348 assert_eq!(counter, 4);
1349 let carried = read_any_from_layout(&dest).unwrap().unwrap();
1351 assert_eq!(
1352 LineStatus::verify_and_parse(&carried, &pk).unwrap().counter,
1353 4
1354 );
1355 }
1356
1357 #[test]
1359 fn attaching_a_stale_document_over_a_newer_one_is_refused() {
1360 use crate::deposit::{DepositSpec, DepositTool, deposit};
1368 let (sk, _pk) = generate_root_keypair();
1369 let tmp = tempfile::tempdir().unwrap();
1370 let dest = tmp.path().join("layout");
1371 deposit(
1372 &DepositSpec {
1373 includes: Vec::new(),
1374 layer: "2026.07.0".parse().unwrap(),
1375 channel: "qualified".into(),
1376 counter: 1,
1377 issued_at: "2026-08-07T00:00:00Z".into(),
1378 tools: vec![DepositTool {
1379 name: "synth".into(),
1380 version: "1".into(),
1381 platform: None,
1382 bytes: b"t".to_vec(),
1383 source: None,
1384 runner: None,
1385 kind: None,
1386 sdk_prefix: None,
1387 }],
1388 },
1389 &sk,
1390 "k",
1391 &dest,
1392 )
1393 .unwrap();
1394
1395 attach_envelope_to_layout(&dest, status(7).sign(&sk, "k").unwrap().as_bytes()).unwrap();
1397 let err = attach_envelope_to_layout(&dest, status(3).sign(&sk, "k").unwrap().as_bytes())
1399 .unwrap_err();
1400 assert!(
1401 matches!(
1402 err,
1403 LineStatusError::Stale {
1404 presented: 3,
1405 cached: 7,
1406 ..
1407 }
1408 ),
1409 "a lower counter must be refused, got {err}"
1410 );
1411 let msg = err.to_string();
1412 assert!(msg.contains('3') && msg.contains('7'), "names both: {msg}");
1413 let carried = parse_unverified(&read_any_from_layout(&dest).unwrap().unwrap()).unwrap();
1415 assert_eq!(
1416 carried.counter, 7,
1417 "the newer baseline survives the attempt"
1418 );
1419 attach_envelope_to_layout(&dest, status(7).sign(&sk, "k").unwrap().as_bytes()).unwrap();
1422 }
1423
1424 #[test]
1426 fn an_advisory_that_could_never_fire_is_refused_at_sign_time() {
1427 let (sk, _pk) = generate_root_keypair();
1432 let cases: &[(&str, &str)] = &[
1433 ("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"),
1437 ];
1438 for (bad, why) in cases {
1439 let mut doc = status(1);
1440 doc.known_problems[0].affected = vec![bad.to_string()];
1441 let err = doc.sign(&sk, "k").unwrap_err();
1442 assert!(
1443 matches!(err, LineStatusError::DeadReference { .. }),
1444 "{why}: affected id {bad:?} must be refused, got: {err}"
1445 );
1446 let msg = err.to_string();
1447 assert!(
1448 msg.contains(bad) && msg.contains("2026.07") && msg.contains("re-sign"),
1449 "the error must name the id, the line, and the fix: {msg}"
1450 );
1451 }
1452 let mut doc = status(1);
1454 doc.yanked = BTreeMap::from([("2026.8.0".to_string(), "CVE".to_string())]);
1455 assert!(matches!(
1456 doc.sign(&sk, "k").unwrap_err(),
1457 LineStatusError::DeadReference { .. }
1458 ));
1459 status(1).sign(&sk, "k").unwrap();
1462 }
1463
1464 #[test]
1466 fn attach_refuses_a_pre_signed_advisory_that_could_never_fire() {
1467 use crate::deposit::{DepositSpec, DepositTool, deposit};
1472 let (sk, _pk) = generate_root_keypair();
1473 let tmp = tempfile::tempdir().unwrap();
1474 let dest = tmp.path().join("layout");
1475 deposit(
1476 &DepositSpec {
1477 includes: Vec::new(),
1478 layer: "2026.07.0".parse().unwrap(),
1479 channel: "qualified".into(),
1480 counter: 1,
1481 issued_at: "2026-08-07T00:00:00Z".into(),
1482 tools: vec![DepositTool {
1483 name: "synth".into(),
1484 version: "1".into(),
1485 platform: None,
1486 bytes: b"t".to_vec(),
1487 source: None,
1488 runner: None,
1489 kind: None,
1490 sdk_prefix: None,
1491 }],
1492 },
1493 &sk,
1494 "k",
1495 &dest,
1496 )
1497 .unwrap();
1498 let mut doc = status(1);
1499 doc.known_problems[0].affected = vec!["2026.7.0".to_string()];
1500 let payload = serde_json::to_vec_pretty(&doc).unwrap();
1501 let envelope = dsse_sign_typed(&payload, LINE_STATUS_PAYLOAD_TYPE, &sk, "k").unwrap();
1502 let err = attach_envelope_to_layout(&dest, envelope.as_bytes()).unwrap_err();
1503 assert!(
1504 matches!(err, LineStatusError::DeadReference { .. }),
1505 "got: {err}"
1506 );
1507 assert!(
1508 read_any_from_layout(&dest).unwrap().is_none(),
1509 "the dead advisory must not land in the layout"
1510 );
1511 }
1512
1513 #[test]
1515 fn attaching_to_a_directory_that_is_not_a_layout_is_refused_before_writing() {
1516 let (sk, _pk) = generate_root_keypair();
1521 let tmp = tempfile::tempdir().unwrap();
1522 let not_a_layout = tmp.path().join("somedir");
1523 std::fs::create_dir_all(¬_a_layout).unwrap();
1524 let envelope = status(1).sign(&sk, "k").unwrap();
1525 let err = attach_envelope_to_layout(¬_a_layout, envelope.as_bytes()).unwrap_err();
1526 assert!(
1527 matches!(err, LineStatusError::NotALayout { .. }),
1528 "got: {err}"
1529 );
1530 assert!(
1531 err.to_string().contains("varve deposit"),
1532 "the error must carry its fix: {err}"
1533 );
1534 assert!(
1535 !not_a_layout.join("blobs").exists(),
1536 "nothing may be written into a directory that is not a layout"
1537 );
1538 }
1539
1540 #[test]
1542 fn the_unsigned_document_mistake_is_named_not_wrapped() {
1543 let raw = serde_json::to_string_pretty(&status(1)).unwrap();
1547 let err = not_an_envelope(&raw);
1548 let msg = err.to_string();
1549 assert!(
1550 msg.contains("UNSIGNED") && msg.contains("varve sign-status"),
1551 "raw document must be diagnosed with its fix: {msg}"
1552 );
1553 let msg = not_an_envelope("garbage").to_string();
1555 assert!(
1556 msg.contains("not a DSSE envelope") && msg.contains("varve sign-status"),
1557 "got: {msg}"
1558 );
1559 let (_sk, pk) = generate_root_keypair();
1561 let err = LineStatus::verify_and_parse(raw.as_bytes(), &pk).unwrap_err();
1562 assert!(err.to_string().contains("UNSIGNED"), "got: {err}");
1563 }
1564
1565 #[test]
1567 fn a_deposit_layouts_baseline_is_readable_without_naming_the_line() {
1568 use crate::deposit::{DepositSpec, DepositTool, deposit};
1572 let (sk, pk) = generate_root_keypair();
1573 let tmp = tempfile::tempdir().unwrap();
1574 let dest = tmp.path().join("layout");
1575 let spec = DepositSpec {
1576 includes: Vec::new(),
1577 layer: "2026.07.0".parse().unwrap(),
1578 channel: "qualified".into(),
1579 counter: 1,
1580 issued_at: "2026-08-07T00:00:00Z".into(),
1581 tools: vec![DepositTool {
1582 name: "synth".into(),
1583 version: "1".into(),
1584 platform: None,
1585 bytes: b"t".to_vec(),
1586 source: None,
1587 runner: None,
1588 kind: None,
1589 sdk_prefix: None,
1590 }],
1591 };
1592 deposit(&spec, &sk, "k", &dest).unwrap();
1593
1594 assert!(read_any_from_layout(&dest).unwrap().is_none());
1596
1597 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1598 let envelope = status(3).sign(&sk, "k").unwrap();
1599 attach_to_layout(&dest, &line, envelope.as_bytes()).unwrap();
1600
1601 let carried = read_any_from_layout(&dest).unwrap().unwrap();
1602 let parsed = LineStatus::verify_and_parse(&carried, &pk).unwrap();
1603 assert_eq!(parsed.counter, 3);
1604 }
1605
1606 fn listing(layers: &[&str]) -> KnownLayers {
1611 KnownLayers::Known {
1612 source: "the signed line-index for 2026.07 (counter 1)".into(),
1613 line: Some("2026.07".into()),
1614 layers: layers.iter().map(|s| s.to_string()).collect(),
1615 }
1616 }
1617
1618 #[test]
1620 fn an_affected_id_naming_no_existing_layer_is_refused_and_the_verdict_lists_what_does_exist() {
1621 let mut doc = status(1);
1626 doc.yanked.clear();
1627 doc.known_problems = vec![KnownProblem {
1628 id: "KP-1".into(),
1629 title: "t".into(),
1630 severity: "high".into(),
1631 affected: vec!["2026.07.10".into()],
1632 workaround: None,
1633 detection: None,
1634 mitigation: None,
1635 }];
1636 let err = doc
1637 .check_layer_refs_against(&listing(&["2026.07.0", "2026.07.1"]), false)
1638 .expect_err("an advisory that can never fire must be refused");
1639 assert!(
1640 matches!(&err, LineStatusError::UnknownLayer { id, .. } if id == "2026.07.10"),
1641 "got: {err}"
1642 );
1643 let msg = err.to_string();
1644 assert!(msg.contains("KP-1"), "name the entry at fault: {msg}");
1645 assert!(
1649 msg.contains("2026.07.0") && msg.contains("2026.07.1"),
1650 "the refusal must list the ids that exist: {msg}"
1651 );
1652 assert!(msg.contains("--force"), "{msg}");
1653 }
1654
1655 #[test]
1657 fn a_yank_key_is_checked_against_existing_layers_too_not_only_affected() {
1658 let mut doc = status(1);
1662 doc.known_problems.clear();
1663 doc.yanked = BTreeMap::from([("2026.07.9".to_string(), "CVE".to_string())]);
1664 let err = doc
1665 .check_layer_refs_against(&listing(&["2026.07.0"]), false)
1666 .expect_err("a yank naming no layer must be refused");
1667 assert!(
1668 matches!(&err, LineStatusError::UnknownLayer { what, id, .. }
1669 if what.contains("yank") && id == "2026.07.9"),
1670 "got: {err}"
1671 );
1672 }
1673
1674 #[test]
1676 fn a_document_whose_ids_all_exist_passes_and_says_what_was_checked() {
1677 let check = status(1)
1680 .check_layer_refs_against(&listing(&["2026.07.0", "2026.07.1"]), false)
1681 .expect("every id in the fixture exists on the line");
1682 assert!(check.existence_checked);
1683 assert!(
1684 check.note.contains("checked against") && check.note.contains("2 layers"),
1685 "the note must state the check that RAN: {}",
1686 check.note
1687 );
1688 }
1689
1690 #[test]
1692 fn where_the_line_is_not_visible_the_answer_says_which_check_was_not_run() {
1693 let check = status(1)
1698 .check_layer_refs_against(&KnownLayers::unknown("no line-index was supplied"), false)
1699 .unwrap();
1700 assert!(
1701 !check.existence_checked,
1702 "an unchecked document must not report itself as checked"
1703 );
1704 assert!(
1705 check.note.contains("NOT") && check.note.contains("no line-index was supplied"),
1706 "the note must name the check that did NOT run, and why: {}",
1707 check.note
1708 );
1709 }
1710
1711 #[test]
1713 fn force_allows_a_layer_not_deposited_yet_but_never_a_malformed_id() {
1714 let mut doc = status(1);
1719 doc.yanked.clear();
1720 doc.known_problems = vec![KnownProblem {
1721 id: "KP-1".into(),
1722 title: "t".into(),
1723 severity: "high".into(),
1724 affected: vec!["2026.07.9".into()],
1725 workaround: None,
1726 detection: None,
1727 mitigation: None,
1728 }];
1729 let check = doc
1730 .check_layer_refs_against(&listing(&["2026.07.0"]), true)
1731 .expect("--force pre-signs for a layer not deposited yet");
1732 assert!(
1733 !check.existence_checked,
1734 "forcing must not report the check as having run"
1735 );
1736 assert!(check.note.contains("--force"), "{}", check.note);
1737
1738 for id in ["2026.07", "twenty-twenty-six", "2026.08.0"] {
1741 doc.known_problems[0].affected = vec![id.to_string()];
1742 match doc.check_layer_refs_against(&listing(&["2026.07.0"]), true) {
1743 Err(LineStatusError::DeadReference { .. }) => {}
1744 other => panic!("'{id}' must be refused even under --force, got: {other:?}"),
1745 }
1746 }
1747 }
1748
1749 #[test]
1751 fn a_listing_for_another_line_is_refused_rather_than_used() {
1752 let wrong = KnownLayers::Known {
1756 source: "the signed line-index for 2026.08".into(),
1757 line: Some("2026.08".into()),
1758 layers: vec!["2026.08.0".into()],
1759 };
1760 let err = status(1)
1761 .check_layer_refs_against(&wrong, false)
1762 .expect_err("a listing for another line must not be used as this line's");
1763 assert!(
1764 matches!(&err, LineStatusError::LineMismatch { expected, got }
1765 if expected == "2026.07" && got == "2026.08"),
1766 "got: {err}"
1767 );
1768 }
1769
1770 #[test]
1772 fn a_layout_becomes_a_listing_only_once_the_signed_index_is_attached() {
1773 use crate::deposit::{DepositSpec, DepositTool, deposit};
1778 let (sk, _pk) = generate_root_keypair();
1779 let tmp = tempfile::tempdir().unwrap();
1780 let dest = tmp.path().join("layout");
1781 deposit(
1782 &DepositSpec {
1783 includes: Vec::new(),
1784 layer: "2026.07.0".parse().unwrap(),
1785 channel: "qualified".into(),
1786 counter: 1,
1787 issued_at: "2026-08-07T00:00:00Z".into(),
1788 tools: vec![DepositTool {
1789 name: "synth".into(),
1790 version: "1".into(),
1791 platform: None,
1792 bytes: b"t".to_vec(),
1793 source: None,
1794 runner: None,
1795 kind: None,
1796 sdk_prefix: None,
1797 }],
1798 },
1799 &sk,
1800 "k",
1801 &dest,
1802 )
1803 .unwrap();
1804
1805 let known = known_layers_in_layout(&dest, "2026.07");
1807 assert!(
1808 matches!(&known, KnownLayers::Unknown { why } if why.contains("not a listing")),
1809 "got: {known:?}"
1810 );
1811
1812 let index = crate::lineindex::LineIndex {
1813 line: "2026.07".into(),
1814 counter: 1,
1815 issued_at: "2026-08-07T00:00:00Z".into(),
1816 layers: vec![crate::lineindex::IndexedLayer {
1817 layer: "2026.07.0".into(),
1818 digest: "sha256:aa".into(),
1819 channel: "qualified".into(),
1820 counter: 1,
1821 }],
1822 };
1823 crate::lineindex::attach_to_layout(
1824 &dest,
1825 "2026.07",
1826 index.sign(&sk, "k").unwrap().as_bytes(),
1827 )
1828 .unwrap();
1829
1830 let known = known_layers_in_layout(&dest, "2026.07");
1831 assert_eq!(
1832 known,
1833 KnownLayers::Known {
1834 source: "the signed line-index for 2026.07 (counter 1)".into(),
1835 line: Some("2026.07".into()),
1836 layers: vec!["2026.07.0".into()],
1837 }
1838 );
1839
1840 let mut doc = status(2);
1844 doc.yanked.clear();
1845 doc.known_problems = vec![KnownProblem {
1846 id: "KP-1".into(),
1847 title: "t".into(),
1848 severity: "high".into(),
1849 affected: vec!["2026.07.1".into()],
1850 workaround: None,
1851 detection: None,
1852 mitigation: None,
1853 }];
1854 let err = attach_envelope_to_layout(&dest, doc.sign(&sk, "k").unwrap().as_bytes())
1855 .expect_err("2026.07.1 is not on this line's index");
1856 assert!(
1857 matches!(&err, LineStatusError::UnknownLayer { .. }),
1858 "got: {err}"
1859 );
1860
1861 doc.known_problems[0].affected = vec!["2026.07.0".into()];
1862 let (_line, counter, check) =
1863 attach_envelope_to_layout_checked(&dest, doc.sign(&sk, "k").unwrap().as_bytes(), false)
1864 .unwrap();
1865 assert_eq!(counter, 2);
1866 assert!(check.existence_checked, "{}", check.note);
1867 }
1868
1869 #[test]
1871 fn signing_reports_the_check_it_ran_alongside_the_envelope() {
1872 let (sk, pk) = generate_root_keypair();
1876 let (envelope, check) = status(1)
1877 .sign_against(&listing(&["2026.07.0", "2026.07.1"]), false, &sk, "k")
1878 .unwrap();
1879 assert!(check.existence_checked);
1880 assert_eq!(
1881 LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap(),
1882 status(1),
1883 "the checked path must sign the same document the plain path does"
1884 );
1885
1886 let mut doc = status(1);
1889 doc.yanked.clear();
1890 doc.known_problems[0].affected = vec!["2026.07.7".into()];
1891 doc.known_problems[1].affected = vec!["2026.07.0".into()];
1892 assert!(
1893 doc.sign_against(&listing(&["2026.07.0"]), false, &sk, "k")
1894 .is_err()
1895 );
1896 }
1897
1898 #[test]
1900 fn a_producer_can_list_their_own_line_without_a_network_or_an_index() {
1901 let tmp = tempfile::tempdir().unwrap();
1906 let (sk, _pk) = crate::generate_root_keypair();
1907 for (id, counter, dir) in [
1914 ("2026.08.0", 1u64, "out-a"),
1915 ("2026.08.1", 2, "out-b"),
1916 ("2026.09.0", 1, "out-other"),
1918 ] {
1919 let spec = crate::deposit::DepositSpec {
1920 layer: id.parse().unwrap(),
1921 channel: "rolling".into(),
1922 counter,
1923 issued_at: "2026-08-07T00:00:00Z".into(),
1924 tools: vec![crate::DepositTool {
1925 name: "t".into(),
1926 version: "1.0".into(),
1927 platform: None,
1928 bytes: b"x".to_vec(),
1929 source: None,
1930 runner: None,
1931 kind: None,
1932 sdk_prefix: None,
1933 }],
1934 includes: Vec::new(),
1935 };
1936 crate::deposit(&spec, &sk, "k", &tmp.path().join(dir)).unwrap();
1937 }
1938
1939 let known = known_layers_in_layout_dirs(&[tmp.path().to_path_buf()], "2026.08");
1941 match &known {
1942 KnownLayers::Known { layers, line, .. } => {
1943 assert_eq!(layers, &["2026.08.0", "2026.08.1"], "got {layers:?}");
1944 assert_eq!(line.as_deref(), Some("2026.08"));
1945 }
1946 KnownLayers::Unknown { why } => panic!("expected a listing, got: {why}"),
1947 }
1948
1949 let mut doc = status(2);
1951 doc.line = "2026.08".into();
1952 doc.yanked = BTreeMap::from([(
1953 "2026.08.10".to_string(),
1954 "typo — never deposited".to_string(),
1955 )]);
1956 doc.known_problems.clear();
1957 let err = doc
1958 .check_layer_refs_against(&known, false)
1959 .expect_err("a yank naming a layer this line does not have must be refused");
1960 let msg = err.to_string();
1961 assert!(
1962 msg.contains("2026.08.10") && msg.contains("2026.08.0"),
1963 "the refusal must name the bad id AND the ids that exist: {msg}"
1964 );
1965
1966 let empty = tempfile::tempdir().unwrap();
1968 assert!(matches!(
1969 known_layers_in_layout_dirs(&[empty.path().to_path_buf()], "2026.08"),
1970 KnownLayers::Unknown { .. }
1971 ));
1972 }
1973}