1use 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
29pub const LINE_INDEX_PAYLOAD_TYPE: &str = "application/vnd.pulseengine.varve.line-index.v1+json";
32
33pub const LINE_INDEX_ARTIFACT_TYPE: &str = LINE_INDEX_PAYLOAD_TYPE;
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct IndexedLayer {
40 pub layer: String,
42 pub digest: String,
45 pub channel: String,
47 pub counter: u64,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct LineIndex {
59 pub line: String,
61 pub counter: u64,
64 #[serde(rename = "issued-at")]
66 pub issued_at: String,
67 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 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 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 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 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 pub fn high_water(&self) -> Option<u64> {
164 self.layers.iter().map(|e| e.counter).max()
165 }
166
167 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 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#[derive(Debug, Clone, Copy)]
195pub struct IndexPolicy<'a> {
196 pub realm: &'a str,
198 pub root_public_key: &'a [u8],
202 pub required: bool,
204}
205
206pub 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 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 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
249pub const ANN_INDEX_LINE: &str = "eu.pulseengine.varve.index-line";
264
265pub const LINE_INDEX_TAG_PREFIX: &str = "line-index-";
270
271pub fn index_tag(line: &str) -> String {
273 format!("{LINE_INDEX_TAG_PREFIX}{line}")
274}
275
276pub 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
317pub 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
353pub 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 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
392pub(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#[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 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 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 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 #[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 assert!(LineIndex::verify_and_parse(envelope.as_bytes(), &other_pk).is_err());
505 }
506
507 #[test]
509 fn a_signed_line_status_cannot_be_replayed_as_an_index() {
510 let (sk, pk) = generate_root_keypair();
515 let status = crate::linestatus::LineStatus {
516 min_counter: None,
517 line: "2026.08".into(),
518 counter: 9,
519 issued_at: "2026-08-18T00:00:00Z".into(),
520 support_until: None,
521 yanked: Default::default(),
522 known_problems: Vec::new(),
523 };
524 let envelope = status.sign(&sk, "root-1").unwrap();
525 match LineIndex::verify_and_parse(envelope.as_bytes(), &pk) {
533 Err(IndexError::Verify(_)) => {}
534 Err(IndexError::Payload(p)) => panic!(
535 "rejected by the SCHEMA ({p}), not by the payload type — the type is the \
536 defence against cross-document replay and must be what fails"
537 ),
538 Ok(_) => panic!("a line-status must not verify as a line-index"),
539 Err(other) => panic!("expected a payload-type rejection, got {other}"),
540 }
541
542 let idx = index(1, &[("2026.08.0", "sha256:aa")]);
545 let idx_env = idx.sign(&sk, "root-1").unwrap();
546 assert!(
547 crate::linestatus::LineStatus::verify_and_parse(idx_env.as_bytes(), &pk).is_err(),
548 "a line-index must not verify as a line-status"
549 );
550 }
551
552 #[test]
554 fn a_source_that_hides_a_layer_the_index_names_is_refused() {
555 let doc = index(1, &[("2026.08.0", "sha256:aa"), ("2026.08.1", "sha256:bb")]);
559 let err = doc
560 .refuse_omission(&["2026.08.0".to_string()])
561 .expect_err("hiding a layer must be refused");
562 match &err {
563 IndexError::Omitted { layer, digest, .. } => {
564 assert_eq!(layer, "2026.08.1");
565 assert_eq!(digest, "sha256:bb", "name the digest, so it can be sought");
566 }
567 other => panic!("expected Omitted, got {other}"),
568 }
569 let msg = err.to_string();
571 assert!(msg.contains("2026.08.1"), "names the hidden layer: {msg}");
572 assert!(
573 msg.contains("still verifies"),
574 "says WHY per-artifact verification did not catch this: {msg}"
575 );
576
577 assert!(
581 doc.refuse_omission(&[
582 "2026.08.0".to_string(),
583 "2026.08.1".to_string(),
584 "2026.08.2".to_string(),
585 ])
586 .is_ok()
587 );
588 }
589
590 #[test]
592 fn the_high_water_mark_comes_from_the_index_not_from_what_was_served() {
593 let doc = index(
596 1,
597 &[
598 ("2026.08.0", "sha256:aa"),
599 ("2026.08.2", "sha256:cc"),
600 ("2026.08.1", "sha256:bb"),
601 ],
602 );
603 assert_eq!(
607 doc.high_water(),
608 Some(3),
609 "the greatest counter the REALM asserts, regardless of the order \
610 entries appear in the document or of what any source served"
611 );
612 assert_eq!(index(1, &[]).high_water(), None);
615 }
616
617 #[test]
619 fn a_stale_index_cannot_replace_a_newer_one() {
620 let newer = index(7, &[("2026.08.1", "sha256:bb")]);
621 let older = index(3, &[("2026.08.0", "sha256:aa")]);
622
623 let err = older
624 .refuse_regression(Some(&newer))
625 .expect_err("a lower counter must be refused");
626 assert!(matches!(
627 err,
628 IndexError::Stale {
629 presented: 3,
630 cached: 7,
631 ..
632 }
633 ));
634 let msg = err.to_string();
635 assert!(msg.contains('3') && msg.contains('7'), "names both: {msg}");
636
637 assert!(newer.refuse_regression(Some(&newer)).is_ok());
640 assert!(newer.refuse_regression(Some(&older)).is_ok());
642 assert!(older.refuse_regression(None).is_ok());
644 }
645
646 #[test]
648 fn an_index_for_another_line_is_not_silently_accepted() {
649 let doc = index(1, &[("2026.08.0", "sha256:aa")]);
650 let other = LineIndex {
651 line: "2026.09".into(),
652 ..index(1, &[("2026.09.0", "sha256:zz")])
653 };
654 assert!(doc.refuse_regression(Some(&other)).is_ok());
657 assert_eq!(doc.line().unwrap().to_string(), "2026.08");
658 }
659
660 #[test]
662 fn a_realm_that_promised_an_index_does_not_fall_back_to_an_unsigned_listing() {
663 let (_sk, pk) = generate_root_keypair();
667 let declaring = IndexPolicy {
668 realm: "acme",
669 root_public_key: &pk,
670 required: true,
671 };
672 let silent = IndexPolicy {
673 required: false,
674 ..declaring
675 };
676
677 let err = check("2026.08", None, None, None, &declaring)
678 .expect_err("a declaring realm must not accept a missing index");
679 assert!(matches!(err, IndexError::Missing { .. }));
680 let msg = err.to_string();
681 assert!(msg.contains("acme"), "names the realm: {msg}");
682 assert!(
683 msg.contains("will not fall back"),
684 "says what it refused to do, not merely that something is absent: {msg}"
685 );
686
687 assert!(
690 check("2026.08", None, None, None, &silent)
691 .unwrap()
692 .is_none()
693 );
694 }
695
696 #[test]
698 fn an_index_for_a_different_line_cannot_satisfy_this_line() {
699 let (sk, pk) = generate_root_keypair();
704 let policy = IndexPolicy {
705 realm: "acme",
706 root_public_key: &pk,
707 required: true,
708 };
709 let quiet = LineIndex {
710 line: "2026.01".into(),
711 ..index(1, &[])
712 };
713 let envelope = quiet.sign(&sk, "k").unwrap();
714 let err = check(
715 "2026.08",
716 Some(envelope.as_bytes()),
717 Some(&["2026.08.0".to_string()]),
718 None,
719 &policy,
720 )
721 .expect_err("an index for another line must not satisfy this one");
722 assert!(matches!(
723 err,
724 IndexError::WrongLine { ref document, ref expected }
725 if document == "2026.01" && expected == "2026.08"
726 ));
727 }
728
729 #[test]
731 fn a_source_that_cannot_enumerate_is_not_treated_as_hiding_everything() {
732 let (sk, pk) = generate_root_keypair();
738 let policy = IndexPolicy {
739 realm: "acme",
740 root_public_key: &pk,
741 required: true,
742 };
743 let doc = index(1, &[("2026.08.0", "sha256:aa")]);
744 let envelope = doc.sign(&sk, "k").unwrap();
745
746 let ok = check("2026.08", Some(envelope.as_bytes()), None, None, &policy)
747 .expect("a source that cannot enumerate is not evidence of hiding");
748 assert_eq!(ok.unwrap().counter, 1);
749
750 assert!(matches!(
752 check(
753 "2026.08",
754 Some(envelope.as_bytes()),
755 Some(&[]),
756 None,
757 &policy
758 ),
759 Err(IndexError::Omitted { .. })
760 ));
761 }
762
763 #[test]
765 fn check_refuses_a_stale_index_not_only_refuse_regression_does() {
766 let (sk, pk) = generate_root_keypair();
773 let policy = IndexPolicy {
774 realm: "acme",
775 root_public_key: &pk,
776 required: true,
777 };
778 let cached = index(7, &[("2026.08.1", "sha256:bb")]);
779 let stale = index(3, &[("2026.08.0", "sha256:aa")]);
780 let envelope = stale.sign(&sk, "k").unwrap();
781
782 let err = check(
783 "2026.08",
784 Some(envelope.as_bytes()),
785 None,
786 Some(&cached),
787 &policy,
788 )
789 .expect_err("a replayed older index must be refused by the path install uses");
790 assert!(matches!(
791 err,
792 IndexError::Stale {
793 presented: 3,
794 cached: 7,
795 ..
796 }
797 ));
798
799 let fresher = index(8, &[("2026.08.1", "sha256:bb")]);
801 let ok = fresher.sign(&sk, "k").unwrap();
802 assert_eq!(
803 check("2026.08", Some(ok.as_bytes()), None, Some(&cached), &policy)
804 .unwrap()
805 .unwrap()
806 .counter,
807 8
808 );
809 }
810
811 fn layout_for(layer: &str, sk: &[u8]) -> (tempfile::TempDir, std::path::PathBuf) {
813 let tmp = tempfile::tempdir().unwrap();
814 let dest = tmp.path().join("layout");
815 let payload = crate::manifest::fixtures::manifest_with_tools(
816 layer,
817 "qualified",
818 1,
819 "2026-08-18T00:00:00Z",
820 &[],
821 );
822 let envelope = crate::verify::sign_layer_manifest(&payload, sk, "root-1").unwrap();
823 crate::archive::write_oci_layout(
824 &payload,
825 envelope.as_bytes(),
826 &[],
827 layer,
828 "qualified",
829 None,
831 &dest,
832 false,
833 )
834 .unwrap();
835 (tmp, dest)
836 }
837
838 #[test]
840 fn an_index_attached_to_a_layout_is_what_an_offline_install_reads_back() {
841 use crate::source::LayerSource;
845 let (sk, _pk) = generate_root_keypair();
846 let (_tmp, layout) = layout_for("2026.08.0", &sk);
847 let envelope = index(4, &[("2026.08.0", "sha256:aa")])
848 .sign(&sk, "root-1")
849 .unwrap();
850 attach_to_layout(&layout, "2026.08", envelope.as_bytes()).unwrap();
851
852 let source = crate::archive::OciLayoutSource::at(&layout);
853 assert_eq!(
854 source.fetch_line_index("2026.08").unwrap().as_deref(),
855 Some(envelope.as_bytes()),
856 "the layout source must hand back the attached index verbatim"
857 );
858 assert_eq!(source.fetch_line_index("2026.09").unwrap(), None);
862 let (_t2, bare) = layout_for("2026.08.0", &sk);
865 assert_eq!(
866 crate::archive::OciLayoutSource::at(&bare)
867 .fetch_line_index("2026.08")
868 .unwrap(),
869 None
870 );
871
872 let status = crate::linestatus::LineStatus {
877 min_counter: None,
878 line: "2026.08".into(),
879 counter: 1,
880 issued_at: "2026-08-18T00:00:00Z".into(),
881 support_until: None,
882 yanked: Default::default(),
883 known_problems: Vec::new(),
884 }
885 .sign(&sk, "root-1")
886 .unwrap();
887 crate::linestatus::attach_to_layout(
888 &layout,
889 &"2026.08".parse().unwrap(),
890 status.as_bytes(),
891 )
892 .unwrap();
893 assert_eq!(
894 source.fetch_line_index("2026.08").unwrap().as_deref(),
895 Some(envelope.as_bytes()),
896 "attaching a status must not displace the index"
897 );
898 assert_eq!(
899 crate::linestatus::read_from_layout(&layout, &"2026.08".parse().unwrap())
900 .unwrap()
901 .as_deref(),
902 Some(status.as_bytes()),
903 "…and the index must not be handed back as the status either"
904 );
905
906 let newer = index(5, &[("2026.08.0", "sha256:aa")])
909 .sign(&sk, "root-1")
910 .unwrap();
911 attach_to_layout(&layout, "2026.08", newer.as_bytes()).unwrap();
912 let json: serde_json::Value =
913 serde_json::from_slice(&std::fs::read(layout.join("index.json")).unwrap()).unwrap();
914 assert_eq!(
915 json["manifests"]
916 .as_array()
917 .unwrap()
918 .iter()
919 .filter(|e| e["artifactType"] == LINE_INDEX_ARTIFACT_TYPE)
920 .count(),
921 1,
922 "one index per line, replaced in place"
923 );
924 assert_eq!(
925 source.fetch_line_index("2026.08").unwrap().as_deref(),
926 Some(newer.as_bytes())
927 );
928 }
929
930 #[test]
932 fn a_producer_cannot_downgrade_or_misfile_a_published_index() {
933 let (sk, _pk) = generate_root_keypair();
938 let (_tmp, layout) = layout_for("2026.08.0", &sk);
939 let newer = index(7, &[("2026.08.0", "sha256:aa")])
940 .sign(&sk, "root-1")
941 .unwrap();
942 let (line, counter) = attach_envelope_to_layout(&layout, newer.as_bytes()).unwrap();
943 assert_eq!((line.as_str(), counter), ("2026.08", 7));
944
945 let older = index(3, &[("2026.08.0", "sha256:aa")])
946 .sign(&sk, "root-1")
947 .unwrap();
948 let err = attach_envelope_to_layout(&layout, older.as_bytes())
949 .expect_err("a producer must not publish an index older than the layout's");
950 assert!(
951 matches!(
952 err,
953 IndexError::Stale {
954 presented: 3,
955 cached: 7,
956 ..
957 }
958 ),
959 "got: {err}"
960 );
961 assert_eq!(
963 read_from_layout(&layout, "2026.08").unwrap().as_deref(),
964 Some(newer.as_bytes())
965 );
966
967 let foreign = LineIndex {
970 line: "2099.01".into(),
971 ..index(1, &[])
972 }
973 .sign(&sk, "root-1")
974 .unwrap();
975 let err = attach_envelope_to_layout(&layout, foreign.as_bytes())
976 .expect_err("a 2099.01 index does not belong on a 2026.08 layout");
977 assert!(
978 matches!(err, IndexError::WrongLine { ref document, ref expected }
979 if document == "2099.01" && expected == "2026.08"),
980 "got: {err}"
981 );
982 let nonsense = LineIndex {
985 line: "twenty-twenty-six".into(),
986 ..index(1, &[])
987 }
988 .sign(&sk, "root-1")
989 .unwrap();
990 assert!(attach_envelope_to_layout(&layout, nonsense.as_bytes()).is_err());
991 }
992
993 #[test]
995 fn the_cache_is_what_gives_clause_two_something_to_compare_against() {
996 let tmp = tempfile::tempdir().unwrap();
1000 let cache = IndexCache::at_root(tmp.path());
1001 assert_eq!(
1002 cache.load("2026.08").unwrap(),
1003 None,
1004 "nothing accepted yet is None, not an empty index — an empty index \
1005 asserts that the line contains nothing"
1006 );
1007
1008 let (sk, _pk) = generate_root_keypair();
1009 let seven = index(7, &[("2026.08.1", "sha256:bb")]);
1010 cache
1011 .update("2026.08", seven.sign(&sk, "k").unwrap().as_bytes(), &seven)
1012 .unwrap();
1013 assert_eq!(cache.load("2026.08").unwrap().unwrap().counter, 7);
1014
1015 let three = index(3, &[("2026.08.0", "sha256:aa")]);
1018 let err = cache
1019 .update("2026.08", three.sign(&sk, "k").unwrap().as_bytes(), &three)
1020 .expect_err("the cache must not accept a regression");
1021 assert!(matches!(err, IndexError::Stale { .. }), "got: {err}");
1022 assert_eq!(cache.load("2026.08").unwrap().unwrap().counter, 7);
1023
1024 cache
1027 .update("2026.08", seven.sign(&sk, "k").unwrap().as_bytes(), &seven)
1028 .unwrap();
1029 let eight = index(8, &[("2026.08.1", "sha256:bb")]);
1030 cache
1031 .update("2026.08", eight.sign(&sk, "k").unwrap().as_bytes(), &eight)
1032 .unwrap();
1033 assert_eq!(cache.load("2026.08").unwrap().unwrap().counter, 8);
1034 let other = LineIndex {
1035 line: "2026.09".into(),
1036 ..index(1, &[])
1037 };
1038 cache
1039 .update("2026.09", other.sign(&sk, "k").unwrap().as_bytes(), &other)
1040 .expect("a low counter on a DIFFERENT line is not a regression");
1041 assert_eq!(cache.load("2026.08").unwrap().unwrap().counter, 8);
1042 }
1043
1044 #[test]
1046 fn the_index_tag_cannot_be_mistaken_for_a_layer_of_the_line() {
1047 assert_eq!(index_tag("2026.08"), "line-index-2026.08");
1052 assert!(
1053 index_tag("2026.08")
1054 .parse::<crate::layer::LayerId>()
1055 .is_err()
1056 );
1057 assert!(index_tag("2026.08").starts_with(LINE_INDEX_TAG_PREFIX));
1058 }
1059
1060 #[test]
1062 fn the_source_never_gets_to_vouch_for_its_own_index() {
1063 let (_realm_sk, realm_pk) = generate_root_keypair();
1066 let (impostor_sk, _impostor_pk) = generate_root_keypair();
1067 let policy = IndexPolicy {
1068 realm: "acme",
1069 root_public_key: &realm_pk,
1070 required: true,
1071 };
1072 let forged = index(99, &[("2026.08.0", "sha256:aa")])
1073 .sign(&impostor_sk, "not-the-realm")
1074 .unwrap();
1075 assert!(matches!(
1076 check("2026.08", Some(forged.as_bytes()), None, None, &policy),
1077 Err(IndexError::Verify(_))
1078 ));
1079 }
1080}