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