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            line: "2026.08".into(),
762            counter: 1,
763            issued_at: "2026-08-07T00:00:00Z".into(),
764            support_until: None,
765            yanked: Default::default(),
766            known_problems: Vec::new(),
767        };
768        crate::linestatus::attach_to_layout(
769            &dest,
770            &"2026.08".parse().unwrap(),
771            status.sign(&sk, "k").unwrap().as_bytes(),
772        )
773        .unwrap();
774
775        let index_before = std::fs::read(dest.join("index.json")).unwrap();
776        let err = deposit(&spec, &sk, "k", &dest).expect_err("must refuse");
777        assert!(
778            matches!(&err, DepositError::WouldDestroySignedWork { .. }),
779            "got: {err}"
780        );
781        let msg = err.to_string();
782        assert!(
783            msg.contains("line-status") && msg.contains("2026.08"),
784            "{msg}"
785        );
786        assert!(msg.contains("varve attach-status"), "{msg}");
787        assert_eq!(
788            index_before,
789            std::fs::read(dest.join("index.json")).unwrap(),
790            "a refused deposit must not have touched the layout"
791        );
792
793        // …and --force is the deliberate way through, because sometimes the
794        // operator really does mean to start the directory over.
795        deposit_with_options(&spec, &sk, "k", &dest, &DepositOptions { force: true })
796            .expect("--force overrides the guard");
797        assert!(
798            crate::linestatus::read_any_from_layout(&dest)
799                .unwrap()
800                .is_none(),
801            "--force is destructive on purpose — the referrer is gone"
802        );
803    }
804
805    // rivet: verifies REQ-NODESTROY-001
806    #[test]
807    fn depositing_into_a_fresh_or_previously_clean_directory_still_works() {
808        // The guard must fire on ATTACHED work only. A guard that also refused
809        // a directory holding nothing but a previous clean deposit would break
810        // every idempotent CI re-run, and a guard that gets switched off
811        // protects nobody.
812        use crate::verify::generate_root_keypair;
813        let (sk, _) = generate_root_keypair();
814        let tmp = tempfile::tempdir().unwrap();
815        let dest = tmp.path().join("layout");
816        let spec = super::tests::spec();
817        let first = deposit(&spec, &sk, "k", &dest).unwrap();
818        let second = deposit(&spec, &sk, "k", &dest).expect("a clean layout may be re-deposited");
819        assert_eq!(first.digest, second.digest);
820    }
821
822    // rivet: verifies REQ-PRODUCER-001
823    #[test]
824    fn issued_at_must_be_a_timestamp_not_merely_a_date() {
825        // A bare date passed `epoch_days` (which needs only day resolution) but
826        // lands verbatim in the SBOM's metadata.timestamp, where it is invalid
827        // and uncorrectable after signing.
828        assert!(is_rfc3339("2026-08-01T00:00:00Z"));
829        assert!(is_rfc3339("2026-08-01T12:34:56.789Z"));
830        assert!(is_rfc3339("2026-08-01T12:34:56+02:00"));
831        for bad in [
832            "2026-10-01",
833            "not-a-date",
834            "",
835            "2026-08-01T",
836            "2026-13-01T00:00:00Z",
837        ] {
838            assert!(!is_rfc3339(bad), "{bad} must be refused");
839        }
840    }
841}
842
843#[cfg(test)]
844mod tests {
845    use super::*;
846    use crate::archive::OciLayoutSource;
847    use crate::install::{InstallPolicy, install};
848    use crate::pin::Pin;
849    use crate::rollback::HighWaterMarks;
850    use crate::store::Store;
851    use crate::verify::{PinnedKeyVerifier, generate_root_keypair};
852
853    pub(super) fn spec() -> DepositSpec {
854        DepositSpec {
855            includes: Vec::new(),
856            layer: "2026.08.0".parse().unwrap(),
857            channel: "qualified".into(),
858            counter: 1,
859            issued_at: "2026-08-07T00:00:00Z".into(),
860            tools: vec![
861                DepositTool {
862                    name: "synth".into(),
863                    version: "0.45.0".into(),
864                    platform: None,
865                    bytes: b"synth-bytes".to_vec(),
866                    source: None,
867                    runner: None,
868                    kind: None,
869                    sdk_prefix: None,
870                },
871                DepositTool {
872                    name: "rivet".into(),
873                    version: "0.32.0".into(),
874                    platform: None,
875                    bytes: b"rivet-bytes".to_vec(),
876                    source: None,
877                    runner: None,
878                    kind: None,
879                    sdk_prefix: None,
880                },
881            ],
882        }
883    }
884
885    // rivet: verifies REQ-DEPOSIT-001
886    #[test]
887    fn a_deposit_round_trips_through_the_standard_install_pipeline() {
888        let (sk, pk) = generate_root_keypair();
889        let tmp = tempfile::tempdir().unwrap();
890        let dest = tmp.path().join("deposit");
891        let outcome = deposit(&spec(), &sk, "varve-root-1", &dest).unwrap();
892        assert_eq!(outcome.layer.to_string(), "2026.08.0");
893
894        // Standard layout markers present.
895        assert!(dest.join("oci-layout").is_file());
896        assert!(dest.join("index.json").is_file());
897
898        // Install from the deposit exactly as from an archive.
899        let pin = Pin::parse(
900            "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.08.0\"\n",
901            "varve.toml",
902        )
903        .unwrap();
904        let root = tmp.path().join("fresh");
905        let store = Store::at(&root);
906        let mut marks = HighWaterMarks::load(&root).unwrap();
907        let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
908        let policy = InstallPolicy {
909            index: None,
910            now: "2026-08-07T00:00:00Z",
911            staleness_threshold_days: 90,
912            platform: "test-platform",
913        };
914        let installed = install(
915            &pin,
916            &OciLayoutSource::at(&dest),
917            &verifier,
918            &store,
919            &mut marks,
920            &policy,
921        )
922        .unwrap();
923        assert_eq!(installed.digest, outcome.digest);
924        assert_eq!(installed.counter, 1);
925        let entry = store.get(&installed.digest).unwrap().unwrap();
926        let checked =
927            crate::reverify::verify_installed(&store, &entry, &verifier, "test-platform").unwrap();
928        assert_eq!(checked, 2);
929    }
930
931    // rivet: verifies REQ-DEPOSIT-001
932    #[test]
933    fn the_payload_records_layer_line_channel_counter_and_tool_versions() {
934        let (sk, _) = generate_root_keypair();
935        let tmp = tempfile::tempdir().unwrap();
936        let dest = tmp.path().join("deposit");
937        let outcome = deposit(&spec(), &sk, "varve-root-1", &dest).unwrap();
938        let hex = outcome.digest.strip_prefix("sha256:").unwrap();
939        let payload = std::fs::read(dest.join("blobs/sha256").join(hex)).unwrap();
940        let json: serde_json::Value = serde_json::from_slice(&payload).unwrap();
941        let ann = &json["annotations"];
942        assert_eq!(ann["eu.pulseengine.varve.layer"], "2026.08.0");
943        assert_eq!(ann["eu.pulseengine.varve.line"], "2026.08");
944        assert_eq!(ann["eu.pulseengine.varve.channel"], "qualified");
945        assert_eq!(ann["eu.pulseengine.varve.counter"], "1");
946        assert_eq!(
947            ann["org.opencontainers.image.created"],
948            "2026-08-07T00:00:00Z"
949        );
950        let entries = json["manifests"].as_array().unwrap();
951        assert_eq!(entries.len(), 2);
952        assert!(entries.iter().any(|e| {
953            e["annotations"]["eu.pulseengine.tool"] == "synth"
954                && e["annotations"]["eu.pulseengine.tool.version"] == "0.45.0"
955        }));
956    }
957
958    // rivet: verifies REQ-DEPOSIT-001
959    #[test]
960    fn identical_specs_deposit_identical_digests() {
961        let (sk, _) = generate_root_keypair();
962        let tmp = tempfile::tempdir().unwrap();
963        let a = deposit(&spec(), &sk, "varve-root-1", &tmp.path().join("a")).unwrap();
964        let b = deposit(&spec(), &sk, "varve-root-1", &tmp.path().join("b")).unwrap();
965        assert_eq!(
966            a.digest, b.digest,
967            "the payload is deterministic — the digest IS the identity"
968        );
969    }
970
971    // rivet: verifies REQ-DEPOSIT-001
972    #[test]
973    fn empty_and_duplicate_tool_lists_are_refused() {
974        let (sk, _) = generate_root_keypair();
975        let tmp = tempfile::tempdir().unwrap();
976        let mut empty = spec();
977        empty.tools.clear();
978        assert!(matches!(
979            deposit(&empty, &sk, "k", &tmp.path().join("x")).unwrap_err(),
980            DepositError::NoTools
981        ));
982        let mut dup = spec();
983        dup.tools[1].name = "synth".into();
984        let err = deposit(&dup, &sk, "k", &tmp.path().join("y")).unwrap_err();
985        assert!(
986            matches!(&err, DepositError::DuplicateTool { name, .. } if name == "synth"),
987            "got: {err}"
988        );
989    }
990
991    /// A tool whose bytes arrived through a NAMED ingestion mechanism
992    /// (REQ-INGEST-001).
993    fn ingested(name: &str, repo: &str, proof: crate::ingest::IngestProof) -> DepositTool {
994        let mut tool = payload(name, "1.0.0", None, Some("x86_64-unknown-linux-gnu"));
995        tool.source = Some(ToolSource {
996            repo: repo.into(),
997            release: "v1.0.0".into(),
998            asset: format!("{name}-v1.0.0-x86_64-unknown-linux-gnu.tar.gz"),
999            sha256: "a".repeat(64),
1000            proof: Some(proof),
1001            proof_signer: match proof {
1002                crate::ingest::IngestProof::Unverified => None,
1003                _ => Some(format!(
1004                    "https://github.com/{repo}/.github/workflows/release.yml@refs/tags/v1.0.0"
1005                )),
1006            },
1007            proof_asserts: Some(match proof {
1008                crate::ingest::IngestProof::CosignSums => {
1009                    "SHA256SUMS.txt signed for this repo".to_string()
1010                }
1011                crate::ingest::IngestProof::BuildProvenance => {
1012                    "built from source commit deadbeef".to_string()
1013                }
1014                crate::ingest::IngestProof::Unverified => {
1015                    "NOTHING — operator opt-in: needed for the 2026.09 bring-up".to_string()
1016                }
1017            }),
1018        });
1019        tool
1020    }
1021
1022    // rivet: verifies REQ-INGEST-001
1023    #[test]
1024    fn the_mechanism_that_vouched_for_each_payload_is_inside_the_signed_layer() {
1025        // Clause 2. A consumer must be able to tell a cosign-signed tool from
1026        // an attested one WITHOUT leaving the layer, so the mechanism and what
1027        // it asserted are annotations on the payload entry — inside the DSSE
1028        // payload, uncorrectable after signing, exactly like the kind and the
1029        // source digests beside them.
1030        use crate::ingest::{ANN_PROOF, ANN_PROOF_ASSERTS, ANN_PROOF_SIGNER, IngestProof};
1031        let (sk, _) = generate_root_keypair();
1032        let tmp = tempfile::tempdir().unwrap();
1033        let mut spec = spec();
1034        spec.tools = vec![
1035            ingested("rivet", "pulseengine/rivet", IngestProof::CosignSums),
1036            ingested(
1037                "wasm-tools",
1038                "bytecodealliance/wasm-tools",
1039                IngestProof::BuildProvenance,
1040            ),
1041            ingested(
1042                "wit-bindgen",
1043                "bytecodealliance/wit-bindgen",
1044                IngestProof::Unverified,
1045            ),
1046        ];
1047        let outcome = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap();
1048        let hex = outcome.digest.strip_prefix("sha256:").unwrap();
1049        let bytes = std::fs::read(tmp.path().join("d/blobs/sha256").join(hex)).unwrap();
1050        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1051        let entries = json["manifests"].as_array().unwrap();
1052        let by_name = |n: &str| {
1053            entries
1054                .iter()
1055                .find(|e| e["annotations"]["eu.pulseengine.tool"] == n)
1056                .unwrap_or_else(|| panic!("{n} is not in the payload"))
1057                .clone()
1058        };
1059
1060        let rivet = by_name("rivet");
1061        assert_eq!(rivet["annotations"][ANN_PROOF], "cosign-sums");
1062        assert_eq!(
1063            rivet["annotations"][ANN_PROOF_SIGNER],
1064            "https://github.com/pulseengine/rivet/.github/workflows/release.yml@refs/tags/v1.0.0"
1065        );
1066
1067        let wasm_tools = by_name("wasm-tools");
1068        assert_eq!(
1069            wasm_tools["annotations"][ANN_PROOF], "build-provenance",
1070            "an attested tool must not read as a cosign-signed one"
1071        );
1072        assert_eq!(
1073            wasm_tools["annotations"][ANN_PROOF_ASSERTS],
1074            "built from source commit deadbeef"
1075        );
1076
1077        let wit_bindgen = by_name("wit-bindgen");
1078        assert_eq!(wit_bindgen["annotations"][ANN_PROOF], "unverified");
1079        assert!(
1080            wit_bindgen["annotations"][ANN_PROOF_SIGNER].is_null(),
1081            "nothing vouched for it, so no signer may be named"
1082        );
1083        assert!(
1084            wit_bindgen["annotations"][ANN_PROOF_ASSERTS]
1085                .as_str()
1086                .unwrap()
1087                .contains("2026.09 bring-up"),
1088            "the recorded opt-in reason travels with the payload"
1089        );
1090    }
1091
1092    // rivet: verifies REQ-INGEST-001
1093    #[test]
1094    fn an_unproven_payload_may_not_be_signed_in_silently() {
1095        // Clause 3, at the signing end. `unverified` is a real, sayable state —
1096        // but only WITH the recorded reason. A bare `proof = "unverified"` and
1097        // no `proof-asserts` is the silent path the requirement forbids, and it
1098        // is refused before a key is read rather than published and believed.
1099        use crate::ingest::IngestProof;
1100        let (sk, _) = generate_root_keypair();
1101        let tmp = tempfile::tempdir().unwrap();
1102        let mut spec = spec();
1103        let mut tool = ingested(
1104            "wit-bindgen",
1105            "bytecodealliance/wit-bindgen",
1106            IngestProof::Unverified,
1107        );
1108        tool.source.as_mut().unwrap().proof_asserts = None;
1109        spec.tools = vec![tool];
1110        let err = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap_err();
1111        assert!(
1112            matches!(&err, DepositError::UnverifiedWithoutReason { name, .. } if name == "wit-bindgen"),
1113            "got: {err}"
1114        );
1115
1116        // …and a signer named for a mechanism that vouched for nothing is a
1117        // claim of provenance where there is none.
1118        let mut spec = super::tests::spec();
1119        let mut tool = ingested(
1120            "wit-bindgen",
1121            "bytecodealliance/wit-bindgen",
1122            IngestProof::Unverified,
1123        );
1124        tool.source.as_mut().unwrap().proof_signer = Some("https://github.com/someone/".into());
1125        spec.tools = vec![tool];
1126        let err = deposit(&spec, &sk, "k", &tmp.path().join("e")).unwrap_err();
1127        assert!(
1128            matches!(&err, DepositError::UnverifiedNamesASigner { name, .. } if name == "wit-bindgen"),
1129            "got: {err}"
1130        );
1131    }
1132
1133    // rivet: verifies REQ-INGEST-001
1134    #[test]
1135    fn proof_detail_without_a_mechanism_is_refused_rather_than_signed() {
1136        // The other direction: `proof-signer` / `proof-asserts` with no
1137        // `proof` would land a signer and a claim in the signed payload with
1138        // nothing saying HOW it was established — attributable, believed, and
1139        // meaningless. Same shape of refusal as an sdk-prefix on a non-tree.
1140        let (sk, _) = generate_root_keypair();
1141        let tmp = tempfile::tempdir().unwrap();
1142        let mut spec = spec();
1143        let mut tool = ingested(
1144            "wasm-tools",
1145            "bytecodealliance/wasm-tools",
1146            crate::ingest::IngestProof::BuildProvenance,
1147        );
1148        tool.source.as_mut().unwrap().proof = None;
1149        spec.tools = vec![tool];
1150        let err = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap_err();
1151        assert!(
1152            matches!(&err, DepositError::ProofDetailWithoutMechanism { name, .. } if name == "wasm-tools"),
1153            "got: {err}"
1154        );
1155    }
1156
1157    // rivet: verifies REQ-INGEST-001
1158    #[test]
1159    fn a_layer_deposited_before_this_requirement_reads_as_unrecorded_not_as_verified() {
1160        // The compatibility promise. Every layer already published carries no
1161        // proof annotation, and the absent case must NOT read as "verified" —
1162        // that would silently upgrade every pre-REQ-INGEST-001 payload to a
1163        // claim nobody made. Absent is its own state: `unrecorded`.
1164        use crate::ingest::IngestProof;
1165        use crate::manifest::ManifestEntry;
1166        let entry = ManifestEntry {
1167            digest: "sha256:abc".into(),
1168            annotations: Default::default(),
1169        };
1170        assert_eq!(entry.ingest_proof(), Ok(None));
1171        assert_eq!(IngestProof::label(None), "unrecorded");
1172
1173        let mut entry = entry;
1174        entry
1175            .annotations
1176            .insert(crate::ingest::ANN_PROOF.into(), "build-provenance".into());
1177        assert_eq!(entry.ingest_proof(), Ok(Some(IngestProof::BuildProvenance)));
1178        // An unknown mechanism is reported verbatim, never guessed into a
1179        // known one — a newer varve may mint mechanisms this build has not
1180        // heard of, and quietly reading one as `cosign-sums` would be a lie.
1181        entry
1182            .annotations
1183            .insert(crate::ingest::ANN_PROOF.into(), "notary-v2".into());
1184        assert_eq!(
1185            entry.ingest_proof(),
1186            Err(crate::ingest::UnknownProof("notary-v2".into()))
1187        );
1188    }
1189
1190    /// One entry of a given kind, at a given version and platform.
1191    fn payload(
1192        name: &str,
1193        version: &str,
1194        kind: Option<crate::kind::PayloadKind>,
1195        platform: Option<&str>,
1196    ) -> DepositTool {
1197        DepositTool {
1198            name: name.into(),
1199            version: version.into(),
1200            platform: platform.map(str::to_string),
1201            bytes: format!("{name}-{version}-bytes").into_bytes(),
1202            source: None,
1203            runner: None,
1204            kind,
1205            sdk_prefix: None,
1206        }
1207    }
1208
1209    // rivet: verifies REQ-STORE-002
1210    #[test]
1211    fn two_versions_of_one_crate_are_a_layer_not_a_duplicate() {
1212        // THE reported defect (varve#69), at the function that raised it.
1213        // `deposit` keyed on (name, platform) and ignored version and kind, so
1214        // serde 1.0.200 beside serde 1.0.210 was refused as "duplicate tool
1215        // name 'serde'" — and varve could not express its own dependency graph
1216        // (252 packages, 14 names at more than one version) as a layer.
1217        use crate::kind::PayloadKind;
1218        let (sk, _) = generate_root_keypair();
1219        let tmp = tempfile::tempdir().unwrap();
1220        let mut spec = spec();
1221        spec.tools = vec![
1222            payload("serde", "1.0.200", Some(PayloadKind::Crate), None),
1223            payload("serde", "1.0.210", Some(PayloadKind::Crate), None),
1224        ];
1225        let outcome = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap();
1226
1227        // Both versions are in the SIGNED payload, each under its own digest.
1228        let hex = outcome.digest.strip_prefix("sha256:").unwrap();
1229        let payload_bytes = std::fs::read(tmp.path().join("d/blobs/sha256").join(hex)).unwrap();
1230        let json: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
1231        let entries = json["manifests"].as_array().unwrap();
1232        assert_eq!(entries.len(), 2);
1233        let versions: Vec<&str> = entries
1234            .iter()
1235            .map(|e| {
1236                e["annotations"]["eu.pulseengine.tool.version"]
1237                    .as_str()
1238                    .unwrap()
1239            })
1240            .collect();
1241        assert_eq!(versions, vec!["1.0.200", "1.0.210"]);
1242        assert_ne!(
1243            entries[0]["digest"], entries[1]["digest"],
1244            "two versions are two artifacts"
1245        );
1246    }
1247
1248    // rivet: verifies REQ-VSIX-001
1249    #[test]
1250    fn extensions_deposit_as_vsix_entries_at_several_versions() {
1251        // Clause 1: the kind reaches the SIGNED payload as `vsix`, spelled that
1252        // way — the annotation is what a consumer's `export-vsix` keys on, and
1253        // it is inside the DSSE payload, so it cannot be corrected afterwards.
1254        // Clause 4: an extension is not dispatched by name, so its identity is
1255        // (name, version) and two versions of one extension is a layer, not a
1256        // duplicate — the same rule REQ-STORE-002 established for crates,
1257        // reached here through `is_dispatchable` rather than a second list.
1258        use crate::kind::PayloadKind;
1259        let (sk, _) = generate_root_keypair();
1260        let tmp = tempfile::tempdir().unwrap();
1261        let mut spec = spec();
1262        spec.tools = vec![
1263            payload(
1264                "rust-lang.rust-analyzer",
1265                "0.3.2260",
1266                Some(PayloadKind::Vsix),
1267                None,
1268            ),
1269            payload(
1270                "rust-lang.rust-analyzer",
1271                "0.3.2300",
1272                Some(PayloadKind::Vsix),
1273                None,
1274            ),
1275            payload(
1276                "vadimcn.vscode-lldb",
1277                "1.11.4",
1278                Some(PayloadKind::Vsix),
1279                None,
1280            ),
1281        ];
1282        let outcome = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap();
1283
1284        let hex = outcome.digest.strip_prefix("sha256:").unwrap();
1285        let payload_bytes = std::fs::read(tmp.path().join("d/blobs/sha256").join(hex)).unwrap();
1286        let json: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
1287        let entries = json["manifests"].as_array().unwrap();
1288        assert_eq!(entries.len(), 3, "all three extensions must be signed in");
1289        for e in entries {
1290            assert_eq!(
1291                e["annotations"][crate::kind::ANN_KIND],
1292                "vsix",
1293                "every entry must carry the vsix kind in the SIGNED payload"
1294            );
1295        }
1296        let ids: Vec<(&str, &str)> = entries
1297            .iter()
1298            .map(|e| {
1299                (
1300                    e["annotations"]["eu.pulseengine.tool"].as_str().unwrap(),
1301                    e["annotations"]["eu.pulseengine.tool.version"]
1302                        .as_str()
1303                        .unwrap(),
1304                )
1305            })
1306            .collect();
1307        assert_eq!(
1308            ids,
1309            vec![
1310                ("rust-lang.rust-analyzer", "0.3.2260"),
1311                ("rust-lang.rust-analyzer", "0.3.2300"),
1312                ("vadimcn.vscode-lldb", "1.11.4"),
1313            ]
1314        );
1315        assert_ne!(
1316            entries[0]["digest"], entries[1]["digest"],
1317            "two versions of one extension are two artifacts"
1318        );
1319
1320        // …and one version deposited twice is still a true duplicate.
1321        let mut dup = spec;
1322        dup.tools = vec![
1323            payload("pub.ext", "1.0.0", Some(PayloadKind::Vsix), None),
1324            payload("pub.ext", "1.0.0", Some(PayloadKind::Vsix), None),
1325        ];
1326        let err = deposit(&dup, &sk, "k", &tmp.path().join("e")).unwrap_err();
1327        assert!(
1328            matches!(&err, DepositError::DuplicatePayload { name, version, .. }
1329                     if name == "pub.ext" && version == "1.0.0"),
1330            "got: {err}"
1331        );
1332    }
1333
1334    // rivet: verifies REQ-STORE-002
1335    #[test]
1336    fn a_tool_may_not_appear_twice_under_one_name_however_its_versions_differ() {
1337        // Clause 1's other half, and the reason the rule is not simply
1338        // "(name, version)": dispatch is BY NAME. `varve run synth` must have
1339        // exactly one answer, so two versions of one TOOL in one layer is a
1340        // real error — and the error must name both versions, or the depositor
1341        // cannot tell which two entries collided.
1342        use crate::kind::PayloadKind;
1343        let (sk, _) = generate_root_keypair();
1344        let tmp = tempfile::tempdir().unwrap();
1345        let mut spec = spec();
1346        spec.tools = vec![
1347            payload("synth", "0.45.0", Some(PayloadKind::Tool), None),
1348            payload("synth", "0.46.0", None, None), // absent kind == tool
1349        ];
1350        let err = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap_err();
1351        let msg = err.to_string();
1352        assert!(
1353            matches!(&err, DepositError::DuplicateTool { name, .. } if name == "synth"),
1354            "got: {err}"
1355        );
1356        assert!(msg.contains("0.45.0") && msg.contains("0.46.0"), "{msg}");
1357        // The identity includes the platform, so the verdict must name it —
1358        // otherwise a depositor of a cross-platform layer is told two entries
1359        // collide without being told on WHICH platform they do.
1360        assert!(
1361            msg.contains("any"),
1362            "an unstamped entry is any-platform: {msg}"
1363        );
1364        let mut stamped = spec;
1365        for tool in &mut stamped.tools {
1366            tool.platform = Some("x86_64-unknown-linux-gnu".into());
1367        }
1368        let msg = deposit(&stamped, &sk, "k", &tmp.path().join("e"))
1369            .unwrap_err()
1370            .to_string();
1371        assert!(msg.contains("x86_64-unknown-linux-gnu"), "{msg}");
1372    }
1373
1374    // rivet: verifies REQ-STORE-002
1375    #[test]
1376    fn one_version_deposited_twice_is_still_refused_and_the_error_names_it() {
1377        // Clause 3: relaxing the check must refuse only TRUE duplicates. Two
1378        // entries with ONE identity are the same payload twice — only one set
1379        // of bytes could land, so this stays an error, and the message carries
1380        // the version the old one lacked.
1381        use crate::kind::PayloadKind;
1382        let (sk, _) = generate_root_keypair();
1383        let tmp = tempfile::tempdir().unwrap();
1384        let mut spec = spec();
1385        spec.tools = vec![
1386            payload("serde", "1.0.200", Some(PayloadKind::Crate), None),
1387            payload("serde", "1.0.200", Some(PayloadKind::Crate), None),
1388        ];
1389        let err = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap_err();
1390        let msg = err.to_string();
1391        assert!(
1392            matches!(&err, DepositError::DuplicatePayload { name, version, .. }
1393                if name == "serde" && version == "1.0.200"),
1394            "got: {err}"
1395        );
1396        assert!(msg.contains("serde") && msg.contains("1.0.200"), "{msg}");
1397    }
1398
1399    // rivet: verifies REQ-STORE-002
1400    #[test]
1401    fn platform_still_separates_identities_for_both_rules() {
1402        // Clause 1 keeps `platform` in BOTH keys. The same tool for two
1403        // platforms is the ordinary cross-platform layer (install filters to
1404        // one), and the same crate for two platforms must likewise be allowed —
1405        // dropping platform from the key would refuse layers that install fine.
1406        use crate::kind::PayloadKind;
1407        let (sk, _) = generate_root_keypair();
1408        let tmp = tempfile::tempdir().unwrap();
1409        let mut spec = spec();
1410        spec.tools = vec![
1411            payload("synth", "0.45.0", None, Some("aarch64-apple-darwin")),
1412            payload("synth", "0.45.0", None, Some("x86_64-unknown-linux-gnu")),
1413            payload(
1414                "serde",
1415                "1.0.200",
1416                Some(PayloadKind::Crate),
1417                Some("aarch64-apple-darwin"),
1418            ),
1419            payload(
1420                "serde",
1421                "1.0.200",
1422                Some(PayloadKind::Crate),
1423                Some("x86_64-unknown-linux-gnu"),
1424            ),
1425        ];
1426        deposit(&spec, &sk, "k", &tmp.path().join("d")).expect(
1427            "distinct platforms, distinct
1428             identities",
1429        );
1430    }
1431
1432    // rivet: verifies REQ-STORE-002
1433    #[test]
1434    fn the_signed_digest_does_not_depend_on_the_order_versions_are_listed_in() {
1435        // The payload is sorted by the FULL identity now that one name can
1436        // appear more than once. Sorting by (name, platform) alone left two
1437        // versions of one name in spec order, so the same layer deposited from
1438        // a reordered spec would have produced a DIFFERENT digest — and the
1439        // digest is the identity a pin freezes against.
1440        use crate::kind::PayloadKind;
1441        let (sk, _) = generate_root_keypair();
1442        let tmp = tempfile::tempdir().unwrap();
1443        let a_first = vec![
1444            payload("serde", "1.0.200", Some(PayloadKind::Crate), None),
1445            payload("serde", "1.0.210", Some(PayloadKind::Crate), None),
1446        ];
1447        let b_first: Vec<DepositTool> = a_first.iter().rev().cloned().collect();
1448        let mut s1 = spec();
1449        s1.tools = a_first;
1450        let mut s2 = spec();
1451        s2.tools = b_first;
1452        assert_eq!(
1453            deposit(&s1, &sk, "k", &tmp.path().join("a"))
1454                .unwrap()
1455                .digest,
1456            deposit(&s2, &sk, "k", &tmp.path().join("b"))
1457                .unwrap()
1458                .digest,
1459        );
1460    }
1461}