diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index d9ce6e27..d7ec3a39 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -5013,3 +5013,52 @@ artifacts: created-by: ai model: claude-opus-5-5 timestamp: 2026-10-14T20:07:55Z + + - id: REQ-COMPOSEWINDOW-001 + type: requirement + title: A composition never promises support past the layers it composes, unless it says why + status: approved + release: v0.43.0 + description: Measured on varve#25(4) The cap shall be applied by the command the deposit already calls (varve support-horizon), given the layer's manifest; a layers repository adopts it with one argument and the realm definitions it already downloads, and no second command decides the window. + links: + - type: satisfies + target: REQ-SUPPORTUNTIL-001 + - type: satisfies + target: REQ-COMPOSE-001 + provenance: + created-by: ai + model: claude-opus-5-5 + timestamp: 2026-10-14T19:09:24Z + + - id: REQ-ENVQUOTE-001 + type: requirement + title: varve env emits the store root quoted, so the line a user is told to eval cannot execute it + status: approved + release: v0.43.0 + description: "varve#46: env_script_sh interpolates the store root inside sh double quotes, where $ and backticks stay live; VARVE_ROOT='/tmp/vr$(id)x' runs id when the user follows 'eval \"$(varve env)\"'. The same injection class v0.19.0 removed from shim dispatch, surviving where the user is told to eval. Clauses: (1) Every shell varve env supports shall receive the path in that shell's literal quoting (sh/bash/zsh single quotes with '\\'' escaping; fish and PowerShell by their own rules). (2) A root containing each shell's metacharacters shall round-trip as the literal path, shown by running the emitted script in that shell." + provenance: + created-by: ai + model: claude-opus-5-5 + timestamp: 2026-10-14T19:09:24Z + + - id: REQ-NOPINNOTOOL-001 + type: requirement + title: With no pin in scope, which, run and the shims refuse as status does + status: approved + release: v0.43.0 + description: "varve#198: in a directory with no varve.toml, varve status refused (no trust root) while varve which ordeal resolved an old ordeal from the legacy pre-realm store ~/.varve/core with no verification, and the shims on PATH did the same. Two code paths answering one question differently. Clauses: (1) With no pin in scope, which, run and shim dispatch shall refuse with the same reason status gives. (2) No resolution shall read the legacy pre-realm store. (3) varve shall say the legacy store exists and that nothing in it verifies, without deleting it." + provenance: + created-by: ai + model: claude-opus-5-5 + timestamp: 2026-10-14T19:09:24Z + + - id: REQ-MUTANTDEBT-001 + type: requirement + title: Declared mutation debt is measured in full, not cut off at a time cap + status: approved + release: v0.43.0 + description: "varve#181: the nightly advisory mutation run is cancelled at its 90-minute cap every night after ~32% of 2,675 mutants, so 11 of the 22 not-yet files are never reached. The instrument that makes declared debt counted rather than hidden counts half of it. Clauses: (1) Every not-yet file in mutation-scope.toml shall be tested by some scheduled run that completes. (2) A run that does not complete shall be reported as incomplete, never as a measurement." + provenance: + created-by: ai + model: claude-opus-5-5 + timestamp: 2026-10-14T19:09:24Z diff --git a/crates/varve-core/src/linestatus.rs b/crates/varve-core/src/linestatus.rs index 21db3eac..7bf87801 100644 --- a/crates/varve-core/src/linestatus.rs +++ b/crates/varve-core/src/linestatus.rs @@ -785,6 +785,46 @@ pub fn cache_baseline_from_source( root_pk: &[u8], store_root: &Path, ) -> Result, LineStatusError> { + match current_line_status(source, layer, line, root_pk, Published::BestEffort)? { + Some((envelope, doc)) => { + StatusCache::at_root(store_root).update(line, &envelope, &doc)?; + Ok(Some(doc.counter)) + } + None => Ok(None), + } +} + +/// What a failed fetch of the line's published correction means. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Published { + /// Read as absence: the reader acts on the best document it can verify. + BestEffort, + /// Refuse: the reader will sign on the answer. + Required, +} + +/// The line-status that currently speaks for `layer`: the baseline deposited +/// beside it or the document later published under the line's own tag, +/// whichever VERIFIES and carries the higher counter. `Ok(None)` when the +/// source carries neither; `Err` when something was present but bad. +/// +/// `published` says what a FAILED fetch of the correction means. Install +/// reads it as absence ([`Published::BestEffort`]) and documents the residual. +/// A reader about to SIGN something on the strength of the answer cannot: +/// a correction only ever narrows what the baseline said, so "could not ask" +/// read as "nothing newer" fails open ([`Published::Required`]). +/// +/// One rule for every reader. The install cache and a composition reading its +/// includes' windows (REQ-COMPOSEWINDOW-001) must agree about which document +/// is current, or a correction that shortened a window would bind one and +/// not the other. +pub fn current_line_status( + source: &dyn crate::source::LayerSource, + layer: &crate::source::LayerRef, + line: &Line, + root_pk: &[u8], + published: Published, +) -> Result, LineStatus)>, LineStatusError> { let baseline = source .fetch_line_status(layer) .map_err(|e| LineStatusError::Payload(format!("fetching baseline line-status: {e}")))?; @@ -793,9 +833,18 @@ pub fn cache_baseline_from_source( // A source that cannot serve one must not be able to make that look like a // failure, so a transport error here is treated as absence — the baseline // path decides whether the consumer ends up with nothing. - let published = source - .fetch_published_line_status(&line.to_string()) - .unwrap_or(None); + let published = match ( + source.fetch_published_line_status(&line.to_string()), + published, + ) { + (Ok(doc), _) => doc, + (Err(_), Published::BestEffort) => None, + (Err(e), Published::Required) => { + return Err(LineStatusError::Payload(format!( + "cannot tell whether a correction was published for line {line}: {e}" + ))); + } + }; // VERIFY FIRST, RANK SECOND. Both documents are untrusted bytes from the // party this evidence exists to constrain. Choosing by counter before @@ -830,10 +879,7 @@ pub fn cache_baseline_from_source( } match best { - Some((counter, envelope, doc)) => { - StatusCache::at_root(store_root).update(line, &envelope, &doc)?; - Ok(Some(counter)) - } + Some((_, envelope, doc)) => Ok(Some((envelope, doc))), // Nothing usable. If something was PRESENT but bad, that is a finding // and is raised; if both were simply absent, the source carries none. None => match first_error { diff --git a/crates/varve-core/src/sdkexport.rs b/crates/varve-core/src/sdkexport.rs index 339e5050..caa856ce 100644 --- a/crates/varve-core/src/sdkexport.rs +++ b/crates/varve-core/src/sdkexport.rs @@ -216,6 +216,40 @@ fn find_sub(haystack: &[u8], needle: &[u8], from: usize) -> Option { (from..=haystack.len() - needle.len()).find(|&i| &haystack[i..i + needle.len()] == needle) } +/// Every occurrence of `from` in `hay` replaced by `to`, and how many there +/// were. ONE copy, used by the text path, the per-string binary path and the +/// SYSDIRS table. +/// +/// Written without index arithmetic on purpose. The three loops this replaced +/// advanced with `i = hit + from.len()`, and cargo-mutants' `+` → `*` turned +/// that into a loop that never advances when the hit is at 0 — while +/// appending on every pass. It did not just hang: it grew a buffer until the +/// CI runner ran out of memory and was shut down, twice, taking the whole +/// mutation shard with it. Slicing with `get` leaves no operator for that +/// mutant, and the loop below cannot run without consuming input. +fn replace_all(hay: &[u8], from: &[u8], to: &[u8]) -> (Vec, usize) { + let mut out = Vec::with_capacity(hay.len()); + let mut rest = hay; + let mut count = 0; + // Every pass must CONSUME input or stop. Advancing only past a match + // that lies wholly inside `rest` makes termination independent of what + // `find_sub` answers: a mutant `find_sub` that always says `Some(0)` + // once spun this loop forever on an empty `rest`, appending each time, + // until the CI runner ran out of memory — the failure it was written to + // end, reached by a different mutant (2026-10-16). + while let Some(at) = find_sub(rest, from, 0) { + let Some(tail) = rest.get(at..).and_then(|t| t.get(from.len()..)) else { + break; + }; + out.extend_from_slice(&rest[..at]); + out.extend_from_slice(to); + rest = tail; + count += 1; + } + out.extend_from_slice(rest); + (out, count) +} + /// What relocating one member's bytes produced. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Relocation { @@ -253,16 +287,7 @@ pub fn relocate_bytes( if !is_binary(bytes) { // The `sed -i` half: no field to overflow, so the text may change length. - let mut out = Vec::with_capacity(bytes.len()); - let mut i = 0; - let mut substitutions = 0; - while let Some(hit) = find_sub(bytes, built, i) { - out.extend_from_slice(&bytes[i..hit]); - out.extend_from_slice(dest); - i = hit + built.len(); - substitutions += 1; - } - out.extend_from_slice(&bytes[i..]); + let (out, substitutions) = replace_all(bytes, built, dest); return Ok(Relocation { bytes: out, fields: 0, @@ -278,6 +303,12 @@ pub fn relocate_bytes( let mut fields = sysdirs.as_ref().map_or(0, |(_, n)| *n); let mut cursor = 0; while let Some(hit) = find_sub(&out, built, cursor) { + // A hit behind the cursor is a search that did not advance; stop + // rather than re-patch the same field forever (a mutant `find_sub` + // returning a fixed position hung here until the test timed out). + if hit < cursor { + break; + } if let Some((range, _)) = &sysdirs && range.contains(&hit) { @@ -317,15 +348,7 @@ pub fn relocate_bytes( let capacity = pad_end - start; // Replace every occurrence of the prefix WITHIN this one string. - let old = out[start..end].to_vec(); - let mut new = Vec::with_capacity(old.len()); - let mut i = 0; - while let Some(h) = find_sub(&old, built, i) { - new.extend_from_slice(&old[i..h]); - new.extend_from_slice(dest); - i = h + built.len(); - } - new.extend_from_slice(&old[i..]); + let (new, _) = replace_all(&out[start..end], built, dest); // `if len(new_dl_path) >= p_filesz: ERROR` — transcribed, not // paraphrased. The destination check has already made this @@ -447,17 +470,7 @@ fn relocate_sysdirs( let paths: Vec> = table .split(|b| *b == 0) .take_while(|p| !p.is_empty()) - .map(|p| { - let mut new = Vec::with_capacity(p.len()); - let mut i = 0; - while let Some(h) = find_sub(p, built, i) { - new.extend_from_slice(&p[i..h]); - new.extend_from_slice(dest); - i = h + built.len(); - } - new.extend_from_slice(&p[i..]); - new - }) + .map(|p| replace_all(p, built, dest).0) .collect(); let width = if class64 { 8 } else { 4 }; let slots = len_size / width; diff --git a/crates/varve-core/src/support.rs b/crates/varve-core/src/support.rs index d1485ae8..2adff5a7 100644 --- a/crates/varve-core/src/support.rs +++ b/crates/varve-core/src/support.rs @@ -67,7 +67,18 @@ impl Policy { #[derive(Debug, Clone, PartialEq, Eq)] pub enum SupportError { UnknownChannel(String), - BadDate { field: &'static str, value: String }, + BadDate { + field: &'static str, + value: String, + }, + /// An include's own signed line-status states no window, so nothing + /// bounds what the composition may promise about it. + IncludeStatesNoWindow { + layer: String, + realm: String, + }, + /// `--past-includes` was given with nothing in it. + OverrideWithoutReason, } impl fmt::Display for SupportError { @@ -85,6 +96,18 @@ impl fmt::Display for SupportError { field has never been validated, so an unparseable value would \ sign cleanly and then be unusable by everything that reads it." ), + SupportError::IncludeStatesNoWindow { layer, realm } => write!( + f, + "included layer {layer} (realm '{realm}') states no support window, so \ + nothing bounds how long this composition may promise to support it. \ + Refusing rather than skipping it: the include nobody vouched for is \ + the one that would have set the cap." + ), + SupportError::OverrideWithoutReason => write!( + f, + "promising support past an included layer's window needs a reason on \ + the record; an empty one is not a decision" + ), } } } @@ -130,6 +153,168 @@ fn days_in_month(y: i64, m: i64) -> i64 { } } +/// One included layer's stated window, read from that layer's OWN signed +/// line-status and verified against ITS realm's root by the caller. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IncludeWindow { + pub layer: String, + pub realm: String, + /// `None` when the include's line-status states no window. + pub support_until: Option, +} + +/// What set a composition's horizon. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Bound { + /// The layer's own channel policy; no include ends sooner. + Own, + /// This include ends sooner, and the composition promises no more. + Include { layer: String, realm: String }, + /// The composition promises past `earliest`, deliberately, for `reason`. + PastIncludes { earliest: String, reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Capped { + pub until: String, + pub bound_by: Bound, +} + +/// Cap a composition's horizon at the earliest window among its includes +/// (REQ-COMPOSEWINDOW-001). +/// +/// A composition's window was derived from its own channel alone, so covalent +/// 2026.10.0 signed support until 2026-11-02 over a wasm layer whose own realm +/// stops vouching for it on 2026-10-24 (varve#254). The composition cannot +/// support bytes longer than the realm that publishes them does. +/// +/// Capping, not refusing, is the default: an auto-deposit should produce the +/// true window without a person. Promising longer is possible and takes a +/// `past_includes` reason, which the caller prints beside the signed date. +pub fn cap_by_includes( + own: &str, + includes: &[IncludeWindow], + past_includes: Option<&str>, +) -> Result { + let own_days = epoch_days(own).ok_or_else(|| SupportError::BadDate { + field: "support-until", + value: own.to_string(), + })?; + let mut earliest: Option<(i64, &IncludeWindow, &str)> = None; + for inc in includes { + let until = + inc.support_until + .as_deref() + .ok_or_else(|| SupportError::IncludeStatesNoWindow { + layer: inc.layer.clone(), + realm: inc.realm.clone(), + })?; + let days = epoch_days(until).ok_or_else(|| SupportError::BadDate { + field: "include support-until", + value: until.to_string(), + })?; + if earliest.is_none_or(|(e, _, _)| days < e) { + earliest = Some((days, inc, until)); + } + } + let own_stands = Capped { + until: own.to_string(), + bound_by: Bound::Own, + }; + let Some((_, inc, until)) = earliest.filter(|(d, _, _)| *d < own_days) else { + return Ok(own_stands); + }; + match past_includes { + Some(reason) if reason.trim().is_empty() => Err(SupportError::OverrideWithoutReason), + Some(reason) => Ok(Capped { + until: own.to_string(), + bound_by: Bound::PastIncludes { + earliest: until.to_string(), + reason: reason.trim().to_string(), + }, + }), + None => Ok(Capped { + until: until.to_string(), + bound_by: Bound::Include { + layer: inc.layer.clone(), + realm: inc.realm.clone(), + }, + }), + } +} + +/// Why one include's window could not be read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IncludeWindowError { + /// The include, by digest — all that is known before its manifest verifies. + pub include: String, + pub realm: String, + pub reason: String, +} + +impl fmt::Display for IncludeWindowError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "cannot read the support window of included layer {} (realm '{}'): {}", + self.include, self.realm, self.reason + ) + } +} + +impl std::error::Error for IncludeWindowError {} + +/// Read one include's stated window from the realm that vouches for it. +/// +/// `source`, `verifier` and `root_pk` are THAT realm's — never the composing +/// realm's. The manifest is verified first, so the layer id the window is +/// reported against is one the realm signed; the line-status is then chosen by +/// [`crate::linestatus::current_line_status`], the same rule install uses, so +/// a correction that shortened the window binds here too. +/// +/// No status at all yields `support_until: None`, which `cap_by_includes` +/// refuses. A status that is present and bad is an error. +pub fn include_window( + source: &dyn crate::source::LayerSource, + verifier: &dyn crate::install::ManifestVerifier, + root_pk: &[u8], + digest: &str, + realm: &str, +) -> Result { + let fail = |reason: String| IncludeWindowError { + include: digest.to_string(), + realm: realm.to_string(), + reason, + }; + let layer_ref = crate::source::LayerRef::Digest(digest.to_string()); + let fetched = source + .fetch_manifest(&layer_ref) + .map_err(|e| fail(format!("fetching its manifest: {e}")))?; + let payload = verifier + .verify(&fetched) + .map_err(|e| fail(format!("its manifest does not verify: {}", e.0)))?; + if crate::store::manifest_digest(&payload) != digest { + return Err(fail( + "the manifest served for this digest hashes to something else".to_string(), + )); + } + let manifest = crate::manifest::LayerManifest::parse(&payload) + .map_err(|e| fail(format!("its manifest does not parse: {e}")))?; + let status = crate::linestatus::current_line_status( + source, + &layer_ref, + manifest.layer.line(), + root_pk, + crate::linestatus::Published::Required, + ) + .map_err(|e| fail(format!("layer {}: its line-status: {e}", manifest.layer)))?; + Ok(IncludeWindow { + layer: manifest.layer.to_string(), + realm: realm.to_string(), + support_until: status.and_then(|(_, doc)| doc.report_for(&manifest.layer).support_until), + }) +} + /// Where a layer stands against its stated horizon. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Standing { @@ -358,4 +543,347 @@ mod tests { ); assert!(far.contains("(382 days)"), "{far}"); } + + fn inc(layer: &str, until: Option<&str>) -> IncludeWindow { + IncludeWindow { + layer: layer.to_string(), + realm: "wasm".to_string(), + support_until: until.map(str::to_string), + } + } + + /// The measured case from varve#254: covalent's own channel says + /// 2026-11-02, the wasm layer it includes says 2026-10-24. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn a_composition_is_capped_at_its_earliest_include() { + let got = cap_by_includes( + "2026-11-02", + &[ + inc("2026.10.0", Some("2026-10-24")), + inc("2026.10.3", Some("2026-10-30")), + ], + None, + ) + .unwrap(); + assert_eq!(got.until, "2026-10-24"); + assert_eq!( + got.bound_by, + Bound::Include { + layer: "2026.10.0".into(), + realm: "wasm".into() + } + ); + } + + /// The earliest include binds wherever it is listed, and on a tie the + /// first listed is named, so the message is the same on every run. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn the_earliest_include_binds_wherever_it_is_listed() { + let got = cap_by_includes( + "2026-11-02", + &[ + inc("late", Some("2026-10-30")), + inc("early", Some("2026-10-24")), + inc("tied", Some("2026-10-24")), + ], + None, + ) + .unwrap(); + assert_eq!(got.until, "2026-10-24"); + assert_eq!( + got.bound_by, + Bound::Include { + layer: "early".into(), + realm: "wasm".into() + } + ); + } + + /// An include supported for longer than the composition does not lengthen + /// it, and a layer with no includes keeps its own window. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn the_own_window_stands_when_it_is_already_the_shortest() { + let got = cap_by_includes("2026-11-02", &[inc("x", Some("2027-01-01"))], None).unwrap(); + assert_eq!( + (got.until.as_str(), &got.bound_by), + ("2026-11-02", &Bound::Own) + ); + let alone = cap_by_includes("2026-11-02", &[], None).unwrap(); + assert_eq!( + (alone.until.as_str(), &alone.bound_by), + ("2026-11-02", &Bound::Own) + ); + } + + /// The same day is not an overshoot: "through that day" on both sides. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn an_include_ending_the_same_day_is_not_a_cap() { + let got = cap_by_includes("2026-11-02", &[inc("x", Some("2026-11-02"))], None).unwrap(); + assert_eq!(got.bound_by, Bound::Own); + } + + /// Clause 2: an include that states no window is not skipped. Skipping it + /// is how the cap goes vacuous — the one include nobody vouched for is + /// exactly the one that would have bound it. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn an_include_that_states_no_window_refuses_rather_than_being_skipped() { + let err = cap_by_includes( + "2026-11-02", + &[inc("a", Some("2026-12-01")), inc("b", None)], + None, + ) + .unwrap_err(); + assert_eq!( + err, + SupportError::IncludeStatesNoWindow { + layer: "b".into(), + realm: "wasm".into() + } + ); + assert!(err.to_string().contains("b"), "{err}"); + } + + /// A malformed include date refuses rather than comparing as text. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn an_unparseable_include_window_refuses() { + let err = cap_by_includes("2026-11-02", &[inc("a", Some("soon"))], None).unwrap_err(); + assert!(matches!(err, SupportError::BadDate { .. }), "{err:?}"); + } + + /// Clause 3: a longer promise is possible, and it is a decision with a + /// reason on the record — the reason is carried out so the caller can + /// print it beside the signed date. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn exceeding_the_cap_takes_a_stated_reason() { + let got = cap_by_includes( + "2026-11-02", + &[inc("2026.10.0", Some("2026-10-24"))], + Some("wasm-layers 2026.11.0 is staged and carries the same tools"), + ) + .unwrap(); + assert_eq!(got.until, "2026-11-02"); + assert_eq!( + got.bound_by, + Bound::PastIncludes { + earliest: "2026-10-24".into(), + reason: "wasm-layers 2026.11.0 is staged and carries the same tools".into() + } + ); + } + + /// An override that says nothing is not a decision. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn an_override_without_a_reason_is_refused() { + let err = + cap_by_includes("2026-11-02", &[inc("a", Some("2026-10-24"))], Some(" ")).unwrap_err(); + assert_eq!(err, SupportError::OverrideWithoutReason); + assert!(!err.to_string().is_empty()); + } + + /// An override that was not needed changes nothing: it must not be + /// recorded as though a longer promise had been made. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn an_unneeded_override_is_not_recorded() { + let got = cap_by_includes( + "2026-11-02", + &[inc("a", Some("2027-01-01"))], + Some("because"), + ) + .unwrap(); + assert_eq!(got.bound_by, Bound::Own); + } + + mod from_source { + use super::super::*; + use crate::install::{ManifestVerifier, VerifyError}; + use crate::linestatus::LineStatus; + use crate::manifest::fixtures::manifest_with_tools; + use crate::source::MemorySource; + use crate::store::manifest_digest; + use crate::verify::generate_root_keypair; + + struct AcceptAll; + impl ManifestVerifier for AcceptAll { + fn verify(&self, fetched: &[u8]) -> Result, VerifyError> { + Ok(fetched.to_vec()) + } + } + struct RejectAll; + impl ManifestVerifier for RejectAll { + fn verify(&self, _: &[u8]) -> Result, VerifyError> { + Err(VerifyError("untrusted signature (test)".into())) + } + } + + fn wasm_layer() -> Vec { + manifest_with_tools("2026.10.0", "rolling", 0, "2026-09-24T00:00:00Z", &[]) + } + + fn status(counter: u64, until: Option<&str>) -> LineStatus { + LineStatus { + min_counter: None, + line: "2026.10".into(), + counter, + issued_at: "2026-09-24T00:00:00Z".into(), + support_until: until.map(str::to_string), + yanked: Default::default(), + known_problems: Vec::new(), + } + } + + /// The include's window is read from ITS realm's signed status, and + /// names the layer the digest turned out to be. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn an_include_window_comes_from_its_own_signed_status() { + let (sk, pk) = generate_root_keypair(); + let bytes = wasm_layer(); + let source = MemorySource::new().with_manifest(&bytes).with_line_status( + status(1, Some("2026-10-24")) + .sign(&sk, "k") + .unwrap() + .as_bytes(), + ); + let w = + include_window(&source, &AcceptAll, &pk, &manifest_digest(&bytes), "wasm").unwrap(); + assert_eq!( + w, + IncludeWindow { + layer: "2026.10.0".into(), + realm: "wasm".into(), + support_until: Some("2026-10-24".into()), + } + ); + } + + /// A later correction that SHORTENS the window binds the cap — the + /// same document install would cache. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn a_published_correction_overtakes_the_baseline_window() { + let (sk, pk) = generate_root_keypair(); + let bytes = wasm_layer(); + let source = MemorySource::new() + .with_manifest(&bytes) + .with_line_status( + status(1, Some("2026-10-24")) + .sign(&sk, "k") + .unwrap() + .as_bytes(), + ) + .with_published_line_status( + status(5, Some("2026-10-10")) + .sign(&sk, "k") + .unwrap() + .as_bytes(), + ); + let w = + include_window(&source, &AcceptAll, &pk, &manifest_digest(&bytes), "wasm").unwrap(); + assert_eq!(w.support_until.as_deref(), Some("2026-10-10")); + } + + /// A status signed by any other key is not that realm speaking. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn a_status_not_signed_by_the_includes_realm_is_refused() { + let (sk_other, _) = generate_root_keypair(); + let (_, pk) = generate_root_keypair(); + let bytes = wasm_layer(); + let source = MemorySource::new().with_manifest(&bytes).with_line_status( + status(1, Some("2027-12-31")) + .sign(&sk_other, "k") + .unwrap() + .as_bytes(), + ); + let err = include_window(&source, &AcceptAll, &pk, &manifest_digest(&bytes), "wasm") + .unwrap_err(); + assert!( + err.to_string().contains("2026.10.0") || err.to_string().contains("sha256:"), + "{err}" + ); + } + + /// A source that cannot SAY whether a correction was published must + /// not be read as "none was". A correction can only shorten what the + /// baseline promised, so treating a failed fetch as absence fails open: + /// the composition would sign the longer, superseded window. Install + /// tolerates that (best-effort floor); a signed promise must not. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn a_correction_that_cannot_be_fetched_refuses_rather_than_reading_as_none() { + use crate::source::{LayerRef, LayerSource, SourceError}; + struct CorrectionUnreachable(MemorySource); + impl LayerSource for CorrectionUnreachable { + fn fetch_manifest(&self, l: &LayerRef) -> Result, SourceError> { + self.0.fetch_manifest(l) + } + fn fetch_blob(&self, d: &str) -> Result, SourceError> { + self.0.fetch_blob(d) + } + fn fetch_line_status(&self, l: &LayerRef) -> Result>, SourceError> { + self.0.fetch_line_status(l) + } + fn fetch_published_line_status( + &self, + _: &str, + ) -> Result>, SourceError> { + Err(SourceError::Transport("connection reset".into())) + } + } + let (sk, pk) = generate_root_keypair(); + let bytes = wasm_layer(); + let source = CorrectionUnreachable( + MemorySource::new().with_manifest(&bytes).with_line_status( + status(1, Some("2026-10-24")) + .sign(&sk, "k") + .unwrap() + .as_bytes(), + ), + ); + let err = include_window(&source, &AcceptAll, &pk, &manifest_digest(&bytes), "wasm") + .unwrap_err(); + assert!(err.reason.contains("connection reset"), "{err}"); + } + + /// No status at all is a window nobody stated — carried as `None`, so + /// `cap_by_includes` refuses it (clause 2) rather than skipping it. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn an_include_with_no_status_states_no_window() { + let (_, pk) = generate_root_keypair(); + let bytes = wasm_layer(); + let source = MemorySource::new().with_manifest(&bytes); + let w = + include_window(&source, &AcceptAll, &pk, &manifest_digest(&bytes), "wasm").unwrap(); + assert_eq!(w.support_until, None); + assert!(cap_by_includes("2026-11-02", &[w], None).is_err()); + } + + /// The include's manifest must verify under its realm before anything + /// it says about itself is believed. + // rivet: verifies REQ-COMPOSEWINDOW-001 + #[test] + fn an_include_whose_manifest_does_not_verify_is_refused() { + let (sk, pk) = generate_root_keypair(); + let bytes = wasm_layer(); + let source = MemorySource::new().with_manifest(&bytes).with_line_status( + status(1, Some("2026-10-24")) + .sign(&sk, "k") + .unwrap() + .as_bytes(), + ); + assert!( + include_window(&source, &RejectAll, &pk, &manifest_digest(&bytes), "wasm").is_err() + ); + } + } } diff --git a/crates/varve/docs/cmd-support-horizon.md b/crates/varve/docs/cmd-support-horizon.md index b040503a..185d1209 100644 --- a/crates/varve/docs/cmd-support-horizon.md +++ b/crates/varve/docs/cmd-support-horizon.md @@ -8,7 +8,17 @@ varve support-horizon --channel rolling --issued-at 2026-09-03T00:00:00Z # 2027-03-03 ``` -`--json` emits `{"channel", "issued_at", "support_until"}` for a pipeline. +`--json` emits `{"channel", "issued_at", "support_until", "policy_until", +"bound_by"}` for a pipeline. + +A layer that composes others passes its `layer.toml`: + +```sh +varve support-horizon --channel rolling --issued-at 2026-10-02T10:00:00Z \ + --manifest layer.toml +# support capped at 2026-10-24 by included layer 2026.09.0 (realm 'pulseengine-wasm'); ... (stderr) +# 2026-10-24 (stdout) +``` ## Why this is derived and not typed @@ -46,10 +56,37 @@ is pointed at, and the one whose contents are deliberately frozen. An unknown channel is refused rather than given a default. A horizon nobody decided is a promise nobody made, and it would be signed with the realm's root. +## A composition cannot outlast what it composes + +covalent 2026.10.0 signed support until **2026-11-02** while the wasm-layers +layer it includes was supported only until **2026-10-24** (varve#254). The +composition promised nine days of support for bytes whose own realm had stopped +vouching for them, and signed that promise with its root. + +With `--manifest`, every `[[include]]` is read from **its own realm**: the +include's manifest is fetched by digest and verified against that realm's +root, then its current line-status (the baseline, or a later published +correction, whichever verifies and is newer) supplies its window. The horizon +is the earliest of the channel's policy and every include's window. + +- **Capping is the default**, so an automatic deposit states the true window + without a person. +- **An include that states no window refuses.** Skipping it would make the cap + vacuous: the include nobody vouched for is exactly the one that would have + bound it. +- **`--past-includes ""` promises longer anyway.** The reason is + printed beside the date, so a longer promise is a decision on the record. + An empty reason is refused. + +The realms the includes name must be defined in a `varve-realms.toml` found +from the working directory, exactly as for `install`. + ## What it does not do + It does not sample a clock beyond the `--issued-at` you give it, and it does -not write anything. The caller puts the result in the status document, and +not write anything. Without `--manifest` it makes no network request; with +one, it reads only the includes' manifests and line-statuses. The caller puts the result in the status document, and `varve sign-status` refuses a document without one. ## Reading it back diff --git a/crates/varve/src/main.rs b/crates/varve/src/main.rs index 067f051c..c6b09627 100644 --- a/crates/varve/src/main.rs +++ b/crates/varve/src/main.rs @@ -205,6 +205,16 @@ enum Cmd { /// The layer's issued-at, RFC 3339. #[arg(long = "issued-at")] issued_at: String, + /// The layer's `layer.toml`. When it composes other layers, the + /// horizon is capped at the earliest window among them, read from each + /// include's own realm (REQ-COMPOSEWINDOW-001). + #[arg(long)] + manifest: Option, + /// Promise support past an include's window anyway, for this reason. + /// The reason is printed beside the date so the decision is on the + /// record. + #[arg(long = "past-includes", value_name = "REASON")] + past_includes: Option, #[arg(long)] json: bool, }, @@ -871,24 +881,16 @@ fn run() -> anyhow::Result { Cmd::SupportHorizon { channel, issued_at, + manifest, + past_includes, json, - } => { - let until = varve_core::support::horizon(&issued_at, &channel)?; - if json { - println!( - "{}", - serde_json::json!({ - "command": "support-horizon", - "channel": channel, - "issued_at": issued_at, - "support_until": until, - }) - ); - } else { - println!("{until}"); - } - Ok(()) - } + } => support_horizon( + &channel, + &issued_at, + manifest.as_deref(), + past_includes.as_deref(), + json, + ), Cmd::Keygen { out, public } => keygen(&out, public.as_deref()), Cmd::Pubkey { key } => pubkey(&key), Cmd::LayerSpec { manifest, json } => layer_spec(&manifest, json), @@ -3382,6 +3384,101 @@ fn today_rfc3339() -> String { format!("{y:04}-{m:02}-{d:02}T00:00:00Z") } +/// `varve support-horizon` — the channel's window, capped at the earliest +/// window among the layers this one composes (REQ-SUPPORTUNTIL-001, +/// REQ-COMPOSEWINDOW-001). +/// +/// The date alone goes to stdout, as it always has, so a workflow's +/// `SUPPORT_UNTIL="$(varve support-horizon ...)"` keeps working; what bound +/// it goes to stderr, where a deposit log keeps it. +fn support_horizon( + channel: &str, + issued_at: &str, + manifest: Option<&std::path::Path>, + past_includes: Option<&str>, + json: bool, +) -> anyhow::Result<()> { + use varve_core::support::{self, Bound}; + let own = support::horizon(issued_at, channel)?; + let includes = match manifest { + Some(path) => { + let text = std::fs::read_to_string(path) + .with_context(|| format!("cannot read {}", path.display()))?; + varve_core::layerspec::parse_layer_manifest(&text) + .with_context(|| format!("{} does not parse", path.display()))? + .includes + } + None => Vec::new(), + }; + let cwd = std::env::current_dir().context("cannot determine working directory")?; + let mut windows = Vec::new(); + for inc in &includes { + let Some(realm_name) = inc.realm.as_deref() else { + bail!( + "include {} names no realm, so no root vouches for its support window", + inc.digest + ); + }; + let realm = varve_core::resolve_realm(&cwd, realm_name).with_context(|| { + format!( + "include {} names realm '{realm_name}', which is not defined here", + inc.digest + ) + })?; + let source = varve_core::RegistrySource::parse(&realm.registry)?; + let verifier = varve_core::PinnedKeyVerifier::from_public_key_bytes(&realm.trust_root) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + windows.push(support::include_window( + &source, + &verifier, + &realm.trust_root, + &inc.digest, + realm_name, + )?); + } + let capped = support::cap_by_includes(&own, &windows, past_includes)?; + let note = match &capped.bound_by { + Bound::Own => None, + Bound::Include { layer, realm } => Some(format!( + "support capped at {} by included layer {layer} (realm '{realm}'); the {channel} \ + policy alone would say {own}", + capped.until + )), + Bound::PastIncludes { earliest, reason } => Some(format!( + "support stated past an included layer's window ({earliest}) to {}, because: {reason}", + capped.until + )), + }; + if json { + let bound_by = match &capped.bound_by { + Bound::Own => serde_json::json!({ "kind": "own" }), + Bound::Include { layer, realm } => { + serde_json::json!({ "kind": "include", "layer": layer, "realm": realm }) + } + Bound::PastIncludes { earliest, reason } => { + serde_json::json!({ "kind": "past-includes", "earliest": earliest, "reason": reason }) + } + }; + println!( + "{}", + serde_json::json!({ + "command": "support-horizon", + "channel": channel, + "issued_at": issued_at, + "support_until": capped.until, + "policy_until": own, + "bound_by": bound_by, + }) + ); + } else { + if let Some(note) = ¬e { + eprintln!("{note}"); + } + println!("{}", capped.until); + } + Ok(()) +} + /// Fetch one layer a composition names, from the realm that vouches for it. /// /// The realm decides everything: its registry is where the bytes come from, diff --git a/crates/varve/tests/cli.rs b/crates/varve/tests/cli.rs index 43ba801c..b12ffe26 100644 --- a/crates/varve/tests/cli.rs +++ b/crates/varve/tests/cli.rs @@ -7052,3 +7052,67 @@ fn the_timing_report_goes_to_stderr_and_not_to_stdout() { to skip:\n{body}" ); } + +const LAYER_TOML_NO_INCLUDES: &str = r#" +[varve] +version = "v0.41.0" + +[realm] +name = "pulseengine" +channel = "rolling" +registry = "oci://ghcr.io/pulseengine/layers" + +[[tool]] +name = "rivet" +version = "v0.34.0" +"#; + +/// A layer that composes nothing keeps its channel's window, and stdout stays +/// the bare date a workflow captures with `$(...)`. +// rivet: verifies REQ-COMPOSEWINDOW-001 +#[test] +fn support_horizon_with_a_manifest_that_composes_nothing_keeps_the_policy_date() { + let fx = fixture(None, &[]); + std::fs::write(fx.project.join("layer.toml"), LAYER_TOML_NO_INCLUDES).unwrap(); + varve(&fx) + .args([ + "support-horizon", + "--channel", + "rolling", + "--issued-at", + "2026-10-02T10:00:00Z", + "--manifest", + "layer.toml", + ]) + .assert() + .success() + .stdout("2026-11-02\n") + .stderr(""); +} + +/// An include whose realm is not defined here cannot be read, and the horizon +/// is refused rather than computed without it (clause 2). +// rivet: verifies REQ-COMPOSEWINDOW-001 +#[test] +fn support_horizon_refuses_an_include_from_an_undefined_realm() { + let fx = fixture(None, &[]); + let text = format!( + "{LAYER_TOML_NO_INCLUDES}\n[[include]]\ndigest = \"sha256:{}\"\nrealm = \"nowhere\"\nlayer = \"2026.09.0\"\n", + "a".repeat(64) + ); + std::fs::write(fx.project.join("layer.toml"), text).unwrap(); + varve(&fx) + .args([ + "support-horizon", + "--channel", + "rolling", + "--issued-at", + "2026-10-02T10:00:00Z", + "--manifest", + "layer.toml", + ]) + .assert() + .failure() + .stdout("") + .stderr(predicate::str::contains("nowhere")); +}