Skip to main content

varve_core/
deposit.rs

1//! Deposit — how a layer comes into being (REQ-DEPOSIT-001).
2//!
3//! One auditable step, run by CI: assemble the layer manifest (an OCI image
4//! index) from the pinned per-tool artifacts, embed the release counter and
5//! issued-at inside the payload, sign it into a DSSE envelope with the
6//! PulseEngine root key, and write the same directory-shaped OCI image
7//! layout that `varve archive` produces — so a fresh deposit and an archived
8//! core are byte-compatible, and both install through the one pipeline.
9//! Hand-edited layer manifests do not exist: this module is the only writer.
10
11use std::path::Path;
12
13use crate::install::VerifyError;
14use crate::layer::LayerId;
15use crate::store::manifest_digest;
16use crate::verify::sign_layer_manifest;
17
18/// An RFC 3339 timestamp, not merely a date. `epoch_days` accepts a bare
19/// `YYYY-MM-DD` because it only needs day resolution; a manifest's issued-at
20/// must carry a time, since it lands verbatim in the SBOM's `metadata.timestamp`
21/// where a bare date is invalid (REQ-PRODUCER-001).
22fn is_rfc3339(s: &str) -> bool {
23    let Some((date, time)) = s.split_once('T') else {
24        return false;
25    };
26    if crate::rollback::epoch_days(date).is_none() {
27        return false;
28    }
29    // hh:mm:ss, then an optional fraction, then Z or a numeric offset.
30    let b = time.as_bytes();
31    if b.len() < 9 || b[2] != b':' || b[5] != b':' {
32        return false;
33    }
34    if !b[..8].iter().enumerate().all(|(i, c)| {
35        if i == 2 || i == 5 {
36            *c == b':'
37        } else {
38            c.is_ascii_digit()
39        }
40    }) {
41        return false;
42    }
43    let rest = &time[8..];
44    rest == "Z" || rest.ends_with('Z') || rest.contains('+') || rest.matches('-').count() == 1
45}
46
47/// What to deposit: the layer identity and the tools that make it up.
48#[derive(Debug, Clone)]
49pub struct DepositSpec {
50    pub layer: LayerId,
51    /// `qualified` | `rolling` — recorded verbatim in the annotations.
52    pub channel: String,
53    /// Monotonic per-line release counter (DD-005). The depositor owns
54    /// monotonicity; clients enforce it.
55    pub counter: u64,
56    /// RFC 3339 issued-at, supplied by the caller (CI knows the time; this
57    /// library does not sample clocks).
58    pub issued_at: String,
59    /// (tool name, tool version, binary bytes) triples.
60    pub tools: Vec<DepositTool>,
61    /// Layers composed into this one (REQ-COMPOSE-001).
62    pub includes: Vec<DepositInclude>,
63}
64
65/// One composed layer, as the depositor names it.
66#[derive(Debug, Clone, Default)]
67pub struct DepositInclude {
68    pub digest: String,
69    pub realm: Option<String>,
70    pub layer: Option<String>,
71}
72
73#[derive(Debug, Clone)]
74pub struct DepositTool {
75    pub name: String,
76    pub version: String,
77    /// Target triple this binary is built for; `None` claims
78    /// platform-independence (scripts, data). New deposits should stamp it.
79    pub platform: Option<String>,
80    pub bytes: Vec<u8>,
81    /// Where the bytes came from — recorded INSIDE the signed payload so
82    /// downstream lockfiles (Bazel registries) inherit the signature anchor
83    /// (REQ-BAZEL-001).
84    pub source: Option<ToolSource>,
85    /// Runner contract for portable wasm entries (REQ-RUNNER-001): the tool
86    /// (from the SAME layer) that executes this entry, prefix args, and an
87    /// optional per-user-argument flag (kilnd's --wasi-arg shape).
88    pub runner: Option<RunnerSpec>,
89    /// Payload kind (REQ-KIND-001). `None` or `Tool` deposits no kind
90    /// annotation — pre-kind and tool layers keep byte-identical payloads.
91    pub kind: Option<crate::kind::PayloadKind>,
92    /// The absolute path a tree-shaped payload was BUILT for, signed into the
93    /// manifest as `eu.pulseengine.varve.sdk.prefix` (REQ-SDK-001 clause 4).
94    ///
95    /// It is the relocation BUDGET, so it has to be attributable rather than
96    /// guessed: `export-sdk` refuses a destination longer than this, and a
97    /// consumer must not be able to talk varve into trying. Only a tree payload
98    /// has one — see `check_identities` for why depositing it on anything else
99    /// is refused rather than ignored.
100    pub sdk_prefix: Option<String>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct RunnerSpec {
106    pub tool: String,
107    #[serde(default)]
108    pub args: Vec<String>,
109    #[serde(rename = "arg-prefix", default)]
110    pub arg_prefix: Option<String>,
111}
112
113/// Upstream provenance of a deposited tool.
114#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct ToolSource {
117    /// e.g. "pulseengine/rivet"
118    pub repo: String,
119    /// e.g. "v0.32.0"
120    pub release: String,
121    /// The release asset AS DOWNLOADED, e.g. "rivet-v0.32.0-<triple>.tar.gz"
122    pub asset: String,
123    /// sha256 of that asset (the bytes Bazel will hash), bare hex or
124    /// sha256:-prefixed.
125    pub sha256: String,
126    /// WHICH mechanism vouched for these bytes (REQ-INGEST-001 clause 2).
127    /// `None` on a spec written before the requirement; a layer deposited that
128    /// way reads as `unrecorded`, never as verified.
129    #[serde(default)]
130    pub proof: Option<crate::ingest::IngestProof>,
131    /// The identity that vouched — the cosign certificate identity, or the
132    /// attestation's `buildSignerURI`.
133    #[serde(rename = "proof-signer", default)]
134    pub proof_signer: Option<String>,
135    /// What that mechanism ASSERTED, in one line. For `unverified` this is the
136    /// operator's recorded reason, and it is mandatory: see
137    /// `DepositError::UnverifiedWithoutReason`.
138    #[serde(rename = "proof-asserts", default)]
139    pub proof_asserts: Option<String>,
140}
141
142/// How a deposit treats a destination that is not empty (REQ-NODESTROY-001).
143#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
144pub struct DepositOptions {
145    /// Overwrite a layout that already carries referrers, destroying them.
146    /// Deliberate and stated — the point of the guard is that the destructive
147    /// case must be ASKED for, not stumbled into.
148    pub force: bool,
149}
150
151/// A completed deposit.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct DepositOutcome {
154    /// Digest of the manifest payload — what pins reference.
155    pub digest: String,
156    pub layer: LayerId,
157    pub counter: u64,
158}
159
160/// A deposit described as a file (CI-authored TOML) rather than flags —
161/// the shape the deposit workflow and the Bazel extension both read.
162#[derive(Debug, serde::Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct DepositFileSpec {
165    pub layer: String,
166    pub channel: String,
167    pub counter: u64,
168    #[serde(default, rename = "tool")]
169    pub tools: Vec<SpecTool>,
170    /// Layers this one composes (REQ-COMPOSE-001). Without this a composed
171    /// layer could not be PRODUCED at all — only hand-authored.
172    #[serde(default, rename = "include")]
173    pub includes: Vec<SpecInclude>,
174}
175
176/// A layer composed into this one: named by the digest of its signed manifest,
177/// plus the realm whose trust root is authoritative for it.
178#[derive(Debug, serde::Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct SpecInclude {
181    /// `sha256:<hex>` of the included layer's signed manifest.
182    pub digest: String,
183    /// The realm that verifies it. Absent = this layer's own realm.
184    #[serde(default)]
185    pub realm: Option<String>,
186    /// The included layer's identifier, so errors can name it before it is
187    /// fetched.
188    #[serde(default)]
189    pub layer: Option<String>,
190}
191
192#[derive(Debug, serde::Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct SpecTool {
195    pub name: String,
196    pub version: String,
197    #[serde(default)]
198    pub platform: Option<String>,
199    /// Binary path, absolute or relative to the spec file's directory.
200    pub path: String,
201    #[serde(default)]
202    pub source: Option<ToolSource>,
203    #[serde(default)]
204    pub runner: Option<RunnerSpec>,
205    /// Payload kind (REQ-KIND-001): tool|crate|wit|zephyr-module|sdk|
206    /// wasm-component|vsix. Absent = tool.
207    #[serde(default)]
208    pub kind: Option<String>,
209    /// `sdk-prefix` — the absolute path this tree was built for (REQ-SDK-001
210    /// clause 4). Required to make an `sdk` exportable at all: without it there
211    /// is no relocation budget, so `varve export-sdk` has nothing to patch.
212    #[serde(rename = "sdk-prefix", default)]
213    pub sdk_prefix: Option<String>,
214}
215
216pub fn parse_deposit_spec(toml_text: &str) -> Result<DepositFileSpec, DepositError> {
217    toml::from_str(toml_text).map_err(|e| DepositError::Spec(e.to_string()))
218}
219
220impl From<crate::archive::LayoutWriteError> for DepositError {
221    fn from(e: crate::archive::LayoutWriteError) -> Self {
222        match e {
223            crate::archive::LayoutWriteError::Io { path, source } => {
224                DepositError::Io { path, source }
225            }
226            crate::archive::LayoutWriteError::WouldDestroy(d) => {
227                DepositError::WouldDestroySignedWork(d)
228            }
229        }
230    }
231}
232
233#[derive(Debug, thiserror::Error)]
234pub enum DepositError {
235    #[error(
236        "the envelope this deposit just signed does not verify against the key that signed \
237         it — refusing to publish an artifact no consumer could accept"
238    )]
239    SelfVerifyFailed,
240    #[error(
241        "channel {channel:?} is not one a pin can name — use `qualified` or `rolling`. \
242         Signing it would produce a layer no varve.toml could ever select."
243    )]
244    BadChannel { channel: String },
245    #[error(
246        "issued-at {issued_at:?} is not an RFC 3339 timestamp (e.g. 2026-08-01T00:00:00Z). \
247         It is signed into the manifest and drives staleness and the SBOM timestamp, so it \
248         cannot be corrected afterwards."
249    )]
250    BadIssuedAt { issued_at: String },
251    #[error("deposit spec is not valid: {0}")]
252    Spec(String),
253    /// REQ-NODESTROY-001. `deposit` writes the WHOLE layout, `index.json`
254    /// included, so a second deposit into a directory that has had evidence
255    /// attached dropped all of it and reported success. Documentation warned
256    /// about this in three topics and guarded nothing.
257    #[error(transparent)]
258    WouldDestroySignedWork(#[from] crate::referrers::WouldDestroy),
259    #[error("deposit has no tools — an empty layer is not a toolchain")]
260    NoTools,
261    #[error(
262        "tool '{name}' is deposited twice for platform {platform} (versions {first} and \
263         {second}) — a tool is DISPATCHED BY NAME (`varve run {name}`, `varve which {name}`, the \
264         argv[0] shims), so one name must resolve to exactly one binary per platform. Deposit the \
265         other version as a separate layer, or give it a distinct name."
266    )]
267    DuplicateTool {
268        name: String,
269        platform: String,
270        first: String,
271        second: String,
272    },
273    #[error(
274        "payload '{name}' version {version} is deposited twice for platform {platform} — a layer \
275         may hold several VERSIONS of one name, but not one version twice: the two would be the \
276         same payload, and only one set of bytes could land."
277    )]
278    DuplicatePayload {
279        name: String,
280        version: String,
281        platform: String,
282    },
283    #[error(
284        "payload '{name}' is kind {kind} and carries `sdk-prefix` — only a tree payload (sdk) is \
285         relocated, so the prefix would be signed into the manifest, ignored by every adapter, \
286         and believed. Drop it, or deposit this payload as kind = \"sdk\"."
287    )]
288    SdkPrefixOnNonTree { name: String, kind: String },
289    #[error(
290        "sdk '{name}' version {version} declares no `sdk-prefix` — the absolute path it was \
291         BUILT for. Without it `varve export-sdk` has no relocation budget and no path to \
292         patch, so the layer would install and verify and could never be exported. Add \
293         `sdk-prefix = \"/opt/poky/4.0\"` (the path the SDK was built for) to the [[tool]] table."
294    )]
295    SdkPrefixMissing { name: String, version: String },
296    #[error(
297        "sdk '{name}' declares sdk-prefix {prefix:?}, which is not absolute — the prefix is the \
298         path PATCHED INTO the SDK's binaries, and a relative one there would resolve against \
299         whatever directory a compiler happens to run in"
300    )]
301    SdkPrefixNotAbsolute { name: String, prefix: String },
302    #[error(
303        "payload '{name}' from {repo} declares proof = \"unverified\" and records no reason — \
304         \"we could not verify this\" must never be the silent path. Nothing vouched for these \
305         bytes, so the only thing that can travel with them is WHY you shipped them anyway: set \
306         `proof-asserts` on [tool.source] to the operator's justification, which is signed into \
307         the layer where `varve inspect` and every consumer will see it."
308    )]
309    UnverifiedWithoutReason { name: String, repo: String },
310    #[error(
311        "payload '{name}' from {repo} declares proof = \"unverified\" and also names \
312         proof-signer {signer:?} — nothing vouched for these bytes, so naming an identity that \
313         did would be signed, attributable and false. Drop the signer, or declare the mechanism \
314         that actually established it."
315    )]
316    UnverifiedNamesASigner {
317        name: String,
318        repo: String,
319        signer: String,
320    },
321    #[error(
322        "payload '{name}' from {repo} carries ingestion-proof detail ({detail}) but declares no \
323         `proof` mechanism — the detail would be signed into the layer with nothing saying HOW \
324         it was established, which makes it attributable and believed rather than checkable. \
325         Declare `proof = \"cosign-sums\" | \"build-provenance\" | \"unverified\"`, or drop the \
326         detail."
327    )]
328    ProofDetailWithoutMechanism {
329        name: String,
330        repo: String,
331        detail: &'static str,
332    },
333    #[error(transparent)]
334    Sign(#[from] VerifyError),
335    #[error("io error at {path}: {source}")]
336    Io {
337        path: String,
338        #[source]
339        source: std::io::Error,
340    },
341}
342
343/// How a platform reads in an error when the entry claims none.
344fn platform_label(platform: Option<&String>) -> String {
345    platform
346        .cloned()
347        .unwrap_or_else(|| "any (unstamped)".to_string())
348}
349
350/// Refuse only TRUE duplicates, under the identity of REQ-STORE-002 clause 1
351/// (clause 3).
352///
353/// A `tool` is dispatched by name, so its identity is (name, platform) and two
354/// versions of one tool in one layer is a genuine error — `varve run synth`
355/// would have no answer. Every other kind is held, not dispatched: its identity
356/// is (name, version, platform), so `serde@1.0.200` beside `serde@1.0.210` is
357/// the ORDINARY shape of a dependency graph and must be accepted. varve's own
358/// Cargo.lock has 14 names at more than one version; refusing them meant varve
359/// could not express its own dependency graph as a layer.
360fn check_identities(tools: &[&DepositTool]) -> Result<(), DepositError> {
361    // (name, platform) -> the version already seen, for dispatchable payloads.
362    let mut dispatched: std::collections::BTreeMap<(&str, Option<&String>), &str> =
363        std::collections::BTreeMap::new();
364    // (name, version, platform) -> seen, for everything else.
365    let mut held: std::collections::BTreeSet<(&str, &str, Option<&String>)> =
366        std::collections::BTreeSet::new();
367    for tool in tools {
368        let dispatchable = tool.kind.unwrap_or_default().is_dispatchable();
369        let platform = tool.platform.as_ref();
370        if dispatchable {
371            if let Some(first) = dispatched.insert((&tool.name, platform), &tool.version) {
372                return Err(DepositError::DuplicateTool {
373                    name: tool.name.clone(),
374                    platform: platform_label(platform),
375                    first: first.to_string(),
376                    second: tool.version.clone(),
377                });
378            }
379        } else if !held.insert((&tool.name, &tool.version, platform)) {
380            return Err(DepositError::DuplicatePayload {
381                name: tool.name.clone(),
382                version: tool.version.clone(),
383                platform: platform_label(platform),
384            });
385        }
386    }
387    Ok(())
388}
389
390/// The relocation budget must be present exactly where it can be acted on, and
391/// absent everywhere else (REQ-SDK-001 clause 4).
392///
393/// Both directions are refusals rather than warnings, at the PRODUCING end. An
394/// `sdk-prefix` on a payload nobody relocates is signed, ignored and believed;
395/// an `sdk` without one installs and verifies and can never be exported, which
396/// the consumer discovers on the far side of an air gap. Neither is repairable
397/// without re-depositing, because the annotation lives inside the signature.
398fn check_sdk_prefixes(tools: &[&DepositTool]) -> Result<(), DepositError> {
399    for tool in tools {
400        let is_tree = tool.kind == Some(crate::kind::PayloadKind::Sdk);
401        match (&tool.sdk_prefix, is_tree) {
402            (Some(_), false) => {
403                return Err(DepositError::SdkPrefixOnNonTree {
404                    name: tool.name.clone(),
405                    kind: tool.kind.unwrap_or_default().as_str().to_string(),
406                });
407            }
408            (None, true) => {
409                return Err(DepositError::SdkPrefixMissing {
410                    name: tool.name.clone(),
411                    version: tool.version.clone(),
412                });
413            }
414            (Some(prefix), true) if !prefix.starts_with('/') => {
415                return Err(DepositError::SdkPrefixNotAbsolute {
416                    name: tool.name.clone(),
417                    prefix: prefix.clone(),
418                });
419            }
420            _ => {}
421        }
422    }
423    Ok(())
424}
425
426/// The ingestion proof must be sayable, or not said at all (REQ-INGEST-001
427/// clause 3), checked at the PRODUCING end for the same reason
428/// `check_sdk_prefixes` is: everything below is immutable the moment it is
429/// signed.
430///
431/// This function does NOT require a proof to be present. A crate ingested from
432/// crates.io and a layer deposited before this requirement both legitimately
433/// carry none, and they read as `unrecorded` rather than as verified. What it
434/// refuses are the three shapes that would be signed and BELIEVED:
435///
436/// * `unverified` with no recorded reason — the silent path the requirement
437///   exists to close;
438/// * `unverified` naming a signer — an identity credited with vouching for
439///   bytes nothing vouched for;
440/// * proof detail with no mechanism — a claim with no account of how it was
441///   established.
442///
443/// Refusing a MISSING proof is the assembler's job, not this one's: only the
444/// assembler knows it went looking for a mechanism and found none.
445fn check_ingest_proofs(tools: &[&DepositTool]) -> Result<(), DepositError> {
446    for tool in tools {
447        let Some(source) = &tool.source else { continue };
448        match source.proof {
449            Some(crate::ingest::IngestProof::Unverified) => {
450                if source
451                    .proof_asserts
452                    .as_ref()
453                    .is_none_or(|r| r.trim().is_empty())
454                {
455                    return Err(DepositError::UnverifiedWithoutReason {
456                        name: tool.name.clone(),
457                        repo: source.repo.clone(),
458                    });
459                }
460                if let Some(signer) = &source.proof_signer {
461                    return Err(DepositError::UnverifiedNamesASigner {
462                        name: tool.name.clone(),
463                        repo: source.repo.clone(),
464                        signer: signer.clone(),
465                    });
466                }
467            }
468            Some(_) => {}
469            None => {
470                let detail = match (&source.proof_signer, &source.proof_asserts) {
471                    (Some(_), Some(_)) => Some("proof-signer and proof-asserts"),
472                    (Some(_), None) => Some("proof-signer"),
473                    (None, Some(_)) => Some("proof-asserts"),
474                    (None, None) => None,
475                };
476                if let Some(detail) = detail {
477                    return Err(DepositError::ProofDetailWithoutMechanism {
478                        name: tool.name.clone(),
479                        repo: source.repo.clone(),
480                        detail,
481                    });
482                }
483            }
484        }
485    }
486    Ok(())
487}
488
489/// Assemble, sign, and write a layer as an OCI image layout at `dest`,
490/// refusing a destination that already carries signed work (REQ-NODESTROY-001).
491pub fn deposit(
492    spec: &DepositSpec,
493    signing_key: &[u8],
494    key_id: &str,
495    dest: &Path,
496) -> Result<DepositOutcome, DepositError> {
497    deposit_with_options(spec, signing_key, key_id, dest, &DepositOptions::default())
498}
499
500/// `deposit`, with the destructive case available to callers that ask for it.
501pub fn deposit_with_options(
502    spec: &DepositSpec,
503    signing_key: &[u8],
504    key_id: &str,
505    dest: &Path,
506    options: &DepositOptions,
507) -> Result<DepositOutcome, DepositError> {
508    // FIRST, before anything is validated or signed. `write_oci_layout` runs
509    // the same guard — it is the single writer, and clause 4 lives there — but
510    // asking here too means a deposit that would destroy signed work is
511    // refused before a key is even read, rather than after a signature exists
512    // for an artifact that will not be written (REQ-NODESTROY-001).
513    crate::referrers::guard(dest, options.force)?;
514    if spec.tools.is_empty() {
515        return Err(DepositError::NoTools);
516    }
517    let mut tools: Vec<&DepositTool> = spec.tools.iter().collect();
518    // Sorted by the FULL identity, so the payload order is deterministic even
519    // when one name appears at several versions — the digest is the identity a
520    // pin freezes against, and it must not depend on spec order.
521    tools.sort_by(|a, b| {
522        (&a.name, &a.version, &a.platform).cmp(&(&b.name, &b.version, &b.platform))
523    });
524    check_identities(&tools)?;
525    check_sdk_prefixes(&tools)?;
526    check_ingest_proofs(&tools)?;
527
528    // Assemble the payload deterministically: sorted tools, fixed key order
529    // (serde_json sorts map keys), no timestamps beyond the caller-supplied
530    // issued-at. Identical specs must produce identical digests — the digest
531    // is the identity a pin freezes against.
532    let entries: Vec<serde_json::Value> = tools
533        .iter()
534        .map(|tool| {
535            let mut annotations = serde_json::Map::new();
536            annotations.insert("eu.pulseengine.tool".into(), tool.name.clone().into());
537            annotations.insert(
538                "eu.pulseengine.tool.version".into(),
539                tool.version.clone().into(),
540            );
541            if let Some(platform) = &tool.platform {
542                annotations.insert(
543                    crate::platform::ANN_PLATFORM.into(),
544                    platform.clone().into(),
545                );
546            }
547            if let Some(source) = &tool.source {
548                annotations.insert(
549                    crate::bazel::ANN_SRC_REPO.into(),
550                    source.repo.clone().into(),
551                );
552                annotations.insert(
553                    crate::bazel::ANN_SRC_RELEASE.into(),
554                    source.release.clone().into(),
555                );
556                annotations.insert(
557                    crate::bazel::ANN_SRC_ASSET.into(),
558                    source.asset.clone().into(),
559                );
560                annotations.insert(
561                    crate::bazel::ANN_SRC_SHA256.into(),
562                    source.sha256.clone().into(),
563                );
564                // WHICH mechanism vouched for these bytes, and what it
565                // asserted (REQ-INGEST-001 clause 2) — inside the signed
566                // payload, so a consumer can tell a cosign-signed tool from an
567                // attested one without leaving the layer.
568                //
569                // Stamped only when the spec declares it. An absent annotation
570                // is the pre-requirement layer and reads as `unrecorded`;
571                // synthesising a default here would silently upgrade every
572                // payload deposited by an older spec to a claim nobody made,
573                // and would change the signed bytes of layers that carry no
574                // proof at all (the crate deposits, whose ingestion is
575                // crates.io and not a release page).
576                if let Some(proof) = source.proof {
577                    annotations.insert(crate::ingest::ANN_PROOF.into(), proof.as_str().into());
578                }
579                if let Some(signer) = &source.proof_signer {
580                    annotations.insert(
581                        crate::ingest::ANN_PROOF_SIGNER.into(),
582                        signer.clone().into(),
583                    );
584                }
585                if let Some(asserts) = &source.proof_asserts {
586                    annotations.insert(
587                        crate::ingest::ANN_PROOF_ASSERTS.into(),
588                        asserts.clone().into(),
589                    );
590                }
591            }
592            // Stamp the payload kind only when it is non-default: a `tool`
593            // (or unspecified) entry carries no kind annotation, so pre-kind
594            // tool layers keep byte-identical signed payloads (REQ-KIND-001).
595            if let Some(kind) = tool.kind
596                && kind != crate::kind::PayloadKind::Tool
597            {
598                annotations.insert(crate::kind::ANN_KIND.into(), kind.as_str().into());
599            }
600            // The relocation budget, inside the signature (REQ-SDK-001
601            // clause 4). `check_identities` has already refused it on a kind
602            // that is not a tree, so an entry carrying it is one `export-sdk`
603            // can act on.
604            if let Some(prefix) = &tool.sdk_prefix {
605                annotations.insert(
606                    crate::sdkexport::ANN_SDK_PREFIX.into(),
607                    prefix.clone().into(),
608                );
609            }
610            if let Some(runner) = &tool.runner {
611                annotations.insert(crate::bazel::ANN_RUNNER.into(), runner.tool.clone().into());
612                if !runner.args.is_empty() {
613                    annotations.insert(
614                        crate::bazel::ANN_RUNNER_ARGS.into(),
615                        runner.args.join(" ").into(),
616                    );
617                }
618                if let Some(prefix) = &runner.arg_prefix {
619                    annotations.insert(
620                        crate::bazel::ANN_RUNNER_ARG_PREFIX.into(),
621                        prefix.clone().into(),
622                    );
623                }
624            }
625            serde_json::json!({
626                "mediaType": "application/vnd.oci.image.manifest.v1+json",
627                "digest": manifest_digest(&tool.bytes),
628                "size": tool.bytes.len(),
629                "annotations": annotations,
630            })
631        })
632        .collect();
633    // Composed layers are entries too: a `layer`-kind reference whose digest is
634    // the included layer's SIGNED MANIFEST digest. Emitting them here is what
635    // makes the composition part of the signed payload — and what lets a
636    // composed layer be produced at all rather than hand-authored.
637    let mut entries = entries;
638    for inc in &spec.includes {
639        let mut annotations = serde_json::Map::new();
640        annotations.insert(
641            crate::kind::ANN_KIND.into(),
642            crate::kind::PayloadKind::Layer.as_str().into(),
643        );
644        if let Some(realm) = &inc.realm {
645            annotations.insert(
646                crate::compose::ANN_INCLUDE_REALM.into(),
647                realm.clone().into(),
648            );
649        }
650        if let Some(layer) = &inc.layer {
651            annotations.insert(
652                crate::compose::ANN_INCLUDE_LAYER.into(),
653                layer.clone().into(),
654            );
655        }
656        entries.push(serde_json::json!({
657            "mediaType": "application/vnd.oci.image.index.v1+json",
658            "digest": inc.digest,
659            "size": 0,
660            "annotations": annotations,
661        }));
662    }
663    // Validate BEFORE signing. Everything below becomes immutable the moment it
664    // is signed, so a bad value here is not a mistake you can correct — it is a
665    // released artifact nobody can use (REQ-PRODUCER-001).
666    if spec.channel.parse::<crate::pin::Channel>().is_err() {
667        return Err(DepositError::BadChannel {
668            channel: spec.channel.clone(),
669        });
670    }
671    if !is_rfc3339(&spec.issued_at) {
672        return Err(DepositError::BadIssuedAt {
673            issued_at: spec.issued_at.clone(),
674        });
675    }
676    let payload_json = serde_json::json!({
677        "schemaVersion": 2,
678        "mediaType": "application/vnd.oci.image.index.v1+json",
679        "artifactType": "application/vnd.pulseengine.varve.layer.v1+json",
680        "annotations": {
681            "eu.pulseengine.varve.layer": spec.layer.to_string(),
682            "eu.pulseengine.varve.line": spec.layer.line().to_string(),
683            "eu.pulseengine.varve.channel": spec.channel,
684            "eu.pulseengine.varve.counter": spec.counter.to_string(),
685            "org.opencontainers.image.created": spec.issued_at,
686        },
687        "manifests": entries,
688    });
689    let payload = serde_json::to_vec_pretty(&payload_json).expect("payload serializes");
690    let envelope = sign_layer_manifest(&payload, signing_key, key_id)?;
691    // Read our own work back before publishing it. The cheapest possible guard
692    // against emitting a release artifact nobody can verify — and the last
693    // point at which it is still correctable (REQ-PRODUCER-001).
694    let public = &signing_key[32..];
695    match crate::verify::dsse_verify_typed(
696        envelope.as_bytes(),
697        crate::verify::LAYER_PAYLOAD_TYPE,
698        public,
699    ) {
700        Ok(back) if back == payload => {}
701        _ => return Err(DepositError::SelfVerifyFailed),
702    }
703
704    let blobs: Vec<(String, Vec<u8>)> = tools
705        .iter()
706        .map(|tool| (manifest_digest(&tool.bytes), tool.bytes.clone()))
707        .collect();
708    crate::archive::write_oci_layout(
709        &payload,
710        envelope.as_bytes(),
711        &blobs,
712        &spec.layer.to_string(),
713        &spec.channel,
714        // A deposit carries every platform the producer built, so it makes no
715        // single-platform claim — unlike an archive (varve#80).
716        None,
717        dest,
718        options.force,
719    )
720    .map_err(DepositError::from)?;
721
722    Ok(DepositOutcome {
723        digest: manifest_digest(&payload),
724        layer: spec.layer.clone(),
725        counter: spec.counter,
726    })
727}
728
729#[cfg(test)]
730mod producer_tests {
731    use super::*;
732
733    // rivet: verifies REQ-PRODUCER-001
734    #[test]
735    fn a_channel_no_pin_could_name_is_refused_before_signing() {
736        // `--channel stable` signed happily and produced a layer no varve.toml
737        // could ever select, exit 0. The enum is shared with the pin parser so
738        // the two cannot drift.
739        assert!("qualified".parse::<crate::pin::Channel>().is_ok());
740        assert!("rolling".parse::<crate::pin::Channel>().is_ok());
741        for bad in ["stable", "Qualified", "", "beta"] {
742            assert!(
743                bad.parse::<crate::pin::Channel>().is_err(),
744                "{bad} must be refused"
745            );
746        }
747    }
748
749    // rivet: verifies REQ-NODESTROY-001
750    #[test]
751    fn a_deposit_that_would_drop_a_referrer_is_refused_before_a_byte_is_written() {
752        use crate::verify::generate_root_keypair;
753        let (sk, _) = generate_root_keypair();
754        let tmp = tempfile::tempdir().unwrap();
755        let dest = tmp.path().join("layout");
756        let spec = super::tests::spec();
757        deposit(&spec, &sk, "k", &dest).unwrap();
758        // Evidence attached after the deposit — the append-only half of the
759        // producer pipeline.
760        let status = crate::linestatus::LineStatus {
761            min_counter: None,
762            line: "2026.08".into(),
763            counter: 1,
764            issued_at: "2026-08-07T00:00:00Z".into(),
765            support_until: None,
766            yanked: Default::default(),
767            known_problems: Vec::new(),
768        };
769        crate::linestatus::attach_to_layout(
770            &dest,
771            &"2026.08".parse().unwrap(),
772            status.sign(&sk, "k").unwrap().as_bytes(),
773        )
774        .unwrap();
775
776        let index_before = std::fs::read(dest.join("index.json")).unwrap();
777        let err = deposit(&spec, &sk, "k", &dest).expect_err("must refuse");
778        assert!(
779            matches!(&err, DepositError::WouldDestroySignedWork { .. }),
780            "got: {err}"
781        );
782        let msg = err.to_string();
783        assert!(
784            msg.contains("line-status") && msg.contains("2026.08"),
785            "{msg}"
786        );
787        assert!(msg.contains("varve attach-status"), "{msg}");
788        assert_eq!(
789            index_before,
790            std::fs::read(dest.join("index.json")).unwrap(),
791            "a refused deposit must not have touched the layout"
792        );
793
794        // …and --force is the deliberate way through, because sometimes the
795        // operator really does mean to start the directory over.
796        deposit_with_options(&spec, &sk, "k", &dest, &DepositOptions { force: true })
797            .expect("--force overrides the guard");
798        assert!(
799            crate::linestatus::read_any_from_layout(&dest)
800                .unwrap()
801                .is_none(),
802            "--force is destructive on purpose — the referrer is gone"
803        );
804    }
805
806    // rivet: verifies REQ-NODESTROY-001
807    #[test]
808    fn depositing_into_a_fresh_or_previously_clean_directory_still_works() {
809        // The guard must fire on ATTACHED work only. A guard that also refused
810        // a directory holding nothing but a previous clean deposit would break
811        // every idempotent CI re-run, and a guard that gets switched off
812        // protects nobody.
813        use crate::verify::generate_root_keypair;
814        let (sk, _) = generate_root_keypair();
815        let tmp = tempfile::tempdir().unwrap();
816        let dest = tmp.path().join("layout");
817        let spec = super::tests::spec();
818        let first = deposit(&spec, &sk, "k", &dest).unwrap();
819        let second = deposit(&spec, &sk, "k", &dest).expect("a clean layout may be re-deposited");
820        assert_eq!(first.digest, second.digest);
821    }
822
823    // rivet: verifies REQ-PRODUCER-001
824    #[test]
825    fn issued_at_must_be_a_timestamp_not_merely_a_date() {
826        // A bare date passed `epoch_days` (which needs only day resolution) but
827        // lands verbatim in the SBOM's metadata.timestamp, where it is invalid
828        // and uncorrectable after signing.
829        assert!(is_rfc3339("2026-08-01T00:00:00Z"));
830        assert!(is_rfc3339("2026-08-01T12:34:56.789Z"));
831        assert!(is_rfc3339("2026-08-01T12:34:56+02:00"));
832        for bad in [
833            "2026-10-01",
834            "not-a-date",
835            "",
836            "2026-08-01T",
837            "2026-13-01T00:00:00Z",
838        ] {
839            assert!(!is_rfc3339(bad), "{bad} must be refused");
840        }
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use crate::archive::OciLayoutSource;
848    use crate::install::{InstallPolicy, install};
849    use crate::pin::Pin;
850    use crate::rollback::HighWaterMarks;
851    use crate::store::Store;
852    use crate::verify::{PinnedKeyVerifier, generate_root_keypair};
853
854    pub(super) fn spec() -> DepositSpec {
855        DepositSpec {
856            includes: Vec::new(),
857            layer: "2026.08.0".parse().unwrap(),
858            channel: "qualified".into(),
859            counter: 1,
860            issued_at: "2026-08-07T00:00:00Z".into(),
861            tools: vec![
862                DepositTool {
863                    name: "synth".into(),
864                    version: "0.45.0".into(),
865                    platform: None,
866                    bytes: b"synth-bytes".to_vec(),
867                    source: None,
868                    runner: None,
869                    kind: None,
870                    sdk_prefix: None,
871                },
872                DepositTool {
873                    name: "rivet".into(),
874                    version: "0.32.0".into(),
875                    platform: None,
876                    bytes: b"rivet-bytes".to_vec(),
877                    source: None,
878                    runner: None,
879                    kind: None,
880                    sdk_prefix: None,
881                },
882            ],
883        }
884    }
885
886    // rivet: verifies REQ-DEPOSIT-001
887    #[test]
888    fn a_deposit_round_trips_through_the_standard_install_pipeline() {
889        let (sk, pk) = generate_root_keypair();
890        let tmp = tempfile::tempdir().unwrap();
891        let dest = tmp.path().join("deposit");
892        let outcome = deposit(&spec(), &sk, "varve-root-1", &dest).unwrap();
893        assert_eq!(outcome.layer.to_string(), "2026.08.0");
894
895        // Standard layout markers present.
896        assert!(dest.join("oci-layout").is_file());
897        assert!(dest.join("index.json").is_file());
898
899        // Install from the deposit exactly as from an archive.
900        let pin = Pin::parse(
901            "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.08.0\"\n",
902            "varve.toml",
903        )
904        .unwrap();
905        let root = tmp.path().join("fresh");
906        let store = Store::at(&root);
907        let mut marks = HighWaterMarks::load(&root).unwrap();
908        let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
909        let policy = InstallPolicy {
910            index: None,
911            now: "2026-08-07T00:00:00Z",
912            staleness_threshold_days: 90,
913            platform: "test-platform",
914        };
915        let installed = install(
916            &pin,
917            &OciLayoutSource::at(&dest),
918            &verifier,
919            &store,
920            &mut marks,
921            &policy,
922        )
923        .unwrap();
924        assert_eq!(installed.digest, outcome.digest);
925        assert_eq!(installed.counter, 1);
926        let entry = store.get(&installed.digest).unwrap().unwrap();
927        let checked =
928            crate::reverify::verify_installed(&store, &entry, &verifier, "test-platform").unwrap();
929        assert_eq!(checked, 2);
930    }
931
932    // rivet: verifies REQ-DEPOSIT-001
933    #[test]
934    fn the_payload_records_layer_line_channel_counter_and_tool_versions() {
935        let (sk, _) = generate_root_keypair();
936        let tmp = tempfile::tempdir().unwrap();
937        let dest = tmp.path().join("deposit");
938        let outcome = deposit(&spec(), &sk, "varve-root-1", &dest).unwrap();
939        let hex = outcome.digest.strip_prefix("sha256:").unwrap();
940        let payload = std::fs::read(dest.join("blobs/sha256").join(hex)).unwrap();
941        let json: serde_json::Value = serde_json::from_slice(&payload).unwrap();
942        let ann = &json["annotations"];
943        assert_eq!(ann["eu.pulseengine.varve.layer"], "2026.08.0");
944        assert_eq!(ann["eu.pulseengine.varve.line"], "2026.08");
945        assert_eq!(ann["eu.pulseengine.varve.channel"], "qualified");
946        assert_eq!(ann["eu.pulseengine.varve.counter"], "1");
947        assert_eq!(
948            ann["org.opencontainers.image.created"],
949            "2026-08-07T00:00:00Z"
950        );
951        let entries = json["manifests"].as_array().unwrap();
952        assert_eq!(entries.len(), 2);
953        assert!(entries.iter().any(|e| {
954            e["annotations"]["eu.pulseengine.tool"] == "synth"
955                && e["annotations"]["eu.pulseengine.tool.version"] == "0.45.0"
956        }));
957    }
958
959    // rivet: verifies REQ-DEPOSIT-001
960    #[test]
961    fn identical_specs_deposit_identical_digests() {
962        let (sk, _) = generate_root_keypair();
963        let tmp = tempfile::tempdir().unwrap();
964        let a = deposit(&spec(), &sk, "varve-root-1", &tmp.path().join("a")).unwrap();
965        let b = deposit(&spec(), &sk, "varve-root-1", &tmp.path().join("b")).unwrap();
966        assert_eq!(
967            a.digest, b.digest,
968            "the payload is deterministic — the digest IS the identity"
969        );
970    }
971
972    // rivet: verifies REQ-DEPOSIT-001
973    #[test]
974    fn empty_and_duplicate_tool_lists_are_refused() {
975        let (sk, _) = generate_root_keypair();
976        let tmp = tempfile::tempdir().unwrap();
977        let mut empty = spec();
978        empty.tools.clear();
979        assert!(matches!(
980            deposit(&empty, &sk, "k", &tmp.path().join("x")).unwrap_err(),
981            DepositError::NoTools
982        ));
983        let mut dup = spec();
984        dup.tools[1].name = "synth".into();
985        let err = deposit(&dup, &sk, "k", &tmp.path().join("y")).unwrap_err();
986        assert!(
987            matches!(&err, DepositError::DuplicateTool { name, .. } if name == "synth"),
988            "got: {err}"
989        );
990    }
991
992    /// A tool whose bytes arrived through a NAMED ingestion mechanism
993    /// (REQ-INGEST-001).
994    fn ingested(name: &str, repo: &str, proof: crate::ingest::IngestProof) -> DepositTool {
995        let mut tool = payload(name, "1.0.0", None, Some("x86_64-unknown-linux-gnu"));
996        tool.source = Some(ToolSource {
997            repo: repo.into(),
998            release: "v1.0.0".into(),
999            asset: format!("{name}-v1.0.0-x86_64-unknown-linux-gnu.tar.gz"),
1000            sha256: "a".repeat(64),
1001            proof: Some(proof),
1002            proof_signer: match proof {
1003                crate::ingest::IngestProof::Unverified => None,
1004                _ => Some(format!(
1005                    "https://github.com/{repo}/.github/workflows/release.yml@refs/tags/v1.0.0"
1006                )),
1007            },
1008            proof_asserts: Some(match proof {
1009                crate::ingest::IngestProof::CosignSums => {
1010                    "SHA256SUMS.txt signed for this repo".to_string()
1011                }
1012                crate::ingest::IngestProof::BuildProvenance => {
1013                    "built from source commit deadbeef".to_string()
1014                }
1015                crate::ingest::IngestProof::Unverified => {
1016                    "NOTHING — operator opt-in: needed for the 2026.09 bring-up".to_string()
1017                }
1018            }),
1019        });
1020        tool
1021    }
1022
1023    // rivet: verifies REQ-INGEST-001
1024    #[test]
1025    fn the_mechanism_that_vouched_for_each_payload_is_inside_the_signed_layer() {
1026        // Clause 2. A consumer must be able to tell a cosign-signed tool from
1027        // an attested one WITHOUT leaving the layer, so the mechanism and what
1028        // it asserted are annotations on the payload entry — inside the DSSE
1029        // payload, uncorrectable after signing, exactly like the kind and the
1030        // source digests beside them.
1031        use crate::ingest::{ANN_PROOF, ANN_PROOF_ASSERTS, ANN_PROOF_SIGNER, IngestProof};
1032        let (sk, _) = generate_root_keypair();
1033        let tmp = tempfile::tempdir().unwrap();
1034        let mut spec = spec();
1035        spec.tools = vec![
1036            ingested("rivet", "pulseengine/rivet", IngestProof::CosignSums),
1037            ingested(
1038                "wasm-tools",
1039                "bytecodealliance/wasm-tools",
1040                IngestProof::BuildProvenance,
1041            ),
1042            ingested(
1043                "wit-bindgen",
1044                "bytecodealliance/wit-bindgen",
1045                IngestProof::Unverified,
1046            ),
1047        ];
1048        let outcome = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap();
1049        let hex = outcome.digest.strip_prefix("sha256:").unwrap();
1050        let bytes = std::fs::read(tmp.path().join("d/blobs/sha256").join(hex)).unwrap();
1051        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1052        let entries = json["manifests"].as_array().unwrap();
1053        let by_name = |n: &str| {
1054            entries
1055                .iter()
1056                .find(|e| e["annotations"]["eu.pulseengine.tool"] == n)
1057                .unwrap_or_else(|| panic!("{n} is not in the payload"))
1058                .clone()
1059        };
1060
1061        let rivet = by_name("rivet");
1062        assert_eq!(rivet["annotations"][ANN_PROOF], "cosign-sums");
1063        assert_eq!(
1064            rivet["annotations"][ANN_PROOF_SIGNER],
1065            "https://github.com/pulseengine/rivet/.github/workflows/release.yml@refs/tags/v1.0.0"
1066        );
1067
1068        let wasm_tools = by_name("wasm-tools");
1069        assert_eq!(
1070            wasm_tools["annotations"][ANN_PROOF], "build-provenance",
1071            "an attested tool must not read as a cosign-signed one"
1072        );
1073        assert_eq!(
1074            wasm_tools["annotations"][ANN_PROOF_ASSERTS],
1075            "built from source commit deadbeef"
1076        );
1077
1078        let wit_bindgen = by_name("wit-bindgen");
1079        assert_eq!(wit_bindgen["annotations"][ANN_PROOF], "unverified");
1080        assert!(
1081            wit_bindgen["annotations"][ANN_PROOF_SIGNER].is_null(),
1082            "nothing vouched for it, so no signer may be named"
1083        );
1084        assert!(
1085            wit_bindgen["annotations"][ANN_PROOF_ASSERTS]
1086                .as_str()
1087                .unwrap()
1088                .contains("2026.09 bring-up"),
1089            "the recorded opt-in reason travels with the payload"
1090        );
1091    }
1092
1093    // rivet: verifies REQ-INGEST-001
1094    #[test]
1095    fn an_unproven_payload_may_not_be_signed_in_silently() {
1096        // Clause 3, at the signing end. `unverified` is a real, sayable state —
1097        // but only WITH the recorded reason. A bare `proof = "unverified"` and
1098        // no `proof-asserts` is the silent path the requirement forbids, and it
1099        // is refused before a key is read rather than published and believed.
1100        use crate::ingest::IngestProof;
1101        let (sk, _) = generate_root_keypair();
1102        let tmp = tempfile::tempdir().unwrap();
1103        let mut spec = spec();
1104        let mut tool = ingested(
1105            "wit-bindgen",
1106            "bytecodealliance/wit-bindgen",
1107            IngestProof::Unverified,
1108        );
1109        tool.source.as_mut().unwrap().proof_asserts = None;
1110        spec.tools = vec![tool];
1111        let err = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap_err();
1112        assert!(
1113            matches!(&err, DepositError::UnverifiedWithoutReason { name, .. } if name == "wit-bindgen"),
1114            "got: {err}"
1115        );
1116
1117        // …and a signer named for a mechanism that vouched for nothing is a
1118        // claim of provenance where there is none.
1119        let mut spec = super::tests::spec();
1120        let mut tool = ingested(
1121            "wit-bindgen",
1122            "bytecodealliance/wit-bindgen",
1123            IngestProof::Unverified,
1124        );
1125        tool.source.as_mut().unwrap().proof_signer = Some("https://github.com/someone/".into());
1126        spec.tools = vec![tool];
1127        let err = deposit(&spec, &sk, "k", &tmp.path().join("e")).unwrap_err();
1128        assert!(
1129            matches!(&err, DepositError::UnverifiedNamesASigner { name, .. } if name == "wit-bindgen"),
1130            "got: {err}"
1131        );
1132    }
1133
1134    // rivet: verifies REQ-INGEST-001
1135    #[test]
1136    fn proof_detail_without_a_mechanism_is_refused_rather_than_signed() {
1137        // The other direction: `proof-signer` / `proof-asserts` with no
1138        // `proof` would land a signer and a claim in the signed payload with
1139        // nothing saying HOW it was established — attributable, believed, and
1140        // meaningless. Same shape of refusal as an sdk-prefix on a non-tree.
1141        let (sk, _) = generate_root_keypair();
1142        let tmp = tempfile::tempdir().unwrap();
1143        let mut spec = spec();
1144        let mut tool = ingested(
1145            "wasm-tools",
1146            "bytecodealliance/wasm-tools",
1147            crate::ingest::IngestProof::BuildProvenance,
1148        );
1149        tool.source.as_mut().unwrap().proof = None;
1150        spec.tools = vec![tool];
1151        let err = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap_err();
1152        assert!(
1153            matches!(&err, DepositError::ProofDetailWithoutMechanism { name, .. } if name == "wasm-tools"),
1154            "got: {err}"
1155        );
1156    }
1157
1158    // rivet: verifies REQ-INGEST-001
1159    #[test]
1160    fn a_layer_deposited_before_this_requirement_reads_as_unrecorded_not_as_verified() {
1161        // The compatibility promise. Every layer already published carries no
1162        // proof annotation, and the absent case must NOT read as "verified" —
1163        // that would silently upgrade every pre-REQ-INGEST-001 payload to a
1164        // claim nobody made. Absent is its own state: `unrecorded`.
1165        use crate::ingest::IngestProof;
1166        use crate::manifest::ManifestEntry;
1167        let entry = ManifestEntry {
1168            digest: "sha256:abc".into(),
1169            annotations: Default::default(),
1170        };
1171        assert_eq!(entry.ingest_proof(), Ok(None));
1172        assert_eq!(IngestProof::label(None), "unrecorded");
1173
1174        let mut entry = entry;
1175        entry
1176            .annotations
1177            .insert(crate::ingest::ANN_PROOF.into(), "build-provenance".into());
1178        assert_eq!(entry.ingest_proof(), Ok(Some(IngestProof::BuildProvenance)));
1179        // An unknown mechanism is reported verbatim, never guessed into a
1180        // known one — a newer varve may mint mechanisms this build has not
1181        // heard of, and quietly reading one as `cosign-sums` would be a lie.
1182        entry
1183            .annotations
1184            .insert(crate::ingest::ANN_PROOF.into(), "notary-v2".into());
1185        assert_eq!(
1186            entry.ingest_proof(),
1187            Err(crate::ingest::UnknownProof("notary-v2".into()))
1188        );
1189    }
1190
1191    /// One entry of a given kind, at a given version and platform.
1192    fn payload(
1193        name: &str,
1194        version: &str,
1195        kind: Option<crate::kind::PayloadKind>,
1196        platform: Option<&str>,
1197    ) -> DepositTool {
1198        DepositTool {
1199            name: name.into(),
1200            version: version.into(),
1201            platform: platform.map(str::to_string),
1202            bytes: format!("{name}-{version}-bytes").into_bytes(),
1203            source: None,
1204            runner: None,
1205            kind,
1206            sdk_prefix: None,
1207        }
1208    }
1209
1210    // rivet: verifies REQ-STORE-002
1211    #[test]
1212    fn two_versions_of_one_crate_are_a_layer_not_a_duplicate() {
1213        // THE reported defect (varve#69), at the function that raised it.
1214        // `deposit` keyed on (name, platform) and ignored version and kind, so
1215        // serde 1.0.200 beside serde 1.0.210 was refused as "duplicate tool
1216        // name 'serde'" — and varve could not express its own dependency graph
1217        // (252 packages, 14 names at more than one version) as a layer.
1218        use crate::kind::PayloadKind;
1219        let (sk, _) = generate_root_keypair();
1220        let tmp = tempfile::tempdir().unwrap();
1221        let mut spec = spec();
1222        spec.tools = vec![
1223            payload("serde", "1.0.200", Some(PayloadKind::Crate), None),
1224            payload("serde", "1.0.210", Some(PayloadKind::Crate), None),
1225        ];
1226        let outcome = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap();
1227
1228        // Both versions are in the SIGNED payload, each under its own digest.
1229        let hex = outcome.digest.strip_prefix("sha256:").unwrap();
1230        let payload_bytes = std::fs::read(tmp.path().join("d/blobs/sha256").join(hex)).unwrap();
1231        let json: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
1232        let entries = json["manifests"].as_array().unwrap();
1233        assert_eq!(entries.len(), 2);
1234        let versions: Vec<&str> = entries
1235            .iter()
1236            .map(|e| {
1237                e["annotations"]["eu.pulseengine.tool.version"]
1238                    .as_str()
1239                    .unwrap()
1240            })
1241            .collect();
1242        assert_eq!(versions, vec!["1.0.200", "1.0.210"]);
1243        assert_ne!(
1244            entries[0]["digest"], entries[1]["digest"],
1245            "two versions are two artifacts"
1246        );
1247    }
1248
1249    // rivet: verifies REQ-VSIX-001
1250    #[test]
1251    fn extensions_deposit_as_vsix_entries_at_several_versions() {
1252        // Clause 1: the kind reaches the SIGNED payload as `vsix`, spelled that
1253        // way — the annotation is what a consumer's `export-vsix` keys on, and
1254        // it is inside the DSSE payload, so it cannot be corrected afterwards.
1255        // Clause 4: an extension is not dispatched by name, so its identity is
1256        // (name, version) and two versions of one extension is a layer, not a
1257        // duplicate — the same rule REQ-STORE-002 established for crates,
1258        // reached here through `is_dispatchable` rather than a second list.
1259        use crate::kind::PayloadKind;
1260        let (sk, _) = generate_root_keypair();
1261        let tmp = tempfile::tempdir().unwrap();
1262        let mut spec = spec();
1263        spec.tools = vec![
1264            payload(
1265                "rust-lang.rust-analyzer",
1266                "0.3.2260",
1267                Some(PayloadKind::Vsix),
1268                None,
1269            ),
1270            payload(
1271                "rust-lang.rust-analyzer",
1272                "0.3.2300",
1273                Some(PayloadKind::Vsix),
1274                None,
1275            ),
1276            payload(
1277                "vadimcn.vscode-lldb",
1278                "1.11.4",
1279                Some(PayloadKind::Vsix),
1280                None,
1281            ),
1282        ];
1283        let outcome = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap();
1284
1285        let hex = outcome.digest.strip_prefix("sha256:").unwrap();
1286        let payload_bytes = std::fs::read(tmp.path().join("d/blobs/sha256").join(hex)).unwrap();
1287        let json: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
1288        let entries = json["manifests"].as_array().unwrap();
1289        assert_eq!(entries.len(), 3, "all three extensions must be signed in");
1290        for e in entries {
1291            assert_eq!(
1292                e["annotations"][crate::kind::ANN_KIND],
1293                "vsix",
1294                "every entry must carry the vsix kind in the SIGNED payload"
1295            );
1296        }
1297        let ids: Vec<(&str, &str)> = entries
1298            .iter()
1299            .map(|e| {
1300                (
1301                    e["annotations"]["eu.pulseengine.tool"].as_str().unwrap(),
1302                    e["annotations"]["eu.pulseengine.tool.version"]
1303                        .as_str()
1304                        .unwrap(),
1305                )
1306            })
1307            .collect();
1308        assert_eq!(
1309            ids,
1310            vec![
1311                ("rust-lang.rust-analyzer", "0.3.2260"),
1312                ("rust-lang.rust-analyzer", "0.3.2300"),
1313                ("vadimcn.vscode-lldb", "1.11.4"),
1314            ]
1315        );
1316        assert_ne!(
1317            entries[0]["digest"], entries[1]["digest"],
1318            "two versions of one extension are two artifacts"
1319        );
1320
1321        // …and one version deposited twice is still a true duplicate.
1322        let mut dup = spec;
1323        dup.tools = vec![
1324            payload("pub.ext", "1.0.0", Some(PayloadKind::Vsix), None),
1325            payload("pub.ext", "1.0.0", Some(PayloadKind::Vsix), None),
1326        ];
1327        let err = deposit(&dup, &sk, "k", &tmp.path().join("e")).unwrap_err();
1328        assert!(
1329            matches!(&err, DepositError::DuplicatePayload { name, version, .. }
1330                     if name == "pub.ext" && version == "1.0.0"),
1331            "got: {err}"
1332        );
1333    }
1334
1335    // rivet: verifies REQ-STORE-002
1336    #[test]
1337    fn a_tool_may_not_appear_twice_under_one_name_however_its_versions_differ() {
1338        // Clause 1's other half, and the reason the rule is not simply
1339        // "(name, version)": dispatch is BY NAME. `varve run synth` must have
1340        // exactly one answer, so two versions of one TOOL in one layer is a
1341        // real error — and the error must name both versions, or the depositor
1342        // cannot tell which two entries collided.
1343        use crate::kind::PayloadKind;
1344        let (sk, _) = generate_root_keypair();
1345        let tmp = tempfile::tempdir().unwrap();
1346        let mut spec = spec();
1347        spec.tools = vec![
1348            payload("synth", "0.45.0", Some(PayloadKind::Tool), None),
1349            payload("synth", "0.46.0", None, None), // absent kind == tool
1350        ];
1351        let err = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap_err();
1352        let msg = err.to_string();
1353        assert!(
1354            matches!(&err, DepositError::DuplicateTool { name, .. } if name == "synth"),
1355            "got: {err}"
1356        );
1357        assert!(msg.contains("0.45.0") && msg.contains("0.46.0"), "{msg}");
1358        // The identity includes the platform, so the verdict must name it —
1359        // otherwise a depositor of a cross-platform layer is told two entries
1360        // collide without being told on WHICH platform they do.
1361        assert!(
1362            msg.contains("any"),
1363            "an unstamped entry is any-platform: {msg}"
1364        );
1365        let mut stamped = spec;
1366        for tool in &mut stamped.tools {
1367            tool.platform = Some("x86_64-unknown-linux-gnu".into());
1368        }
1369        let msg = deposit(&stamped, &sk, "k", &tmp.path().join("e"))
1370            .unwrap_err()
1371            .to_string();
1372        assert!(msg.contains("x86_64-unknown-linux-gnu"), "{msg}");
1373    }
1374
1375    // rivet: verifies REQ-STORE-002
1376    #[test]
1377    fn one_version_deposited_twice_is_still_refused_and_the_error_names_it() {
1378        // Clause 3: relaxing the check must refuse only TRUE duplicates. Two
1379        // entries with ONE identity are the same payload twice — only one set
1380        // of bytes could land, so this stays an error, and the message carries
1381        // the version the old one lacked.
1382        use crate::kind::PayloadKind;
1383        let (sk, _) = generate_root_keypair();
1384        let tmp = tempfile::tempdir().unwrap();
1385        let mut spec = spec();
1386        spec.tools = vec![
1387            payload("serde", "1.0.200", Some(PayloadKind::Crate), None),
1388            payload("serde", "1.0.200", Some(PayloadKind::Crate), None),
1389        ];
1390        let err = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap_err();
1391        let msg = err.to_string();
1392        assert!(
1393            matches!(&err, DepositError::DuplicatePayload { name, version, .. }
1394                if name == "serde" && version == "1.0.200"),
1395            "got: {err}"
1396        );
1397        assert!(msg.contains("serde") && msg.contains("1.0.200"), "{msg}");
1398    }
1399
1400    // rivet: verifies REQ-STORE-002
1401    #[test]
1402    fn platform_still_separates_identities_for_both_rules() {
1403        // Clause 1 keeps `platform` in BOTH keys. The same tool for two
1404        // platforms is the ordinary cross-platform layer (install filters to
1405        // one), and the same crate for two platforms must likewise be allowed —
1406        // dropping platform from the key would refuse layers that install fine.
1407        use crate::kind::PayloadKind;
1408        let (sk, _) = generate_root_keypair();
1409        let tmp = tempfile::tempdir().unwrap();
1410        let mut spec = spec();
1411        spec.tools = vec![
1412            payload("synth", "0.45.0", None, Some("aarch64-apple-darwin")),
1413            payload("synth", "0.45.0", None, Some("x86_64-unknown-linux-gnu")),
1414            payload(
1415                "serde",
1416                "1.0.200",
1417                Some(PayloadKind::Crate),
1418                Some("aarch64-apple-darwin"),
1419            ),
1420            payload(
1421                "serde",
1422                "1.0.200",
1423                Some(PayloadKind::Crate),
1424                Some("x86_64-unknown-linux-gnu"),
1425            ),
1426        ];
1427        deposit(&spec, &sk, "k", &tmp.path().join("d")).expect(
1428            "distinct platforms, distinct
1429             identities",
1430        );
1431    }
1432
1433    // rivet: verifies REQ-STORE-002
1434    #[test]
1435    fn the_signed_digest_does_not_depend_on_the_order_versions_are_listed_in() {
1436        // The payload is sorted by the FULL identity now that one name can
1437        // appear more than once. Sorting by (name, platform) alone left two
1438        // versions of one name in spec order, so the same layer deposited from
1439        // a reordered spec would have produced a DIFFERENT digest — and the
1440        // digest is the identity a pin freezes against.
1441        use crate::kind::PayloadKind;
1442        let (sk, _) = generate_root_keypair();
1443        let tmp = tempfile::tempdir().unwrap();
1444        let a_first = vec![
1445            payload("serde", "1.0.200", Some(PayloadKind::Crate), None),
1446            payload("serde", "1.0.210", Some(PayloadKind::Crate), None),
1447        ];
1448        let b_first: Vec<DepositTool> = a_first.iter().rev().cloned().collect();
1449        let mut s1 = spec();
1450        s1.tools = a_first;
1451        let mut s2 = spec();
1452        s2.tools = b_first;
1453        assert_eq!(
1454            deposit(&s1, &sk, "k", &tmp.path().join("a"))
1455                .unwrap()
1456                .digest,
1457            deposit(&s2, &sk, "k", &tmp.path().join("b"))
1458                .unwrap()
1459                .digest,
1460        );
1461    }
1462}