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 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 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 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 #[test]
553 fn a_source_that_hides_a_layer_the_index_names_is_refused() {
554 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 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 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 #[test]
591 fn the_high_water_mark_comes_from_the_index_not_from_what_was_served() {
592 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 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 assert_eq!(index(1, &[]).high_water(), None);
614 }
615
616 #[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 assert!(newer.refuse_regression(Some(&newer)).is_ok());
639 assert!(newer.refuse_regression(Some(&older)).is_ok());
641 assert!(older.refuse_regression(None).is_ok());
643 }
644
645 #[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 assert!(doc.refuse_regression(Some(&other)).is_ok());
656 assert_eq!(doc.line().unwrap().to_string(), "2026.08");
657 }
658
659 #[test]
661 fn a_realm_that_promised_an_index_does_not_fall_back_to_an_unsigned_listing() {
662 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 assert!(
689 check("2026.08", None, None, None, &silent)
690 .unwrap()
691 .is_none()
692 );
693 }
694
695 #[test]
697 fn an_index_for_a_different_line_cannot_satisfy_this_line() {
698 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 #[test]
730 fn a_source_that_cannot_enumerate_is_not_treated_as_hiding_everything() {
731 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 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 #[test]
764 fn check_refuses_a_stale_index_not_only_refuse_regression_does() {
765 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 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 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 None,
830 &dest,
831 false,
832 )
833 .unwrap();
834 (tmp, dest)
835 }
836
837 #[test]
839 fn an_index_attached_to_a_layout_is_what_an_offline_install_reads_back() {
840 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 assert_eq!(source.fetch_line_index("2026.09").unwrap(), None);
861 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 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 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 #[test]
930 fn a_producer_cannot_downgrade_or_misfile_a_published_index() {
931 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 assert_eq!(
961 read_from_layout(&layout, "2026.08").unwrap().as_deref(),
962 Some(newer.as_bytes())
963 );
964
965 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 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 #[test]
993 fn the_cache_is_what_gives_clause_two_something_to_compare_against() {
994 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 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 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 #[test]
1044 fn the_index_tag_cannot_be_mistaken_for_a_layer_of_the_line() {
1045 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 #[test]
1060 fn the_source_never_gets_to_vouch_for_its_own_index() {
1061 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}