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}
127
128/// How a deposit treats a destination that is not empty (REQ-NODESTROY-001).
129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
130pub struct DepositOptions {
131    /// Overwrite a layout that already carries referrers, destroying them.
132    /// Deliberate and stated — the point of the guard is that the destructive
133    /// case must be ASKED for, not stumbled into.
134    pub force: bool,
135}
136
137/// A completed deposit.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct DepositOutcome {
140    /// Digest of the manifest payload — what pins reference.
141    pub digest: String,
142    pub layer: LayerId,
143    pub counter: u64,
144}
145
146/// A deposit described as a file (CI-authored TOML) rather than flags —
147/// the shape the deposit workflow and the Bazel extension both read.
148#[derive(Debug, serde::Deserialize)]
149#[serde(deny_unknown_fields)]
150pub struct DepositFileSpec {
151    pub layer: String,
152    pub channel: String,
153    pub counter: u64,
154    #[serde(default, rename = "tool")]
155    pub tools: Vec<SpecTool>,
156    /// Layers this one composes (REQ-COMPOSE-001). Without this a composed
157    /// layer could not be PRODUCED at all — only hand-authored.
158    #[serde(default, rename = "include")]
159    pub includes: Vec<SpecInclude>,
160}
161
162/// A layer composed into this one: named by the digest of its signed manifest,
163/// plus the realm whose trust root is authoritative for it.
164#[derive(Debug, serde::Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct SpecInclude {
167    /// `sha256:<hex>` of the included layer's signed manifest.
168    pub digest: String,
169    /// The realm that verifies it. Absent = this layer's own realm.
170    #[serde(default)]
171    pub realm: Option<String>,
172    /// The included layer's identifier, so errors can name it before it is
173    /// fetched.
174    #[serde(default)]
175    pub layer: Option<String>,
176}
177
178#[derive(Debug, serde::Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct SpecTool {
181    pub name: String,
182    pub version: String,
183    #[serde(default)]
184    pub platform: Option<String>,
185    /// Binary path, absolute or relative to the spec file's directory.
186    pub path: String,
187    #[serde(default)]
188    pub source: Option<ToolSource>,
189    #[serde(default)]
190    pub runner: Option<RunnerSpec>,
191    /// Payload kind (REQ-KIND-001): tool|crate|wit|zephyr-module|sdk|
192    /// wasm-component|vsix. Absent = tool.
193    #[serde(default)]
194    pub kind: Option<String>,
195    /// `sdk-prefix` — the absolute path this tree was built for (REQ-SDK-001
196    /// clause 4). Required to make an `sdk` exportable at all: without it there
197    /// is no relocation budget, so `varve export-sdk` has nothing to patch.
198    #[serde(rename = "sdk-prefix", default)]
199    pub sdk_prefix: Option<String>,
200}
201
202pub fn parse_deposit_spec(toml_text: &str) -> Result<DepositFileSpec, DepositError> {
203    toml::from_str(toml_text).map_err(|e| DepositError::Spec(e.to_string()))
204}
205
206impl From<crate::archive::LayoutWriteError> for DepositError {
207    fn from(e: crate::archive::LayoutWriteError) -> Self {
208        match e {
209            crate::archive::LayoutWriteError::Io { path, source } => {
210                DepositError::Io { path, source }
211            }
212            crate::archive::LayoutWriteError::WouldDestroy(d) => {
213                DepositError::WouldDestroySignedWork(d)
214            }
215        }
216    }
217}
218
219#[derive(Debug, thiserror::Error)]
220pub enum DepositError {
221    #[error(
222        "the envelope this deposit just signed does not verify against the key that signed \
223         it — refusing to publish an artifact no consumer could accept"
224    )]
225    SelfVerifyFailed,
226    #[error(
227        "channel {channel:?} is not one a pin can name — use `qualified` or `rolling`. \
228         Signing it would produce a layer no varve.toml could ever select."
229    )]
230    BadChannel { channel: String },
231    #[error(
232        "issued-at {issued_at:?} is not an RFC 3339 timestamp (e.g. 2026-08-01T00:00:00Z). \
233         It is signed into the manifest and drives staleness and the SBOM timestamp, so it \
234         cannot be corrected afterwards."
235    )]
236    BadIssuedAt { issued_at: String },
237    #[error("deposit spec is not valid: {0}")]
238    Spec(String),
239    /// REQ-NODESTROY-001. `deposit` writes the WHOLE layout, `index.json`
240    /// included, so a second deposit into a directory that has had evidence
241    /// attached dropped all of it and reported success. Documentation warned
242    /// about this in three topics and guarded nothing.
243    #[error(transparent)]
244    WouldDestroySignedWork(#[from] crate::referrers::WouldDestroy),
245    #[error("deposit has no tools — an empty layer is not a toolchain")]
246    NoTools,
247    #[error(
248        "tool '{name}' is deposited twice for platform {platform} (versions {first} and \
249         {second}) — a tool is DISPATCHED BY NAME (`varve run {name}`, `varve which {name}`, the \
250         argv[0] shims), so one name must resolve to exactly one binary per platform. Deposit the \
251         other version as a separate layer, or give it a distinct name."
252    )]
253    DuplicateTool {
254        name: String,
255        platform: String,
256        first: String,
257        second: String,
258    },
259    #[error(
260        "payload '{name}' version {version} is deposited twice for platform {platform} — a layer \
261         may hold several VERSIONS of one name, but not one version twice: the two would be the \
262         same payload, and only one set of bytes could land."
263    )]
264    DuplicatePayload {
265        name: String,
266        version: String,
267        platform: String,
268    },
269    #[error(
270        "payload '{name}' is kind {kind} and carries `sdk-prefix` — only a tree payload (sdk) is \
271         relocated, so the prefix would be signed into the manifest, ignored by every adapter, \
272         and believed. Drop it, or deposit this payload as kind = \"sdk\"."
273    )]
274    SdkPrefixOnNonTree { name: String, kind: String },
275    #[error(
276        "sdk '{name}' version {version} declares no `sdk-prefix` — the absolute path it was \
277         BUILT for. Without it `varve export-sdk` has no relocation budget and no path to \
278         patch, so the layer would install and verify and could never be exported. Add \
279         `sdk-prefix = \"/opt/poky/4.0\"` (the path the SDK was built for) to the [[tool]] table."
280    )]
281    SdkPrefixMissing { name: String, version: String },
282    #[error(
283        "sdk '{name}' declares sdk-prefix {prefix:?}, which is not absolute — the prefix is the \
284         path PATCHED INTO the SDK's binaries, and a relative one there would resolve against \
285         whatever directory a compiler happens to run in"
286    )]
287    SdkPrefixNotAbsolute { name: String, prefix: String },
288    #[error(transparent)]
289    Sign(#[from] VerifyError),
290    #[error("io error at {path}: {source}")]
291    Io {
292        path: String,
293        #[source]
294        source: std::io::Error,
295    },
296}
297
298/// How a platform reads in an error when the entry claims none.
299fn platform_label(platform: Option<&String>) -> String {
300    platform
301        .cloned()
302        .unwrap_or_else(|| "any (unstamped)".to_string())
303}
304
305/// Refuse only TRUE duplicates, under the identity of REQ-STORE-002 clause 1
306/// (clause 3).
307///
308/// A `tool` is dispatched by name, so its identity is (name, platform) and two
309/// versions of one tool in one layer is a genuine error — `varve run synth`
310/// would have no answer. Every other kind is held, not dispatched: its identity
311/// is (name, version, platform), so `serde@1.0.200` beside `serde@1.0.210` is
312/// the ORDINARY shape of a dependency graph and must be accepted. varve's own
313/// Cargo.lock has 14 names at more than one version; refusing them meant varve
314/// could not express its own dependency graph as a layer.
315fn check_identities(tools: &[&DepositTool]) -> Result<(), DepositError> {
316    // (name, platform) -> the version already seen, for dispatchable payloads.
317    let mut dispatched: std::collections::BTreeMap<(&str, Option<&String>), &str> =
318        std::collections::BTreeMap::new();
319    // (name, version, platform) -> seen, for everything else.
320    let mut held: std::collections::BTreeSet<(&str, &str, Option<&String>)> =
321        std::collections::BTreeSet::new();
322    for tool in tools {
323        let dispatchable = tool.kind.unwrap_or_default().is_dispatchable();
324        let platform = tool.platform.as_ref();
325        if dispatchable {
326            if let Some(first) = dispatched.insert((&tool.name, platform), &tool.version) {
327                return Err(DepositError::DuplicateTool {
328                    name: tool.name.clone(),
329                    platform: platform_label(platform),
330                    first: first.to_string(),
331                    second: tool.version.clone(),
332                });
333            }
334        } else if !held.insert((&tool.name, &tool.version, platform)) {
335            return Err(DepositError::DuplicatePayload {
336                name: tool.name.clone(),
337                version: tool.version.clone(),
338                platform: platform_label(platform),
339            });
340        }
341    }
342    Ok(())
343}
344
345/// The relocation budget must be present exactly where it can be acted on, and
346/// absent everywhere else (REQ-SDK-001 clause 4).
347///
348/// Both directions are refusals rather than warnings, at the PRODUCING end. An
349/// `sdk-prefix` on a payload nobody relocates is signed, ignored and believed;
350/// an `sdk` without one installs and verifies and can never be exported, which
351/// the consumer discovers on the far side of an air gap. Neither is repairable
352/// without re-depositing, because the annotation lives inside the signature.
353fn check_sdk_prefixes(tools: &[&DepositTool]) -> Result<(), DepositError> {
354    for tool in tools {
355        let is_tree = tool.kind == Some(crate::kind::PayloadKind::Sdk);
356        match (&tool.sdk_prefix, is_tree) {
357            (Some(_), false) => {
358                return Err(DepositError::SdkPrefixOnNonTree {
359                    name: tool.name.clone(),
360                    kind: tool.kind.unwrap_or_default().as_str().to_string(),
361                });
362            }
363            (None, true) => {
364                return Err(DepositError::SdkPrefixMissing {
365                    name: tool.name.clone(),
366                    version: tool.version.clone(),
367                });
368            }
369            (Some(prefix), true) if !prefix.starts_with('/') => {
370                return Err(DepositError::SdkPrefixNotAbsolute {
371                    name: tool.name.clone(),
372                    prefix: prefix.clone(),
373                });
374            }
375            _ => {}
376        }
377    }
378    Ok(())
379}
380
381/// Assemble, sign, and write a layer as an OCI image layout at `dest`,
382/// refusing a destination that already carries signed work (REQ-NODESTROY-001).
383pub fn deposit(
384    spec: &DepositSpec,
385    signing_key: &[u8],
386    key_id: &str,
387    dest: &Path,
388) -> Result<DepositOutcome, DepositError> {
389    deposit_with_options(spec, signing_key, key_id, dest, &DepositOptions::default())
390}
391
392/// `deposit`, with the destructive case available to callers that ask for it.
393pub fn deposit_with_options(
394    spec: &DepositSpec,
395    signing_key: &[u8],
396    key_id: &str,
397    dest: &Path,
398    options: &DepositOptions,
399) -> Result<DepositOutcome, DepositError> {
400    // FIRST, before anything is validated or signed. `write_oci_layout` runs
401    // the same guard — it is the single writer, and clause 4 lives there — but
402    // asking here too means a deposit that would destroy signed work is
403    // refused before a key is even read, rather than after a signature exists
404    // for an artifact that will not be written (REQ-NODESTROY-001).
405    crate::referrers::guard(dest, options.force)?;
406    if spec.tools.is_empty() {
407        return Err(DepositError::NoTools);
408    }
409    let mut tools: Vec<&DepositTool> = spec.tools.iter().collect();
410    // Sorted by the FULL identity, so the payload order is deterministic even
411    // when one name appears at several versions — the digest is the identity a
412    // pin freezes against, and it must not depend on spec order.
413    tools.sort_by(|a, b| {
414        (&a.name, &a.version, &a.platform).cmp(&(&b.name, &b.version, &b.platform))
415    });
416    check_identities(&tools)?;
417    check_sdk_prefixes(&tools)?;
418
419    // Assemble the payload deterministically: sorted tools, fixed key order
420    // (serde_json sorts map keys), no timestamps beyond the caller-supplied
421    // issued-at. Identical specs must produce identical digests — the digest
422    // is the identity a pin freezes against.
423    let entries: Vec<serde_json::Value> = tools
424        .iter()
425        .map(|tool| {
426            let mut annotations = serde_json::Map::new();
427            annotations.insert("eu.pulseengine.tool".into(), tool.name.clone().into());
428            annotations.insert(
429                "eu.pulseengine.tool.version".into(),
430                tool.version.clone().into(),
431            );
432            if let Some(platform) = &tool.platform {
433                annotations.insert(
434                    crate::platform::ANN_PLATFORM.into(),
435                    platform.clone().into(),
436                );
437            }
438            if let Some(source) = &tool.source {
439                annotations.insert(
440                    crate::bazel::ANN_SRC_REPO.into(),
441                    source.repo.clone().into(),
442                );
443                annotations.insert(
444                    crate::bazel::ANN_SRC_RELEASE.into(),
445                    source.release.clone().into(),
446                );
447                annotations.insert(
448                    crate::bazel::ANN_SRC_ASSET.into(),
449                    source.asset.clone().into(),
450                );
451                annotations.insert(
452                    crate::bazel::ANN_SRC_SHA256.into(),
453                    source.sha256.clone().into(),
454                );
455            }
456            // Stamp the payload kind only when it is non-default: a `tool`
457            // (or unspecified) entry carries no kind annotation, so pre-kind
458            // tool layers keep byte-identical signed payloads (REQ-KIND-001).
459            if let Some(kind) = tool.kind
460                && kind != crate::kind::PayloadKind::Tool
461            {
462                annotations.insert(crate::kind::ANN_KIND.into(), kind.as_str().into());
463            }
464            // The relocation budget, inside the signature (REQ-SDK-001
465            // clause 4). `check_identities` has already refused it on a kind
466            // that is not a tree, so an entry carrying it is one `export-sdk`
467            // can act on.
468            if let Some(prefix) = &tool.sdk_prefix {
469                annotations.insert(
470                    crate::sdkexport::ANN_SDK_PREFIX.into(),
471                    prefix.clone().into(),
472                );
473            }
474            if let Some(runner) = &tool.runner {
475                annotations.insert(crate::bazel::ANN_RUNNER.into(), runner.tool.clone().into());
476                if !runner.args.is_empty() {
477                    annotations.insert(
478                        crate::bazel::ANN_RUNNER_ARGS.into(),
479                        runner.args.join(" ").into(),
480                    );
481                }
482                if let Some(prefix) = &runner.arg_prefix {
483                    annotations.insert(
484                        crate::bazel::ANN_RUNNER_ARG_PREFIX.into(),
485                        prefix.clone().into(),
486                    );
487                }
488            }
489            serde_json::json!({
490                "mediaType": "application/vnd.oci.image.manifest.v1+json",
491                "digest": manifest_digest(&tool.bytes),
492                "size": tool.bytes.len(),
493                "annotations": annotations,
494            })
495        })
496        .collect();
497    // Composed layers are entries too: a `layer`-kind reference whose digest is
498    // the included layer's SIGNED MANIFEST digest. Emitting them here is what
499    // makes the composition part of the signed payload — and what lets a
500    // composed layer be produced at all rather than hand-authored.
501    let mut entries = entries;
502    for inc in &spec.includes {
503        let mut annotations = serde_json::Map::new();
504        annotations.insert(
505            crate::kind::ANN_KIND.into(),
506            crate::kind::PayloadKind::Layer.as_str().into(),
507        );
508        if let Some(realm) = &inc.realm {
509            annotations.insert(
510                crate::compose::ANN_INCLUDE_REALM.into(),
511                realm.clone().into(),
512            );
513        }
514        if let Some(layer) = &inc.layer {
515            annotations.insert(
516                crate::compose::ANN_INCLUDE_LAYER.into(),
517                layer.clone().into(),
518            );
519        }
520        entries.push(serde_json::json!({
521            "mediaType": "application/vnd.oci.image.index.v1+json",
522            "digest": inc.digest,
523            "size": 0,
524            "annotations": annotations,
525        }));
526    }
527    // Validate BEFORE signing. Everything below becomes immutable the moment it
528    // is signed, so a bad value here is not a mistake you can correct — it is a
529    // released artifact nobody can use (REQ-PRODUCER-001).
530    if spec.channel.parse::<crate::pin::Channel>().is_err() {
531        return Err(DepositError::BadChannel {
532            channel: spec.channel.clone(),
533        });
534    }
535    if !is_rfc3339(&spec.issued_at) {
536        return Err(DepositError::BadIssuedAt {
537            issued_at: spec.issued_at.clone(),
538        });
539    }
540    let payload_json = serde_json::json!({
541        "schemaVersion": 2,
542        "mediaType": "application/vnd.oci.image.index.v1+json",
543        "artifactType": "application/vnd.pulseengine.varve.layer.v1+json",
544        "annotations": {
545            "eu.pulseengine.varve.layer": spec.layer.to_string(),
546            "eu.pulseengine.varve.line": spec.layer.line().to_string(),
547            "eu.pulseengine.varve.channel": spec.channel,
548            "eu.pulseengine.varve.counter": spec.counter.to_string(),
549            "org.opencontainers.image.created": spec.issued_at,
550        },
551        "manifests": entries,
552    });
553    let payload = serde_json::to_vec_pretty(&payload_json).expect("payload serializes");
554    let envelope = sign_layer_manifest(&payload, signing_key, key_id)?;
555    // Read our own work back before publishing it. The cheapest possible guard
556    // against emitting a release artifact nobody can verify — and the last
557    // point at which it is still correctable (REQ-PRODUCER-001).
558    let public = &signing_key[32..];
559    match crate::verify::dsse_verify_typed(
560        envelope.as_bytes(),
561        crate::verify::LAYER_PAYLOAD_TYPE,
562        public,
563    ) {
564        Ok(back) if back == payload => {}
565        _ => return Err(DepositError::SelfVerifyFailed),
566    }
567
568    let blobs: Vec<(String, Vec<u8>)> = tools
569        .iter()
570        .map(|tool| (manifest_digest(&tool.bytes), tool.bytes.clone()))
571        .collect();
572    crate::archive::write_oci_layout(
573        &payload,
574        envelope.as_bytes(),
575        &blobs,
576        &spec.layer.to_string(),
577        &spec.channel,
578        // A deposit carries every platform the producer built, so it makes no
579        // single-platform claim — unlike an archive (varve#80).
580        None,
581        dest,
582        options.force,
583    )
584    .map_err(DepositError::from)?;
585
586    Ok(DepositOutcome {
587        digest: manifest_digest(&payload),
588        layer: spec.layer.clone(),
589        counter: spec.counter,
590    })
591}
592
593#[cfg(test)]
594mod producer_tests {
595    use super::*;
596
597    // rivet: verifies REQ-PRODUCER-001
598    #[test]
599    fn a_channel_no_pin_could_name_is_refused_before_signing() {
600        // `--channel stable` signed happily and produced a layer no varve.toml
601        // could ever select, exit 0. The enum is shared with the pin parser so
602        // the two cannot drift.
603        assert!("qualified".parse::<crate::pin::Channel>().is_ok());
604        assert!("rolling".parse::<crate::pin::Channel>().is_ok());
605        for bad in ["stable", "Qualified", "", "beta"] {
606            assert!(
607                bad.parse::<crate::pin::Channel>().is_err(),
608                "{bad} must be refused"
609            );
610        }
611    }
612
613    // rivet: verifies REQ-NODESTROY-001
614    #[test]
615    fn a_deposit_that_would_drop_a_referrer_is_refused_before_a_byte_is_written() {
616        use crate::verify::generate_root_keypair;
617        let (sk, _) = generate_root_keypair();
618        let tmp = tempfile::tempdir().unwrap();
619        let dest = tmp.path().join("layout");
620        let spec = super::tests::spec();
621        deposit(&spec, &sk, "k", &dest).unwrap();
622        // Evidence attached after the deposit — the append-only half of the
623        // producer pipeline.
624        let status = crate::linestatus::LineStatus {
625            line: "2026.08".into(),
626            counter: 1,
627            issued_at: "2026-08-07T00:00:00Z".into(),
628            support_until: None,
629            yanked: Default::default(),
630            known_problems: Vec::new(),
631        };
632        crate::linestatus::attach_to_layout(
633            &dest,
634            &"2026.08".parse().unwrap(),
635            status.sign(&sk, "k").unwrap().as_bytes(),
636        )
637        .unwrap();
638
639        let index_before = std::fs::read(dest.join("index.json")).unwrap();
640        let err = deposit(&spec, &sk, "k", &dest).expect_err("must refuse");
641        assert!(
642            matches!(&err, DepositError::WouldDestroySignedWork { .. }),
643            "got: {err}"
644        );
645        let msg = err.to_string();
646        assert!(
647            msg.contains("line-status") && msg.contains("2026.08"),
648            "{msg}"
649        );
650        assert!(msg.contains("varve attach-status"), "{msg}");
651        assert_eq!(
652            index_before,
653            std::fs::read(dest.join("index.json")).unwrap(),
654            "a refused deposit must not have touched the layout"
655        );
656
657        // …and --force is the deliberate way through, because sometimes the
658        // operator really does mean to start the directory over.
659        deposit_with_options(&spec, &sk, "k", &dest, &DepositOptions { force: true })
660            .expect("--force overrides the guard");
661        assert!(
662            crate::linestatus::read_any_from_layout(&dest)
663                .unwrap()
664                .is_none(),
665            "--force is destructive on purpose — the referrer is gone"
666        );
667    }
668
669    // rivet: verifies REQ-NODESTROY-001
670    #[test]
671    fn depositing_into_a_fresh_or_previously_clean_directory_still_works() {
672        // The guard must fire on ATTACHED work only. A guard that also refused
673        // a directory holding nothing but a previous clean deposit would break
674        // every idempotent CI re-run, and a guard that gets switched off
675        // protects nobody.
676        use crate::verify::generate_root_keypair;
677        let (sk, _) = generate_root_keypair();
678        let tmp = tempfile::tempdir().unwrap();
679        let dest = tmp.path().join("layout");
680        let spec = super::tests::spec();
681        let first = deposit(&spec, &sk, "k", &dest).unwrap();
682        let second = deposit(&spec, &sk, "k", &dest).expect("a clean layout may be re-deposited");
683        assert_eq!(first.digest, second.digest);
684    }
685
686    // rivet: verifies REQ-PRODUCER-001
687    #[test]
688    fn issued_at_must_be_a_timestamp_not_merely_a_date() {
689        // A bare date passed `epoch_days` (which needs only day resolution) but
690        // lands verbatim in the SBOM's metadata.timestamp, where it is invalid
691        // and uncorrectable after signing.
692        assert!(is_rfc3339("2026-08-01T00:00:00Z"));
693        assert!(is_rfc3339("2026-08-01T12:34:56.789Z"));
694        assert!(is_rfc3339("2026-08-01T12:34:56+02:00"));
695        for bad in [
696            "2026-10-01",
697            "not-a-date",
698            "",
699            "2026-08-01T",
700            "2026-13-01T00:00:00Z",
701        ] {
702            assert!(!is_rfc3339(bad), "{bad} must be refused");
703        }
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use crate::archive::OciLayoutSource;
711    use crate::install::{InstallPolicy, install};
712    use crate::pin::Pin;
713    use crate::rollback::HighWaterMarks;
714    use crate::store::Store;
715    use crate::verify::{PinnedKeyVerifier, generate_root_keypair};
716
717    pub(super) fn spec() -> DepositSpec {
718        DepositSpec {
719            includes: Vec::new(),
720            layer: "2026.08.0".parse().unwrap(),
721            channel: "qualified".into(),
722            counter: 1,
723            issued_at: "2026-08-07T00:00:00Z".into(),
724            tools: vec![
725                DepositTool {
726                    name: "synth".into(),
727                    version: "0.45.0".into(),
728                    platform: None,
729                    bytes: b"synth-bytes".to_vec(),
730                    source: None,
731                    runner: None,
732                    kind: None,
733                    sdk_prefix: None,
734                },
735                DepositTool {
736                    name: "rivet".into(),
737                    version: "0.32.0".into(),
738                    platform: None,
739                    bytes: b"rivet-bytes".to_vec(),
740                    source: None,
741                    runner: None,
742                    kind: None,
743                    sdk_prefix: None,
744                },
745            ],
746        }
747    }
748
749    // rivet: verifies REQ-DEPOSIT-001
750    #[test]
751    fn a_deposit_round_trips_through_the_standard_install_pipeline() {
752        let (sk, pk) = generate_root_keypair();
753        let tmp = tempfile::tempdir().unwrap();
754        let dest = tmp.path().join("deposit");
755        let outcome = deposit(&spec(), &sk, "varve-root-1", &dest).unwrap();
756        assert_eq!(outcome.layer.to_string(), "2026.08.0");
757
758        // Standard layout markers present.
759        assert!(dest.join("oci-layout").is_file());
760        assert!(dest.join("index.json").is_file());
761
762        // Install from the deposit exactly as from an archive.
763        let pin = Pin::parse(
764            "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.08.0\"\n",
765            "varve.toml",
766        )
767        .unwrap();
768        let root = tmp.path().join("fresh");
769        let store = Store::at(&root);
770        let mut marks = HighWaterMarks::load(&root).unwrap();
771        let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
772        let policy = InstallPolicy {
773            index: None,
774            now: "2026-08-07T00:00:00Z",
775            staleness_threshold_days: 90,
776            platform: "test-platform",
777        };
778        let installed = install(
779            &pin,
780            &OciLayoutSource::at(&dest),
781            &verifier,
782            &store,
783            &mut marks,
784            &policy,
785        )
786        .unwrap();
787        assert_eq!(installed.digest, outcome.digest);
788        assert_eq!(installed.counter, 1);
789        let entry = store.get(&installed.digest).unwrap().unwrap();
790        let checked =
791            crate::reverify::verify_installed(&store, &entry, &verifier, "test-platform").unwrap();
792        assert_eq!(checked, 2);
793    }
794
795    // rivet: verifies REQ-DEPOSIT-001
796    #[test]
797    fn the_payload_records_layer_line_channel_counter_and_tool_versions() {
798        let (sk, _) = generate_root_keypair();
799        let tmp = tempfile::tempdir().unwrap();
800        let dest = tmp.path().join("deposit");
801        let outcome = deposit(&spec(), &sk, "varve-root-1", &dest).unwrap();
802        let hex = outcome.digest.strip_prefix("sha256:").unwrap();
803        let payload = std::fs::read(dest.join("blobs/sha256").join(hex)).unwrap();
804        let json: serde_json::Value = serde_json::from_slice(&payload).unwrap();
805        let ann = &json["annotations"];
806        assert_eq!(ann["eu.pulseengine.varve.layer"], "2026.08.0");
807        assert_eq!(ann["eu.pulseengine.varve.line"], "2026.08");
808        assert_eq!(ann["eu.pulseengine.varve.channel"], "qualified");
809        assert_eq!(ann["eu.pulseengine.varve.counter"], "1");
810        assert_eq!(
811            ann["org.opencontainers.image.created"],
812            "2026-08-07T00:00:00Z"
813        );
814        let entries = json["manifests"].as_array().unwrap();
815        assert_eq!(entries.len(), 2);
816        assert!(entries.iter().any(|e| {
817            e["annotations"]["eu.pulseengine.tool"] == "synth"
818                && e["annotations"]["eu.pulseengine.tool.version"] == "0.45.0"
819        }));
820    }
821
822    // rivet: verifies REQ-DEPOSIT-001
823    #[test]
824    fn identical_specs_deposit_identical_digests() {
825        let (sk, _) = generate_root_keypair();
826        let tmp = tempfile::tempdir().unwrap();
827        let a = deposit(&spec(), &sk, "varve-root-1", &tmp.path().join("a")).unwrap();
828        let b = deposit(&spec(), &sk, "varve-root-1", &tmp.path().join("b")).unwrap();
829        assert_eq!(
830            a.digest, b.digest,
831            "the payload is deterministic — the digest IS the identity"
832        );
833    }
834
835    // rivet: verifies REQ-DEPOSIT-001
836    #[test]
837    fn empty_and_duplicate_tool_lists_are_refused() {
838        let (sk, _) = generate_root_keypair();
839        let tmp = tempfile::tempdir().unwrap();
840        let mut empty = spec();
841        empty.tools.clear();
842        assert!(matches!(
843            deposit(&empty, &sk, "k", &tmp.path().join("x")).unwrap_err(),
844            DepositError::NoTools
845        ));
846        let mut dup = spec();
847        dup.tools[1].name = "synth".into();
848        let err = deposit(&dup, &sk, "k", &tmp.path().join("y")).unwrap_err();
849        assert!(
850            matches!(&err, DepositError::DuplicateTool { name, .. } if name == "synth"),
851            "got: {err}"
852        );
853    }
854
855    /// One entry of a given kind, at a given version and platform.
856    fn payload(
857        name: &str,
858        version: &str,
859        kind: Option<crate::kind::PayloadKind>,
860        platform: Option<&str>,
861    ) -> DepositTool {
862        DepositTool {
863            name: name.into(),
864            version: version.into(),
865            platform: platform.map(str::to_string),
866            bytes: format!("{name}-{version}-bytes").into_bytes(),
867            source: None,
868            runner: None,
869            kind,
870            sdk_prefix: None,
871        }
872    }
873
874    // rivet: verifies REQ-STORE-002
875    #[test]
876    fn two_versions_of_one_crate_are_a_layer_not_a_duplicate() {
877        // THE reported defect (varve#69), at the function that raised it.
878        // `deposit` keyed on (name, platform) and ignored version and kind, so
879        // serde 1.0.200 beside serde 1.0.210 was refused as "duplicate tool
880        // name 'serde'" — and varve could not express its own dependency graph
881        // (252 packages, 14 names at more than one version) as a layer.
882        use crate::kind::PayloadKind;
883        let (sk, _) = generate_root_keypair();
884        let tmp = tempfile::tempdir().unwrap();
885        let mut spec = spec();
886        spec.tools = vec![
887            payload("serde", "1.0.200", Some(PayloadKind::Crate), None),
888            payload("serde", "1.0.210", Some(PayloadKind::Crate), None),
889        ];
890        let outcome = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap();
891
892        // Both versions are in the SIGNED payload, each under its own digest.
893        let hex = outcome.digest.strip_prefix("sha256:").unwrap();
894        let payload_bytes = std::fs::read(tmp.path().join("d/blobs/sha256").join(hex)).unwrap();
895        let json: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
896        let entries = json["manifests"].as_array().unwrap();
897        assert_eq!(entries.len(), 2);
898        let versions: Vec<&str> = entries
899            .iter()
900            .map(|e| {
901                e["annotations"]["eu.pulseengine.tool.version"]
902                    .as_str()
903                    .unwrap()
904            })
905            .collect();
906        assert_eq!(versions, vec!["1.0.200", "1.0.210"]);
907        assert_ne!(
908            entries[0]["digest"], entries[1]["digest"],
909            "two versions are two artifacts"
910        );
911    }
912
913    // rivet: verifies REQ-VSIX-001
914    #[test]
915    fn extensions_deposit_as_vsix_entries_at_several_versions() {
916        // Clause 1: the kind reaches the SIGNED payload as `vsix`, spelled that
917        // way — the annotation is what a consumer's `export-vsix` keys on, and
918        // it is inside the DSSE payload, so it cannot be corrected afterwards.
919        // Clause 4: an extension is not dispatched by name, so its identity is
920        // (name, version) and two versions of one extension is a layer, not a
921        // duplicate — the same rule REQ-STORE-002 established for crates,
922        // reached here through `is_dispatchable` rather than a second list.
923        use crate::kind::PayloadKind;
924        let (sk, _) = generate_root_keypair();
925        let tmp = tempfile::tempdir().unwrap();
926        let mut spec = spec();
927        spec.tools = vec![
928            payload(
929                "rust-lang.rust-analyzer",
930                "0.3.2260",
931                Some(PayloadKind::Vsix),
932                None,
933            ),
934            payload(
935                "rust-lang.rust-analyzer",
936                "0.3.2300",
937                Some(PayloadKind::Vsix),
938                None,
939            ),
940            payload(
941                "vadimcn.vscode-lldb",
942                "1.11.4",
943                Some(PayloadKind::Vsix),
944                None,
945            ),
946        ];
947        let outcome = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap();
948
949        let hex = outcome.digest.strip_prefix("sha256:").unwrap();
950        let payload_bytes = std::fs::read(tmp.path().join("d/blobs/sha256").join(hex)).unwrap();
951        let json: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
952        let entries = json["manifests"].as_array().unwrap();
953        assert_eq!(entries.len(), 3, "all three extensions must be signed in");
954        for e in entries {
955            assert_eq!(
956                e["annotations"][crate::kind::ANN_KIND],
957                "vsix",
958                "every entry must carry the vsix kind in the SIGNED payload"
959            );
960        }
961        let ids: Vec<(&str, &str)> = entries
962            .iter()
963            .map(|e| {
964                (
965                    e["annotations"]["eu.pulseengine.tool"].as_str().unwrap(),
966                    e["annotations"]["eu.pulseengine.tool.version"]
967                        .as_str()
968                        .unwrap(),
969                )
970            })
971            .collect();
972        assert_eq!(
973            ids,
974            vec![
975                ("rust-lang.rust-analyzer", "0.3.2260"),
976                ("rust-lang.rust-analyzer", "0.3.2300"),
977                ("vadimcn.vscode-lldb", "1.11.4"),
978            ]
979        );
980        assert_ne!(
981            entries[0]["digest"], entries[1]["digest"],
982            "two versions of one extension are two artifacts"
983        );
984
985        // …and one version deposited twice is still a true duplicate.
986        let mut dup = spec;
987        dup.tools = vec![
988            payload("pub.ext", "1.0.0", Some(PayloadKind::Vsix), None),
989            payload("pub.ext", "1.0.0", Some(PayloadKind::Vsix), None),
990        ];
991        let err = deposit(&dup, &sk, "k", &tmp.path().join("e")).unwrap_err();
992        assert!(
993            matches!(&err, DepositError::DuplicatePayload { name, version, .. }
994                     if name == "pub.ext" && version == "1.0.0"),
995            "got: {err}"
996        );
997    }
998
999    // rivet: verifies REQ-STORE-002
1000    #[test]
1001    fn a_tool_may_not_appear_twice_under_one_name_however_its_versions_differ() {
1002        // Clause 1's other half, and the reason the rule is not simply
1003        // "(name, version)": dispatch is BY NAME. `varve run synth` must have
1004        // exactly one answer, so two versions of one TOOL in one layer is a
1005        // real error — and the error must name both versions, or the depositor
1006        // cannot tell which two entries collided.
1007        use crate::kind::PayloadKind;
1008        let (sk, _) = generate_root_keypair();
1009        let tmp = tempfile::tempdir().unwrap();
1010        let mut spec = spec();
1011        spec.tools = vec![
1012            payload("synth", "0.45.0", Some(PayloadKind::Tool), None),
1013            payload("synth", "0.46.0", None, None), // absent kind == tool
1014        ];
1015        let err = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap_err();
1016        let msg = err.to_string();
1017        assert!(
1018            matches!(&err, DepositError::DuplicateTool { name, .. } if name == "synth"),
1019            "got: {err}"
1020        );
1021        assert!(msg.contains("0.45.0") && msg.contains("0.46.0"), "{msg}");
1022        // The identity includes the platform, so the verdict must name it —
1023        // otherwise a depositor of a cross-platform layer is told two entries
1024        // collide without being told on WHICH platform they do.
1025        assert!(
1026            msg.contains("any"),
1027            "an unstamped entry is any-platform: {msg}"
1028        );
1029        let mut stamped = spec;
1030        for tool in &mut stamped.tools {
1031            tool.platform = Some("x86_64-unknown-linux-gnu".into());
1032        }
1033        let msg = deposit(&stamped, &sk, "k", &tmp.path().join("e"))
1034            .unwrap_err()
1035            .to_string();
1036        assert!(msg.contains("x86_64-unknown-linux-gnu"), "{msg}");
1037    }
1038
1039    // rivet: verifies REQ-STORE-002
1040    #[test]
1041    fn one_version_deposited_twice_is_still_refused_and_the_error_names_it() {
1042        // Clause 3: relaxing the check must refuse only TRUE duplicates. Two
1043        // entries with ONE identity are the same payload twice — only one set
1044        // of bytes could land, so this stays an error, and the message carries
1045        // the version the old one lacked.
1046        use crate::kind::PayloadKind;
1047        let (sk, _) = generate_root_keypair();
1048        let tmp = tempfile::tempdir().unwrap();
1049        let mut spec = spec();
1050        spec.tools = vec![
1051            payload("serde", "1.0.200", Some(PayloadKind::Crate), None),
1052            payload("serde", "1.0.200", Some(PayloadKind::Crate), None),
1053        ];
1054        let err = deposit(&spec, &sk, "k", &tmp.path().join("d")).unwrap_err();
1055        let msg = err.to_string();
1056        assert!(
1057            matches!(&err, DepositError::DuplicatePayload { name, version, .. }
1058                if name == "serde" && version == "1.0.200"),
1059            "got: {err}"
1060        );
1061        assert!(msg.contains("serde") && msg.contains("1.0.200"), "{msg}");
1062    }
1063
1064    // rivet: verifies REQ-STORE-002
1065    #[test]
1066    fn platform_still_separates_identities_for_both_rules() {
1067        // Clause 1 keeps `platform` in BOTH keys. The same tool for two
1068        // platforms is the ordinary cross-platform layer (install filters to
1069        // one), and the same crate for two platforms must likewise be allowed —
1070        // dropping platform from the key would refuse layers that install fine.
1071        use crate::kind::PayloadKind;
1072        let (sk, _) = generate_root_keypair();
1073        let tmp = tempfile::tempdir().unwrap();
1074        let mut spec = spec();
1075        spec.tools = vec![
1076            payload("synth", "0.45.0", None, Some("aarch64-apple-darwin")),
1077            payload("synth", "0.45.0", None, Some("x86_64-unknown-linux-gnu")),
1078            payload(
1079                "serde",
1080                "1.0.200",
1081                Some(PayloadKind::Crate),
1082                Some("aarch64-apple-darwin"),
1083            ),
1084            payload(
1085                "serde",
1086                "1.0.200",
1087                Some(PayloadKind::Crate),
1088                Some("x86_64-unknown-linux-gnu"),
1089            ),
1090        ];
1091        deposit(&spec, &sk, "k", &tmp.path().join("d")).expect(
1092            "distinct platforms, distinct
1093             identities",
1094        );
1095    }
1096
1097    // rivet: verifies REQ-STORE-002
1098    #[test]
1099    fn the_signed_digest_does_not_depend_on_the_order_versions_are_listed_in() {
1100        // The payload is sorted by the FULL identity now that one name can
1101        // appear more than once. Sorting by (name, platform) alone left two
1102        // versions of one name in spec order, so the same layer deposited from
1103        // a reordered spec would have produced a DIFFERENT digest — and the
1104        // digest is the identity a pin freezes against.
1105        use crate::kind::PayloadKind;
1106        let (sk, _) = generate_root_keypair();
1107        let tmp = tempfile::tempdir().unwrap();
1108        let a_first = vec![
1109            payload("serde", "1.0.200", Some(PayloadKind::Crate), None),
1110            payload("serde", "1.0.210", Some(PayloadKind::Crate), None),
1111        ];
1112        let b_first: Vec<DepositTool> = a_first.iter().rev().cloned().collect();
1113        let mut s1 = spec();
1114        s1.tools = a_first;
1115        let mut s2 = spec();
1116        s2.tools = b_first;
1117        assert_eq!(
1118            deposit(&s1, &sk, "k", &tmp.path().join("a"))
1119                .unwrap()
1120                .digest,
1121            deposit(&s2, &sk, "k", &tmp.path().join("b"))
1122                .unwrap()
1123                .digest,
1124        );
1125    }
1126}