Skip to main content

varve_core/
lineindex.rs

1//! The signed line index (REQ-INDEXAUTH-001) — which layers a line HAS.
2//!
3//! varve signs each layer manifest and each realm, but the LISTING of a line's
4//! layers has been the registry's raw `/tags/list`: unauthenticated. A
5//! compromised or merely stale index host can HIDE a layer and every artifact
6//! it does serve still verifies, so nothing detects it. Hiding also breaks
7//! digest-pinned resolution, because a `LayerRef::Digest` is resolved by
8//! enumerating tags and checking each candidate's payload digest.
9//!
10//! This is the Uptane insight applied to varve: rollback protection has to
11//! survive a compromised Director/registry, not merely a tampered artifact.
12//! Guix gets the equivalent property by authenticating its index git-history
13//! through signed commits.
14//!
15//! The document is deliberately the same shape as `linestatus`: a DSSE
16//! envelope under its own payload type, a monotonic per-line counter, and the
17//! same refusal on a counter regression. Two signed documents about the same
18//! line that disagree on how they are handled would be a bug generator.
19
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24
25use crate::install::VerifyError;
26use crate::layer::Line;
27use crate::verify::{dsse_sign_typed, dsse_verify_typed};
28
29/// The authenticated payload type — a signed something-else (a layer manifest,
30/// a line-status) cannot be replayed as an index.
31pub const LINE_INDEX_PAYLOAD_TYPE: &str = "application/vnd.pulseengine.varve.line-index.v1+json";
32
33/// The artifact type under which an index travels as an OCI referrer.
34pub const LINE_INDEX_ARTIFACT_TYPE: &str = LINE_INDEX_PAYLOAD_TYPE;
35
36/// One layer the realm asserts exists on this line.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct IndexedLayer {
40    /// The layer id, e.g. "2026.08.2".
41    pub layer: String,
42    /// The digest of that layer's signed PAYLOAD — the same identity a pin's
43    /// `digest` names, so an index entry and a pin can be compared directly.
44    pub digest: String,
45    /// The channel the layer was published on.
46    pub channel: String,
47    /// That layer's manifest counter. The anti-rollback high-water mark is
48    /// keyed on a COUNTER, not a layer id, so the index must carry the counter
49    /// or clause 4 cannot feed the mechanism it exists to protect. (An earlier
50    /// draft returned the greatest layer id here, which type-checked and was
51    /// useless.)
52    pub counter: u64,
53}
54
55/// What the realm says a line contains.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct LineIndex {
59    /// The release line, e.g. "2026.08".
60    pub line: String,
61    /// Monotonic per-line document counter — a stale index must not silently
62    /// replace a newer one, exactly as for line-status.
63    pub counter: u64,
64    /// RFC 3339 issue time of this document.
65    #[serde(rename = "issued-at")]
66    pub issued_at: String,
67    /// Every layer of this line, in publication order.
68    pub layers: Vec<IndexedLayer>,
69}
70
71#[derive(Debug, thiserror::Error)]
72pub enum IndexError {
73    #[error(transparent)]
74    Verify(#[from] VerifyError),
75    #[error("line-index payload is not valid: {0}")]
76    Payload(String),
77    #[error(
78        "refusing stale line-index for {line}: presented counter {presented}, cached {cached} — \
79         a withdrawn or superseded index cannot be replayed over a newer one"
80    )]
81    Stale {
82        line: String,
83        presented: u64,
84        cached: u64,
85    },
86    #[error(
87        "the realm's signed index for {line} names layer {layer} ({digest}), which this source \
88         does not serve. A source that hides a layer is either compromised or stale; every \
89         layer it DOES serve still verifies, which is exactly why this check exists. Use a \
90         different source, or obtain a newer signed index."
91    )]
92    Omitted {
93        line: String,
94        layer: String,
95        digest: String,
96    },
97    #[error(
98        "realm '{realm}' declares that it publishes a signed line index, but none was found for \
99         {line}. Either the source is not serving it, or the realm's declaration is wrong — \
100         varve will not fall back to an unauthenticated listing for a realm that promised one."
101    )]
102    Missing { realm: String, line: String },
103    #[error("line-index document is for line {document}, not {expected}")]
104    WrongLine { document: String, expected: String },
105    #[error("io error at {path}: {source}")]
106    Io {
107        path: String,
108        #[source]
109        source: std::io::Error,
110    },
111}
112
113impl LineIndex {
114    /// Verify an envelope against the realm's trust root and parse it.
115    pub fn verify_and_parse(envelope: &[u8], root_public_key: &[u8]) -> Result<Self, IndexError> {
116        let payload = dsse_verify_typed(envelope, LINE_INDEX_PAYLOAD_TYPE, root_public_key)?;
117        serde_json::from_slice(&payload).map_err(|e| IndexError::Payload(e.to_string()))
118    }
119
120    /// Sign an index (the producing side — CI, beside deposit).
121    pub fn sign(&self, secret_key: &[u8], key_id: &str) -> Result<String, IndexError> {
122        let payload = serde_json::to_vec_pretty(self).expect("index serializes");
123        Ok(dsse_sign_typed(
124            &payload,
125            LINE_INDEX_PAYLOAD_TYPE,
126            secret_key,
127            key_id,
128        )?)
129    }
130
131    /// The line this document is about.
132    pub fn line(&self) -> Result<Line, IndexError> {
133        self.line
134            .parse()
135            .map_err(|e: crate::layer::LayerIdError| IndexError::Payload(e.to_string()))
136    }
137
138    /// Clause 3: every layer the index names must be present in what the
139    /// source actually offers. `served` is the set of layer ids the source
140    /// enumerated. Returns the FIRST omission, named — not a boolean, because
141    /// an error a user cannot act on is not a check.
142    pub fn refuse_omission(&self, served: &[String]) -> Result<(), IndexError> {
143        for entry in &self.layers {
144            if !served.iter().any(|s| s == &entry.layer) {
145                return Err(IndexError::Omitted {
146                    line: self.line.clone(),
147                    layer: entry.layer.clone(),
148                    digest: entry.digest.clone(),
149                });
150            }
151        }
152        Ok(())
153    }
154
155    /// Clause 4: the high-water mark this index justifies, independent of what
156    /// the source chose to serve. A registry that hides the newest layer must
157    /// not thereby lower the bar a later install has to clear, which is the
158    /// whole point of authenticating the listing.
159    ///
160    /// The greatest counter the realm asserts for this line. `None` for an
161    /// empty index, which asserts nothing and must not be mistaken for a mark
162    /// of zero.
163    pub fn high_water(&self) -> Option<u64> {
164        self.layers.iter().map(|e| e.counter).max()
165    }
166
167    /// Clause 2: refuse a presented index older than one already held.
168    /// Equality is allowed — a CI re-run must stay idempotent, the same rule
169    /// `attach_envelope_to_layout` follows for line-status.
170    pub fn refuse_regression(&self, cached: Option<&LineIndex>) -> Result<(), IndexError> {
171        if let Some(prev) = cached
172            && prev.line == self.line
173            && self.counter < prev.counter
174        {
175            return Err(IndexError::Stale {
176                line: self.line.clone(),
177                presented: self.counter,
178                cached: prev.counter,
179            });
180        }
181        Ok(())
182    }
183
184    /// The index as a lookup from layer id to its signed payload digest.
185    pub fn by_layer(&self) -> BTreeMap<&str, &str> {
186        self.layers
187            .iter()
188            .map(|e| (e.layer.as_str(), e.digest.as_str()))
189            .collect()
190    }
191}
192
193/// What a consumer knows about a realm's index obligation.
194#[derive(Debug, Clone, Copy)]
195pub struct IndexPolicy<'a> {
196    /// The realm's name, for error messages.
197    pub realm: &'a str,
198    /// The realm's trust root — the index verifies against this and nothing
199    /// else. The SOURCE is the party being constrained, so it is never asked
200    /// whether the index is good.
201    pub root_public_key: &'a [u8],
202    /// The realm declared `signed-index = true` (clause 5).
203    pub required: bool,
204}
205
206/// Run the whole index check for one line, returning the verified index when
207/// there is one. Separate from `install` so each clause is exercised directly:
208/// integration tests cannot kill mutants under `--workspace --lib`, and this
209/// is trust-critical code.
210///
211/// `envelope` is what the source offered (None = it offered nothing);
212/// `served` is what the source is willing to serve (None = it cannot
213/// enumerate); `cached` is the index already held, if any.
214pub fn check(
215    line: &str,
216    envelope: Option<&[u8]>,
217    served: Option<&[String]>,
218    cached: Option<&LineIndex>,
219    policy: &IndexPolicy<'_>,
220) -> Result<Option<LineIndex>, IndexError> {
221    let Some(bytes) = envelope else {
222        // Clause 5: absence is an error only where the realm promised one.
223        if policy.required {
224            return Err(IndexError::Missing {
225                realm: policy.realm.to_string(),
226                line: line.to_string(),
227            });
228        }
229        return Ok(None);
230    };
231
232    let index = LineIndex::verify_and_parse(bytes, policy.root_public_key)?;
233    // The document must be about the line we asked about. Without this, a
234    // valid index for a QUIET line would satisfy the check for a busy one
235    // while naming none of its layers — omission detection that always passes.
236    if index.line != line {
237        return Err(IndexError::WrongLine {
238            document: index.line.clone(),
239            expected: line.to_string(),
240        });
241    }
242    index.refuse_regression(cached)?;
243    if let Some(served) = served {
244        index.refuse_omission(served)?;
245    }
246    Ok(Some(index))
247}
248
249// ─────────────────────── carriage: how an index travels ───────────────────
250//
251// A line-status rides INSIDE a layer's artifact manifest, because it is
252// evidence about the layer being fetched. An index is about the LINE and must
253// be obtainable before any layer is chosen — including when the layer a
254// consumer wants is the one being hidden — so it gets its own address:
255// one tag per line on a registry, one referrer entry per line in a layout.
256// Reading it through a layer would let a source suppress the index by
257// suppressing the layer, which is the attack.
258
259/// Annotation naming the line a carried index document covers. Distinct from
260/// `linestatus::ANN_LINE` on purpose: a layout carries both documents about
261/// the same line, and two readers keyed on one annotation name is the sort of
262/// near-miss that ends with a status being read as an index.
263pub const ANN_INDEX_LINE: &str = "eu.pulseengine.varve.index-line";
264
265/// Tag prefix under which a line's signed index is published on a registry.
266/// Deliberately not parseable as a `LayerId` (`YYYY.MM.P`), so an index tag
267/// can never be mistaken for a layer the line contains — including by
268/// `served_layers`, which would otherwise report the index itself as a layer.
269pub const LINE_INDEX_TAG_PREFIX: &str = "line-index-";
270
271/// The registry tag carrying the signed index for a line.
272pub fn index_tag(line: &str) -> String {
273    format!("{LINE_INDEX_TAG_PREFIX}{line}")
274}
275
276/// Attach a signed index envelope to an OCI image layout as a referrer,
277/// replacing any previous index for the same line. No layer blob or digest is
278/// touched: evidence is added beside the artifact, never folded into it.
279pub fn attach_to_layout(layout: &Path, line: &str, envelope: &[u8]) -> Result<(), IndexError> {
280    let io = |path: &Path, source: std::io::Error| IndexError::Io {
281        path: path.display().to_string(),
282        source,
283    };
284    let digest = crate::store::manifest_digest(envelope);
285    let hex = digest.strip_prefix("sha256:").expect("digest shape");
286    let blob_dir = layout.join("blobs").join("sha256");
287    std::fs::create_dir_all(&blob_dir).map_err(|e| io(&blob_dir, e))?;
288    let blob_path = blob_dir.join(hex);
289    std::fs::write(&blob_path, envelope).map_err(|e| io(&blob_path, e))?;
290
291    let index_path = layout.join("index.json");
292    let mut index: serde_json::Value =
293        serde_json::from_slice(&std::fs::read(&index_path).map_err(|e| io(&index_path, e))?)
294            .map_err(|e| IndexError::Payload(format!("index.json: {e}")))?;
295    let entries = index["manifests"]
296        .as_array_mut()
297        .ok_or_else(|| IndexError::Payload("index.json has no manifests array".into()))?;
298    entries.retain(|e| {
299        !(e["artifactType"] == LINE_INDEX_ARTIFACT_TYPE
300            && e["annotations"][ANN_INDEX_LINE] == *line)
301    });
302    entries.push(serde_json::json!({
303        "mediaType": "application/json",
304        "artifactType": LINE_INDEX_ARTIFACT_TYPE,
305        "digest": digest,
306        "size": envelope.len(),
307        "annotations": { ANN_INDEX_LINE: line }
308    }));
309    std::fs::write(
310        &index_path,
311        serde_json::to_vec_pretty(&index).expect("index serializes"),
312    )
313    .map_err(|e| io(&index_path, e))?;
314    Ok(())
315}
316
317/// Attach an index envelope to a layout, deriving the line from the document
318/// itself (the producing side, `varve attach-index`). Returns (line, counter).
319///
320/// The payload is read to learn the line, not trusted: the consumer re-verifies
321/// against its realm's root. Two guards mirror `attach-status`, because a
322/// producer mistake here is a consumer outage later:
323///  * a counter regression is refused, so a re-run of a CI step cannot
324///    downgrade a published layout's index — and so the same rule holds at the
325///    only place that PRODUCES the artifact, not merely where it is read;
326///  * an index for a different line than the layout's own layer is refused,
327///    rather than being left for the consumer to discover as `WrongLine`.
328pub fn attach_envelope_to_layout(
329    layout: &Path,
330    envelope: &[u8],
331) -> Result<(String, u64), IndexError> {
332    let doc = parse_unverified(envelope)?;
333    let line: Line = doc.line.parse().map_err(|e: crate::layer::LayerIdError| {
334        IndexError::Payload(format!("index line '{}': {e}", doc.line))
335    })?;
336    let line = line.to_string();
337    if let Some(existing) = read_from_layout(layout, &line)? {
338        let prev = parse_unverified(&existing)?;
339        doc.refuse_regression(Some(&prev))?;
340    }
341    if let Some(layout_line) = crate::linestatus::layout_line(layout)
342        && layout_line != line
343    {
344        return Err(IndexError::WrongLine {
345            document: line,
346            expected: layout_line,
347        });
348    }
349    attach_to_layout(layout, &line, envelope)?;
350    Ok((line, doc.counter))
351}
352
353/// Read the index envelope a layout carries for a line, if any.
354pub fn read_from_layout(layout: &Path, line: &str) -> Result<Option<Vec<u8>>, IndexError> {
355    let index_path = layout.join("index.json");
356    let bytes = match std::fs::read(&index_path) {
357        Ok(bytes) => bytes,
358        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
359        Err(source) => {
360            return Err(IndexError::Io {
361                path: index_path.display().to_string(),
362                source,
363            });
364        }
365    };
366    let index: serde_json::Value = serde_json::from_slice(&bytes)
367        .map_err(|e| IndexError::Payload(format!("index.json: {e}")))?;
368    // Both artifactType AND line must match. Matching on the type alone would
369    // hand a consumer another line's index, which `check` would then reject as
370    // `WrongLine` — a correct refusal with a misleading cause.
371    let Some(entry) = index["manifests"].as_array().and_then(|entries| {
372        entries.iter().find(|e| {
373            e["artifactType"] == LINE_INDEX_ARTIFACT_TYPE
374                && e["annotations"][ANN_INDEX_LINE] == *line
375        })
376    }) else {
377        return Ok(None);
378    };
379    let digest = entry["digest"]
380        .as_str()
381        .ok_or_else(|| IndexError::Payload("index entry has no digest".into()))?;
382    let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
383    let blob_path = layout.join("blobs").join("sha256").join(hex);
384    std::fs::read(&blob_path)
385        .map(Some)
386        .map_err(|source| IndexError::Io {
387            path: blob_path.display().to_string(),
388            source,
389        })
390}
391
392/// Parse an index out of an envelope WITHOUT verifying it. Used only to read
393/// back a document this machine already accepted (the cache) or is about to
394/// publish (attach); every place the document is TRUSTED goes through
395/// `verify_and_parse`.
396pub(crate) fn parse_unverified(envelope: &[u8]) -> Result<LineIndex, IndexError> {
397    let text = std::str::from_utf8(envelope)
398        .map_err(|e| IndexError::Payload(format!("envelope is not utf-8: {e}")))?;
399    let env = wsc::dsse::DsseEnvelope::from_json(text)
400        .map_err(|e| IndexError::Payload(format!("not a DSSE envelope: {e}")))?;
401    let payload = env
402        .payload_bytes()
403        .map_err(|e| IndexError::Payload(format!("envelope payload: {e}")))?;
404    serde_json::from_slice(&payload)
405        .map_err(|e| IndexError::Payload(format!("index document: {e}")))
406}
407
408/// Per-line cache of the newest index this machine has accepted — what clause
409/// 2 compares a presented index against.
410///
411/// Local, unsigned state, exactly like `HighWaterMarks`: it is written only
412/// after an index has verified against the realm's root, and it is read back
413/// unverified. The failure it can produce is REFUSING an install, never
414/// accepting a bad one — an attacker who can already write here can delete the
415/// core instead.
416#[derive(Debug)]
417pub struct IndexCache {
418    dir: PathBuf,
419}
420
421impl IndexCache {
422    pub fn at_root(root: &Path) -> Self {
423        IndexCache {
424            dir: root.join("state").join("line-index"),
425        }
426    }
427
428    /// The index cached for a line, if one has ever been accepted.
429    pub fn load(&self, line: &str) -> Result<Option<LineIndex>, IndexError> {
430        let path = self.path(line);
431        match std::fs::read(&path) {
432            Ok(bytes) => Ok(Some(parse_unverified(&bytes)?)),
433            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
434            Err(source) => Err(IndexError::Io {
435                path: path.display().to_string(),
436                source,
437            }),
438        }
439    }
440
441    /// Record a VERIFIED envelope, refusing a counter regression — the same
442    /// rule `check` applies, held here too so a caller that skipped the check
443    /// cannot quietly lower the cached mark.
444    pub fn update(
445        &self,
446        line: &str,
447        envelope: &[u8],
448        parsed: &LineIndex,
449    ) -> Result<(), IndexError> {
450        parsed.refuse_regression(self.load(line)?.as_ref())?;
451        let io = |path: &Path, source: std::io::Error| IndexError::Io {
452            path: path.display().to_string(),
453            source,
454        };
455        std::fs::create_dir_all(&self.dir).map_err(|e| io(&self.dir, e))?;
456        let path = self.path(line);
457        std::fs::write(&path, envelope).map_err(|e| io(&path, e))?;
458        Ok(())
459    }
460
461    fn path(&self, line: &str) -> PathBuf {
462        self.dir.join(format!("{line}.dsse.json"))
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use crate::verify::generate_root_keypair;
470
471    fn index(counter: u64, layers: &[(&str, &str)]) -> LineIndex {
472        // Layer counters ascend with the layer id, as a real line's do.
473        LineIndex {
474            line: "2026.08".into(),
475            counter,
476            issued_at: "2026-08-18T00:00:00Z".into(),
477            layers: layers
478                .iter()
479                .enumerate()
480                .map(|(i, (l, d))| IndexedLayer {
481                    layer: (*l).into(),
482                    digest: (*d).into(),
483                    channel: "qualified".into(),
484                    counter: (i as u64) + 1,
485                })
486                .collect(),
487        }
488    }
489
490    // rivet: verifies REQ-INDEXAUTH-001
491    #[test]
492    fn an_index_verifies_only_against_the_realm_that_signed_it() {
493        let (sk, pk) = generate_root_keypair();
494        let (_other_sk, other_pk) = generate_root_keypair();
495        let doc = index(1, &[("2026.08.0", "sha256:aa"), ("2026.08.1", "sha256:bb")]);
496        let envelope = doc.sign(&sk, "root-1").unwrap();
497
498        assert_eq!(
499            LineIndex::verify_and_parse(envelope.as_bytes(), &pk).unwrap(),
500            doc
501        );
502        // Another realm's root must not accept it — an index is an assertion
503        // BY a realm, so it carries that realm's authority and no other's.
504        assert!(LineIndex::verify_and_parse(envelope.as_bytes(), &other_pk).is_err());
505    }
506
507    // rivet: verifies REQ-INDEXAUTH-001
508    #[test]
509    fn a_signed_line_status_cannot_be_replayed_as_an_index() {
510        // The payload type is the whole defence against cross-document replay.
511        // Both documents are signed by the SAME root and both are about a
512        // line, so without a distinct type a status could stand in for an
513        // index and assert that a line contains nothing.
514        let (sk, pk) = generate_root_keypair();
515        let status = crate::linestatus::LineStatus {
516            line: "2026.08".into(),
517            counter: 9,
518            issued_at: "2026-08-18T00:00:00Z".into(),
519            support_until: None,
520            yanked: Default::default(),
521            known_problems: Vec::new(),
522        };
523        let envelope = status.sign(&sk, "root-1").unwrap();
524        // Assert the TYPE rejected it, not the schema. Mutating the payload
525        // type constant to line-status's left an `is_err()` version of this
526        // test GREEN, because the two structs have divergent
527        // deny_unknown_fields shapes and serde refused it anyway — the test
528        // proved schema divergence while claiming to prove the type check. If
529        // the schemas ever converged, replay would open and nothing would
530        // notice.
531        match LineIndex::verify_and_parse(envelope.as_bytes(), &pk) {
532            Err(IndexError::Verify(_)) => {}
533            Err(IndexError::Payload(p)) => panic!(
534                "rejected by the SCHEMA ({p}), not by the payload type — the type is the \
535                 defence against cross-document replay and must be what fails"
536            ),
537            Ok(_) => panic!("a line-status must not verify as a line-index"),
538            Err(other) => panic!("expected a payload-type rejection, got {other}"),
539        }
540
541        // The converse, so the defence is shown to be symmetric: an index
542        // must not be accepted as a status either.
543        let idx = index(1, &[("2026.08.0", "sha256:aa")]);
544        let idx_env = idx.sign(&sk, "root-1").unwrap();
545        assert!(
546            crate::linestatus::LineStatus::verify_and_parse(idx_env.as_bytes(), &pk).is_err(),
547            "a line-index must not verify as a line-status"
548        );
549    }
550
551    // rivet: verifies REQ-INDEXAUTH-001
552    #[test]
553    fn a_source_that_hides_a_layer_the_index_names_is_refused() {
554        // THE attack this requirement exists for. The registry serves a valid,
555        // correctly-signed 2026.08.0 and simply omits 2026.08.1 — a freshness
556        // attack in which every byte the consumer receives verifies.
557        let doc = index(1, &[("2026.08.0", "sha256:aa"), ("2026.08.1", "sha256:bb")]);
558        let err = doc
559            .refuse_omission(&["2026.08.0".to_string()])
560            .expect_err("hiding a layer must be refused");
561        match &err {
562            IndexError::Omitted { layer, digest, .. } => {
563                assert_eq!(layer, "2026.08.1");
564                assert_eq!(digest, "sha256:bb", "name the digest, so it can be sought");
565            }
566            other => panic!("expected Omitted, got {other}"),
567        }
568        // The message must be actionable, not merely correct.
569        let msg = err.to_string();
570        assert!(msg.contains("2026.08.1"), "names the hidden layer: {msg}");
571        assert!(
572            msg.contains("still verifies"),
573            "says WHY per-artifact verification did not catch this: {msg}"
574        );
575
576        // A source serving everything the index names is accepted, and extra
577        // layers are not an error: an index is a floor, not a whitelist, so a
578        // consumer holding an older index still works against a newer source.
579        assert!(
580            doc.refuse_omission(&[
581                "2026.08.0".to_string(),
582                "2026.08.1".to_string(),
583                "2026.08.2".to_string(),
584            ])
585            .is_ok()
586        );
587    }
588
589    // rivet: verifies REQ-INDEXAUTH-001
590    #[test]
591    fn the_high_water_mark_comes_from_the_index_not_from_what_was_served() {
592        // Clause 4, and the Uptane property: a registry hiding the newest
593        // layer must not lower the bar a later install has to clear.
594        let doc = index(
595            1,
596            &[
597                ("2026.08.0", "sha256:aa"),
598                ("2026.08.2", "sha256:cc"),
599                ("2026.08.1", "sha256:bb"),
600            ],
601        );
602        // The mark is a COUNTER — the units HighWaterMarks actually stores.
603        // An earlier draft returned the greatest LayerId here: it type-checked
604        // and could not feed the mechanism clause 4 exists to protect.
605        assert_eq!(
606            doc.high_water(),
607            Some(3),
608            "the greatest counter the REALM asserts, regardless of the order \
609             entries appear in the document or of what any source served"
610        );
611        // An empty index asserts nothing and must not be read as a mark of 0,
612        // which would be a mark that every layer clears.
613        assert_eq!(index(1, &[]).high_water(), None);
614    }
615
616    // rivet: verifies REQ-INDEXAUTH-001
617    #[test]
618    fn a_stale_index_cannot_replace_a_newer_one() {
619        let newer = index(7, &[("2026.08.1", "sha256:bb")]);
620        let older = index(3, &[("2026.08.0", "sha256:aa")]);
621
622        let err = older
623            .refuse_regression(Some(&newer))
624            .expect_err("a lower counter must be refused");
625        assert!(matches!(
626            err,
627            IndexError::Stale {
628                presented: 3,
629                cached: 7,
630                ..
631            }
632        ));
633        let msg = err.to_string();
634        assert!(msg.contains('3') && msg.contains('7'), "names both: {msg}");
635
636        // Equal is not a regression — CI re-runs must stay idempotent, the
637        // same rule line-status follows.
638        assert!(newer.refuse_regression(Some(&newer)).is_ok());
639        // Newer over older is the ordinary case.
640        assert!(newer.refuse_regression(Some(&older)).is_ok());
641        // Nothing cached yet is not a regression either.
642        assert!(older.refuse_regression(None).is_ok());
643    }
644
645    // rivet: verifies REQ-INDEXAUTH-001
646    #[test]
647    fn an_index_for_another_line_is_not_silently_accepted() {
648        let doc = index(1, &[("2026.08.0", "sha256:aa")]);
649        let other = LineIndex {
650            line: "2026.09".into(),
651            ..index(1, &[("2026.09.0", "sha256:zz")])
652        };
653        // A different line is not a regression — the counters are per line, so
654        // comparing them would refuse a legitimate document.
655        assert!(doc.refuse_regression(Some(&other)).is_ok());
656        assert_eq!(doc.line().unwrap().to_string(), "2026.08");
657    }
658
659    // rivet: verifies REQ-INDEXAUTH-001
660    #[test]
661    fn a_realm_that_promised_an_index_does_not_fall_back_to_an_unsigned_listing() {
662        // Clause 5, both directions. If absence were tolerated for a
663        // declaring realm, an attacker would disable the entire check by
664        // deleting one file — the check would be advisory, not a control.
665        let (_sk, pk) = generate_root_keypair();
666        let declaring = IndexPolicy {
667            realm: "acme",
668            root_public_key: &pk,
669            required: true,
670        };
671        let silent = IndexPolicy {
672            required: false,
673            ..declaring
674        };
675
676        let err = check("2026.08", None, None, None, &declaring)
677            .expect_err("a declaring realm must not accept a missing index");
678        assert!(matches!(err, IndexError::Missing { .. }));
679        let msg = err.to_string();
680        assert!(msg.contains("acme"), "names the realm: {msg}");
681        assert!(
682            msg.contains("will not fall back"),
683            "says what it refused to do, not merely that something is absent: {msg}"
684        );
685
686        // A realm that never promised one keeps working — the default must not
687        // break every realm in existence.
688        assert!(
689            check("2026.08", None, None, None, &silent)
690                .unwrap()
691                .is_none()
692        );
693    }
694
695    // rivet: verifies REQ-INDEXAUTH-001
696    #[test]
697    fn an_index_for_a_different_line_cannot_satisfy_this_line() {
698        // Without this, a valid index for a QUIET line satisfies the check for
699        // a busy one while naming none of its layers — omission detection that
700        // structurally always passes, which is worse than no check because it
701        // reports success.
702        let (sk, pk) = generate_root_keypair();
703        let policy = IndexPolicy {
704            realm: "acme",
705            root_public_key: &pk,
706            required: true,
707        };
708        let quiet = LineIndex {
709            line: "2026.01".into(),
710            ..index(1, &[])
711        };
712        let envelope = quiet.sign(&sk, "k").unwrap();
713        let err = check(
714            "2026.08",
715            Some(envelope.as_bytes()),
716            Some(&["2026.08.0".to_string()]),
717            None,
718            &policy,
719        )
720        .expect_err("an index for another line must not satisfy this one");
721        assert!(matches!(
722            err,
723            IndexError::WrongLine { ref document, ref expected }
724                if document == "2026.01" && expected == "2026.08"
725        ));
726    }
727
728    // rivet: verifies REQ-INDEXAUTH-001
729    #[test]
730    fn a_source_that_cannot_enumerate_is_not_treated_as_hiding_everything() {
731        // `None` (cannot enumerate — an offline archive directory) must be
732        // distinct from `Some(vec![])` (enumerates, has nothing). Collapsing
733        // them would make every air-gapped install fail with a false
734        // accusation of tampering, which is how a security control gets turned
735        // off in the field.
736        let (sk, pk) = generate_root_keypair();
737        let policy = IndexPolicy {
738            realm: "acme",
739            root_public_key: &pk,
740            required: true,
741        };
742        let doc = index(1, &[("2026.08.0", "sha256:aa")]);
743        let envelope = doc.sign(&sk, "k").unwrap();
744
745        let ok = check("2026.08", Some(envelope.as_bytes()), None, None, &policy)
746            .expect("a source that cannot enumerate is not evidence of hiding");
747        assert_eq!(ok.unwrap().counter, 1);
748
749        // …but one that CAN enumerate and serves nothing is hiding everything.
750        assert!(matches!(
751            check(
752                "2026.08",
753                Some(envelope.as_bytes()),
754                Some(&[]),
755                None,
756                &policy
757            ),
758            Err(IndexError::Omitted { .. })
759        ));
760    }
761
762    // rivet: verifies REQ-INDEXAUTH-001
763    #[test]
764    fn check_refuses_a_stale_index_not_only_refuse_regression_does() {
765        // `a_stale_index_cannot_replace_a_newer_one` calls refuse_regression
766        // DIRECTLY, so deleting the call from `check` — the function install
767        // actually goes through — left the suite green. That is the exact
768        // shape four consecutive reviews have found: evidence that exercises a
769        // different function than the clause runs through. This one goes
770        // through `check`.
771        let (sk, pk) = generate_root_keypair();
772        let policy = IndexPolicy {
773            realm: "acme",
774            root_public_key: &pk,
775            required: true,
776        };
777        let cached = index(7, &[("2026.08.1", "sha256:bb")]);
778        let stale = index(3, &[("2026.08.0", "sha256:aa")]);
779        let envelope = stale.sign(&sk, "k").unwrap();
780
781        let err = check(
782            "2026.08",
783            Some(envelope.as_bytes()),
784            None,
785            Some(&cached),
786            &policy,
787        )
788        .expect_err("a replayed older index must be refused by the path install uses");
789        assert!(matches!(
790            err,
791            IndexError::Stale {
792                presented: 3,
793                cached: 7,
794                ..
795            }
796        ));
797
798        // The newer one is accepted through the same path.
799        let fresher = index(8, &[("2026.08.1", "sha256:bb")]);
800        let ok = fresher.sign(&sk, "k").unwrap();
801        assert_eq!(
802            check("2026.08", Some(ok.as_bytes()), None, Some(&cached), &policy)
803                .unwrap()
804                .unwrap()
805                .counter,
806            8
807        );
808    }
809
810    /// A layout of one signed layer, the shape `deposit` writes.
811    fn layout_for(layer: &str, sk: &[u8]) -> (tempfile::TempDir, std::path::PathBuf) {
812        let tmp = tempfile::tempdir().unwrap();
813        let dest = tmp.path().join("layout");
814        let payload = crate::manifest::fixtures::manifest_with_tools(
815            layer,
816            "qualified",
817            1,
818            "2026-08-18T00:00:00Z",
819            &[],
820        );
821        let envelope = crate::verify::sign_layer_manifest(&payload, sk, "root-1").unwrap();
822        crate::archive::write_oci_layout(
823            &payload,
824            envelope.as_bytes(),
825            &[],
826            layer,
827            "qualified",
828            // A test fixture layout, not a real archive: no platform stamp.
829            None,
830            &dest,
831            false,
832        )
833        .unwrap();
834        (tmp, dest)
835    }
836
837    // rivet: verifies REQ-INDEXAUTH-001
838    #[test]
839    fn an_index_attached_to_a_layout_is_what_an_offline_install_reads_back() {
840        // Clause 1's transport half for the air-gapped path. Without it the
841        // document exists, verifies, and reaches nobody who is not on a
842        // registry — which is the transport varve exists to serve.
843        use crate::source::LayerSource;
844        let (sk, _pk) = generate_root_keypair();
845        let (_tmp, layout) = layout_for("2026.08.0", &sk);
846        let envelope = index(4, &[("2026.08.0", "sha256:aa")])
847            .sign(&sk, "root-1")
848            .unwrap();
849        attach_to_layout(&layout, "2026.08", envelope.as_bytes()).unwrap();
850
851        let source = crate::archive::OciLayoutSource::at(&layout);
852        assert_eq!(
853            source.fetch_line_index("2026.08").unwrap().as_deref(),
854            Some(envelope.as_bytes()),
855            "the layout source must hand back the attached index verbatim"
856        );
857        // Another line's index is not served for this one: a reader keyed on
858        // the artifact type alone would produce a `WrongLine` refusal whose
859        // stated cause is wrong.
860        assert_eq!(source.fetch_line_index("2026.09").unwrap(), None);
861        // A layout with no index at all is `None`, not an error — whether that
862        // is tolerable is the realm's call, not the layout's.
863        let (_t2, bare) = layout_for("2026.08.0", &sk);
864        assert_eq!(
865            crate::archive::OciLayoutSource::at(&bare)
866                .fetch_line_index("2026.08")
867                .unwrap(),
868            None
869        );
870
871        // A layout carries BOTH documents about one line. Attaching a
872        // line-status must not disturb the index, and the index reader must
873        // not pick up the status: they are separate artifact types and are
874        // kept apart by type, not by luck of ordering.
875        let status = crate::linestatus::LineStatus {
876            line: "2026.08".into(),
877            counter: 1,
878            issued_at: "2026-08-18T00:00:00Z".into(),
879            support_until: None,
880            yanked: Default::default(),
881            known_problems: Vec::new(),
882        }
883        .sign(&sk, "root-1")
884        .unwrap();
885        crate::linestatus::attach_to_layout(
886            &layout,
887            &"2026.08".parse().unwrap(),
888            status.as_bytes(),
889        )
890        .unwrap();
891        assert_eq!(
892            source.fetch_line_index("2026.08").unwrap().as_deref(),
893            Some(envelope.as_bytes()),
894            "attaching a status must not displace the index"
895        );
896        assert_eq!(
897            crate::linestatus::read_from_layout(&layout, &"2026.08".parse().unwrap())
898                .unwrap()
899                .as_deref(),
900            Some(status.as_bytes()),
901            "…and the index must not be handed back as the status either"
902        );
903
904        // Re-attaching replaces rather than accumulating: two indexes for one
905        // line in a layout is a document whose meaning depends on read order.
906        let newer = index(5, &[("2026.08.0", "sha256:aa")])
907            .sign(&sk, "root-1")
908            .unwrap();
909        attach_to_layout(&layout, "2026.08", newer.as_bytes()).unwrap();
910        let json: serde_json::Value =
911            serde_json::from_slice(&std::fs::read(layout.join("index.json")).unwrap()).unwrap();
912        assert_eq!(
913            json["manifests"]
914                .as_array()
915                .unwrap()
916                .iter()
917                .filter(|e| e["artifactType"] == LINE_INDEX_ARTIFACT_TYPE)
918                .count(),
919            1,
920            "one index per line, replaced in place"
921        );
922        assert_eq!(
923            source.fetch_line_index("2026.08").unwrap().as_deref(),
924            Some(newer.as_bytes())
925        );
926    }
927
928    // rivet: verifies REQ-INDEXAUTH-001
929    #[test]
930    fn a_producer_cannot_downgrade_or_misfile_a_published_index() {
931        // Clause 2 at the PRODUCING end. `attach-status` learned this the hard
932        // way: the one place that creates the artifact was the one place the
933        // monotonicity rule was missing, so a re-run of a CI step silently
934        // shipped a superseded document that fresh consumers then cached.
935        let (sk, _pk) = generate_root_keypair();
936        let (_tmp, layout) = layout_for("2026.08.0", &sk);
937        let newer = index(7, &[("2026.08.0", "sha256:aa")])
938            .sign(&sk, "root-1")
939            .unwrap();
940        let (line, counter) = attach_envelope_to_layout(&layout, newer.as_bytes()).unwrap();
941        assert_eq!((line.as_str(), counter), ("2026.08", 7));
942
943        let older = index(3, &[("2026.08.0", "sha256:aa")])
944            .sign(&sk, "root-1")
945            .unwrap();
946        let err = attach_envelope_to_layout(&layout, older.as_bytes())
947            .expect_err("a producer must not publish an index older than the layout's");
948        assert!(
949            matches!(
950                err,
951                IndexError::Stale {
952                    presented: 3,
953                    cached: 7,
954                    ..
955                }
956            ),
957            "got: {err}"
958        );
959        // The newer one is still what the layout serves.
960        assert_eq!(
961            read_from_layout(&layout, "2026.08").unwrap().as_deref(),
962            Some(newer.as_bytes())
963        );
964
965        // An index for another line than the layout's own layer is refused
966        // here rather than left for the consumer to hit as `WrongLine`.
967        let foreign = LineIndex {
968            line: "2099.01".into(),
969            ..index(1, &[])
970        }
971        .sign(&sk, "root-1")
972        .unwrap();
973        let err = attach_envelope_to_layout(&layout, foreign.as_bytes())
974            .expect_err("a 2099.01 index does not belong on a 2026.08 layout");
975        assert!(
976            matches!(err, IndexError::WrongLine { ref document, ref expected }
977                if document == "2099.01" && expected == "2026.08"),
978            "got: {err}"
979        );
980        // …and a document whose `line` is not a line at all never gets signed
981        // onto a layout: it would verify forever and match nothing.
982        let nonsense = LineIndex {
983            line: "twenty-twenty-six".into(),
984            ..index(1, &[])
985        }
986        .sign(&sk, "root-1")
987        .unwrap();
988        assert!(attach_envelope_to_layout(&layout, nonsense.as_bytes()).is_err());
989    }
990
991    // rivet: verifies REQ-INDEXAUTH-001
992    #[test]
993    fn the_cache_is_what_gives_clause_two_something_to_compare_against() {
994        // `refuse_regression` needs a HELD document, and nothing held one:
995        // every test supplied the "cached" index by hand, so the rule could
996        // never fire on a real machine. This is that store.
997        let tmp = tempfile::tempdir().unwrap();
998        let cache = IndexCache::at_root(tmp.path());
999        assert_eq!(
1000            cache.load("2026.08").unwrap(),
1001            None,
1002            "nothing accepted yet is None, not an empty index — an empty index \
1003             asserts that the line contains nothing"
1004        );
1005
1006        let (sk, _pk) = generate_root_keypair();
1007        let seven = index(7, &[("2026.08.1", "sha256:bb")]);
1008        cache
1009            .update("2026.08", seven.sign(&sk, "k").unwrap().as_bytes(), &seven)
1010            .unwrap();
1011        assert_eq!(cache.load("2026.08").unwrap().unwrap().counter, 7);
1012
1013        // A replayed older document cannot lower the mark, even by a caller
1014        // that skipped `check`.
1015        let three = index(3, &[("2026.08.0", "sha256:aa")]);
1016        let err = cache
1017            .update("2026.08", three.sign(&sk, "k").unwrap().as_bytes(), &three)
1018            .expect_err("the cache must not accept a regression");
1019        assert!(matches!(err, IndexError::Stale { .. }), "got: {err}");
1020        assert_eq!(cache.load("2026.08").unwrap().unwrap().counter, 7);
1021
1022        // Equal is idempotent; newer advances; another line is independent,
1023        // because the counters are per line.
1024        cache
1025            .update("2026.08", seven.sign(&sk, "k").unwrap().as_bytes(), &seven)
1026            .unwrap();
1027        let eight = index(8, &[("2026.08.1", "sha256:bb")]);
1028        cache
1029            .update("2026.08", eight.sign(&sk, "k").unwrap().as_bytes(), &eight)
1030            .unwrap();
1031        assert_eq!(cache.load("2026.08").unwrap().unwrap().counter, 8);
1032        let other = LineIndex {
1033            line: "2026.09".into(),
1034            ..index(1, &[])
1035        };
1036        cache
1037            .update("2026.09", other.sign(&sk, "k").unwrap().as_bytes(), &other)
1038            .expect("a low counter on a DIFFERENT line is not a regression");
1039        assert_eq!(cache.load("2026.08").unwrap().unwrap().counter, 8);
1040    }
1041
1042    // rivet: verifies REQ-INDEXAUTH-001
1043    #[test]
1044    fn the_index_tag_cannot_be_mistaken_for_a_layer_of_the_line() {
1045        // `served_layers` filters a registry's tags by parsing them as layer
1046        // ids. If the tag carrying the index parsed as one, the index would
1047        // appear in the line's own listing — and, worse, an index naming a tag
1048        // shaped like its own would be self-satisfying.
1049        assert_eq!(index_tag("2026.08"), "line-index-2026.08");
1050        assert!(
1051            index_tag("2026.08")
1052                .parse::<crate::layer::LayerId>()
1053                .is_err()
1054        );
1055        assert!(index_tag("2026.08").starts_with(LINE_INDEX_TAG_PREFIX));
1056    }
1057
1058    // rivet: verifies REQ-INDEXAUTH-001
1059    #[test]
1060    fn the_source_never_gets_to_vouch_for_its_own_index() {
1061        // The source is the party this document exists to constrain, so an
1062        // index it signs with its own key must be worthless.
1063        let (_realm_sk, realm_pk) = generate_root_keypair();
1064        let (impostor_sk, _impostor_pk) = generate_root_keypair();
1065        let policy = IndexPolicy {
1066            realm: "acme",
1067            root_public_key: &realm_pk,
1068            required: true,
1069        };
1070        let forged = index(99, &[("2026.08.0", "sha256:aa")])
1071            .sign(&impostor_sk, "not-the-realm")
1072            .unwrap();
1073        assert!(matches!(
1074            check("2026.08", Some(forged.as_bytes()), None, None, &policy),
1075            Err(IndexError::Verify(_))
1076        ));
1077    }
1078}