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/// Tag prefix under which a line's status document is published in its own
26/// right (REQ-POSTDEPOSIT-001 clause 1).
27///
28/// Deliberately not parseable as a `LayerId` (`YYYY.MM.P`), for the same
29/// reason `LINE_INDEX_TAG_PREFIX` is not: `layers_of_line` decides what a
30/// registry is WILLING TO SERVE, and a status tag counted as a layer would
31/// make an honest registry appear to serve one the signed index never named.
32pub const LINE_STATUS_TAG_PREFIX: &str = "line-status-";
33
34/// The registry tag carrying the published status document for a line.
35///
36/// The point of a separate tag is that a correction touches no layer digest.
37/// The baseline lives as a blob inside a LAYER's artifact manifest, so
38/// reissuing it means re-pushing that manifest with a different blob — which
39/// changes the manifest digest, and is exactly the republish
40/// REQ-IMMUTABLE-001 refuses. Yanking a layer must not require mutating it.
41pub fn status_tag(line: &str) -> String {
42    format!("{LINE_STATUS_TAG_PREFIX}{line}")
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct KnownProblem {
48    pub id: String,
49    pub title: String,
50    pub severity: String,
51    /// Layers affected, e.g. ["2026.07.0", "2026.07.1"].
52    pub affected: Vec<String>,
53    #[serde(skip_serializing_if = "Option::is_none", default)]
54    pub workaround: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none", default)]
56    pub detection: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none", default)]
58    pub mitigation: Option<String>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct LineStatus {
64    /// The release line, e.g. "2026.07".
65    pub line: String,
66    /// Monotonic per-line document counter — a stale advisory must not
67    /// silently replace a newer one.
68    pub counter: u64,
69    /// RFC 3339 issue time of this document.
70    #[serde(rename = "issued-at")]
71    pub issued_at: String,
72    /// Stated support window end, RFC 3339 date, if committed.
73    #[serde(
74        rename = "support-until",
75        skip_serializing_if = "Option::is_none",
76        default
77    )]
78    pub support_until: Option<String>,
79    /// The lowest counter a consumer with NO history should accept on this
80    /// line (REQ-FIRSTCONTACT-001).
81    ///
82    /// Carried here because this document is already DSSE-signed by the realm
83    /// root and already distributed beside the layer — a floor is only worth
84    /// anything if it cannot be chosen by whoever serves the bytes.
85    ///
86    /// Optional, so every existing line-status keeps parsing. Absent means the
87    /// first-contact window stays open for that line, which is the behaviour
88    /// varve has always had; stating one closes it.
89    #[serde(
90        rename = "min-counter",
91        skip_serializing_if = "Option::is_none",
92        default
93    )]
94    pub min_counter: Option<u64>,
95    /// Yanked layers of this line, layer → reason.
96    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
97    pub yanked: BTreeMap<String, String>,
98    #[serde(
99        rename = "known-problems",
100        skip_serializing_if = "Vec::is_empty",
101        default
102    )]
103    pub known_problems: Vec<KnownProblem>,
104}
105
106#[derive(Debug, thiserror::Error)]
107pub enum LineStatusError {
108    /// The DSSE envelope failed verification or was malformed. Carries the
109    /// verifier's reason verbatim — but under a line-status heading, because
110    /// the old `#[error(transparent)]` route surfaced these as "manifest
111    /// signature verification failed", sending the reader to the wrong
112    /// document entirely (varve#60).
113    #[error("line-status envelope rejected: {0}")]
114    Envelope(String),
115    #[error("cannot sign the line-status document: {0}")]
116    Sign(String),
117    #[error("line-status payload is not valid: {0}")]
118    Payload(String),
119    #[error("line-status covers line {got} but line {expected} was requested")]
120    LineMismatch { expected: String, got: String },
121    #[error(
122        "refusing stale line-status document for {line}: presented counter {presented}, cached {cached}"
123    )]
124    Stale {
125        line: String,
126        presented: u64,
127        cached: u64,
128    },
129    /// An advisory entry that can never fire (varve#61): `varve status`
130    /// matches `affected` ids and yank keys against installed layer ids
131    /// EXACTLY, so a typo'd id signs fine and then warns nobody. Refused on
132    /// the producing side, where the fix (re-sign) is still cheap.
133    #[error(
134        "{what} names layer '{id}', which is not a layer of line {line} ({reason}) — `varve \
135         status` matches layer ids exactly, so this entry would never fire for any installed \
136         layer; fix the id and re-sign the document"
137    )]
138    DeadReference {
139        what: String,
140        id: String,
141        line: String,
142        reason: String,
143    },
144    /// REQ-ADVISORY-002. The id is a well-formed layer of this line and names
145    /// no layer that EXISTS — a typo one character deep. It signs cleanly, the
146    /// producer sees success, the consumer sees nothing, and the yank silently
147    /// does not exist. Refused wherever the signer can see the line's layers;
148    /// `--force` is for the legitimate case of pre-signing an advisory for a
149    /// layer not deposited yet.
150    #[error(
151        "{what} names layer '{id}', which line {line} does not contain — it exposes: {existing}. \
152         `varve status` matches layer ids EXACTLY, so this entry would fire for nobody: you \
153         would see success, every consumer would see nothing, and the advisory would silently \
154         not exist. Fix the id, or pass --force to pre-sign an advisory for a layer that is not \
155         deposited yet."
156    )]
157    UnknownLayer {
158        what: String,
159        id: String,
160        line: String,
161        existing: String,
162    },
163    #[error(
164        "{layout} is not an OCI image layout (it has no index.json) — point --layout at the \
165         directory `varve deposit --out` produced"
166    )]
167    NotALayout { layout: String },
168    // The io source is NOT repeated in the message: anyhow's `{err:#}` chain
169    // already appends every source, and including it here printed the cause
170    // twice ("… No such file or directory: No such file or directory").
171    #[error("io error at {path}")]
172    Io {
173        path: String,
174        #[source]
175        source: std::io::Error,
176    },
177}
178
179/// What the signer could see of a line's layers when it validated an advisory
180/// (REQ-ADVISORY-002).
181///
182/// The distinction is the whole point. An `affected` id is only checkable
183/// against a LISTING of the line — the realm's signed line-index. A deposit
184/// layout holds one layer, so it is not a listing, and treating it as one
185/// would refuse advisories about layers that exist perfectly well elsewhere.
186/// Where no listing is in reach the check cannot be run, and the caller must
187/// be told WHICH check was skipped rather than handed a success that implies
188/// a complete one.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum KnownLayers {
191    /// The ids the signer could enumerate, and where they came from.
192    Known {
193        /// For the note: which document these came out of.
194        source: String,
195        /// The line the listing covers, when it names one — a listing for
196        /// another line is not a listing for this one.
197        line: Option<String>,
198        layers: Vec<String>,
199    },
200    /// The signer could not see the line's layers, and why not.
201    Unknown { why: String },
202}
203
204impl KnownLayers {
205    /// The realm's own statement of which layers a line has — the only
206    /// authoritative listing varve has.
207    pub fn from_index(index: &crate::lineindex::LineIndex) -> Self {
208        KnownLayers::Known {
209            source: format!(
210                "the signed line-index for {} (counter {})",
211                index.line, index.counter
212            ),
213            line: Some(index.line.clone()),
214            layers: index.layers.iter().map(|e| e.layer.clone()).collect(),
215        }
216    }
217
218    pub fn unknown(why: impl Into<String>) -> Self {
219        KnownLayers::Unknown { why: why.into() }
220    }
221}
222
223/// Which validation actually ran, so a caller reports the check it performed
224/// and not the one it did not (REQ-ADVISORY-002).
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct RefCheck {
227    /// True only when every id was matched against a real listing of the
228    /// line's layers.
229    pub existence_checked: bool,
230    /// One line for the operator: the check that ran, or the one that did not.
231    pub note: String,
232}
233
234/// The layers a signed line-index asserts, verified against the root that is
235/// about to sign the advisory (REQ-ADVISORY-002 clause 2).
236///
237/// Verified, not merely parsed: an unverified listing is one an attacker could
238/// choose, and one that names the typo'd layer would wave the dead advisory
239/// through. At sign time the producer holds the key, so the check is free.
240pub fn known_layers_from_index(
241    envelope: &[u8],
242    root_public_key: &[u8],
243) -> Result<KnownLayers, crate::lineindex::IndexError> {
244    let doc = crate::lineindex::LineIndex::verify_and_parse(envelope, root_public_key)?;
245    Ok(KnownLayers::from_index(&doc))
246}
247
248/// The line's layers as the PRODUCER can see them, from their own layouts —
249/// no network, and no published index required (REQ-ADVISORY-002 clause 5,
250/// DD-023).
251///
252/// `signed-index` is false by default, so an index-only existence check would
253/// rarely have anything to check against: opt-in safety, which is how a typo'd
254/// `affected` id came to sign cleanly and fire for nobody. The producer already
255/// HOLDS the layers on disk. That listing is more trustworthy than a registry's
256/// — a compromised registry can hide a layer and thereby block the yank of the
257/// very layer it hides (the reason DD-023 keeps the network out of the signing
258/// command) — and it works for a realm that never publishes an index at all.
259///
260/// Each path is either a layout directory or a directory OF layout
261/// directories, because a producer's output tree is usually the latter.
262pub fn known_layers_in_layout_dirs(dirs: &[std::path::PathBuf], line: &str) -> KnownLayers {
263    let mut layers: Vec<String> = Vec::new();
264    let mut scanned = 0usize;
265    let visit = |dir: &Path, layers: &mut Vec<String>| {
266        // A real `varve deposit --out` writes an OCI layout: index.json plus
267        // blobs/sha256/. The bare `manifests/`+`blobs/` shape is the other
268        // source form varve accepts. Both are read, because a fixture that
269        // only spoke one of them is how this function first shipped passing a
270        // test against bytes the tool never produces.
271        let mut candidates: Vec<Vec<u8>> = Vec::new();
272        if let Ok(index) = std::fs::read(dir.join("index.json"))
273            && let Ok(idx) = serde_json::from_slice::<serde_json::Value>(&index)
274        {
275            for m in idx
276                .get("manifests")
277                .and_then(|m| m.as_array())
278                .into_iter()
279                .flatten()
280            {
281                if let Some(d) = m.get("digest").and_then(|d| d.as_str())
282                    && let Some((_, hex)) = d.split_once(':')
283                    && let Ok(b) = std::fs::read(dir.join("blobs").join("sha256").join(hex))
284                {
285                    candidates.push(b);
286                }
287            }
288        }
289        if let Ok(entries) = std::fs::read_dir(dir.join("manifests")) {
290            for e in entries.filter_map(|e| e.ok()) {
291                if let Ok(b) = std::fs::read(e.path()) {
292                    candidates.push(b);
293                }
294            }
295        }
296        if candidates.is_empty() {
297            return false;
298        }
299        for bytes in candidates {
300            // A layout stores the SIGNED envelope; the layer id lives in its
301            // payload. Unverified is correct here: this is the producer's own
302            // output tree, and the id is being used to catch a typo, not to
303            // decide trust.
304            let payload = std::str::from_utf8(&bytes)
305                .ok()
306                .and_then(|t| wsc::dsse::DsseEnvelope::from_json(t).ok())
307                .and_then(|env| env.payload_bytes().ok())
308                .unwrap_or_else(|| bytes.clone());
309            if let Ok(m) = crate::manifest::LayerManifest::parse(&payload) {
310                let id = m.layer.to_string();
311                if id.starts_with(&format!("{line}.")) && !layers.contains(&id) {
312                    layers.push(id);
313                }
314            }
315        }
316        true
317    };
318    for dir in dirs {
319        if visit(dir, &mut layers) {
320            scanned += 1;
321            continue;
322        }
323        // Not a layout itself — try its children.
324        if let Ok(children) = std::fs::read_dir(dir) {
325            for c in children.filter_map(|e| e.ok()) {
326                if c.path().is_dir() && visit(&c.path(), &mut layers) {
327                    scanned += 1;
328                }
329            }
330        }
331    }
332    if scanned == 0 {
333        return KnownLayers::unknown(format!(
334            "no oci-layout was found under {} — pass the directory `varve deposit --out` \
335             wrote, or one holding several of them",
336            dirs.iter()
337                .map(|d| d.display().to_string())
338                .collect::<Vec<_>>()
339                .join(", ")
340        ));
341    }
342    layers.sort();
343    KnownLayers::Known {
344        source: format!("{scanned} local layout(s) the producer holds"),
345        line: Some(line.to_string()),
346        layers,
347    }
348}
349
350/// What a deposit layout can tell a producer about the line's layers.
351///
352/// A layout carries the realm's signed index only once `attach-index` has run,
353/// and the documented CI order attaches the status FIRST — so the ordinary
354/// answer here is `Unknown`, stated plainly rather than passed off as a clean
355/// bill of health.
356pub fn known_layers_in_layout(layout: &Path, line: &str) -> KnownLayers {
357    match crate::lineindex::read_from_layout(layout, line) {
358        Ok(Some(envelope)) => match crate::lineindex::parse_unverified(&envelope) {
359            Ok(doc) if doc.line == line => KnownLayers::from_index(&doc),
360            Ok(doc) => KnownLayers::unknown(format!(
361                "the line-index this layout carries is for line {}, not {line}",
362                doc.line
363            )),
364            Err(e) => KnownLayers::unknown(format!(
365                "the line-index this layout carries could not be read ({e})"
366            )),
367        },
368        Ok(None) => KnownLayers::unknown(format!(
369            "this layout carries no signed line-index for {line}, and a layout holds ONE layer \
370             — it is not a listing of the line. Attach the index first (`varve attach-index`), \
371             or sign against it (`varve sign-status --index <envelope>`)"
372        )),
373        Err(e) => KnownLayers::unknown(format!("the layout's index.json could not be read ({e})")),
374    }
375}
376
377impl LineStatus {
378    /// Verify an envelope against the trust root and parse the payload.
379    pub fn verify_and_parse(
380        envelope: &[u8],
381        root_public_key: &[u8],
382    ) -> Result<Self, LineStatusError> {
383        // Diagnose the not-an-envelope case BEFORE the verifier does: its
384        // wrapped parser error prints the cause twice and never names the
385        // commonest mistake — handing over the raw status JSON instead of the
386        // signed envelope (varve#60).
387        if let Ok(text) = std::str::from_utf8(envelope)
388            && wsc::dsse::DsseEnvelope::from_json(text).is_err()
389        {
390            return Err(not_an_envelope(text));
391        }
392        let payload = dsse_verify_typed(envelope, LINE_STATUS_PAYLOAD_TYPE, root_public_key)
393            .map_err(|VerifyError(msg)| {
394                // A wrong-realm signature is indistinguishable from tampering
395                // at this layer; say so, because "No valid signatures" alone
396                // sends the reader hunting for corruption.
397                let hint = if msg.contains("does not verify") {
398                    " (is the document signed by THIS realm's root? `varve pubkey <key>` \
399                     prints the public half a signature verifies against)"
400                } else {
401                    ""
402                };
403                LineStatusError::Envelope(format!("{msg}{hint}"))
404            })?;
405        serde_json::from_slice(&payload).map_err(|e| LineStatusError::Payload(e.to_string()))
406    }
407
408    /// Sign a status document (the producing side — CI, next to deposit).
409    /// Refuses a document whose yank or `affected` entries could never fire
410    /// (varve#61) — a typo'd layer id is cheapest to fix before the signature
411    /// exists.
412    pub fn sign(&self, secret_key: &[u8], key_id: &str) -> Result<String, LineStatusError> {
413        self.check_layer_refs()?;
414        let payload = serde_json::to_vec_pretty(self).expect("status serializes");
415        dsse_sign_typed(&payload, LINE_STATUS_PAYLOAD_TYPE, secret_key, key_id)
416            .map_err(|VerifyError(msg)| LineStatusError::Sign(msg))
417    }
418
419    /// Sign, having checked every advisory reference against what the signer
420    /// can actually see of the line (REQ-ADVISORY-002). Returns the envelope
421    /// and a statement of which check ran — a caller that prints only
422    /// "signed" implies a completeness it may not have.
423    pub fn sign_against(
424        &self,
425        known: &KnownLayers,
426        force: bool,
427        secret_key: &[u8],
428        key_id: &str,
429    ) -> Result<(String, RefCheck), LineStatusError> {
430        let check = self.check_layer_refs_against(known, force)?;
431        let payload = serde_json::to_vec_pretty(self).expect("status serializes");
432        let envelope = dsse_sign_typed(&payload, LINE_STATUS_PAYLOAD_TYPE, secret_key, key_id)
433            .map_err(|VerifyError(msg)| LineStatusError::Sign(msg))?;
434        Ok((envelope, check))
435    }
436
437    /// Every yank key and every `affected` id, checked as far as this signer
438    /// can see (REQ-ADVISORY-002).
439    ///
440    /// Two checks, deliberately separated:
441    ///
442    ///  * SHAPE and line membership — always run, never overridable. An id
443    ///    that is not a well-formed layer identifier of this line cannot
444    ///    become correct later, so `--force` has nothing to allow.
445    ///  * EXISTENCE — run only where a listing of the line is in reach.
446    ///    `--force` allows it through, because pre-signing an advisory for a
447    ///    layer about to be deposited is a legitimate thing to do. Silence is
448    ///    not: where the check does not run, the returned `RefCheck` says so.
449    pub fn check_layer_refs_against(
450        &self,
451        known: &KnownLayers,
452        force: bool,
453    ) -> Result<RefCheck, LineStatusError> {
454        self.check_layer_refs()?;
455        // How many layer ids this document actually asserts anything about.
456        // A BASELINE status — no yanks, no known problems — refers to nothing,
457        // so there is nothing an existence check could have caught. Warning
458        // there is cry-wolf: it fires on the correct setup `docs own-realm`
459        // tells every new operator to perform, and REQ-SHADOW-001's own lesson
460        // is that a check which fires on correct setups is one people switch
461        // off. Found by clean-room review, which noted it also printed TWICE
462        // (sign-status and attach-status) on the documented happy path.
463        let referenced = self.yanked.len()
464            + self
465                .known_problems
466                .iter()
467                .map(|p| p.affected.len())
468                .sum::<usize>();
469        let (source, layers) = match known {
470            KnownLayers::Unknown { .. } if referenced == 0 => {
471                return Ok(RefCheck {
472                    existence_checked: true,
473                    note: "this document names no layer — nothing to check against the line"
474                        .to_string(),
475                });
476            }
477            KnownLayers::Unknown { why } => {
478                return Ok(RefCheck {
479                    existence_checked: false,
480                    note: format!(
481                        "advisory references were checked for SHAPE only — NOT against the \
482                         layers line {} actually has: {why}. An id naming a layer that does not \
483                         exist still signs cleanly here and fires for nobody.",
484                        self.line
485                    ),
486                });
487            }
488            KnownLayers::Known {
489                source,
490                line,
491                layers,
492            } => {
493                // A listing for another line answers a different question. It
494                // would either wave everything through or refuse everything,
495                // and both verdicts would be reported as if they meant
496                // something.
497                if let Some(listing_line) = line
498                    && listing_line != &self.line
499                {
500                    return Err(LineStatusError::LineMismatch {
501                        expected: self.line.clone(),
502                        got: listing_line.clone(),
503                    });
504                }
505                (source, layers)
506            }
507        };
508        if force {
509            return Ok(RefCheck {
510                existence_checked: false,
511                note: format!(
512                    "--force: advisory references were NOT checked against the layers line {} \
513                     has. An entry naming a layer that has not been deposited yet fires only \
514                     once it is.",
515                    self.line
516                ),
517            });
518        }
519        let existing = if layers.is_empty() {
520            "no layers at all — this line has none yet".to_string()
521        } else {
522            layers.join(", ")
523        };
524        let mut refs = 0usize;
525        let mut check = |what: String, id: &str| -> Result<(), LineStatusError> {
526            refs += 1;
527            if layers.iter().any(|l| l == id) {
528                return Ok(());
529            }
530            Err(LineStatusError::UnknownLayer {
531                what,
532                id: id.to_string(),
533                line: self.line.clone(),
534                existing: existing.clone(),
535            })
536        };
537        for id in self.yanked.keys() {
538            check("the yank entry".to_string(), id)?;
539        }
540        for kp in &self.known_problems {
541            for id in &kp.affected {
542                check(format!("known problem '{}'", kp.id), id)?;
543            }
544        }
545        Ok(RefCheck {
546            existence_checked: true,
547            note: format!(
548                "{refs} advisory reference{} checked against the {} layer{} {source} lists for \
549                 line {}",
550                if refs == 1 { "" } else { "s" },
551                layers.len(),
552                if layers.len() == 1 { "" } else { "s" },
553                self.line
554            ),
555        })
556    }
557
558    /// Every yank key and every known problem's `affected` id must be a layer
559    /// of THIS document's line (varve#61). `report_for` matches ids exactly,
560    /// and the cache is keyed per line, so an id outside the line — or one
561    /// that is not a layer id at all — is an advisory that signs fine and
562    /// then fires for nobody. Enforced wherever a producer commits the
563    /// document: `sign` and `attach_envelope_to_layout`.
564    pub fn check_layer_refs(&self) -> Result<(), LineStatusError> {
565        let line: Line = self.line.parse().map_err(|e| {
566            LineStatusError::Payload(format!("'{}' is not a YYYY.MM line: {e}", self.line))
567        })?;
568        let check = |what: String, id: &str| -> Result<(), LineStatusError> {
569            let dead = |reason: String| LineStatusError::DeadReference {
570                what: what.clone(),
571                id: id.to_string(),
572                line: self.line.clone(),
573                reason,
574            };
575            match id.parse::<LayerId>() {
576                Ok(layer) if layer.line() == &line => Ok(()),
577                Ok(layer) => Err(dead(format!("it belongs to line {}", layer.line()))),
578                Err(e) => Err(dead(e.to_string())),
579            }
580        };
581        for id in self.yanked.keys() {
582            check("the yank entry".to_string(), id)?;
583        }
584        for kp in &self.known_problems {
585            for id in &kp.affected {
586                check(format!("known problem '{}'", kp.id), id)?;
587            }
588        }
589        Ok(())
590    }
591
592    /// What this document says about one layer.
593    pub fn report_for(&self, layer: &LayerId) -> LayerStatusReport {
594        let name = layer.to_string();
595        let problems: Vec<&KnownProblem> = self
596            .known_problems
597            .iter()
598            .filter(|kp| kp.affected.iter().any(|a| a == &name))
599            .collect();
600        LayerStatusReport {
601            yanked_reason: self.yanked.get(&name).cloned(),
602            support_until: self.support_until.clone(),
603            problems_total: problems.len(),
604            problems_with_workaround: problems.iter().filter(|kp| kp.workaround.is_some()).count(),
605        }
606    }
607}
608
609#[derive(Debug, Clone, PartialEq, Eq)]
610pub struct LayerStatusReport {
611    pub yanked_reason: Option<String>,
612    pub support_until: Option<String>,
613    pub problems_total: usize,
614    pub problems_with_workaround: usize,
615}
616
617/// Per-line cache of the newest verified document, under the varve root.
618#[derive(Debug)]
619pub struct StatusCache {
620    dir: PathBuf,
621}
622
623impl StatusCache {
624    pub fn at_root(root: &Path) -> Self {
625        StatusCache {
626            dir: root.join("state").join("line-status"),
627        }
628    }
629
630    /// Store a VERIFIED envelope for its line, refusing counter regressions.
631    pub fn update(
632        &self,
633        line: &Line,
634        envelope: &[u8],
635        parsed: &LineStatus,
636    ) -> Result<(), LineStatusError> {
637        if let Some(cached) = self.load_parsed(line)?
638            && parsed.counter < cached.counter
639        {
640            return Err(LineStatusError::Stale {
641                line: line.to_string(),
642                presented: parsed.counter,
643                cached: cached.counter,
644            });
645        }
646        let io = |path: &Path, source: std::io::Error| LineStatusError::Io {
647            path: path.display().to_string(),
648            source,
649        };
650        std::fs::create_dir_all(&self.dir).map_err(|e| io(&self.dir, e))?;
651        let path = self.envelope_path(line);
652        std::fs::write(&path, envelope).map_err(|e| io(&path, e))?;
653        Ok(())
654    }
655
656    /// The cached envelope bytes for a line, unparsed, if any.
657    ///
658    /// Used by `archive` to carry the baseline across an air gap (varve#77).
659    /// Deliberately opaque: the caller re-attaches the bytes verbatim, and the
660    /// far side re-verifies against its own trust root — archiving must not
661    /// become a place where a document is re-signed or re-shaped.
662    pub fn envelope_bytes(&self, line: &Line) -> Result<Option<Vec<u8>>, LineStatusError> {
663        let path = self.envelope_path(line);
664        match std::fs::read(&path) {
665            Ok(bytes) => Ok(Some(bytes)),
666            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
667            Err(source) => Err(LineStatusError::Io {
668                path: path.display().to_string(),
669                source,
670            }),
671        }
672    }
673
674    /// Load and re-verify the cached envelope for a line.
675    pub fn load(
676        &self,
677        line: &Line,
678        root_public_key: &[u8],
679    ) -> Result<Option<LineStatus>, LineStatusError> {
680        let path = self.envelope_path(line);
681        match std::fs::read(&path) {
682            Ok(bytes) => Ok(Some(LineStatus::verify_and_parse(&bytes, root_public_key)?)),
683            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
684            Err(source) => Err(LineStatusError::Io {
685                path: path.display().to_string(),
686                source,
687            }),
688        }
689    }
690
691    fn load_parsed(&self, line: &Line) -> Result<Option<LineStatus>, LineStatusError> {
692        let path = self.envelope_path(line);
693        match std::fs::read(&path) {
694            Ok(bytes) => {
695                // Cached envelopes were verified at update time; parse the
696                // payload without re-verifying just to read the counter.
697                let text = std::str::from_utf8(&bytes)
698                    .map_err(|_| LineStatusError::Payload("cache is not UTF-8".into()))?;
699                let env = wsc::dsse::DsseEnvelope::from_json(text)
700                    .map_err(|e| LineStatusError::Payload(e.to_string()))?;
701                let payload = env
702                    .payload_bytes()
703                    .map_err(|e| LineStatusError::Payload(e.to_string()))?;
704                Ok(Some(
705                    serde_json::from_slice(&payload)
706                        .map_err(|e| LineStatusError::Payload(e.to_string()))?,
707                ))
708            }
709            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
710            Err(source) => Err(LineStatusError::Io {
711                path: path.display().to_string(),
712                source,
713            }),
714        }
715    }
716
717    fn envelope_path(&self, line: &Line) -> PathBuf {
718        self.dir.join(format!("{line}.dsse.json"))
719    }
720}
721
722/// artifactType for a line-status envelope carried in an OCI image layout.
723pub const LINE_STATUS_ARTIFACT_TYPE: &str = LINE_STATUS_PAYLOAD_TYPE;
724/// Annotation naming the line a carried status document covers.
725pub const ANN_LINE: &str = "eu.pulseengine.varve.status-line";
726
727/// Attach a (verified-by-the-caller) status envelope to an existing OCI
728/// image layout — evidence added AFTER deposit, without touching any layer
729/// blob or digest. Replaces a previous document for the same line.
730pub fn attach_to_layout(
731    layout: &Path,
732    line: &Line,
733    envelope: &[u8],
734) -> Result<(), LineStatusError> {
735    let io = |path: &Path, source: std::io::Error| LineStatusError::Io {
736        path: path.display().to_string(),
737        source,
738    };
739    let digest = crate::store::manifest_digest(envelope);
740    let hex = digest.strip_prefix("sha256:").expect("digest shape");
741    let blob_dir = layout.join("blobs").join("sha256");
742    std::fs::create_dir_all(&blob_dir).map_err(|e| io(&blob_dir, e))?;
743    let blob_path = blob_dir.join(hex);
744    std::fs::write(&blob_path, envelope).map_err(|e| io(&blob_path, e))?;
745
746    let index_path = layout.join("index.json");
747    let mut index: serde_json::Value =
748        serde_json::from_slice(&std::fs::read(&index_path).map_err(|e| io(&index_path, e))?)
749            .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
750    let entries = index["manifests"]
751        .as_array_mut()
752        .ok_or_else(|| LineStatusError::Payload("index.json has no manifests array".into()))?;
753    let line_name = line.to_string();
754    entries.retain(|e| {
755        !(e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE
756            && e["annotations"][ANN_LINE] == *line_name)
757    });
758    entries.push(serde_json::json!({
759        "mediaType": "application/json",
760        "artifactType": LINE_STATUS_ARTIFACT_TYPE,
761        "digest": digest,
762        "size": envelope.len(),
763        "annotations": { ANN_LINE: line_name }
764    }));
765    std::fs::write(
766        &index_path,
767        serde_json::to_vec_pretty(&index).expect("index serializes"),
768    )
769    .map_err(|e| io(&index_path, e))?;
770    Ok(())
771}
772
773/// Fetch the baseline line-status a source carries beside a layer, verify it
774/// against the trust root, and cache it monotonically (REQ-STATUS-DIST-001).
775/// Returns `Ok(Some(counter))` when a baseline was cached, `Ok(None)` when the
776/// source carries none. Verification, cache, or transport failures are `Err`
777/// — the caller decides severity (the CLI downgrades them to a note, since a
778/// bad baseline never blocks an otherwise-verified install, but it is never
779/// silently cached). The untrusted bytes are re-verified here; the source is
780/// not trusted to have checked them.
781pub fn cache_baseline_from_source(
782    source: &dyn crate::source::LayerSource,
783    layer: &crate::source::LayerRef,
784    line: &Line,
785    root_pk: &[u8],
786    store_root: &Path,
787) -> Result<Option<u64>, LineStatusError> {
788    let baseline = source
789        .fetch_line_status(layer)
790        .map_err(|e| LineStatusError::Payload(format!("fetching baseline line-status: {e}")))?;
791
792    // The document published under the line's own tag (REQ-POSTDEPOSIT-001).
793    // A source that cannot serve one must not be able to make that look like a
794    // failure, so a transport error here is treated as absence — the baseline
795    // path decides whether the consumer ends up with nothing.
796    let published = source
797        .fetch_published_line_status(&line.to_string())
798        .unwrap_or(None);
799
800    // VERIFY FIRST, RANK SECOND. Both documents are untrusted bytes from the
801    // party this evidence exists to constrain. Choosing by counter before
802    // checking the signature would hand the choice to whoever serves the tag:
803    // write a large counter, win the comparison, and a forged document
804    // displaces a real one — or, if it then failed verification, denied the
805    // consumer the good baseline it already had.
806    let mut best: Option<(u64, Vec<u8>, LineStatus)> = None;
807    let mut first_error: Option<LineStatusError> = None;
808    for envelope in [baseline, published].into_iter().flatten() {
809        let doc = match LineStatus::verify_and_parse(&envelope, root_pk) {
810            Ok(doc) => doc,
811            Err(e) => {
812                first_error.get_or_insert(e);
813                continue;
814            }
815        };
816        // A validly-signed status for a DIFFERENT line must not be cached
817        // under this one — mirror the `--from-file` guard so all cache paths
818        // agree, and apply it to BOTH documents rather than adding a second
819        // rule for the new one.
820        if doc.line != line.to_string() {
821            first_error.get_or_insert(LineStatusError::LineMismatch {
822                expected: line.to_string(),
823                got: doc.line.clone(),
824            });
825            continue;
826        }
827        if best.as_ref().is_none_or(|(c, _, _)| doc.counter > *c) {
828            best = Some((doc.counter, envelope, doc));
829        }
830    }
831
832    match best {
833        Some((counter, envelope, doc)) => {
834            StatusCache::at_root(store_root).update(line, &envelope, &doc)?;
835            Ok(Some(counter))
836        }
837        // Nothing usable. If something was PRESENT but bad, that is a finding
838        // and is raised; if both were simply absent, the source carries none.
839        None => match first_error {
840            Some(e) => Err(e),
841            None => Ok(None),
842        },
843    }
844}
845
846/// Attach a signed line-status envelope to a deposit layout, deriving the
847/// line from the document itself (REQ-STATUS-DIST-001). Returns the line and
848/// counter attached. The payload is read to learn the line but not verified
849/// here — install re-verifies the bytes against the trust root, and the
850/// deposit pipeline produced this envelope moments earlier with its own key.
851pub fn attach_envelope_to_layout(
852    layout: &Path,
853    envelope: &[u8],
854) -> Result<(Line, u64), LineStatusError> {
855    let (line, counter, _) = attach_envelope_to_layout_checked(layout, envelope, false)?;
856    Ok((line, counter))
857}
858
859/// `attach_envelope_to_layout`, reporting which advisory check it was able to
860/// run and allowing the deliberate override (REQ-ADVISORY-002).
861pub fn attach_envelope_to_layout_checked(
862    layout: &Path,
863    envelope: &[u8],
864    force: bool,
865) -> Result<(Line, u64, RefCheck), LineStatusError> {
866    // Refuse a directory that is not a layout BEFORE writing anything into
867    // it: the old path created blobs/ inside an arbitrary directory and then
868    // failed on the missing index.json with a bare io error (varve#60).
869    if !layout.join("index.json").is_file() {
870        return Err(LineStatusError::NotALayout {
871            layout: layout.display().to_string(),
872        });
873    }
874    let text = std::str::from_utf8(envelope)
875        .map_err(|e| LineStatusError::Payload(format!("envelope is not utf-8: {e}")))?;
876    let env = wsc::dsse::DsseEnvelope::from_json(text).map_err(|_| not_an_envelope(text))?;
877    let payload = env
878        .payload_bytes()
879        .map_err(|e| LineStatusError::Payload(format!("envelope payload: {e}")))?;
880    let doc: LineStatus = serde_json::from_slice(&payload)
881        .map_err(|e| LineStatusError::Payload(format!("status document: {e}")))?;
882    let line: Line = doc
883        .line
884        .parse()
885        .map_err(|e| LineStatusError::Payload(format!("status line '{}': {e}", doc.line)))?;
886    // A yank or affected id outside the layout's line would attach fine and
887    // fire for nobody (varve#61) — this command knows the line, so it is the
888    // last producer-side place the typo is cheap to fix. And where the layout
889    // carries the realm's signed index, the ids can be checked against the
890    // layers that actually EXIST, not merely against the shape of a layer id
891    // (REQ-ADVISORY-002).
892    let check = doc.check_layer_refs_against(&known_layers_in_layout(layout, &doc.line), force)?;
893    // Monotonicity holds here too. `status --from-file` and `install` both
894    // refuse a counter regression; attaching did not, so a re-run CI step could
895    // silently downgrade a layout's baseline — shipping a pre-yank document
896    // that fresh consumers cache and are told "not yanked" about a YANKED
897    // layer. The one place the rule was missing was the one that produces the
898    // artifact.
899    if let Some(existing) = read_any_from_layout(layout)?
900        && let Ok(prev) = parse_unverified(&existing)
901        && prev.line == doc.line
902        && doc.counter < prev.counter
903    {
904        return Err(LineStatusError::Stale {
905            line: doc.line.clone(),
906            presented: doc.counter,
907            cached: prev.counter,
908        });
909    }
910    // The status must belong to THIS layout's line. Attaching a 2099.01 status
911    // to a 2026.08 layout used to succeed, leaving the consumer to discover it
912    // (REQ-PRODUCER-001).
913    if let Some(layout_line) = layout_line(layout)
914        && layout_line != line.to_string()
915    {
916        return Err(LineStatusError::LineMismatch {
917            expected: layout_line,
918            got: line.to_string(),
919        });
920    }
921    attach_to_layout(layout, &line, envelope)?;
922    Ok((line, doc.counter, check))
923}
924
925/// The bytes are not a DSSE envelope — with the commonest cause named: the
926/// raw status document handed over where the SIGNED envelope belongs. The
927/// verifier's own wrapping ("not a DSSE envelope: Internal error: [Failed to
928/// parse DSSE envelope: …]") said the same thing twice and the fix zero
929/// times (varve#60).
930fn not_an_envelope(text: &str) -> LineStatusError {
931    if serde_json::from_str::<LineStatus>(text).is_ok() {
932        LineStatusError::Payload(
933            "this is the UNSIGNED status document, not a signed envelope — sign it first \
934             (`varve sign-status --file <doc> --key <key> --out <envelope>`) and pass the \
935             envelope"
936                .into(),
937        )
938    } else {
939        LineStatusError::Payload(
940            "not a DSSE envelope — expected the signed output of `varve sign-status`".into(),
941        )
942    }
943}
944
945/// Parse a status document out of an envelope WITHOUT verifying it. Used only
946/// to read back what a layout already carries, so a regression can be refused;
947/// the signature is checked wherever the document is actually trusted.
948fn parse_unverified(envelope: &[u8]) -> Result<LineStatus, LineStatusError> {
949    let text = std::str::from_utf8(envelope)
950        .map_err(|e| LineStatusError::Payload(format!("envelope is not utf-8: {e}")))?;
951    let env = wsc::dsse::DsseEnvelope::from_json(text).map_err(|_| not_an_envelope(text))?;
952    let payload = env
953        .payload_bytes()
954        .map_err(|e| LineStatusError::Payload(format!("envelope payload: {e}")))?;
955    serde_json::from_slice(&payload)
956        .map_err(|e| LineStatusError::Payload(format!("status document: {e}")))
957}
958
959/// The line a deposit layout's own manifest declares, if it can be read. Best
960/// effort: a layout we cannot introspect is not blocked from being annotated,
961/// but one that plainly disagrees is.
962pub(crate) fn layout_line(layout: &Path) -> Option<String> {
963    let index: serde_json::Value =
964        serde_json::from_slice(&std::fs::read(layout.join("index.json")).ok()?).ok()?;
965    for m in index["manifests"].as_array()? {
966        let digest = m["digest"].as_str()?.replace(':', "-");
967        let blob = layout
968            .join("blobs")
969            .join("sha256")
970            .join(digest.trim_start_matches("sha256-"));
971        let Ok(bytes) = std::fs::read(&blob) else {
972            continue;
973        };
974        // The layer envelope's payload carries the line annotation. Reuse the
975        // DSSE reader already used in this module rather than hand-rolling.
976        let Ok(text) = std::str::from_utf8(&bytes) else {
977            continue;
978        };
979        let Ok(env) = wsc::dsse::DsseEnvelope::from_json(text) else {
980            continue;
981        };
982        let Ok(payload) = env.payload_bytes() else {
983            continue;
984        };
985        let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&payload) else {
986            continue;
987        };
988        if let Some(line) = doc["annotations"]["eu.pulseengine.varve.line"].as_str() {
989            return Some(line.to_string());
990        }
991    }
992    None
993}
994
995/// Read the single baseline status envelope a deposit layout carries,
996/// without needing to name its line (REQ-STATUS-DIST-001). A deposit layout
997/// holds exactly one line-status; a consumer installing by digest may not
998/// know the line up front. Returns the first line-status referrer found.
999pub fn read_any_from_layout(layout: &Path) -> Result<Option<Vec<u8>>, LineStatusError> {
1000    let index_path = layout.join("index.json");
1001    let bytes = match std::fs::read(&index_path) {
1002        Ok(bytes) => bytes,
1003        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1004        Err(source) => {
1005            return Err(LineStatusError::Io {
1006                path: index_path.display().to_string(),
1007                source,
1008            });
1009        }
1010    };
1011    let index: serde_json::Value = serde_json::from_slice(&bytes)
1012        .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
1013    let Some(entry) = index["manifests"].as_array().and_then(|entries| {
1014        entries
1015            .iter()
1016            .find(|e| e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE)
1017    }) else {
1018        return Ok(None);
1019    };
1020    let digest = entry["digest"]
1021        .as_str()
1022        .ok_or_else(|| LineStatusError::Payload("status entry has no digest".into()))?;
1023    let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
1024    let blob_path = layout.join("blobs").join("sha256").join(hex);
1025    std::fs::read(&blob_path)
1026        .map(Some)
1027        .map_err(|source| LineStatusError::Io {
1028            path: blob_path.display().to_string(),
1029            source,
1030        })
1031}
1032
1033/// Read the status envelope for a line from an OCI image layout, if carried.
1034pub fn read_from_layout(layout: &Path, line: &Line) -> Result<Option<Vec<u8>>, LineStatusError> {
1035    let index_path = layout.join("index.json");
1036    let bytes = match std::fs::read(&index_path) {
1037        Ok(bytes) => bytes,
1038        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1039        Err(source) => {
1040            return Err(LineStatusError::Io {
1041                path: index_path.display().to_string(),
1042                source,
1043            });
1044        }
1045    };
1046    let index: serde_json::Value = serde_json::from_slice(&bytes)
1047        .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
1048    let line_name = line.to_string();
1049    let Some(entry) = index["manifests"].as_array().and_then(|entries| {
1050        entries.iter().find(|e| {
1051            e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE
1052                && e["annotations"][ANN_LINE] == *line_name
1053        })
1054    }) else {
1055        return Ok(None);
1056    };
1057    let digest = entry["digest"]
1058        .as_str()
1059        .ok_or_else(|| LineStatusError::Payload("status entry has no digest".into()))?;
1060    let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
1061    let blob_path = layout.join("blobs").join("sha256").join(hex);
1062    std::fs::read(&blob_path)
1063        .map(Some)
1064        .map_err(|source| LineStatusError::Io {
1065            path: blob_path.display().to_string(),
1066            source,
1067        })
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073    use crate::verify::generate_root_keypair;
1074
1075    fn status(counter: u64) -> LineStatus {
1076        LineStatus {
1077            min_counter: None,
1078            line: "2026.07".into(),
1079            counter,
1080            issued_at: "2026-08-07T00:00:00Z".into(),
1081            support_until: Some("2028-07-31".into()),
1082            yanked: BTreeMap::from([(
1083                "2026.07.0".to_string(),
1084                "CVE-2026-0001 in synth".to_string(),
1085            )]),
1086            known_problems: vec![
1087                KnownProblem {
1088                    id: "KP-1".into(),
1089                    title: "synth mla fusion regresses flat_flight".into(),
1090                    severity: "medium".into(),
1091                    affected: vec!["2026.07.0".into()],
1092                    workaround: Some("disable mla fusion".into()),
1093                    detection: None,
1094                    mitigation: None,
1095                },
1096                KnownProblem {
1097                    id: "KP-2".into(),
1098                    title: "witness truth-table gap on nested variants".into(),
1099                    severity: "high".into(),
1100                    affected: vec!["2026.07.0".into(), "2026.07.1".into()],
1101                    workaround: None,
1102                    detection: Some("witness gap rows non-empty".into()),
1103                    mitigation: None,
1104                },
1105            ],
1106        }
1107    }
1108
1109    // rivet: verifies REQ-KP-001
1110    #[test]
1111    fn a_signed_status_document_round_trips() {
1112        let (sk, pk) = generate_root_keypair();
1113        let envelope = status(1).sign(&sk, "varve-root-1").unwrap();
1114        let parsed = LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap();
1115        assert_eq!(parsed, status(1));
1116    }
1117
1118    // rivet: verifies REQ-KP-001
1119    #[test]
1120    fn a_layer_manifest_envelope_cannot_pose_as_a_status_document() {
1121        let (sk, pk) = generate_root_keypair();
1122        // Signed with the right key but the wrong payload type.
1123        let manifest = crate::manifest::fixtures::manifest(
1124            "2026.07.0",
1125            "qualified",
1126            1,
1127            "2026-08-07T00:00:00Z",
1128        );
1129        let envelope = crate::verify::sign_layer_manifest(&manifest, &sk, "varve-root-1").unwrap();
1130        let err = LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap_err();
1131        assert!(err.to_string().contains("payload type"), "got: {err}");
1132    }
1133
1134    // rivet: verifies REQ-KP-001
1135    #[test]
1136    fn the_report_names_yank_support_window_and_problem_counts() {
1137        let doc = status(1);
1138        let report = doc.report_for(&"2026.07.0".parse().unwrap());
1139        assert_eq!(
1140            report.yanked_reason.as_deref(),
1141            Some("CVE-2026-0001 in synth")
1142        );
1143        assert_eq!(report.support_until.as_deref(), Some("2028-07-31"));
1144        assert_eq!(report.problems_total, 2);
1145        assert_eq!(report.problems_with_workaround, 1);
1146        let clean = doc.report_for(&"2026.07.2".parse().unwrap());
1147        assert_eq!(clean.yanked_reason, None);
1148        assert_eq!(clean.problems_total, 0);
1149    }
1150
1151    // rivet: verifies REQ-KP-001
1152    #[test]
1153    fn attaching_status_to_a_layout_leaves_every_layer_blob_untouched() {
1154        use crate::deposit::{DepositSpec, DepositTool, deposit};
1155        let (sk, pk) = generate_root_keypair();
1156        let tmp = tempfile::tempdir().unwrap();
1157        let dest = tmp.path().join("layout");
1158        let spec = DepositSpec {
1159            includes: Vec::new(),
1160            layer: "2026.07.0".parse().unwrap(),
1161            channel: "qualified".into(),
1162            counter: 1,
1163            issued_at: "2026-08-07T00:00:00Z".into(),
1164            tools: vec![DepositTool {
1165                name: "synth".into(),
1166                version: "1".into(),
1167                platform: None,
1168                bytes: b"t".to_vec(),
1169                source: None,
1170                runner: None,
1171                kind: None,
1172                sdk_prefix: None,
1173            }],
1174        };
1175        let outcome = deposit(&spec, &sk, "k", &dest).unwrap();
1176
1177        // Snapshot the layer-relevant blobs before attaching evidence.
1178        let blob_dir = dest.join("blobs/sha256");
1179        let before: std::collections::BTreeMap<String, Vec<u8>> = std::fs::read_dir(&blob_dir)
1180            .unwrap()
1181            .map(|e| {
1182                let p = e.unwrap().path();
1183                (
1184                    p.file_name().unwrap().to_string_lossy().into_owned(),
1185                    std::fs::read(&p).unwrap(),
1186                )
1187            })
1188            .collect();
1189
1190        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1191        let envelope = status(1).sign(&sk, "k").unwrap();
1192        attach_to_layout(&dest, &line, envelope.as_bytes()).unwrap();
1193
1194        // Every pre-existing blob is byte-identical; the manifest digest is
1195        // unchanged — evidence was added, identity was not.
1196        for (name, bytes) in &before {
1197            assert_eq!(&std::fs::read(blob_dir.join(name)).unwrap(), bytes);
1198        }
1199        let carried = read_from_layout(&dest, &line).unwrap().unwrap();
1200        let parsed = LineStatus::verify_and_parse(&carried, &pk).unwrap();
1201        assert_eq!(parsed.counter, 1);
1202        let hex = outcome.digest.strip_prefix("sha256:").unwrap();
1203        assert!(
1204            blob_dir.join(hex).is_file(),
1205            "layer manifest blob still present"
1206        );
1207
1208        // Replacing the document for the same line keeps exactly one entry.
1209        let envelope2 = status(2).sign(&sk, "k").unwrap();
1210        attach_to_layout(&dest, &line, envelope2.as_bytes()).unwrap();
1211        let index: serde_json::Value =
1212            serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
1213        let count = index["manifests"]
1214            .as_array()
1215            .unwrap()
1216            .iter()
1217            .filter(|e| e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE)
1218            .count();
1219        assert_eq!(count, 1);
1220    }
1221
1222    // rivet: verifies REQ-KP-001
1223    #[test]
1224    fn the_cache_refuses_a_counter_regression() {
1225        let (sk, pk) = generate_root_keypair();
1226        let tmp = tempfile::tempdir().unwrap();
1227        let cache = StatusCache::at_root(tmp.path());
1228        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1229
1230        let newer = status(2);
1231        let env2 = newer.sign(&sk, "k").unwrap();
1232        cache.update(&line, env2.as_bytes(), &newer).unwrap();
1233
1234        let older = status(1);
1235        let env1 = older.sign(&sk, "k").unwrap();
1236        let err = cache.update(&line, env1.as_bytes(), &older).unwrap_err();
1237        assert!(matches!(
1238            err,
1239            LineStatusError::Stale {
1240                presented: 1,
1241                cached: 2,
1242                ..
1243            }
1244        ));
1245
1246        // The cached newer document survives and re-verifies.
1247        let loaded = cache.load(&line, &pk).unwrap().unwrap();
1248        assert_eq!(loaded.counter, 2);
1249    }
1250
1251    /// A correction published under the line's OWN tag reaches a consumer
1252    /// whose layer carries an older baseline. This is the whole point of
1253    /// REQ-POSTDEPOSIT-001: before it, saying anything new about a published
1254    /// layer meant re-pushing that layer's manifest with a different blob,
1255    /// which changes the manifest digest — the republish REQ-IMMUTABLE-001
1256    /// refuses. A separate tag touches no layer digest.
1257    // rivet: verifies REQ-POSTDEPOSIT-001
1258    #[test]
1259    fn a_correction_published_under_its_own_tag_overtakes_an_older_baseline() {
1260        use crate::source::{LayerRef, MemorySource};
1261        let (sk, pk) = generate_root_keypair();
1262        let tmp = tempfile::tempdir().unwrap();
1263        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1264
1265        let baseline = status(1).sign(&sk, "k").unwrap();
1266        let correction = status(7).sign(&sk, "k").unwrap();
1267        let source = MemorySource::new()
1268            .with_line_status(baseline.as_bytes())
1269            .with_published_line_status(correction.as_bytes());
1270
1271        let cached = cache_baseline_from_source(
1272            &source,
1273            &LayerRef::Name("2026.07.0".parse().unwrap()),
1274            &line,
1275            &pk,
1276            tmp.path(),
1277        )
1278        .unwrap();
1279        assert_eq!(cached, Some(7), "the newer of the two is what is kept");
1280        assert_eq!(
1281            StatusCache::at_root(tmp.path())
1282                .load(&line, &pk)
1283                .unwrap()
1284                .unwrap()
1285                .counter,
1286            7
1287        );
1288    }
1289
1290    /// Clause 2 says prefer the NEWER, not "prefer the tag". A registry that
1291    /// serves a stale tag document must not be able to walk a consumer
1292    /// backwards to a state where a known problem has disappeared — which is
1293    /// exactly what a yank-suppressing registry would try.
1294    // rivet: verifies REQ-POSTDEPOSIT-001
1295    #[test]
1296    fn a_stale_tag_document_does_not_walk_a_consumer_back_past_its_baseline() {
1297        use crate::source::{LayerRef, MemorySource};
1298        let (sk, pk) = generate_root_keypair();
1299        let tmp = tempfile::tempdir().unwrap();
1300        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1301
1302        let source = MemorySource::new()
1303            .with_line_status(status(9).sign(&sk, "k").unwrap().as_bytes())
1304            .with_published_line_status(status(2).sign(&sk, "k").unwrap().as_bytes());
1305
1306        let cached = cache_baseline_from_source(
1307            &source,
1308            &LayerRef::Name("2026.07.0".parse().unwrap()),
1309            &line,
1310            &pk,
1311            tmp.path(),
1312        )
1313        .unwrap();
1314        assert_eq!(cached, Some(9), "the baseline is newer and must win");
1315    }
1316
1317    /// EQUAL counters must keep the BASELINE, not the tag document. The
1318    /// comparison is strictly greater-than for a reason mutation testing
1319    /// exposed: relaxing it to `>=` lets whatever is served under the tag
1320    /// displace a baseline of the same counter, and the interesting version of
1321    /// that is a yank being suppressed.
1322    ///
1323    /// A registry serving a same-counter document WITHOUT the yank would
1324    /// otherwise overwrite the baseline that has it. Two validly-signed
1325    /// documents at one counter means the signer issued two, which is an error
1326    /// or a compromise; in either case the conservative choice is the copy
1327    /// bound to the immutable layer.
1328    // rivet: verifies REQ-POSTDEPOSIT-001
1329    #[test]
1330    fn an_equal_counter_tag_document_cannot_displace_the_baseline() {
1331        use crate::source::{LayerRef, MemorySource};
1332        let (sk, pk) = generate_root_keypair();
1333        let tmp = tempfile::tempdir().unwrap();
1334        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1335
1336        // The baseline yanks the layer. Same counter, NO yank, under the tag.
1337        //
1338        // `status()` already yanks 2026.07.0, so the quiet document must have
1339        // it CLEARED — the first version of this test built "quiet" from the
1340        // helper and left the yank in, which made the assertion pass whichever
1341        // document won. A test that cannot fail proves nothing.
1342        let yanking = status(5);
1343        assert!(
1344            yanking.yanked.contains_key("2026.07.0"),
1345            "fixture precondition: the baseline must actually yank"
1346        );
1347        let mut quiet = status(5);
1348        quiet.yanked.clear();
1349
1350        let source = MemorySource::new()
1351            .with_line_status(yanking.sign(&sk, "k").unwrap().as_bytes())
1352            .with_published_line_status(quiet.sign(&sk, "k").unwrap().as_bytes());
1353
1354        cache_baseline_from_source(
1355            &source,
1356            &LayerRef::Name("2026.07.0".parse().unwrap()),
1357            &line,
1358            &pk,
1359            tmp.path(),
1360        )
1361        .unwrap();
1362
1363        let kept = StatusCache::at_root(tmp.path())
1364            .load(&line, &pk)
1365            .unwrap()
1366            .unwrap();
1367        assert!(
1368            kept.yanked.contains_key("2026.07.0"),
1369            "an equal-counter document served under the tag must NOT suppress the \
1370             baseline's yank"
1371        );
1372    }
1373
1374    /// A forged tag document must not deny service. The registry is the party
1375    /// this document constrains, so "I put unverifiable bytes under the tag"
1376    /// must not be a way to stop a consumer reading the good baseline it
1377    /// already has beside the layer.
1378    // rivet: verifies REQ-POSTDEPOSIT-001
1379    #[test]
1380    fn an_unverifiable_tag_document_does_not_deny_the_good_baseline() {
1381        use crate::source::{LayerRef, MemorySource};
1382        let (sk, pk) = generate_root_keypair();
1383        let (other_sk, _) = generate_root_keypair();
1384        let tmp = tempfile::tempdir().unwrap();
1385        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1386
1387        // Signed by a key that is NOT the realm root, and claiming a counter
1388        // high enough to win if anyone were foolish enough to rank before
1389        // verifying.
1390        let forged = status(999).sign(&other_sk, "k").unwrap();
1391        let source = MemorySource::new()
1392            .with_line_status(status(3).sign(&sk, "k").unwrap().as_bytes())
1393            .with_published_line_status(forged.as_bytes());
1394
1395        let cached = cache_baseline_from_source(
1396            &source,
1397            &LayerRef::Name("2026.07.0".parse().unwrap()),
1398            &line,
1399            &pk,
1400            tmp.path(),
1401        )
1402        .unwrap();
1403        assert_eq!(
1404            cached,
1405            Some(3),
1406            "the forged document is discarded and the baseline still lands"
1407        );
1408    }
1409
1410    /// A tag document for a different line is refused by the same guard the
1411    /// baseline path uses, rather than a second rule that could drift.
1412    // rivet: verifies REQ-POSTDEPOSIT-001
1413    #[test]
1414    fn a_tag_document_for_the_wrong_line_is_not_cached_under_this_one() {
1415        use crate::source::{LayerRef, MemorySource};
1416        let (sk, pk) = generate_root_keypair();
1417        let tmp = tempfile::tempdir().unwrap();
1418        let requested: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1419        let wrong = LineStatus {
1420            min_counter: None,
1421            line: "2026.08".into(),
1422            counter: 500,
1423            issued_at: "2026-08-07T00:00:00Z".into(),
1424            support_until: None,
1425            yanked: BTreeMap::new(),
1426            known_problems: Vec::new(),
1427        };
1428        let source = MemorySource::new()
1429            .with_line_status(status(4).sign(&sk, "k").unwrap().as_bytes())
1430            .with_published_line_status(wrong.sign(&sk, "k").unwrap().as_bytes());
1431
1432        let cached = cache_baseline_from_source(
1433            &source,
1434            &LayerRef::Name("2026.07.0".parse().unwrap()),
1435            &requested,
1436            &pk,
1437            tmp.path(),
1438        )
1439        .unwrap();
1440        assert_eq!(cached, Some(4), "the wrong-line document is not cached");
1441    }
1442
1443    /// The tag must never be mistakable for a layer. `layers_of_line` decides
1444    /// what a registry is willing to serve, and a status tag reported as a
1445    /// layer would make an honest registry look like it was serving one the
1446    /// signed index never named.
1447    // rivet: verifies REQ-POSTDEPOSIT-001
1448    #[test]
1449    fn the_status_tag_cannot_be_parsed_as_a_layer_id() {
1450        let tag = status_tag("2026.07");
1451        assert_eq!(tag, "line-status-2026.07");
1452        assert!(
1453            tag.parse::<LayerId>().is_err(),
1454            "a status tag that parses as a layer id would be reported as a served layer"
1455        );
1456        assert_ne!(
1457            tag,
1458            crate::lineindex::index_tag("2026.07"),
1459            "status and index must not collide on one tag"
1460        );
1461    }
1462
1463    // rivet: verifies REQ-STATUS-DIST-001
1464    #[test]
1465    fn a_source_baseline_is_verified_and_cached_so_status_works_offline() {
1466        use crate::source::{LayerRef, MemorySource};
1467        let (sk, pk) = generate_root_keypair();
1468        let tmp = tempfile::tempdir().unwrap();
1469        let store_root = tmp.path();
1470        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1471        let doc = status(5);
1472        let envelope = doc.sign(&sk, "k").unwrap();
1473        let source = MemorySource::new().with_line_status(envelope.as_bytes());
1474        let layer = LayerRef::Name("2026.07.0".parse().unwrap());
1475
1476        let cached = cache_baseline_from_source(&source, &layer, &line, &pk, store_root).unwrap();
1477        assert_eq!(
1478            cached,
1479            Some(5),
1480            "a carried baseline is cached at its counter"
1481        );
1482
1483        // Now `status` works offline: the cache has it, verified.
1484        let loaded = StatusCache::at_root(store_root)
1485            .load(&line, &pk)
1486            .unwrap()
1487            .unwrap();
1488        assert_eq!(loaded.counter, 5);
1489    }
1490
1491    // rivet: verifies REQ-STATUS-DIST-001, REQ-VERIFY-001
1492    #[test]
1493    fn a_baseline_for_the_wrong_line_is_refused_not_miscached() {
1494        // A root-signed status document for a DIFFERENT line must not be
1495        // cached under the requested line — even validly signed. (Clean-room
1496        // review finding: the --from-file path asserted this; the baseline
1497        // path did not.)
1498        use crate::source::{LayerRef, MemorySource};
1499        let (sk, pk) = generate_root_keypair();
1500        let tmp = tempfile::tempdir().unwrap();
1501        let requested: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1502        // A self-consistent document for the WRONG line — internally valid,
1503        // validly signed, and still not the line this consumer asked about.
1504        let doc = LineStatus {
1505            min_counter: None,
1506            line: "2026.08".into(),
1507            counter: 5,
1508            issued_at: "2026-08-07T00:00:00Z".into(),
1509            support_until: None,
1510            yanked: BTreeMap::new(),
1511            known_problems: Vec::new(),
1512        };
1513        let envelope = doc.sign(&sk, "k").unwrap();
1514        let source = MemorySource::new().with_line_status(envelope.as_bytes());
1515        let err = cache_baseline_from_source(
1516            &source,
1517            &LayerRef::Name("2026.07.0".parse().unwrap()),
1518            &requested,
1519            &pk,
1520            tmp.path(),
1521        )
1522        .unwrap_err();
1523        assert!(
1524            matches!(err, LineStatusError::LineMismatch { .. }),
1525            "a baseline for the wrong line must be refused: {err}"
1526        );
1527        assert!(
1528            StatusCache::at_root(tmp.path())
1529                .load(&requested, &pk)
1530                .unwrap()
1531                .is_none(),
1532            "nothing is cached under the requested line"
1533        );
1534    }
1535
1536    // rivet: verifies REQ-STATUS-DIST-001
1537    #[test]
1538    fn a_source_with_no_baseline_caches_nothing_and_does_not_error() {
1539        use crate::source::{LayerRef, MemorySource};
1540        let (_sk, pk) = generate_root_keypair();
1541        let tmp = tempfile::tempdir().unwrap();
1542        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1543        let source = MemorySource::new();
1544        let cached = cache_baseline_from_source(
1545            &source,
1546            &LayerRef::Name("2026.07.0".parse().unwrap()),
1547            &line,
1548            &pk,
1549            tmp.path(),
1550        )
1551        .unwrap();
1552        assert_eq!(cached, None);
1553    }
1554
1555    // rivet: verifies REQ-STATUS-DIST-001, REQ-VERIFY-001
1556    #[test]
1557    fn a_baseline_signed_by_an_impostor_is_refused_not_cached() {
1558        use crate::source::{LayerRef, MemorySource};
1559        let (attacker_sk, _) = generate_root_keypair();
1560        let (_real_sk, real_pk) = generate_root_keypair();
1561        let tmp = tempfile::tempdir().unwrap();
1562        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1563        let envelope = status(5).sign(&attacker_sk, "k").unwrap();
1564        let source = MemorySource::new().with_line_status(envelope.as_bytes());
1565        let err = cache_baseline_from_source(
1566            &source,
1567            &LayerRef::Name("2026.07.0".parse().unwrap()),
1568            &line,
1569            &real_pk,
1570            tmp.path(),
1571        )
1572        .unwrap_err();
1573        // The impostor's baseline never reaches the cache.
1574        assert!(
1575            StatusCache::at_root(tmp.path())
1576                .load(&line, &real_pk)
1577                .unwrap()
1578                .is_none(),
1579            "a baseline that fails verification must not be cached: {err}"
1580        );
1581    }
1582
1583    // rivet: verifies REQ-STATUS-DIST-001
1584    #[test]
1585    fn attaching_by_envelope_derives_the_line_from_the_document() {
1586        use crate::deposit::{DepositSpec, DepositTool, deposit};
1587        let (sk, pk) = generate_root_keypair();
1588        let tmp = tempfile::tempdir().unwrap();
1589        let dest = tmp.path().join("layout");
1590        deposit(
1591            &DepositSpec {
1592                includes: Vec::new(),
1593                layer: "2026.07.0".parse().unwrap(),
1594                channel: "qualified".into(),
1595                counter: 1,
1596                issued_at: "2026-08-07T00:00:00Z".into(),
1597                tools: vec![DepositTool {
1598                    name: "synth".into(),
1599                    version: "1".into(),
1600                    platform: None,
1601                    bytes: b"t".to_vec(),
1602                    source: None,
1603                    runner: None,
1604                    kind: None,
1605                    sdk_prefix: None,
1606                }],
1607            },
1608            &sk,
1609            "k",
1610            &dest,
1611        )
1612        .unwrap();
1613
1614        let envelope = status(4).sign(&sk, "k").unwrap();
1615        let (line, counter) = attach_envelope_to_layout(&dest, envelope.as_bytes()).unwrap();
1616        assert_eq!(line.to_string(), "2026.07");
1617        assert_eq!(counter, 4);
1618        // The layout now carries it and it re-verifies.
1619        let carried = read_any_from_layout(&dest).unwrap().unwrap();
1620        assert_eq!(
1621            LineStatus::verify_and_parse(&carried, &pk).unwrap().counter,
1622            4
1623        );
1624    }
1625
1626    // rivet: verifies REQ-PRODUCE-002
1627    #[test]
1628    fn attaching_a_stale_document_over_a_newer_one_is_refused() {
1629        // An independent review deleted the Stale block from
1630        // attach_envelope_to_layout and the whole workspace stayed green: the
1631        // test cited as this clause's evidence exercises StatusCache::update, a
1632        // DIFFERENT function, and the attach test above attaches exactly once.
1633        // Unguarded, a re-run CI step downgrades a layout's baseline — shipping
1634        // a pre-yank document that fresh consumers cache and are told "not
1635        // yanked" about a YANKED layer.
1636        use crate::deposit::{DepositSpec, DepositTool, deposit};
1637        let (sk, _pk) = generate_root_keypair();
1638        let tmp = tempfile::tempdir().unwrap();
1639        let dest = tmp.path().join("layout");
1640        deposit(
1641            &DepositSpec {
1642                includes: Vec::new(),
1643                layer: "2026.07.0".parse().unwrap(),
1644                channel: "qualified".into(),
1645                counter: 1,
1646                issued_at: "2026-08-07T00:00:00Z".into(),
1647                tools: vec![DepositTool {
1648                    name: "synth".into(),
1649                    version: "1".into(),
1650                    platform: None,
1651                    bytes: b"t".to_vec(),
1652                    source: None,
1653                    runner: None,
1654                    kind: None,
1655                    sdk_prefix: None,
1656                }],
1657            },
1658            &sk,
1659            "k",
1660            &dest,
1661        )
1662        .unwrap();
1663
1664        // The newer document lands…
1665        attach_envelope_to_layout(&dest, status(7).sign(&sk, "k").unwrap().as_bytes()).unwrap();
1666        // …and the older one is refused, naming both counters.
1667        let err = attach_envelope_to_layout(&dest, status(3).sign(&sk, "k").unwrap().as_bytes())
1668            .unwrap_err();
1669        assert!(
1670            matches!(
1671                err,
1672                LineStatusError::Stale {
1673                    presented: 3,
1674                    cached: 7,
1675                    ..
1676                }
1677            ),
1678            "a lower counter must be refused, got {err}"
1679        );
1680        let msg = err.to_string();
1681        assert!(msg.contains('3') && msg.contains('7'), "names both: {msg}");
1682        // The layout still carries the NEWER document, not the stale one.
1683        let carried = parse_unverified(&read_any_from_layout(&dest).unwrap().unwrap()).unwrap();
1684        assert_eq!(
1685            carried.counter, 7,
1686            "the newer baseline survives the attempt"
1687        );
1688        // Re-attaching the SAME counter is not a regression and is allowed —
1689        // CI re-runs must stay idempotent.
1690        attach_envelope_to_layout(&dest, status(7).sign(&sk, "k").unwrap().as_bytes()).unwrap();
1691    }
1692
1693    // rivet: verifies REQ-PRODUCE-002
1694    #[test]
1695    fn an_advisory_that_could_never_fire_is_refused_at_sign_time() {
1696        // varve#61: `report_for` matches ids EXACTLY and the cache is keyed
1697        // per line, so a typo'd affected id — "2026.9.0" for "2026.09.0" —
1698        // signed fine and the advisory then fired for nobody. The signature
1699        // is the cheapest place to stop it.
1700        let (sk, _pk) = generate_root_keypair();
1701        let cases: &[(&str, &str)] = &[
1702            ("2026.7.0", "not a valid YYYY.MM.P id"), // typo'd month width
1703            ("2026.07", "missing its patch component"), // a line, not a layer
1704            ("2026.08.0", "belongs to another line"), // wrong line entirely
1705            ("2026.07.O", "letter O for zero"),
1706        ];
1707        for (bad, why) in cases {
1708            let mut doc = status(1);
1709            doc.known_problems[0].affected = vec![bad.to_string()];
1710            let err = doc.sign(&sk, "k").unwrap_err();
1711            assert!(
1712                matches!(err, LineStatusError::DeadReference { .. }),
1713                "{why}: affected id {bad:?} must be refused, got: {err}"
1714            );
1715            let msg = err.to_string();
1716            assert!(
1717                msg.contains(bad) && msg.contains("2026.07") && msg.contains("re-sign"),
1718                "the error must name the id, the line, and the fix: {msg}"
1719            );
1720        }
1721        // A typo'd YANK key is the same dead advisory.
1722        let mut doc = status(1);
1723        doc.yanked = BTreeMap::from([("2026.8.0".to_string(), "CVE".to_string())]);
1724        assert!(matches!(
1725            doc.sign(&sk, "k").unwrap_err(),
1726            LineStatusError::DeadReference { .. }
1727        ));
1728        // …and the untouched fixture still signs: the gate can pass, not
1729        // merely fail.
1730        status(1).sign(&sk, "k").unwrap();
1731    }
1732
1733    // rivet: verifies REQ-PRODUCE-002
1734    #[test]
1735    fn attach_refuses_a_pre_signed_advisory_that_could_never_fire() {
1736        // The envelope may come from an older varve whose sign-status did not
1737        // validate — attach is the last producer-side gate before the layout
1738        // ships. Built with the raw signer to bypass `sign`'s own check,
1739        // exactly as an old binary would have.
1740        use crate::deposit::{DepositSpec, DepositTool, deposit};
1741        let (sk, _pk) = generate_root_keypair();
1742        let tmp = tempfile::tempdir().unwrap();
1743        let dest = tmp.path().join("layout");
1744        deposit(
1745            &DepositSpec {
1746                includes: Vec::new(),
1747                layer: "2026.07.0".parse().unwrap(),
1748                channel: "qualified".into(),
1749                counter: 1,
1750                issued_at: "2026-08-07T00:00:00Z".into(),
1751                tools: vec![DepositTool {
1752                    name: "synth".into(),
1753                    version: "1".into(),
1754                    platform: None,
1755                    bytes: b"t".to_vec(),
1756                    source: None,
1757                    runner: None,
1758                    kind: None,
1759                    sdk_prefix: None,
1760                }],
1761            },
1762            &sk,
1763            "k",
1764            &dest,
1765        )
1766        .unwrap();
1767        let mut doc = status(1);
1768        doc.known_problems[0].affected = vec!["2026.7.0".to_string()];
1769        let payload = serde_json::to_vec_pretty(&doc).unwrap();
1770        let envelope = dsse_sign_typed(&payload, LINE_STATUS_PAYLOAD_TYPE, &sk, "k").unwrap();
1771        let err = attach_envelope_to_layout(&dest, envelope.as_bytes()).unwrap_err();
1772        assert!(
1773            matches!(err, LineStatusError::DeadReference { .. }),
1774            "got: {err}"
1775        );
1776        assert!(
1777            read_any_from_layout(&dest).unwrap().is_none(),
1778            "the dead advisory must not land in the layout"
1779        );
1780    }
1781
1782    // rivet: verifies REQ-PRODUCE-002
1783    #[test]
1784    fn attaching_to_a_directory_that_is_not_a_layout_is_refused_before_writing() {
1785        // The old path created blobs/sha256/ inside the directory and then
1786        // failed on index.json with "io error at …: No such file or directory
1787        // (os error 2): No such file or directory (os error 2)" — the cause
1788        // twice, the fix never (varve#60).
1789        let (sk, _pk) = generate_root_keypair();
1790        let tmp = tempfile::tempdir().unwrap();
1791        let not_a_layout = tmp.path().join("somedir");
1792        std::fs::create_dir_all(&not_a_layout).unwrap();
1793        let envelope = status(1).sign(&sk, "k").unwrap();
1794        let err = attach_envelope_to_layout(&not_a_layout, envelope.as_bytes()).unwrap_err();
1795        assert!(
1796            matches!(err, LineStatusError::NotALayout { .. }),
1797            "got: {err}"
1798        );
1799        assert!(
1800            err.to_string().contains("varve deposit"),
1801            "the error must carry its fix: {err}"
1802        );
1803        assert!(
1804            !not_a_layout.join("blobs").exists(),
1805            "nothing may be written into a directory that is not a layout"
1806        );
1807    }
1808
1809    // rivet: verifies REQ-PRODUCE-002
1810    #[test]
1811    fn the_unsigned_document_mistake_is_named_not_wrapped() {
1812        // Handing the raw status JSON where the signed envelope belongs is
1813        // the commonest producer mistake; the old error was a doubled parser
1814        // wrap that never said "sign it" (varve#60).
1815        let raw = serde_json::to_string_pretty(&status(1)).unwrap();
1816        let err = not_an_envelope(&raw);
1817        let msg = err.to_string();
1818        assert!(
1819            msg.contains("UNSIGNED") && msg.contains("varve sign-status"),
1820            "raw document must be diagnosed with its fix: {msg}"
1821        );
1822        // Garbage is still garbage, said once, with the expected shape named.
1823        let msg = not_an_envelope("garbage").to_string();
1824        assert!(
1825            msg.contains("not a DSSE envelope") && msg.contains("varve sign-status"),
1826            "got: {msg}"
1827        );
1828        // And verify_and_parse routes through the same diagnosis.
1829        let (_sk, pk) = generate_root_keypair();
1830        let err = LineStatus::verify_and_parse(raw.as_bytes(), &pk).unwrap_err();
1831        assert!(err.to_string().contains("UNSIGNED"), "got: {err}");
1832    }
1833
1834    // rivet: verifies REQ-STATUS-DIST-001
1835    #[test]
1836    fn a_deposit_layouts_baseline_is_readable_without_naming_the_line() {
1837        // A registry/layout consumer that installs by digest may not know the
1838        // line up front — the baseline must be recoverable from the layout
1839        // alone. A deposit layout carries exactly one line-status.
1840        use crate::deposit::{DepositSpec, DepositTool, deposit};
1841        let (sk, pk) = generate_root_keypair();
1842        let tmp = tempfile::tempdir().unwrap();
1843        let dest = tmp.path().join("layout");
1844        let spec = DepositSpec {
1845            includes: Vec::new(),
1846            layer: "2026.07.0".parse().unwrap(),
1847            channel: "qualified".into(),
1848            counter: 1,
1849            issued_at: "2026-08-07T00:00:00Z".into(),
1850            tools: vec![DepositTool {
1851                name: "synth".into(),
1852                version: "1".into(),
1853                platform: None,
1854                bytes: b"t".to_vec(),
1855                source: None,
1856                runner: None,
1857                kind: None,
1858                sdk_prefix: None,
1859            }],
1860        };
1861        deposit(&spec, &sk, "k", &dest).unwrap();
1862
1863        // No baseline yet -> None, not an error.
1864        assert!(read_any_from_layout(&dest).unwrap().is_none());
1865
1866        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1867        let envelope = status(3).sign(&sk, "k").unwrap();
1868        attach_to_layout(&dest, &line, envelope.as_bytes()).unwrap();
1869
1870        let carried = read_any_from_layout(&dest).unwrap().unwrap();
1871        let parsed = LineStatus::verify_and_parse(&carried, &pk).unwrap();
1872        assert_eq!(parsed.counter, 3);
1873    }
1874
1875    // ───────────────────────── REQ-ADVISORY-002 ─────────────────────────
1876
1877    /// A listing naming exactly the layers of the July line the fixture
1878    /// document talks about.
1879    fn listing(layers: &[&str]) -> KnownLayers {
1880        KnownLayers::Known {
1881            source: "the signed line-index for 2026.07 (counter 1)".into(),
1882            line: Some("2026.07".into()),
1883            layers: layers.iter().map(|s| s.to_string()).collect(),
1884        }
1885    }
1886
1887    // rivet: verifies REQ-ADVISORY-002
1888    #[test]
1889    fn an_affected_id_naming_no_existing_layer_is_refused_and_the_verdict_lists_what_does_exist() {
1890        // The defect: one wrong character. `2026.07.10` is a well-formed layer
1891        // id of the right line, so every shape check passes; it names no layer
1892        // that exists, so `varve status` — which matches ids EXACTLY — never
1893        // fires it. The producer sees success, the consumer sees nothing.
1894        let mut doc = status(1);
1895        doc.yanked.clear();
1896        doc.known_problems = vec![KnownProblem {
1897            id: "KP-1".into(),
1898            title: "t".into(),
1899            severity: "high".into(),
1900            affected: vec!["2026.07.10".into()],
1901            workaround: None,
1902            detection: None,
1903            mitigation: None,
1904        }];
1905        let err = doc
1906            .check_layer_refs_against(&listing(&["2026.07.0", "2026.07.1"]), false)
1907            .expect_err("an advisory that can never fire must be refused");
1908        assert!(
1909            matches!(&err, LineStatusError::UnknownLayer { id, .. } if id == "2026.07.10"),
1910            "got: {err}"
1911        );
1912        let msg = err.to_string();
1913        assert!(msg.contains("KP-1"), "name the entry at fault: {msg}");
1914        // The ids that DO exist — the shape varve already uses for tools
1915        // ("it exposes: …"). A refusal that does not show the alternatives
1916        // sends the operator back to the registry to guess.
1917        assert!(
1918            msg.contains("2026.07.0") && msg.contains("2026.07.1"),
1919            "the refusal must list the ids that exist: {msg}"
1920        );
1921        assert!(msg.contains("--force"), "{msg}");
1922    }
1923
1924    // rivet: verifies REQ-ADVISORY-002
1925    #[test]
1926    fn a_yank_key_is_checked_against_existing_layers_too_not_only_affected() {
1927        // A yank is the entry with the most consequence and the least
1928        // redundancy: nothing else in the document repeats it, so a typo'd
1929        // yank key is a withdrawal that silently never happened.
1930        let mut doc = status(1);
1931        doc.known_problems.clear();
1932        doc.yanked = BTreeMap::from([("2026.07.9".to_string(), "CVE".to_string())]);
1933        let err = doc
1934            .check_layer_refs_against(&listing(&["2026.07.0"]), false)
1935            .expect_err("a yank naming no layer must be refused");
1936        assert!(
1937            matches!(&err, LineStatusError::UnknownLayer { what, id, .. }
1938                     if what.contains("yank") && id == "2026.07.9"),
1939            "got: {err}"
1940        );
1941    }
1942
1943    // rivet: verifies REQ-ADVISORY-002
1944    #[test]
1945    fn a_document_whose_ids_all_exist_passes_and_says_what_was_checked() {
1946        // The other half of the rule: the check must be capable of PASSING, or
1947        // it is not a check, it is a ban on advisories.
1948        let check = status(1)
1949            .check_layer_refs_against(&listing(&["2026.07.0", "2026.07.1"]), false)
1950            .expect("every id in the fixture exists on the line");
1951        assert!(check.existence_checked);
1952        assert!(
1953            check.note.contains("checked against") && check.note.contains("2 layers"),
1954            "the note must state the check that RAN: {}",
1955            check.note
1956        );
1957    }
1958
1959    // rivet: verifies REQ-ADVISORY-002
1960    #[test]
1961    fn where_the_line_is_not_visible_the_answer_says_which_check_was_not_run() {
1962        // "Silence must not." Where no listing is in reach the existence check
1963        // cannot run, and a bare "signed" would imply a completeness that was
1964        // never established — the exact shape of the defect, moved one level
1965        // up into the tool's own reporting.
1966        let check = status(1)
1967            .check_layer_refs_against(&KnownLayers::unknown("no line-index was supplied"), false)
1968            .unwrap();
1969        assert!(
1970            !check.existence_checked,
1971            "an unchecked document must not report itself as checked"
1972        );
1973        assert!(
1974            check.note.contains("NOT") && check.note.contains("no line-index was supplied"),
1975            "the note must name the check that did NOT run, and why: {}",
1976            check.note
1977        );
1978    }
1979
1980    // rivet: verifies REQ-ADVISORY-002
1981    #[test]
1982    fn force_allows_a_layer_not_deposited_yet_but_never_a_malformed_id() {
1983        // `--force` exists for one legitimate case: pre-signing an advisory
1984        // for a layer about to be deposited. It must not become a way past the
1985        // SHAPE check — an id that is not a layer identifier of this line
1986        // cannot become correct later, so there is nothing for force to allow.
1987        let mut doc = status(1);
1988        doc.yanked.clear();
1989        doc.known_problems = vec![KnownProblem {
1990            id: "KP-1".into(),
1991            title: "t".into(),
1992            severity: "high".into(),
1993            affected: vec!["2026.07.9".into()],
1994            workaround: None,
1995            detection: None,
1996            mitigation: None,
1997        }];
1998        let check = doc
1999            .check_layer_refs_against(&listing(&["2026.07.0"]), true)
2000            .expect("--force pre-signs for a layer not deposited yet");
2001        assert!(
2002            !check.existence_checked,
2003            "forcing must not report the check as having run"
2004        );
2005        assert!(check.note.contains("--force"), "{}", check.note);
2006
2007        // …and the same document with a typo that is not a layer id at all is
2008        // still refused, force or no force.
2009        for id in ["2026.07", "twenty-twenty-six", "2026.08.0"] {
2010            doc.known_problems[0].affected = vec![id.to_string()];
2011            match doc.check_layer_refs_against(&listing(&["2026.07.0"]), true) {
2012                Err(LineStatusError::DeadReference { .. }) => {}
2013                other => panic!("'{id}' must be refused even under --force, got: {other:?}"),
2014            }
2015        }
2016    }
2017
2018    // rivet: verifies REQ-ADVISORY-002
2019    #[test]
2020    fn a_listing_for_another_line_is_refused_rather_than_used() {
2021        // A listing for a different line answers a different question. Used
2022        // anyway it would either wave everything through or refuse everything,
2023        // and both verdicts would be reported as if they meant something.
2024        let wrong = KnownLayers::Known {
2025            source: "the signed line-index for 2026.08".into(),
2026            line: Some("2026.08".into()),
2027            layers: vec!["2026.08.0".into()],
2028        };
2029        let err = status(1)
2030            .check_layer_refs_against(&wrong, false)
2031            .expect_err("a listing for another line must not be used as this line's");
2032        assert!(
2033            matches!(&err, LineStatusError::LineMismatch { expected, got }
2034                     if expected == "2026.07" && got == "2026.08"),
2035            "got: {err}"
2036        );
2037    }
2038
2039    // rivet: verifies REQ-ADVISORY-002
2040    #[test]
2041    fn a_layout_becomes_a_listing_only_once_the_signed_index_is_attached() {
2042        // Where the signer CAN see the line's layers, and where it cannot. A
2043        // deposit layout holds ONE layer — it is not a listing of the line, and
2044        // treating it as one would refuse advisories about layers that exist
2045        // perfectly well elsewhere. The realm's signed index IS a listing.
2046        use crate::deposit::{DepositSpec, DepositTool, deposit};
2047        let (sk, _pk) = generate_root_keypair();
2048        let tmp = tempfile::tempdir().unwrap();
2049        let dest = tmp.path().join("layout");
2050        deposit(
2051            &DepositSpec {
2052                includes: Vec::new(),
2053                layer: "2026.07.0".parse().unwrap(),
2054                channel: "qualified".into(),
2055                counter: 1,
2056                issued_at: "2026-08-07T00:00:00Z".into(),
2057                tools: vec![DepositTool {
2058                    name: "synth".into(),
2059                    version: "1".into(),
2060                    platform: None,
2061                    bytes: b"t".to_vec(),
2062                    source: None,
2063                    runner: None,
2064                    kind: None,
2065                    sdk_prefix: None,
2066                }],
2067            },
2068            &sk,
2069            "k",
2070            &dest,
2071        )
2072        .unwrap();
2073
2074        // No index yet: NOT a listing, and it says why rather than pretending.
2075        let known = known_layers_in_layout(&dest, "2026.07");
2076        assert!(
2077            matches!(&known, KnownLayers::Unknown { why } if why.contains("not a listing")),
2078            "got: {known:?}"
2079        );
2080
2081        let index = crate::lineindex::LineIndex {
2082            line: "2026.07".into(),
2083            counter: 1,
2084            issued_at: "2026-08-07T00:00:00Z".into(),
2085            layers: vec![crate::lineindex::IndexedLayer {
2086                layer: "2026.07.0".into(),
2087                digest: "sha256:aa".into(),
2088                channel: "qualified".into(),
2089                counter: 1,
2090            }],
2091        };
2092        crate::lineindex::attach_to_layout(
2093            &dest,
2094            "2026.07",
2095            index.sign(&sk, "k").unwrap().as_bytes(),
2096        )
2097        .unwrap();
2098
2099        let known = known_layers_in_layout(&dest, "2026.07");
2100        assert_eq!(
2101            known,
2102            KnownLayers::Known {
2103                source: "the signed line-index for 2026.07 (counter 1)".into(),
2104                line: Some("2026.07".into()),
2105                layers: vec!["2026.07.0".into()],
2106            }
2107        );
2108
2109        // …and the attach seam now refuses the advisory that could never fire,
2110        // while the same document naming the real layer attaches and reports
2111        // the check it ran.
2112        let mut doc = status(2);
2113        doc.yanked.clear();
2114        doc.known_problems = vec![KnownProblem {
2115            id: "KP-1".into(),
2116            title: "t".into(),
2117            severity: "high".into(),
2118            affected: vec!["2026.07.1".into()],
2119            workaround: None,
2120            detection: None,
2121            mitigation: None,
2122        }];
2123        let err = attach_envelope_to_layout(&dest, doc.sign(&sk, "k").unwrap().as_bytes())
2124            .expect_err("2026.07.1 is not on this line's index");
2125        assert!(
2126            matches!(&err, LineStatusError::UnknownLayer { .. }),
2127            "got: {err}"
2128        );
2129
2130        doc.known_problems[0].affected = vec!["2026.07.0".into()];
2131        let (_line, counter, check) =
2132            attach_envelope_to_layout_checked(&dest, doc.sign(&sk, "k").unwrap().as_bytes(), false)
2133                .unwrap();
2134        assert_eq!(counter, 2);
2135        assert!(check.existence_checked, "{}", check.note);
2136    }
2137
2138    // rivet: verifies REQ-ADVISORY-002
2139    #[test]
2140    fn signing_reports_the_check_it_ran_alongside_the_envelope() {
2141        // The producing seam. `sign_against` hands back the envelope AND what
2142        // was verified about it, so the CLI can print the check rather than a
2143        // bare "signed" that implies a complete one.
2144        let (sk, pk) = generate_root_keypair();
2145        let (envelope, check) = status(1)
2146            .sign_against(&listing(&["2026.07.0", "2026.07.1"]), false, &sk, "k")
2147            .unwrap();
2148        assert!(check.existence_checked);
2149        assert_eq!(
2150            LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap(),
2151            status(1),
2152            "the checked path must sign the same document the plain path does"
2153        );
2154
2155        // And a document that could never fire is not signed at all — the
2156        // point is that the signature must not exist.
2157        let mut doc = status(1);
2158        doc.yanked.clear();
2159        doc.known_problems[0].affected = vec!["2026.07.7".into()];
2160        doc.known_problems[1].affected = vec!["2026.07.0".into()];
2161        assert!(
2162            doc.sign_against(&listing(&["2026.07.0"]), false, &sk, "k")
2163                .is_err()
2164        );
2165    }
2166
2167    // rivet: verifies REQ-ADVISORY-002
2168    #[test]
2169    fn a_producer_can_list_their_own_line_without_a_network_or_an_index() {
2170        // DD-023 clause 5. `signed-index` is false by default, so an
2171        // index-only existence check would usually have nothing to check
2172        // against — and opt-in safety is how a typo'd `affected` id came to
2173        // sign cleanly and fire for nobody. The producer holds the layers.
2174        let tmp = tempfile::tempdir().unwrap();
2175        let (sk, _pk) = crate::generate_root_keypair();
2176        // REAL layouts, written by `deposit` itself. The first version of this
2177        // test built them with `DirSource::put`, which writes the bare
2178        // manifests/+blobs/ SOURCE shape — not what `varve deposit --out`
2179        // produces. It passed, and the CLI then found nothing at all. A
2180        // fixture speaking a shape the tool never emits is the defect this
2181        // release is named for, so this one uses the real writer.
2182        for (id, counter, dir) in [
2183            ("2026.08.0", 1u64, "out-a"),
2184            ("2026.08.1", 2, "out-b"),
2185            // A layer of a DIFFERENT line must not be counted as this line's.
2186            ("2026.09.0", 1, "out-other"),
2187        ] {
2188            let spec = crate::deposit::DepositSpec {
2189                layer: id.parse().unwrap(),
2190                channel: "rolling".into(),
2191                counter,
2192                issued_at: "2026-08-07T00:00:00Z".into(),
2193                tools: vec![crate::DepositTool {
2194                    name: "t".into(),
2195                    version: "1.0".into(),
2196                    platform: None,
2197                    bytes: b"x".to_vec(),
2198                    source: None,
2199                    runner: None,
2200                    kind: None,
2201                    sdk_prefix: None,
2202                }],
2203                includes: Vec::new(),
2204            };
2205            crate::deposit(&spec, &sk, "k", &tmp.path().join(dir)).unwrap();
2206        }
2207
2208        // Pointed at the PARENT of several layouts, which is the usual shape.
2209        let known = known_layers_in_layout_dirs(&[tmp.path().to_path_buf()], "2026.08");
2210        match &known {
2211            KnownLayers::Known { layers, line, .. } => {
2212                assert_eq!(layers, &["2026.08.0", "2026.08.1"], "got {layers:?}");
2213                assert_eq!(line.as_deref(), Some("2026.08"));
2214            }
2215            KnownLayers::Unknown { why } => panic!("expected a listing, got: {why}"),
2216        }
2217
2218        // …and it actually catches the typo it exists for.
2219        let mut doc = status(2);
2220        doc.line = "2026.08".into();
2221        doc.yanked = BTreeMap::from([(
2222            "2026.08.10".to_string(),
2223            "typo — never deposited".to_string(),
2224        )]);
2225        doc.known_problems.clear();
2226        let err = doc
2227            .check_layer_refs_against(&known, false)
2228            .expect_err("a yank naming a layer this line does not have must be refused");
2229        let msg = err.to_string();
2230        assert!(
2231            msg.contains("2026.08.10") && msg.contains("2026.08.0"),
2232            "the refusal must name the bad id AND the ids that exist: {msg}"
2233        );
2234
2235        // A directory holding no layout says so rather than passing clean.
2236        let empty = tempfile::tempdir().unwrap();
2237        assert!(matches!(
2238            known_layers_in_layout_dirs(&[empty.path().to_path_buf()], "2026.08"),
2239            KnownLayers::Unknown { .. }
2240        ));
2241    }
2242}