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