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(
109 "{layout} is not an OCI image layout (it has no index.json) — point --layout at the \
110 directory `varve deposit --out` produced"
111 )]
112 NotALayout { layout: String },
113 #[error("io error at {path}")]
117 Io {
118 path: String,
119 #[source]
120 source: std::io::Error,
121 },
122}
123
124impl LineStatus {
125 pub fn verify_and_parse(
127 envelope: &[u8],
128 root_public_key: &[u8],
129 ) -> Result<Self, LineStatusError> {
130 if let Ok(text) = std::str::from_utf8(envelope)
135 && wsc::dsse::DsseEnvelope::from_json(text).is_err()
136 {
137 return Err(not_an_envelope(text));
138 }
139 let payload = dsse_verify_typed(envelope, LINE_STATUS_PAYLOAD_TYPE, root_public_key)
140 .map_err(|VerifyError(msg)| {
141 let hint = if msg.contains("does not verify") {
145 " (is the document signed by THIS realm's root? `varve pubkey <key>` \
146 prints the public half a signature verifies against)"
147 } else {
148 ""
149 };
150 LineStatusError::Envelope(format!("{msg}{hint}"))
151 })?;
152 serde_json::from_slice(&payload).map_err(|e| LineStatusError::Payload(e.to_string()))
153 }
154
155 pub fn sign(&self, secret_key: &[u8], key_id: &str) -> Result<String, LineStatusError> {
160 self.check_layer_refs()?;
161 let payload = serde_json::to_vec_pretty(self).expect("status serializes");
162 dsse_sign_typed(&payload, LINE_STATUS_PAYLOAD_TYPE, secret_key, key_id)
163 .map_err(|VerifyError(msg)| LineStatusError::Sign(msg))
164 }
165
166 pub fn check_layer_refs(&self) -> Result<(), LineStatusError> {
173 let line: Line = self.line.parse().map_err(|e| {
174 LineStatusError::Payload(format!("'{}' is not a YYYY.MM line: {e}", self.line))
175 })?;
176 let check = |what: String, id: &str| -> Result<(), LineStatusError> {
177 let dead = |reason: String| LineStatusError::DeadReference {
178 what: what.clone(),
179 id: id.to_string(),
180 line: self.line.clone(),
181 reason,
182 };
183 match id.parse::<LayerId>() {
184 Ok(layer) if layer.line() == &line => Ok(()),
185 Ok(layer) => Err(dead(format!("it belongs to line {}", layer.line()))),
186 Err(e) => Err(dead(e.to_string())),
187 }
188 };
189 for id in self.yanked.keys() {
190 check("the yank entry".to_string(), id)?;
191 }
192 for kp in &self.known_problems {
193 for id in &kp.affected {
194 check(format!("known problem '{}'", kp.id), id)?;
195 }
196 }
197 Ok(())
198 }
199
200 pub fn report_for(&self, layer: &LayerId) -> LayerStatusReport {
202 let name = layer.to_string();
203 let problems: Vec<&KnownProblem> = self
204 .known_problems
205 .iter()
206 .filter(|kp| kp.affected.iter().any(|a| a == &name))
207 .collect();
208 LayerStatusReport {
209 yanked_reason: self.yanked.get(&name).cloned(),
210 support_until: self.support_until.clone(),
211 problems_total: problems.len(),
212 problems_with_workaround: problems.iter().filter(|kp| kp.workaround.is_some()).count(),
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct LayerStatusReport {
219 pub yanked_reason: Option<String>,
220 pub support_until: Option<String>,
221 pub problems_total: usize,
222 pub problems_with_workaround: usize,
223}
224
225#[derive(Debug)]
227pub struct StatusCache {
228 dir: PathBuf,
229}
230
231impl StatusCache {
232 pub fn at_root(root: &Path) -> Self {
233 StatusCache {
234 dir: root.join("state").join("line-status"),
235 }
236 }
237
238 pub fn update(
240 &self,
241 line: &Line,
242 envelope: &[u8],
243 parsed: &LineStatus,
244 ) -> Result<(), LineStatusError> {
245 if let Some(cached) = self.load_parsed(line)?
246 && parsed.counter < cached.counter
247 {
248 return Err(LineStatusError::Stale {
249 line: line.to_string(),
250 presented: parsed.counter,
251 cached: cached.counter,
252 });
253 }
254 let io = |path: &Path, source: std::io::Error| LineStatusError::Io {
255 path: path.display().to_string(),
256 source,
257 };
258 std::fs::create_dir_all(&self.dir).map_err(|e| io(&self.dir, e))?;
259 let path = self.envelope_path(line);
260 std::fs::write(&path, envelope).map_err(|e| io(&path, e))?;
261 Ok(())
262 }
263
264 pub fn envelope_bytes(&self, line: &Line) -> Result<Option<Vec<u8>>, LineStatusError> {
271 let path = self.envelope_path(line);
272 match std::fs::read(&path) {
273 Ok(bytes) => Ok(Some(bytes)),
274 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
275 Err(source) => Err(LineStatusError::Io {
276 path: path.display().to_string(),
277 source,
278 }),
279 }
280 }
281
282 pub fn load(
284 &self,
285 line: &Line,
286 root_public_key: &[u8],
287 ) -> Result<Option<LineStatus>, LineStatusError> {
288 let path = self.envelope_path(line);
289 match std::fs::read(&path) {
290 Ok(bytes) => Ok(Some(LineStatus::verify_and_parse(&bytes, root_public_key)?)),
291 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
292 Err(source) => Err(LineStatusError::Io {
293 path: path.display().to_string(),
294 source,
295 }),
296 }
297 }
298
299 fn load_parsed(&self, line: &Line) -> Result<Option<LineStatus>, LineStatusError> {
300 let path = self.envelope_path(line);
301 match std::fs::read(&path) {
302 Ok(bytes) => {
303 let text = std::str::from_utf8(&bytes)
306 .map_err(|_| LineStatusError::Payload("cache is not UTF-8".into()))?;
307 let env = wsc::dsse::DsseEnvelope::from_json(text)
308 .map_err(|e| LineStatusError::Payload(e.to_string()))?;
309 let payload = env
310 .payload_bytes()
311 .map_err(|e| LineStatusError::Payload(e.to_string()))?;
312 Ok(Some(
313 serde_json::from_slice(&payload)
314 .map_err(|e| LineStatusError::Payload(e.to_string()))?,
315 ))
316 }
317 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
318 Err(source) => Err(LineStatusError::Io {
319 path: path.display().to_string(),
320 source,
321 }),
322 }
323 }
324
325 fn envelope_path(&self, line: &Line) -> PathBuf {
326 self.dir.join(format!("{line}.dsse.json"))
327 }
328}
329
330pub const LINE_STATUS_ARTIFACT_TYPE: &str = LINE_STATUS_PAYLOAD_TYPE;
332pub const ANN_LINE: &str = "eu.pulseengine.varve.status-line";
334
335pub fn attach_to_layout(
339 layout: &Path,
340 line: &Line,
341 envelope: &[u8],
342) -> Result<(), LineStatusError> {
343 let io = |path: &Path, source: std::io::Error| LineStatusError::Io {
344 path: path.display().to_string(),
345 source,
346 };
347 let digest = crate::store::manifest_digest(envelope);
348 let hex = digest.strip_prefix("sha256:").expect("digest shape");
349 let blob_dir = layout.join("blobs").join("sha256");
350 std::fs::create_dir_all(&blob_dir).map_err(|e| io(&blob_dir, e))?;
351 let blob_path = blob_dir.join(hex);
352 std::fs::write(&blob_path, envelope).map_err(|e| io(&blob_path, e))?;
353
354 let index_path = layout.join("index.json");
355 let mut index: serde_json::Value =
356 serde_json::from_slice(&std::fs::read(&index_path).map_err(|e| io(&index_path, e))?)
357 .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
358 let entries = index["manifests"]
359 .as_array_mut()
360 .ok_or_else(|| LineStatusError::Payload("index.json has no manifests array".into()))?;
361 let line_name = line.to_string();
362 entries.retain(|e| {
363 !(e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE
364 && e["annotations"][ANN_LINE] == *line_name)
365 });
366 entries.push(serde_json::json!({
367 "mediaType": "application/json",
368 "artifactType": LINE_STATUS_ARTIFACT_TYPE,
369 "digest": digest,
370 "size": envelope.len(),
371 "annotations": { ANN_LINE: line_name }
372 }));
373 std::fs::write(
374 &index_path,
375 serde_json::to_vec_pretty(&index).expect("index serializes"),
376 )
377 .map_err(|e| io(&index_path, e))?;
378 Ok(())
379}
380
381pub fn cache_baseline_from_source(
390 source: &dyn crate::source::LayerSource,
391 layer: &crate::source::LayerRef,
392 line: &Line,
393 root_pk: &[u8],
394 store_root: &Path,
395) -> Result<Option<u64>, LineStatusError> {
396 let envelope = match source
397 .fetch_line_status(layer)
398 .map_err(|e| LineStatusError::Payload(format!("fetching baseline line-status: {e}")))?
399 {
400 Some(bytes) => bytes,
401 None => return Ok(None),
402 };
403 let doc = LineStatus::verify_and_parse(&envelope, root_pk)?;
404 if doc.line != line.to_string() {
407 return Err(LineStatusError::LineMismatch {
408 expected: line.to_string(),
409 got: doc.line,
410 });
411 }
412 let counter = doc.counter;
413 StatusCache::at_root(store_root).update(line, &envelope, &doc)?;
414 Ok(Some(counter))
415}
416
417pub fn attach_envelope_to_layout(
423 layout: &Path,
424 envelope: &[u8],
425) -> Result<(Line, u64), LineStatusError> {
426 if !layout.join("index.json").is_file() {
430 return Err(LineStatusError::NotALayout {
431 layout: layout.display().to_string(),
432 });
433 }
434 let text = std::str::from_utf8(envelope)
435 .map_err(|e| LineStatusError::Payload(format!("envelope is not utf-8: {e}")))?;
436 let env = wsc::dsse::DsseEnvelope::from_json(text).map_err(|_| not_an_envelope(text))?;
437 let payload = env
438 .payload_bytes()
439 .map_err(|e| LineStatusError::Payload(format!("envelope payload: {e}")))?;
440 let doc: LineStatus = serde_json::from_slice(&payload)
441 .map_err(|e| LineStatusError::Payload(format!("status document: {e}")))?;
442 let line: Line = doc
443 .line
444 .parse()
445 .map_err(|e| LineStatusError::Payload(format!("status line '{}': {e}", doc.line)))?;
446 doc.check_layer_refs()?;
450 if let Some(existing) = read_any_from_layout(layout)?
457 && let Ok(prev) = parse_unverified(&existing)
458 && prev.line == doc.line
459 && doc.counter < prev.counter
460 {
461 return Err(LineStatusError::Stale {
462 line: doc.line.clone(),
463 presented: doc.counter,
464 cached: prev.counter,
465 });
466 }
467 if let Some(layout_line) = layout_line(layout)
471 && layout_line != line.to_string()
472 {
473 return Err(LineStatusError::LineMismatch {
474 expected: layout_line,
475 got: line.to_string(),
476 });
477 }
478 attach_to_layout(layout, &line, envelope)?;
479 Ok((line, doc.counter))
480}
481
482fn not_an_envelope(text: &str) -> LineStatusError {
488 if serde_json::from_str::<LineStatus>(text).is_ok() {
489 LineStatusError::Payload(
490 "this is the UNSIGNED status document, not a signed envelope — sign it first \
491 (`varve sign-status --file <doc> --key <key> --out <envelope>`) and pass the \
492 envelope"
493 .into(),
494 )
495 } else {
496 LineStatusError::Payload(
497 "not a DSSE envelope — expected the signed output of `varve sign-status`".into(),
498 )
499 }
500}
501
502fn parse_unverified(envelope: &[u8]) -> Result<LineStatus, LineStatusError> {
506 let text = std::str::from_utf8(envelope)
507 .map_err(|e| LineStatusError::Payload(format!("envelope is not utf-8: {e}")))?;
508 let env = wsc::dsse::DsseEnvelope::from_json(text).map_err(|_| not_an_envelope(text))?;
509 let payload = env
510 .payload_bytes()
511 .map_err(|e| LineStatusError::Payload(format!("envelope payload: {e}")))?;
512 serde_json::from_slice(&payload)
513 .map_err(|e| LineStatusError::Payload(format!("status document: {e}")))
514}
515
516pub(crate) fn layout_line(layout: &Path) -> Option<String> {
520 let index: serde_json::Value =
521 serde_json::from_slice(&std::fs::read(layout.join("index.json")).ok()?).ok()?;
522 for m in index["manifests"].as_array()? {
523 let digest = m["digest"].as_str()?.replace(':', "-");
524 let blob = layout
525 .join("blobs")
526 .join("sha256")
527 .join(digest.trim_start_matches("sha256-"));
528 let Ok(bytes) = std::fs::read(&blob) else {
529 continue;
530 };
531 let Ok(text) = std::str::from_utf8(&bytes) else {
534 continue;
535 };
536 let Ok(env) = wsc::dsse::DsseEnvelope::from_json(text) else {
537 continue;
538 };
539 let Ok(payload) = env.payload_bytes() else {
540 continue;
541 };
542 let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&payload) else {
543 continue;
544 };
545 if let Some(line) = doc["annotations"]["eu.pulseengine.varve.line"].as_str() {
546 return Some(line.to_string());
547 }
548 }
549 None
550}
551
552pub fn read_any_from_layout(layout: &Path) -> Result<Option<Vec<u8>>, LineStatusError> {
557 let index_path = layout.join("index.json");
558 let bytes = match std::fs::read(&index_path) {
559 Ok(bytes) => bytes,
560 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
561 Err(source) => {
562 return Err(LineStatusError::Io {
563 path: index_path.display().to_string(),
564 source,
565 });
566 }
567 };
568 let index: serde_json::Value = serde_json::from_slice(&bytes)
569 .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
570 let Some(entry) = index["manifests"].as_array().and_then(|entries| {
571 entries
572 .iter()
573 .find(|e| e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE)
574 }) else {
575 return Ok(None);
576 };
577 let digest = entry["digest"]
578 .as_str()
579 .ok_or_else(|| LineStatusError::Payload("status entry has no digest".into()))?;
580 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
581 let blob_path = layout.join("blobs").join("sha256").join(hex);
582 std::fs::read(&blob_path)
583 .map(Some)
584 .map_err(|source| LineStatusError::Io {
585 path: blob_path.display().to_string(),
586 source,
587 })
588}
589
590pub fn read_from_layout(layout: &Path, line: &Line) -> Result<Option<Vec<u8>>, LineStatusError> {
592 let index_path = layout.join("index.json");
593 let bytes = match std::fs::read(&index_path) {
594 Ok(bytes) => bytes,
595 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
596 Err(source) => {
597 return Err(LineStatusError::Io {
598 path: index_path.display().to_string(),
599 source,
600 });
601 }
602 };
603 let index: serde_json::Value = serde_json::from_slice(&bytes)
604 .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
605 let line_name = line.to_string();
606 let Some(entry) = index["manifests"].as_array().and_then(|entries| {
607 entries.iter().find(|e| {
608 e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE
609 && e["annotations"][ANN_LINE] == *line_name
610 })
611 }) else {
612 return Ok(None);
613 };
614 let digest = entry["digest"]
615 .as_str()
616 .ok_or_else(|| LineStatusError::Payload("status entry has no digest".into()))?;
617 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
618 let blob_path = layout.join("blobs").join("sha256").join(hex);
619 std::fs::read(&blob_path)
620 .map(Some)
621 .map_err(|source| LineStatusError::Io {
622 path: blob_path.display().to_string(),
623 source,
624 })
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630 use crate::verify::generate_root_keypair;
631
632 fn status(counter: u64) -> LineStatus {
633 LineStatus {
634 line: "2026.07".into(),
635 counter,
636 issued_at: "2026-08-07T00:00:00Z".into(),
637 support_until: Some("2028-07-31".into()),
638 yanked: BTreeMap::from([(
639 "2026.07.0".to_string(),
640 "CVE-2026-0001 in synth".to_string(),
641 )]),
642 known_problems: vec![
643 KnownProblem {
644 id: "KP-1".into(),
645 title: "synth mla fusion regresses flat_flight".into(),
646 severity: "medium".into(),
647 affected: vec!["2026.07.0".into()],
648 workaround: Some("disable mla fusion".into()),
649 detection: None,
650 mitigation: None,
651 },
652 KnownProblem {
653 id: "KP-2".into(),
654 title: "witness truth-table gap on nested variants".into(),
655 severity: "high".into(),
656 affected: vec!["2026.07.0".into(), "2026.07.1".into()],
657 workaround: None,
658 detection: Some("witness gap rows non-empty".into()),
659 mitigation: None,
660 },
661 ],
662 }
663 }
664
665 #[test]
667 fn a_signed_status_document_round_trips() {
668 let (sk, pk) = generate_root_keypair();
669 let envelope = status(1).sign(&sk, "varve-root-1").unwrap();
670 let parsed = LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap();
671 assert_eq!(parsed, status(1));
672 }
673
674 #[test]
676 fn a_layer_manifest_envelope_cannot_pose_as_a_status_document() {
677 let (sk, pk) = generate_root_keypair();
678 let manifest = crate::manifest::fixtures::manifest(
680 "2026.07.0",
681 "qualified",
682 1,
683 "2026-08-07T00:00:00Z",
684 );
685 let envelope = crate::verify::sign_layer_manifest(&manifest, &sk, "varve-root-1").unwrap();
686 let err = LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap_err();
687 assert!(err.to_string().contains("payload type"), "got: {err}");
688 }
689
690 #[test]
692 fn the_report_names_yank_support_window_and_problem_counts() {
693 let doc = status(1);
694 let report = doc.report_for(&"2026.07.0".parse().unwrap());
695 assert_eq!(
696 report.yanked_reason.as_deref(),
697 Some("CVE-2026-0001 in synth")
698 );
699 assert_eq!(report.support_until.as_deref(), Some("2028-07-31"));
700 assert_eq!(report.problems_total, 2);
701 assert_eq!(report.problems_with_workaround, 1);
702 let clean = doc.report_for(&"2026.07.2".parse().unwrap());
703 assert_eq!(clean.yanked_reason, None);
704 assert_eq!(clean.problems_total, 0);
705 }
706
707 #[test]
709 fn attaching_status_to_a_layout_leaves_every_layer_blob_untouched() {
710 use crate::deposit::{DepositSpec, DepositTool, deposit};
711 let (sk, pk) = generate_root_keypair();
712 let tmp = tempfile::tempdir().unwrap();
713 let dest = tmp.path().join("layout");
714 let spec = DepositSpec {
715 includes: Vec::new(),
716 layer: "2026.07.0".parse().unwrap(),
717 channel: "qualified".into(),
718 counter: 1,
719 issued_at: "2026-08-07T00:00:00Z".into(),
720 tools: vec![DepositTool {
721 name: "synth".into(),
722 version: "1".into(),
723 platform: None,
724 bytes: b"t".to_vec(),
725 source: None,
726 runner: None,
727 kind: None,
728 sdk_prefix: None,
729 }],
730 };
731 let outcome = deposit(&spec, &sk, "k", &dest).unwrap();
732
733 let blob_dir = dest.join("blobs/sha256");
735 let before: std::collections::BTreeMap<String, Vec<u8>> = std::fs::read_dir(&blob_dir)
736 .unwrap()
737 .map(|e| {
738 let p = e.unwrap().path();
739 (
740 p.file_name().unwrap().to_string_lossy().into_owned(),
741 std::fs::read(&p).unwrap(),
742 )
743 })
744 .collect();
745
746 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
747 let envelope = status(1).sign(&sk, "k").unwrap();
748 attach_to_layout(&dest, &line, envelope.as_bytes()).unwrap();
749
750 for (name, bytes) in &before {
753 assert_eq!(&std::fs::read(blob_dir.join(name)).unwrap(), bytes);
754 }
755 let carried = read_from_layout(&dest, &line).unwrap().unwrap();
756 let parsed = LineStatus::verify_and_parse(&carried, &pk).unwrap();
757 assert_eq!(parsed.counter, 1);
758 let hex = outcome.digest.strip_prefix("sha256:").unwrap();
759 assert!(
760 blob_dir.join(hex).is_file(),
761 "layer manifest blob still present"
762 );
763
764 let envelope2 = status(2).sign(&sk, "k").unwrap();
766 attach_to_layout(&dest, &line, envelope2.as_bytes()).unwrap();
767 let index: serde_json::Value =
768 serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
769 let count = index["manifests"]
770 .as_array()
771 .unwrap()
772 .iter()
773 .filter(|e| e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE)
774 .count();
775 assert_eq!(count, 1);
776 }
777
778 #[test]
780 fn the_cache_refuses_a_counter_regression() {
781 let (sk, pk) = generate_root_keypair();
782 let tmp = tempfile::tempdir().unwrap();
783 let cache = StatusCache::at_root(tmp.path());
784 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
785
786 let newer = status(2);
787 let env2 = newer.sign(&sk, "k").unwrap();
788 cache.update(&line, env2.as_bytes(), &newer).unwrap();
789
790 let older = status(1);
791 let env1 = older.sign(&sk, "k").unwrap();
792 let err = cache.update(&line, env1.as_bytes(), &older).unwrap_err();
793 assert!(matches!(
794 err,
795 LineStatusError::Stale {
796 presented: 1,
797 cached: 2,
798 ..
799 }
800 ));
801
802 let loaded = cache.load(&line, &pk).unwrap().unwrap();
804 assert_eq!(loaded.counter, 2);
805 }
806
807 #[test]
809 fn a_source_baseline_is_verified_and_cached_so_status_works_offline() {
810 use crate::source::{LayerRef, MemorySource};
811 let (sk, pk) = generate_root_keypair();
812 let tmp = tempfile::tempdir().unwrap();
813 let store_root = tmp.path();
814 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
815 let doc = status(5);
816 let envelope = doc.sign(&sk, "k").unwrap();
817 let source = MemorySource::new().with_line_status(envelope.as_bytes());
818 let layer = LayerRef::Name("2026.07.0".parse().unwrap());
819
820 let cached = cache_baseline_from_source(&source, &layer, &line, &pk, store_root).unwrap();
821 assert_eq!(
822 cached,
823 Some(5),
824 "a carried baseline is cached at its counter"
825 );
826
827 let loaded = StatusCache::at_root(store_root)
829 .load(&line, &pk)
830 .unwrap()
831 .unwrap();
832 assert_eq!(loaded.counter, 5);
833 }
834
835 #[test]
837 fn a_baseline_for_the_wrong_line_is_refused_not_miscached() {
838 use crate::source::{LayerRef, MemorySource};
843 let (sk, pk) = generate_root_keypair();
844 let tmp = tempfile::tempdir().unwrap();
845 let requested: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
846 let doc = LineStatus {
849 line: "2026.08".into(),
850 counter: 5,
851 issued_at: "2026-08-07T00:00:00Z".into(),
852 support_until: None,
853 yanked: BTreeMap::new(),
854 known_problems: Vec::new(),
855 };
856 let envelope = doc.sign(&sk, "k").unwrap();
857 let source = MemorySource::new().with_line_status(envelope.as_bytes());
858 let err = cache_baseline_from_source(
859 &source,
860 &LayerRef::Name("2026.07.0".parse().unwrap()),
861 &requested,
862 &pk,
863 tmp.path(),
864 )
865 .unwrap_err();
866 assert!(
867 matches!(err, LineStatusError::LineMismatch { .. }),
868 "a baseline for the wrong line must be refused: {err}"
869 );
870 assert!(
871 StatusCache::at_root(tmp.path())
872 .load(&requested, &pk)
873 .unwrap()
874 .is_none(),
875 "nothing is cached under the requested line"
876 );
877 }
878
879 #[test]
881 fn a_source_with_no_baseline_caches_nothing_and_does_not_error() {
882 use crate::source::{LayerRef, MemorySource};
883 let (_sk, pk) = generate_root_keypair();
884 let tmp = tempfile::tempdir().unwrap();
885 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
886 let source = MemorySource::new();
887 let cached = cache_baseline_from_source(
888 &source,
889 &LayerRef::Name("2026.07.0".parse().unwrap()),
890 &line,
891 &pk,
892 tmp.path(),
893 )
894 .unwrap();
895 assert_eq!(cached, None);
896 }
897
898 #[test]
900 fn a_baseline_signed_by_an_impostor_is_refused_not_cached() {
901 use crate::source::{LayerRef, MemorySource};
902 let (attacker_sk, _) = generate_root_keypair();
903 let (_real_sk, real_pk) = generate_root_keypair();
904 let tmp = tempfile::tempdir().unwrap();
905 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
906 let envelope = status(5).sign(&attacker_sk, "k").unwrap();
907 let source = MemorySource::new().with_line_status(envelope.as_bytes());
908 let err = cache_baseline_from_source(
909 &source,
910 &LayerRef::Name("2026.07.0".parse().unwrap()),
911 &line,
912 &real_pk,
913 tmp.path(),
914 )
915 .unwrap_err();
916 assert!(
918 StatusCache::at_root(tmp.path())
919 .load(&line, &real_pk)
920 .unwrap()
921 .is_none(),
922 "a baseline that fails verification must not be cached: {err}"
923 );
924 }
925
926 #[test]
928 fn attaching_by_envelope_derives_the_line_from_the_document() {
929 use crate::deposit::{DepositSpec, DepositTool, deposit};
930 let (sk, pk) = generate_root_keypair();
931 let tmp = tempfile::tempdir().unwrap();
932 let dest = tmp.path().join("layout");
933 deposit(
934 &DepositSpec {
935 includes: Vec::new(),
936 layer: "2026.07.0".parse().unwrap(),
937 channel: "qualified".into(),
938 counter: 1,
939 issued_at: "2026-08-07T00:00:00Z".into(),
940 tools: vec![DepositTool {
941 name: "synth".into(),
942 version: "1".into(),
943 platform: None,
944 bytes: b"t".to_vec(),
945 source: None,
946 runner: None,
947 kind: None,
948 sdk_prefix: None,
949 }],
950 },
951 &sk,
952 "k",
953 &dest,
954 )
955 .unwrap();
956
957 let envelope = status(4).sign(&sk, "k").unwrap();
958 let (line, counter) = attach_envelope_to_layout(&dest, envelope.as_bytes()).unwrap();
959 assert_eq!(line.to_string(), "2026.07");
960 assert_eq!(counter, 4);
961 let carried = read_any_from_layout(&dest).unwrap().unwrap();
963 assert_eq!(
964 LineStatus::verify_and_parse(&carried, &pk).unwrap().counter,
965 4
966 );
967 }
968
969 #[test]
971 fn attaching_a_stale_document_over_a_newer_one_is_refused() {
972 use crate::deposit::{DepositSpec, DepositTool, deposit};
980 let (sk, _pk) = generate_root_keypair();
981 let tmp = tempfile::tempdir().unwrap();
982 let dest = tmp.path().join("layout");
983 deposit(
984 &DepositSpec {
985 includes: Vec::new(),
986 layer: "2026.07.0".parse().unwrap(),
987 channel: "qualified".into(),
988 counter: 1,
989 issued_at: "2026-08-07T00:00:00Z".into(),
990 tools: vec![DepositTool {
991 name: "synth".into(),
992 version: "1".into(),
993 platform: None,
994 bytes: b"t".to_vec(),
995 source: None,
996 runner: None,
997 kind: None,
998 sdk_prefix: None,
999 }],
1000 },
1001 &sk,
1002 "k",
1003 &dest,
1004 )
1005 .unwrap();
1006
1007 attach_envelope_to_layout(&dest, status(7).sign(&sk, "k").unwrap().as_bytes()).unwrap();
1009 let err = attach_envelope_to_layout(&dest, status(3).sign(&sk, "k").unwrap().as_bytes())
1011 .unwrap_err();
1012 assert!(
1013 matches!(
1014 err,
1015 LineStatusError::Stale {
1016 presented: 3,
1017 cached: 7,
1018 ..
1019 }
1020 ),
1021 "a lower counter must be refused, got {err}"
1022 );
1023 let msg = err.to_string();
1024 assert!(msg.contains('3') && msg.contains('7'), "names both: {msg}");
1025 let carried = parse_unverified(&read_any_from_layout(&dest).unwrap().unwrap()).unwrap();
1027 assert_eq!(
1028 carried.counter, 7,
1029 "the newer baseline survives the attempt"
1030 );
1031 attach_envelope_to_layout(&dest, status(7).sign(&sk, "k").unwrap().as_bytes()).unwrap();
1034 }
1035
1036 #[test]
1038 fn an_advisory_that_could_never_fire_is_refused_at_sign_time() {
1039 let (sk, _pk) = generate_root_keypair();
1044 let cases: &[(&str, &str)] = &[
1045 ("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"),
1049 ];
1050 for (bad, why) in cases {
1051 let mut doc = status(1);
1052 doc.known_problems[0].affected = vec![bad.to_string()];
1053 let err = doc.sign(&sk, "k").unwrap_err();
1054 assert!(
1055 matches!(err, LineStatusError::DeadReference { .. }),
1056 "{why}: affected id {bad:?} must be refused, got: {err}"
1057 );
1058 let msg = err.to_string();
1059 assert!(
1060 msg.contains(bad) && msg.contains("2026.07") && msg.contains("re-sign"),
1061 "the error must name the id, the line, and the fix: {msg}"
1062 );
1063 }
1064 let mut doc = status(1);
1066 doc.yanked = BTreeMap::from([("2026.8.0".to_string(), "CVE".to_string())]);
1067 assert!(matches!(
1068 doc.sign(&sk, "k").unwrap_err(),
1069 LineStatusError::DeadReference { .. }
1070 ));
1071 status(1).sign(&sk, "k").unwrap();
1074 }
1075
1076 #[test]
1078 fn attach_refuses_a_pre_signed_advisory_that_could_never_fire() {
1079 use crate::deposit::{DepositSpec, DepositTool, deposit};
1084 let (sk, _pk) = generate_root_keypair();
1085 let tmp = tempfile::tempdir().unwrap();
1086 let dest = tmp.path().join("layout");
1087 deposit(
1088 &DepositSpec {
1089 includes: Vec::new(),
1090 layer: "2026.07.0".parse().unwrap(),
1091 channel: "qualified".into(),
1092 counter: 1,
1093 issued_at: "2026-08-07T00:00:00Z".into(),
1094 tools: vec![DepositTool {
1095 name: "synth".into(),
1096 version: "1".into(),
1097 platform: None,
1098 bytes: b"t".to_vec(),
1099 source: None,
1100 runner: None,
1101 kind: None,
1102 sdk_prefix: None,
1103 }],
1104 },
1105 &sk,
1106 "k",
1107 &dest,
1108 )
1109 .unwrap();
1110 let mut doc = status(1);
1111 doc.known_problems[0].affected = vec!["2026.7.0".to_string()];
1112 let payload = serde_json::to_vec_pretty(&doc).unwrap();
1113 let envelope = dsse_sign_typed(&payload, LINE_STATUS_PAYLOAD_TYPE, &sk, "k").unwrap();
1114 let err = attach_envelope_to_layout(&dest, envelope.as_bytes()).unwrap_err();
1115 assert!(
1116 matches!(err, LineStatusError::DeadReference { .. }),
1117 "got: {err}"
1118 );
1119 assert!(
1120 read_any_from_layout(&dest).unwrap().is_none(),
1121 "the dead advisory must not land in the layout"
1122 );
1123 }
1124
1125 #[test]
1127 fn attaching_to_a_directory_that_is_not_a_layout_is_refused_before_writing() {
1128 let (sk, _pk) = generate_root_keypair();
1133 let tmp = tempfile::tempdir().unwrap();
1134 let not_a_layout = tmp.path().join("somedir");
1135 std::fs::create_dir_all(¬_a_layout).unwrap();
1136 let envelope = status(1).sign(&sk, "k").unwrap();
1137 let err = attach_envelope_to_layout(¬_a_layout, envelope.as_bytes()).unwrap_err();
1138 assert!(
1139 matches!(err, LineStatusError::NotALayout { .. }),
1140 "got: {err}"
1141 );
1142 assert!(
1143 err.to_string().contains("varve deposit"),
1144 "the error must carry its fix: {err}"
1145 );
1146 assert!(
1147 !not_a_layout.join("blobs").exists(),
1148 "nothing may be written into a directory that is not a layout"
1149 );
1150 }
1151
1152 #[test]
1154 fn the_unsigned_document_mistake_is_named_not_wrapped() {
1155 let raw = serde_json::to_string_pretty(&status(1)).unwrap();
1159 let err = not_an_envelope(&raw);
1160 let msg = err.to_string();
1161 assert!(
1162 msg.contains("UNSIGNED") && msg.contains("varve sign-status"),
1163 "raw document must be diagnosed with its fix: {msg}"
1164 );
1165 let msg = not_an_envelope("garbage").to_string();
1167 assert!(
1168 msg.contains("not a DSSE envelope") && msg.contains("varve sign-status"),
1169 "got: {msg}"
1170 );
1171 let (_sk, pk) = generate_root_keypair();
1173 let err = LineStatus::verify_and_parse(raw.as_bytes(), &pk).unwrap_err();
1174 assert!(err.to_string().contains("UNSIGNED"), "got: {err}");
1175 }
1176
1177 #[test]
1179 fn a_deposit_layouts_baseline_is_readable_without_naming_the_line() {
1180 use crate::deposit::{DepositSpec, DepositTool, deposit};
1184 let (sk, pk) = generate_root_keypair();
1185 let tmp = tempfile::tempdir().unwrap();
1186 let dest = tmp.path().join("layout");
1187 let spec = DepositSpec {
1188 includes: Vec::new(),
1189 layer: "2026.07.0".parse().unwrap(),
1190 channel: "qualified".into(),
1191 counter: 1,
1192 issued_at: "2026-08-07T00:00:00Z".into(),
1193 tools: vec![DepositTool {
1194 name: "synth".into(),
1195 version: "1".into(),
1196 platform: None,
1197 bytes: b"t".to_vec(),
1198 source: None,
1199 runner: None,
1200 kind: None,
1201 sdk_prefix: None,
1202 }],
1203 };
1204 deposit(&spec, &sk, "k", &dest).unwrap();
1205
1206 assert!(read_any_from_layout(&dest).unwrap().is_none());
1208
1209 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1210 let envelope = status(3).sign(&sk, "k").unwrap();
1211 attach_to_layout(&dest, &line, envelope.as_bytes()).unwrap();
1212
1213 let carried = read_any_from_layout(&dest).unwrap().unwrap();
1214 let parsed = LineStatus::verify_and_parse(&carried, &pk).unwrap();
1215 assert_eq!(parsed.counter, 3);
1216 }
1217}