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(transparent)]
73 Verify(#[from] VerifyError),
74 #[error("line-status payload is not valid: {0}")]
75 Payload(String),
76 #[error("line-status covers line {got} but line {expected} was requested")]
77 LineMismatch { expected: String, got: String },
78 #[error(
79 "refusing stale line-status document for {line}: presented counter {presented}, cached {cached}"
80 )]
81 Stale {
82 line: String,
83 presented: u64,
84 cached: u64,
85 },
86 #[error("io error at {path}: {source}")]
87 Io {
88 path: String,
89 #[source]
90 source: std::io::Error,
91 },
92}
93
94impl LineStatus {
95 pub fn verify_and_parse(
97 envelope: &[u8],
98 root_public_key: &[u8],
99 ) -> Result<Self, LineStatusError> {
100 let payload = dsse_verify_typed(envelope, LINE_STATUS_PAYLOAD_TYPE, root_public_key)?;
101 serde_json::from_slice(&payload).map_err(|e| LineStatusError::Payload(e.to_string()))
102 }
103
104 pub fn sign(&self, secret_key: &[u8], key_id: &str) -> Result<String, LineStatusError> {
106 let payload = serde_json::to_vec_pretty(self).expect("status serializes");
107 Ok(dsse_sign_typed(
108 &payload,
109 LINE_STATUS_PAYLOAD_TYPE,
110 secret_key,
111 key_id,
112 )?)
113 }
114
115 pub fn report_for(&self, layer: &LayerId) -> LayerStatusReport {
117 let name = layer.to_string();
118 let problems: Vec<&KnownProblem> = self
119 .known_problems
120 .iter()
121 .filter(|kp| kp.affected.iter().any(|a| a == &name))
122 .collect();
123 LayerStatusReport {
124 yanked_reason: self.yanked.get(&name).cloned(),
125 support_until: self.support_until.clone(),
126 problems_total: problems.len(),
127 problems_with_workaround: problems.iter().filter(|kp| kp.workaround.is_some()).count(),
128 }
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct LayerStatusReport {
134 pub yanked_reason: Option<String>,
135 pub support_until: Option<String>,
136 pub problems_total: usize,
137 pub problems_with_workaround: usize,
138}
139
140#[derive(Debug)]
142pub struct StatusCache {
143 dir: PathBuf,
144}
145
146impl StatusCache {
147 pub fn at_root(root: &Path) -> Self {
148 StatusCache {
149 dir: root.join("state").join("line-status"),
150 }
151 }
152
153 pub fn update(
155 &self,
156 line: &Line,
157 envelope: &[u8],
158 parsed: &LineStatus,
159 ) -> Result<(), LineStatusError> {
160 if let Some(cached) = self.load_parsed(line)?
161 && parsed.counter < cached.counter
162 {
163 return Err(LineStatusError::Stale {
164 line: line.to_string(),
165 presented: parsed.counter,
166 cached: cached.counter,
167 });
168 }
169 let io = |path: &Path, source: std::io::Error| LineStatusError::Io {
170 path: path.display().to_string(),
171 source,
172 };
173 std::fs::create_dir_all(&self.dir).map_err(|e| io(&self.dir, e))?;
174 let path = self.envelope_path(line);
175 std::fs::write(&path, envelope).map_err(|e| io(&path, e))?;
176 Ok(())
177 }
178
179 pub fn envelope_bytes(&self, line: &Line) -> Result<Option<Vec<u8>>, LineStatusError> {
186 let path = self.envelope_path(line);
187 match std::fs::read(&path) {
188 Ok(bytes) => Ok(Some(bytes)),
189 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
190 Err(source) => Err(LineStatusError::Io {
191 path: path.display().to_string(),
192 source,
193 }),
194 }
195 }
196
197 pub fn load(
199 &self,
200 line: &Line,
201 root_public_key: &[u8],
202 ) -> Result<Option<LineStatus>, LineStatusError> {
203 let path = self.envelope_path(line);
204 match std::fs::read(&path) {
205 Ok(bytes) => Ok(Some(LineStatus::verify_and_parse(&bytes, root_public_key)?)),
206 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
207 Err(source) => Err(LineStatusError::Io {
208 path: path.display().to_string(),
209 source,
210 }),
211 }
212 }
213
214 fn load_parsed(&self, line: &Line) -> Result<Option<LineStatus>, LineStatusError> {
215 let path = self.envelope_path(line);
216 match std::fs::read(&path) {
217 Ok(bytes) => {
218 let text = std::str::from_utf8(&bytes)
221 .map_err(|_| LineStatusError::Payload("cache is not UTF-8".into()))?;
222 let env = wsc::dsse::DsseEnvelope::from_json(text)
223 .map_err(|e| LineStatusError::Payload(e.to_string()))?;
224 let payload = env
225 .payload_bytes()
226 .map_err(|e| LineStatusError::Payload(e.to_string()))?;
227 Ok(Some(
228 serde_json::from_slice(&payload)
229 .map_err(|e| LineStatusError::Payload(e.to_string()))?,
230 ))
231 }
232 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
233 Err(source) => Err(LineStatusError::Io {
234 path: path.display().to_string(),
235 source,
236 }),
237 }
238 }
239
240 fn envelope_path(&self, line: &Line) -> PathBuf {
241 self.dir.join(format!("{line}.dsse.json"))
242 }
243}
244
245pub const LINE_STATUS_ARTIFACT_TYPE: &str = LINE_STATUS_PAYLOAD_TYPE;
247pub const ANN_LINE: &str = "eu.pulseengine.varve.status-line";
249
250pub fn attach_to_layout(
254 layout: &Path,
255 line: &Line,
256 envelope: &[u8],
257) -> Result<(), LineStatusError> {
258 let io = |path: &Path, source: std::io::Error| LineStatusError::Io {
259 path: path.display().to_string(),
260 source,
261 };
262 let digest = crate::store::manifest_digest(envelope);
263 let hex = digest.strip_prefix("sha256:").expect("digest shape");
264 let blob_dir = layout.join("blobs").join("sha256");
265 std::fs::create_dir_all(&blob_dir).map_err(|e| io(&blob_dir, e))?;
266 let blob_path = blob_dir.join(hex);
267 std::fs::write(&blob_path, envelope).map_err(|e| io(&blob_path, e))?;
268
269 let index_path = layout.join("index.json");
270 let mut index: serde_json::Value =
271 serde_json::from_slice(&std::fs::read(&index_path).map_err(|e| io(&index_path, e))?)
272 .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
273 let entries = index["manifests"]
274 .as_array_mut()
275 .ok_or_else(|| LineStatusError::Payload("index.json has no manifests array".into()))?;
276 let line_name = line.to_string();
277 entries.retain(|e| {
278 !(e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE
279 && e["annotations"][ANN_LINE] == *line_name)
280 });
281 entries.push(serde_json::json!({
282 "mediaType": "application/json",
283 "artifactType": LINE_STATUS_ARTIFACT_TYPE,
284 "digest": digest,
285 "size": envelope.len(),
286 "annotations": { ANN_LINE: line_name }
287 }));
288 std::fs::write(
289 &index_path,
290 serde_json::to_vec_pretty(&index).expect("index serializes"),
291 )
292 .map_err(|e| io(&index_path, e))?;
293 Ok(())
294}
295
296pub fn cache_baseline_from_source(
305 source: &dyn crate::source::LayerSource,
306 layer: &crate::source::LayerRef,
307 line: &Line,
308 root_pk: &[u8],
309 store_root: &Path,
310) -> Result<Option<u64>, LineStatusError> {
311 let envelope = match source
312 .fetch_line_status(layer)
313 .map_err(|e| LineStatusError::Payload(format!("fetching baseline line-status: {e}")))?
314 {
315 Some(bytes) => bytes,
316 None => return Ok(None),
317 };
318 let doc = LineStatus::verify_and_parse(&envelope, root_pk)?;
319 if doc.line != line.to_string() {
322 return Err(LineStatusError::LineMismatch {
323 expected: line.to_string(),
324 got: doc.line,
325 });
326 }
327 let counter = doc.counter;
328 StatusCache::at_root(store_root).update(line, &envelope, &doc)?;
329 Ok(Some(counter))
330}
331
332pub fn attach_envelope_to_layout(
338 layout: &Path,
339 envelope: &[u8],
340) -> Result<(Line, u64), LineStatusError> {
341 let text = std::str::from_utf8(envelope)
342 .map_err(|e| LineStatusError::Payload(format!("envelope is not utf-8: {e}")))?;
343 let env = wsc::dsse::DsseEnvelope::from_json(text)
344 .map_err(|e| LineStatusError::Payload(format!("not a DSSE envelope: {e}")))?;
345 let payload = env
346 .payload_bytes()
347 .map_err(|e| LineStatusError::Payload(format!("envelope payload: {e}")))?;
348 let doc: LineStatus = serde_json::from_slice(&payload)
349 .map_err(|e| LineStatusError::Payload(format!("status document: {e}")))?;
350 let line: Line = doc
351 .line
352 .parse()
353 .map_err(|e| LineStatusError::Payload(format!("status line '{}': {e}", doc.line)))?;
354 if let Some(existing) = read_any_from_layout(layout)?
361 && let Ok(prev) = parse_unverified(&existing)
362 && prev.line == doc.line
363 && doc.counter < prev.counter
364 {
365 return Err(LineStatusError::Stale {
366 line: doc.line.clone(),
367 presented: doc.counter,
368 cached: prev.counter,
369 });
370 }
371 if let Some(layout_line) = layout_line(layout)
375 && layout_line != line.to_string()
376 {
377 return Err(LineStatusError::LineMismatch {
378 expected: layout_line,
379 got: line.to_string(),
380 });
381 }
382 attach_to_layout(layout, &line, envelope)?;
383 Ok((line, doc.counter))
384}
385
386fn parse_unverified(envelope: &[u8]) -> Result<LineStatus, LineStatusError> {
390 let text = std::str::from_utf8(envelope)
391 .map_err(|e| LineStatusError::Payload(format!("envelope is not utf-8: {e}")))?;
392 let env = wsc::dsse::DsseEnvelope::from_json(text)
393 .map_err(|e| LineStatusError::Payload(format!("not a DSSE envelope: {e}")))?;
394 let payload = env
395 .payload_bytes()
396 .map_err(|e| LineStatusError::Payload(format!("envelope payload: {e}")))?;
397 serde_json::from_slice(&payload)
398 .map_err(|e| LineStatusError::Payload(format!("status document: {e}")))
399}
400
401pub(crate) fn layout_line(layout: &Path) -> Option<String> {
405 let index: serde_json::Value =
406 serde_json::from_slice(&std::fs::read(layout.join("index.json")).ok()?).ok()?;
407 for m in index["manifests"].as_array()? {
408 let digest = m["digest"].as_str()?.replace(':', "-");
409 let blob = layout
410 .join("blobs")
411 .join("sha256")
412 .join(digest.trim_start_matches("sha256-"));
413 let Ok(bytes) = std::fs::read(&blob) else {
414 continue;
415 };
416 let Ok(text) = std::str::from_utf8(&bytes) else {
419 continue;
420 };
421 let Ok(env) = wsc::dsse::DsseEnvelope::from_json(text) else {
422 continue;
423 };
424 let Ok(payload) = env.payload_bytes() else {
425 continue;
426 };
427 let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&payload) else {
428 continue;
429 };
430 if let Some(line) = doc["annotations"]["eu.pulseengine.varve.line"].as_str() {
431 return Some(line.to_string());
432 }
433 }
434 None
435}
436
437pub fn read_any_from_layout(layout: &Path) -> Result<Option<Vec<u8>>, LineStatusError> {
442 let index_path = layout.join("index.json");
443 let bytes = match std::fs::read(&index_path) {
444 Ok(bytes) => bytes,
445 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
446 Err(source) => {
447 return Err(LineStatusError::Io {
448 path: index_path.display().to_string(),
449 source,
450 });
451 }
452 };
453 let index: serde_json::Value = serde_json::from_slice(&bytes)
454 .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
455 let Some(entry) = index["manifests"].as_array().and_then(|entries| {
456 entries
457 .iter()
458 .find(|e| e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE)
459 }) else {
460 return Ok(None);
461 };
462 let digest = entry["digest"]
463 .as_str()
464 .ok_or_else(|| LineStatusError::Payload("status entry has no digest".into()))?;
465 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
466 let blob_path = layout.join("blobs").join("sha256").join(hex);
467 std::fs::read(&blob_path)
468 .map(Some)
469 .map_err(|source| LineStatusError::Io {
470 path: blob_path.display().to_string(),
471 source,
472 })
473}
474
475pub fn read_from_layout(layout: &Path, line: &Line) -> Result<Option<Vec<u8>>, LineStatusError> {
477 let index_path = layout.join("index.json");
478 let bytes = match std::fs::read(&index_path) {
479 Ok(bytes) => bytes,
480 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
481 Err(source) => {
482 return Err(LineStatusError::Io {
483 path: index_path.display().to_string(),
484 source,
485 });
486 }
487 };
488 let index: serde_json::Value = serde_json::from_slice(&bytes)
489 .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
490 let line_name = line.to_string();
491 let Some(entry) = index["manifests"].as_array().and_then(|entries| {
492 entries.iter().find(|e| {
493 e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE
494 && e["annotations"][ANN_LINE] == *line_name
495 })
496 }) else {
497 return Ok(None);
498 };
499 let digest = entry["digest"]
500 .as_str()
501 .ok_or_else(|| LineStatusError::Payload("status entry has no digest".into()))?;
502 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
503 let blob_path = layout.join("blobs").join("sha256").join(hex);
504 std::fs::read(&blob_path)
505 .map(Some)
506 .map_err(|source| LineStatusError::Io {
507 path: blob_path.display().to_string(),
508 source,
509 })
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use crate::verify::generate_root_keypair;
516
517 fn status(counter: u64) -> LineStatus {
518 LineStatus {
519 line: "2026.07".into(),
520 counter,
521 issued_at: "2026-08-07T00:00:00Z".into(),
522 support_until: Some("2028-07-31".into()),
523 yanked: BTreeMap::from([(
524 "2026.07.0".to_string(),
525 "CVE-2026-0001 in synth".to_string(),
526 )]),
527 known_problems: vec![
528 KnownProblem {
529 id: "KP-1".into(),
530 title: "synth mla fusion regresses flat_flight".into(),
531 severity: "medium".into(),
532 affected: vec!["2026.07.0".into()],
533 workaround: Some("disable mla fusion".into()),
534 detection: None,
535 mitigation: None,
536 },
537 KnownProblem {
538 id: "KP-2".into(),
539 title: "witness truth-table gap on nested variants".into(),
540 severity: "high".into(),
541 affected: vec!["2026.07.0".into(), "2026.07.1".into()],
542 workaround: None,
543 detection: Some("witness gap rows non-empty".into()),
544 mitigation: None,
545 },
546 ],
547 }
548 }
549
550 #[test]
552 fn a_signed_status_document_round_trips() {
553 let (sk, pk) = generate_root_keypair();
554 let envelope = status(1).sign(&sk, "varve-root-1").unwrap();
555 let parsed = LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap();
556 assert_eq!(parsed, status(1));
557 }
558
559 #[test]
561 fn a_layer_manifest_envelope_cannot_pose_as_a_status_document() {
562 let (sk, pk) = generate_root_keypair();
563 let manifest = crate::manifest::fixtures::manifest(
565 "2026.07.0",
566 "qualified",
567 1,
568 "2026-08-07T00:00:00Z",
569 );
570 let envelope = crate::verify::sign_layer_manifest(&manifest, &sk, "varve-root-1").unwrap();
571 let err = LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap_err();
572 assert!(err.to_string().contains("payload type"), "got: {err}");
573 }
574
575 #[test]
577 fn the_report_names_yank_support_window_and_problem_counts() {
578 let doc = status(1);
579 let report = doc.report_for(&"2026.07.0".parse().unwrap());
580 assert_eq!(
581 report.yanked_reason.as_deref(),
582 Some("CVE-2026-0001 in synth")
583 );
584 assert_eq!(report.support_until.as_deref(), Some("2028-07-31"));
585 assert_eq!(report.problems_total, 2);
586 assert_eq!(report.problems_with_workaround, 1);
587 let clean = doc.report_for(&"2026.07.2".parse().unwrap());
588 assert_eq!(clean.yanked_reason, None);
589 assert_eq!(clean.problems_total, 0);
590 }
591
592 #[test]
594 fn attaching_status_to_a_layout_leaves_every_layer_blob_untouched() {
595 use crate::deposit::{DepositSpec, DepositTool, deposit};
596 let (sk, pk) = generate_root_keypair();
597 let tmp = tempfile::tempdir().unwrap();
598 let dest = tmp.path().join("layout");
599 let spec = DepositSpec {
600 includes: Vec::new(),
601 layer: "2026.07.0".parse().unwrap(),
602 channel: "qualified".into(),
603 counter: 1,
604 issued_at: "2026-08-07T00:00:00Z".into(),
605 tools: vec![DepositTool {
606 name: "synth".into(),
607 version: "1".into(),
608 platform: None,
609 bytes: b"t".to_vec(),
610 source: None,
611 runner: None,
612 kind: None,
613 }],
614 };
615 let outcome = deposit(&spec, &sk, "k", &dest).unwrap();
616
617 let blob_dir = dest.join("blobs/sha256");
619 let before: std::collections::BTreeMap<String, Vec<u8>> = std::fs::read_dir(&blob_dir)
620 .unwrap()
621 .map(|e| {
622 let p = e.unwrap().path();
623 (
624 p.file_name().unwrap().to_string_lossy().into_owned(),
625 std::fs::read(&p).unwrap(),
626 )
627 })
628 .collect();
629
630 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
631 let envelope = status(1).sign(&sk, "k").unwrap();
632 attach_to_layout(&dest, &line, envelope.as_bytes()).unwrap();
633
634 for (name, bytes) in &before {
637 assert_eq!(&std::fs::read(blob_dir.join(name)).unwrap(), bytes);
638 }
639 let carried = read_from_layout(&dest, &line).unwrap().unwrap();
640 let parsed = LineStatus::verify_and_parse(&carried, &pk).unwrap();
641 assert_eq!(parsed.counter, 1);
642 let hex = outcome.digest.strip_prefix("sha256:").unwrap();
643 assert!(
644 blob_dir.join(hex).is_file(),
645 "layer manifest blob still present"
646 );
647
648 let envelope2 = status(2).sign(&sk, "k").unwrap();
650 attach_to_layout(&dest, &line, envelope2.as_bytes()).unwrap();
651 let index: serde_json::Value =
652 serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
653 let count = index["manifests"]
654 .as_array()
655 .unwrap()
656 .iter()
657 .filter(|e| e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE)
658 .count();
659 assert_eq!(count, 1);
660 }
661
662 #[test]
664 fn the_cache_refuses_a_counter_regression() {
665 let (sk, pk) = generate_root_keypair();
666 let tmp = tempfile::tempdir().unwrap();
667 let cache = StatusCache::at_root(tmp.path());
668 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
669
670 let newer = status(2);
671 let env2 = newer.sign(&sk, "k").unwrap();
672 cache.update(&line, env2.as_bytes(), &newer).unwrap();
673
674 let older = status(1);
675 let env1 = older.sign(&sk, "k").unwrap();
676 let err = cache.update(&line, env1.as_bytes(), &older).unwrap_err();
677 assert!(matches!(
678 err,
679 LineStatusError::Stale {
680 presented: 1,
681 cached: 2,
682 ..
683 }
684 ));
685
686 let loaded = cache.load(&line, &pk).unwrap().unwrap();
688 assert_eq!(loaded.counter, 2);
689 }
690
691 #[test]
693 fn a_source_baseline_is_verified_and_cached_so_status_works_offline() {
694 use crate::source::{LayerRef, MemorySource};
695 let (sk, pk) = generate_root_keypair();
696 let tmp = tempfile::tempdir().unwrap();
697 let store_root = tmp.path();
698 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
699 let doc = status(5);
700 let envelope = doc.sign(&sk, "k").unwrap();
701 let source = MemorySource::new().with_line_status(envelope.as_bytes());
702 let layer = LayerRef::Name("2026.07.0".parse().unwrap());
703
704 let cached = cache_baseline_from_source(&source, &layer, &line, &pk, store_root).unwrap();
705 assert_eq!(
706 cached,
707 Some(5),
708 "a carried baseline is cached at its counter"
709 );
710
711 let loaded = StatusCache::at_root(store_root)
713 .load(&line, &pk)
714 .unwrap()
715 .unwrap();
716 assert_eq!(loaded.counter, 5);
717 }
718
719 #[test]
721 fn a_baseline_for_the_wrong_line_is_refused_not_miscached() {
722 use crate::source::{LayerRef, MemorySource};
727 let (sk, pk) = generate_root_keypair();
728 let tmp = tempfile::tempdir().unwrap();
729 let requested: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
730 let mut doc = status(5);
731 doc.line = "2026.08".into(); let envelope = doc.sign(&sk, "k").unwrap();
733 let source = MemorySource::new().with_line_status(envelope.as_bytes());
734 let err = cache_baseline_from_source(
735 &source,
736 &LayerRef::Name("2026.07.0".parse().unwrap()),
737 &requested,
738 &pk,
739 tmp.path(),
740 )
741 .unwrap_err();
742 assert!(
743 matches!(err, LineStatusError::LineMismatch { .. }),
744 "a baseline for the wrong line must be refused: {err}"
745 );
746 assert!(
747 StatusCache::at_root(tmp.path())
748 .load(&requested, &pk)
749 .unwrap()
750 .is_none(),
751 "nothing is cached under the requested line"
752 );
753 }
754
755 #[test]
757 fn a_source_with_no_baseline_caches_nothing_and_does_not_error() {
758 use crate::source::{LayerRef, MemorySource};
759 let (_sk, pk) = generate_root_keypair();
760 let tmp = tempfile::tempdir().unwrap();
761 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
762 let source = MemorySource::new();
763 let cached = cache_baseline_from_source(
764 &source,
765 &LayerRef::Name("2026.07.0".parse().unwrap()),
766 &line,
767 &pk,
768 tmp.path(),
769 )
770 .unwrap();
771 assert_eq!(cached, None);
772 }
773
774 #[test]
776 fn a_baseline_signed_by_an_impostor_is_refused_not_cached() {
777 use crate::source::{LayerRef, MemorySource};
778 let (attacker_sk, _) = generate_root_keypair();
779 let (_real_sk, real_pk) = generate_root_keypair();
780 let tmp = tempfile::tempdir().unwrap();
781 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
782 let envelope = status(5).sign(&attacker_sk, "k").unwrap();
783 let source = MemorySource::new().with_line_status(envelope.as_bytes());
784 let err = cache_baseline_from_source(
785 &source,
786 &LayerRef::Name("2026.07.0".parse().unwrap()),
787 &line,
788 &real_pk,
789 tmp.path(),
790 )
791 .unwrap_err();
792 assert!(
794 StatusCache::at_root(tmp.path())
795 .load(&line, &real_pk)
796 .unwrap()
797 .is_none(),
798 "a baseline that fails verification must not be cached: {err}"
799 );
800 }
801
802 #[test]
804 fn attaching_by_envelope_derives_the_line_from_the_document() {
805 use crate::deposit::{DepositSpec, DepositTool, deposit};
806 let (sk, pk) = generate_root_keypair();
807 let tmp = tempfile::tempdir().unwrap();
808 let dest = tmp.path().join("layout");
809 deposit(
810 &DepositSpec {
811 includes: Vec::new(),
812 layer: "2026.07.0".parse().unwrap(),
813 channel: "qualified".into(),
814 counter: 1,
815 issued_at: "2026-08-07T00:00:00Z".into(),
816 tools: vec![DepositTool {
817 name: "synth".into(),
818 version: "1".into(),
819 platform: None,
820 bytes: b"t".to_vec(),
821 source: None,
822 runner: None,
823 kind: None,
824 }],
825 },
826 &sk,
827 "k",
828 &dest,
829 )
830 .unwrap();
831
832 let envelope = status(4).sign(&sk, "k").unwrap();
833 let (line, counter) = attach_envelope_to_layout(&dest, envelope.as_bytes()).unwrap();
834 assert_eq!(line.to_string(), "2026.07");
835 assert_eq!(counter, 4);
836 let carried = read_any_from_layout(&dest).unwrap().unwrap();
838 assert_eq!(
839 LineStatus::verify_and_parse(&carried, &pk).unwrap().counter,
840 4
841 );
842 }
843
844 #[test]
846 fn attaching_a_stale_document_over_a_newer_one_is_refused() {
847 use crate::deposit::{DepositSpec, DepositTool, deposit};
855 let (sk, _pk) = generate_root_keypair();
856 let tmp = tempfile::tempdir().unwrap();
857 let dest = tmp.path().join("layout");
858 deposit(
859 &DepositSpec {
860 includes: Vec::new(),
861 layer: "2026.07.0".parse().unwrap(),
862 channel: "qualified".into(),
863 counter: 1,
864 issued_at: "2026-08-07T00:00:00Z".into(),
865 tools: vec![DepositTool {
866 name: "synth".into(),
867 version: "1".into(),
868 platform: None,
869 bytes: b"t".to_vec(),
870 source: None,
871 runner: None,
872 kind: None,
873 }],
874 },
875 &sk,
876 "k",
877 &dest,
878 )
879 .unwrap();
880
881 attach_envelope_to_layout(&dest, status(7).sign(&sk, "k").unwrap().as_bytes()).unwrap();
883 let err = attach_envelope_to_layout(&dest, status(3).sign(&sk, "k").unwrap().as_bytes())
885 .unwrap_err();
886 assert!(
887 matches!(
888 err,
889 LineStatusError::Stale {
890 presented: 3,
891 cached: 7,
892 ..
893 }
894 ),
895 "a lower counter must be refused, got {err}"
896 );
897 let msg = err.to_string();
898 assert!(msg.contains('3') && msg.contains('7'), "names both: {msg}");
899 let carried = parse_unverified(&read_any_from_layout(&dest).unwrap().unwrap()).unwrap();
901 assert_eq!(
902 carried.counter, 7,
903 "the newer baseline survives the attempt"
904 );
905 attach_envelope_to_layout(&dest, status(7).sign(&sk, "k").unwrap().as_bytes()).unwrap();
908 }
909
910 #[test]
912 fn a_deposit_layouts_baseline_is_readable_without_naming_the_line() {
913 use crate::deposit::{DepositSpec, DepositTool, deposit};
917 let (sk, pk) = generate_root_keypair();
918 let tmp = tempfile::tempdir().unwrap();
919 let dest = tmp.path().join("layout");
920 let spec = DepositSpec {
921 includes: Vec::new(),
922 layer: "2026.07.0".parse().unwrap(),
923 channel: "qualified".into(),
924 counter: 1,
925 issued_at: "2026-08-07T00:00:00Z".into(),
926 tools: vec![DepositTool {
927 name: "synth".into(),
928 version: "1".into(),
929 platform: None,
930 bytes: b"t".to_vec(),
931 source: None,
932 runner: None,
933 kind: None,
934 }],
935 };
936 deposit(&spec, &sk, "k", &dest).unwrap();
937
938 assert!(read_any_from_layout(&dest).unwrap().is_none());
940
941 let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
942 let envelope = status(3).sign(&sk, "k").unwrap();
943 attach_to_layout(&dest, &line, envelope.as_bytes()).unwrap();
944
945 let carried = read_any_from_layout(&dest).unwrap().unwrap();
946 let parsed = LineStatus::verify_and_parse(&carried, &pk).unwrap();
947 assert_eq!(parsed.counter, 3);
948 }
949}