1use std::path::Path;
12
13use crate::install::VerifyError;
14use crate::layer::LayerId;
15use crate::store::manifest_digest;
16use crate::verify::sign_layer_manifest;
17
18fn 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 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#[derive(Debug, Clone)]
49pub struct DepositSpec {
50 pub layer: LayerId,
51 pub channel: String,
53 pub counter: u64,
56 pub issued_at: String,
59 pub tools: Vec<DepositTool>,
61 pub includes: Vec<DepositInclude>,
63}
64
65#[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 pub platform: Option<String>,
80 pub bytes: Vec<u8>,
81 pub source: Option<ToolSource>,
85 pub runner: Option<RunnerSpec>,
89 pub kind: Option<crate::kind::PayloadKind>,
92 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#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct ToolSource {
117 pub repo: String,
119 pub release: String,
121 pub asset: String,
123 pub sha256: String,
126}
127
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
130pub struct DepositOptions {
131 pub force: bool,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct DepositOutcome {
140 pub digest: String,
142 pub layer: LayerId,
143 pub counter: u64,
144}
145
146#[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 #[serde(default, rename = "include")]
159 pub includes: Vec<SpecInclude>,
160}
161
162#[derive(Debug, serde::Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct SpecInclude {
167 pub digest: String,
169 #[serde(default)]
171 pub realm: Option<String>,
172 #[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 pub path: String,
187 #[serde(default)]
188 pub source: Option<ToolSource>,
189 #[serde(default)]
190 pub runner: Option<RunnerSpec>,
191 #[serde(default)]
194 pub kind: Option<String>,
195 #[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 #[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
298fn platform_label(platform: Option<&String>) -> String {
300 platform
301 .cloned()
302 .unwrap_or_else(|| "any (unstamped)".to_string())
303}
304
305fn check_identities(tools: &[&DepositTool]) -> Result<(), DepositError> {
316 let mut dispatched: std::collections::BTreeMap<(&str, Option<&String>), &str> =
318 std::collections::BTreeMap::new();
319 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
345fn 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
381pub 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
392pub 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 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 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 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 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 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 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 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 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 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 #[test]
599 fn a_channel_no_pin_could_name_is_refused_before_signing() {
600 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 #[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 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 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 #[test]
671 fn depositing_into_a_fresh_or_previously_clean_directory_still_works() {
672 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 #[test]
688 fn issued_at_must_be_a_timestamp_not_merely_a_date() {
689 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 #[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 assert!(dest.join("oci-layout").is_file());
760 assert!(dest.join("index.json").is_file());
761
762 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 #[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 #[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 #[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 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 #[test]
876 fn two_versions_of_one_crate_are_a_layer_not_a_duplicate() {
877 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 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 #[test]
915 fn extensions_deposit_as_vsix_entries_at_several_versions() {
916 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 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 #[test]
1001 fn a_tool_may_not_appear_twice_under_one_name_however_its_versions_differ() {
1002 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), ];
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 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 #[test]
1041 fn one_version_deposited_twice_is_still_refused_and_the_error_names_it() {
1042 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 #[test]
1066 fn platform_still_separates_identities_for_both_rules() {
1067 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 #[test]
1099 fn the_signed_digest_does_not_depend_on_the_order_versions_are_listed_in() {
1100 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}