Skip to main content

varve_core/
linestatus.rs

1//! Line-status documents (REQ-KP-001, DD-008) — signed, updatable evidence
2//! beside immutable layers.
3//!
4//! One document per release line carries what changes *after* deposit:
5//! known problems (the Ferrocene schema: workaround, detection, mitigation,
6//! affected layers), the support window, and yank markers. It is DSSE-signed
7//! with the same root as layers but under its own payload type, carries its
8//! own monotonic counter, and is cached per line so `varve status` answers
9//! offline. A yanked layer warns loudly but remains installable — the
10//! consumer owns the freeze decision.
11
12use 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
21/// The authenticated payload type — a signed something-else cannot be
22/// replayed as a status document.
23pub 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    /// Layers affected, e.g. ["2026.07.0", "2026.07.1"].
32    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    /// The release line, e.g. "2026.07".
45    pub line: String,
46    /// Monotonic per-line document counter — a stale advisory must not
47    /// silently replace a newer one.
48    pub counter: u64,
49    /// RFC 3339 issue time of this document.
50    #[serde(rename = "issued-at")]
51    pub issued_at: String,
52    /// Stated support window end, RFC 3339 date, if committed.
53    #[serde(
54        rename = "support-until",
55        skip_serializing_if = "Option::is_none",
56        default
57    )]
58    pub support_until: Option<String>,
59    /// Yanked layers of this line, layer → reason.
60    #[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    /// Verify an envelope against the trust root and parse the payload.
96    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    /// Sign a status document (the producing side — CI, next to deposit).
105    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    /// What this document says about one layer.
116    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/// Per-line cache of the newest verified document, under the varve root.
141#[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    /// Store a VERIFIED envelope for its line, refusing counter regressions.
154    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    /// The cached envelope bytes for a line, unparsed, if any.
180    ///
181    /// Used by `archive` to carry the baseline across an air gap (varve#77).
182    /// Deliberately opaque: the caller re-attaches the bytes verbatim, and the
183    /// far side re-verifies against its own trust root — archiving must not
184    /// become a place where a document is re-signed or re-shaped.
185    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    /// Load and re-verify the cached envelope for a line.
198    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                // Cached envelopes were verified at update time; parse the
219                // payload without re-verifying just to read the counter.
220                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
245/// artifactType for a line-status envelope carried in an OCI image layout.
246pub const LINE_STATUS_ARTIFACT_TYPE: &str = LINE_STATUS_PAYLOAD_TYPE;
247/// Annotation naming the line a carried status document covers.
248pub const ANN_LINE: &str = "eu.pulseengine.varve.status-line";
249
250/// Attach a (verified-by-the-caller) status envelope to an existing OCI
251/// image layout — evidence added AFTER deposit, without touching any layer
252/// blob or digest. Replaces a previous document for the same line.
253pub 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
296/// Fetch the baseline line-status a source carries beside a layer, verify it
297/// against the trust root, and cache it monotonically (REQ-STATUS-DIST-001).
298/// Returns `Ok(Some(counter))` when a baseline was cached, `Ok(None)` when the
299/// source carries none. Verification, cache, or transport failures are `Err`
300/// — the caller decides severity (the CLI downgrades them to a note, since a
301/// bad baseline never blocks an otherwise-verified install, but it is never
302/// silently cached). The untrusted bytes are re-verified here; the source is
303/// not trusted to have checked them.
304pub 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    // A validly-signed status for a DIFFERENT line must not be cached under
320    // this one — mirror the `--from-file` guard so all cache paths agree.
321    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
332/// Attach a signed line-status envelope to a deposit layout, deriving the
333/// line from the document itself (REQ-STATUS-DIST-001). Returns the line and
334/// counter attached. The payload is read to learn the line but not verified
335/// here — install re-verifies the bytes against the trust root, and the
336/// deposit pipeline produced this envelope moments earlier with its own key.
337pub 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    // Monotonicity holds here too. `status --from-file` and `install` both
355    // refuse a counter regression; attaching did not, so a re-run CI step could
356    // silently downgrade a layout's baseline — shipping a pre-yank document
357    // that fresh consumers cache and are told "not yanked" about a YANKED
358    // layer. The one place the rule was missing was the one that produces the
359    // artifact.
360    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    // The status must belong to THIS layout's line. Attaching a 2099.01 status
372    // to a 2026.08 layout used to succeed, leaving the consumer to discover it
373    // (REQ-PRODUCER-001).
374    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
386/// Parse a status document out of an envelope WITHOUT verifying it. Used only
387/// to read back what a layout already carries, so a regression can be refused;
388/// the signature is checked wherever the document is actually trusted.
389fn 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
401/// The line a deposit layout's own manifest declares, if it can be read. Best
402/// effort: a layout we cannot introspect is not blocked from being annotated,
403/// but one that plainly disagrees is.
404pub(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        // The layer envelope's payload carries the line annotation. Reuse the
417        // DSSE reader already used in this module rather than hand-rolling.
418        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
437/// Read the single baseline status envelope a deposit layout carries,
438/// without needing to name its line (REQ-STATUS-DIST-001). A deposit layout
439/// holds exactly one line-status; a consumer installing by digest may not
440/// know the line up front. Returns the first line-status referrer found.
441pub 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
475/// Read the status envelope for a line from an OCI image layout, if carried.
476pub 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    // rivet: verifies REQ-KP-001
551    #[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    // rivet: verifies REQ-KP-001
560    #[test]
561    fn a_layer_manifest_envelope_cannot_pose_as_a_status_document() {
562        let (sk, pk) = generate_root_keypair();
563        // Signed with the right key but the wrong payload type.
564        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    // rivet: verifies REQ-KP-001
576    #[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    // rivet: verifies REQ-KP-001
593    #[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        // Snapshot the layer-relevant blobs before attaching evidence.
618        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        // Every pre-existing blob is byte-identical; the manifest digest is
635        // unchanged — evidence was added, identity was not.
636        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        // Replacing the document for the same line keeps exactly one entry.
649        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    // rivet: verifies REQ-KP-001
663    #[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        // The cached newer document survives and re-verifies.
687        let loaded = cache.load(&line, &pk).unwrap().unwrap();
688        assert_eq!(loaded.counter, 2);
689    }
690
691    // rivet: verifies REQ-STATUS-DIST-001
692    #[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        // Now `status` works offline: the cache has it, verified.
712        let loaded = StatusCache::at_root(store_root)
713            .load(&line, &pk)
714            .unwrap()
715            .unwrap();
716        assert_eq!(loaded.counter, 5);
717    }
718
719    // rivet: verifies REQ-STATUS-DIST-001, REQ-VERIFY-001
720    #[test]
721    fn a_baseline_for_the_wrong_line_is_refused_not_miscached() {
722        // A root-signed status document for a DIFFERENT line must not be
723        // cached under the requested line — even validly signed. (Clean-room
724        // review finding: the --from-file path asserted this; the baseline
725        // path did not.)
726        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(); // signed, but for the WRONG line
732        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    // rivet: verifies REQ-STATUS-DIST-001
756    #[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    // rivet: verifies REQ-STATUS-DIST-001, REQ-VERIFY-001
775    #[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        // The impostor's baseline never reaches the cache.
793        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    // rivet: verifies REQ-STATUS-DIST-001
803    #[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        // The layout now carries it and it re-verifies.
837        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    // rivet: verifies REQ-PRODUCE-002
845    #[test]
846    fn attaching_a_stale_document_over_a_newer_one_is_refused() {
847        // An independent review deleted the Stale block from
848        // attach_envelope_to_layout and the whole workspace stayed green: the
849        // test cited as this clause's evidence exercises StatusCache::update, a
850        // DIFFERENT function, and the attach test above attaches exactly once.
851        // Unguarded, a re-run CI step downgrades a layout's baseline — shipping
852        // a pre-yank document that fresh consumers cache and are told "not
853        // yanked" about a YANKED layer.
854        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        // The newer document lands…
882        attach_envelope_to_layout(&dest, status(7).sign(&sk, "k").unwrap().as_bytes()).unwrap();
883        // …and the older one is refused, naming both counters.
884        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        // The layout still carries the NEWER document, not the stale one.
900        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        // Re-attaching the SAME counter is not a regression and is allowed —
906        // CI re-runs must stay idempotent.
907        attach_envelope_to_layout(&dest, status(7).sign(&sk, "k").unwrap().as_bytes()).unwrap();
908    }
909
910    // rivet: verifies REQ-STATUS-DIST-001
911    #[test]
912    fn a_deposit_layouts_baseline_is_readable_without_naming_the_line() {
913        // A registry/layout consumer that installs by digest may not know the
914        // line up front — the baseline must be recoverable from the layout
915        // alone. A deposit layout carries exactly one line-status.
916        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        // No baseline yet -> None, not an error.
939        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}