1use std::path::{Path, PathBuf};
19use std::str::FromStr;
20
21use serde::Deserialize;
22
23use crate::layer::{LayerId, LayerIdError};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum Channel {
32 Qualified,
33 Rolling,
34}
35
36impl Channel {
37 pub fn as_str(self) -> &'static str {
39 match self {
40 Channel::Qualified => "qualified",
41 Channel::Rolling => "rolling",
42 }
43 }
44}
45
46impl std::str::FromStr for Channel {
47 type Err = ();
48 fn from_str(s: &str) -> Result<Self, ()> {
52 match s {
53 "qualified" => Ok(Channel::Qualified),
54 "rolling" => Ok(Channel::Rolling),
55 _ => Err(()),
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
69pub enum ExportKind {
70 Cargo,
71 CratesVendor,
72 BazelRegistry,
73 BazelDistdir,
74 Vsix,
75 Sdk,
76}
77
78impl ExportKind {
79 pub fn as_str(self) -> &'static str {
80 match self {
81 ExportKind::Cargo => "cargo",
82 ExportKind::CratesVendor => "crates-vendor",
83 ExportKind::BazelRegistry => "bazel-registry",
84 ExportKind::BazelDistdir => "bazel-distdir",
85 ExportKind::Vsix => "vsix",
86 ExportKind::Sdk => "sdk",
87 }
88 }
89
90 pub fn is_sourced(self) -> bool {
99 matches!(self, ExportKind::Sdk)
100 }
101
102 pub const ALL: &'static [ExportKind] = &[
104 ExportKind::Cargo,
105 ExportKind::CratesVendor,
106 ExportKind::BazelRegistry,
107 ExportKind::BazelDistdir,
108 ExportKind::Vsix,
109 ExportKind::Sdk,
110 ];
111}
112
113impl std::fmt::Display for ExportKind {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 f.write_str(self.as_str())
116 }
117}
118
119impl FromStr for ExportKind {
120 type Err = ();
121 fn from_str(s: &str) -> Result<Self, ()> {
122 ExportKind::ALL
123 .iter()
124 .find(|k| k.as_str() == s)
125 .copied()
126 .ok_or(())
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ShimOrder {
140 BeforeShims,
143 AfterShims,
146}
147
148impl ShimOrder {
149 pub fn as_str(self) -> &'static str {
150 match self {
151 ShimOrder::BeforeShims => "before-shims",
152 ShimOrder::AfterShims => "after-shims",
153 }
154 }
155}
156
157impl FromStr for ShimOrder {
158 type Err = ();
159 fn from_str(s: &str) -> Result<Self, ()> {
160 match s {
161 "before-shims" => Ok(ShimOrder::BeforeShims),
162 "after-shims" => Ok(ShimOrder::AfterShims),
163 _ => Err(()),
164 }
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct ExportEnv {
171 pub script: String,
173 pub path: ShimOrder,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct ExportDecl {
181 pub kind: ExportKind,
182 pub out: String,
184 pub select: Option<Vec<String>>,
188 pub env: Option<ExportEnv>,
189}
190
191impl ExportDecl {
192 pub fn dir(&self, project_root: &Path) -> PathBuf {
197 project_root.join(&self.out)
198 }
199
200 pub fn env_script(&self, project_root: &Path) -> Option<PathBuf> {
202 self.env
203 .as_ref()
204 .map(|e| self.dir(project_root).join(&e.script))
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct ToolSelector {
218 pub realm: Option<String>,
220 pub name: String,
222}
223
224impl ToolSelector {
225 pub fn parse(entry: &str) -> Option<Self> {
233 let plain = |s: &str| {
234 !s.is_empty()
235 && s != "."
236 && s != ".."
237 && !s.contains('/')
238 && !s.contains('\\')
239 && !s.contains('\0')
240 };
241 match entry.split_once('/') {
242 Some((realm, name)) => (plain(realm) && plain(name)).then(|| ToolSelector {
243 realm: Some(realm.to_string()),
244 name: name.to_string(),
245 }),
246 None => plain(entry).then(|| ToolSelector {
247 realm: None,
248 name: entry.to_string(),
249 }),
250 }
251 }
252}
253
254impl std::fmt::Display for ToolSelector {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 match &self.realm {
257 Some(realm) => write!(f, "{realm}/{}", self.name),
258 None => f.write_str(&self.name),
259 }
260 }
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct Pin {
266 pub realm: Option<String>,
269 pub channel: Channel,
270 pub layer: LayerId,
271 pub digest: Option<String>,
275 pub tools: Option<Vec<ToolSelector>>,
278 pub exports: Vec<ExportDecl>,
281}
282
283#[derive(Debug, thiserror::Error)]
285pub enum PinError {
286 #[error("failed to read {path}")]
287 Io {
288 path: String,
289 #[source]
290 source: std::io::Error,
291 },
292 #[error("{path}: not valid varve.toml")]
293 Toml {
294 path: String,
295 #[source]
296 source: Box<toml::de::Error>,
297 },
298 #[error("{path}: manifest-version {found} is not supported (this varve understands version 1)")]
299 UnsupportedManifestVersion { path: String, found: i64 },
300 #[error("{path}: invalid layer identifier")]
304 Layer {
305 path: String,
306 #[source]
307 source: LayerIdError,
308 },
309 #[error(
310 "{path}: digest '{found}' is not a valid digest: expected 'sha256:' followed by 64 hex characters"
311 )]
312 MalformedDigest { path: String, found: String },
313 #[error(
314 "{path}: tools entry {name:?} is neither a tool name nor a realm-qualified one — \
315 a tool is looked up INSIDE the verified composition, so a path would resolve \
316 outside it. Write either tools = [\"rivet\"] or, where two realms ship one name, \
317 tools = [\"bytecodealliance/wasm-tools\"]."
318 )]
319 ToolNameIsAPath { path: String, name: String },
320 #[error("{path}: tools list is present but empty — omit it to select every tool in the layer")]
321 EmptyTools { path: String },
322 #[error(
323 "{path}: export kind {kind:?} is not one this varve can produce — expected one of {expected}"
324 )]
325 UnknownExportKind {
326 path: String,
327 kind: String,
328 expected: String,
329 },
330 #[error(
331 "{path}: export destination {out:?} is not usable ({why}) — an export directory is \
332 RELATIVE to the directory holding varve.toml, so the declaration travels with the \
333 repository and means the same thing on every machine"
334 )]
335 ExportOutEscapes {
336 path: String,
337 out: String,
338 why: String,
339 },
340 #[error(
341 "{path}: exports {first:?} and {second:?} both write to {out:?} — the second would \
342 overwrite the first's stamp, and `verify` would then check one export twice while \
343 never checking the other at all"
344 )]
345 DuplicateExportOut {
346 path: String,
347 out: String,
348 first: String,
349 second: String,
350 },
351 #[error(
352 "{path}: export to {out:?} has an empty select list — omit it to export the whole layer"
353 )]
354 EmptyExportSelect { path: String, out: String },
355 #[error(
356 "{path}: export to {out:?} selects {name:?}, which is not a plain payload name — a \
357 selection indexes the VERIFIED layer, so a path would reach outside it"
358 )]
359 ExportSelectIsAPath {
360 path: String,
361 out: String,
362 name: String,
363 },
364 #[error(
365 "{path}: export to {out:?} is a {kind} export, which is consumed by POINTING at it, not \
366 by sourcing it — an [export.env] here would be accepted, ignored, and believed. Only \
367 these kinds are entered as an environment: {sourced}"
368 )]
369 ExportEnvNotSourced {
370 path: String,
371 out: String,
372 kind: String,
373 sourced: String,
374 },
375 #[error(
376 "{path}: export to {out:?} declares an environment but not where it sits relative to \
377 varve's shims. Add `path = \"before-shims\"` if this environment's bin is meant to win \
378 on PATH, or `path = \"after-shims\"` if varve's pinned tools are. Undeclared, `verify` \
379 cannot tell a legitimate sourced SDK from a hijacked PATH (REQ-SHADOW-001), and \
380 guessing wrong either misses a real one or cries wolf on a correct setup"
381 )]
382 ExportEnvNeedsShimOrder { path: String, out: String },
383 #[error(
384 "{path}: export to {out:?} declares path = {found:?} — expected \"before-shims\" or \
385 \"after-shims\""
386 )]
387 UnknownShimOrder {
388 path: String,
389 out: String,
390 found: String,
391 },
392 #[error(
393 "{path}: export to {out:?} sources {script:?}, which is not usable ({why}) — the script \
394 is relative to the export directory, and it must stay inside it"
395 )]
396 ExportScriptEscapes {
397 path: String,
398 out: String,
399 script: String,
400 why: String,
401 },
402}
403
404#[derive(Deserialize)]
405#[serde(deny_unknown_fields)]
406struct RawPin {
407 #[serde(rename = "manifest-version")]
408 manifest_version: i64,
409 toolchain: RawToolchain,
410 #[serde(default, rename = "export")]
412 exports: Vec<RawExport>,
413}
414
415#[derive(Deserialize)]
416#[serde(deny_unknown_fields)]
417struct RawExport {
418 kind: String,
419 out: String,
420 select: Option<Vec<String>>,
421 env: Option<RawExportEnv>,
422}
423
424#[derive(Deserialize)]
425#[serde(deny_unknown_fields)]
426struct RawExportEnv {
427 script: String,
428 path: Option<String>,
433}
434
435#[derive(Deserialize)]
436#[serde(deny_unknown_fields)]
437struct RawToolchain {
438 #[serde(default)]
439 realm: Option<String>,
440 channel: Channel,
441 layer: String,
442 digest: Option<String>,
443 tools: Option<Vec<String>>,
444}
445
446impl Pin {
447 pub fn parse(content: &str, origin: &str) -> Result<Self, PinError> {
450 let raw: RawPin = toml::from_str(content).map_err(|source| PinError::Toml {
451 path: origin.to_string(),
452 source: Box::new(source),
453 })?;
454 if raw.manifest_version != 1 {
455 return Err(PinError::UnsupportedManifestVersion {
456 path: origin.to_string(),
457 found: raw.manifest_version,
458 });
459 }
460 let layer = LayerId::from_str(&raw.toolchain.layer).map_err(|source| PinError::Layer {
461 path: origin.to_string(),
462 source,
463 })?;
464 if let Some(digest) = &raw.toolchain.digest {
465 let hex = digest
466 .strip_prefix("sha256:")
467 .ok_or_else(|| PinError::MalformedDigest {
468 path: origin.to_string(),
469 found: digest.clone(),
470 })?;
471 if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
472 return Err(PinError::MalformedDigest {
473 path: origin.to_string(),
474 found: digest.clone(),
475 });
476 }
477 }
478 let tools = match &raw.toolchain.tools {
479 None => None,
480 Some(entries) => {
481 if entries.is_empty() {
482 return Err(PinError::EmptyTools {
483 path: origin.to_string(),
484 });
485 }
486 let mut selectors = Vec::with_capacity(entries.len());
492 for entry in entries {
493 let Some(selector) = ToolSelector::parse(entry) else {
494 return Err(PinError::ToolNameIsAPath {
495 path: origin.to_string(),
496 name: entry.clone(),
497 });
498 };
499 selectors.push(selector);
500 }
501 Some(selectors)
502 }
503 };
504 let exports = parse_exports(&raw.exports, origin)?;
505 Ok(Pin {
506 realm: raw.toolchain.realm,
507 channel: raw.toolchain.channel,
508 layer,
509 digest: raw.toolchain.digest,
510 tools,
511 exports,
512 })
513 }
514
515 pub fn load(path: &Path) -> Result<Self, PinError> {
517 let content = std::fs::read_to_string(path).map_err(|source| PinError::Io {
518 path: path.display().to_string(),
519 source,
520 })?;
521 Self::parse(&content, &path.display().to_string())
522 }
523}
524
525fn contained_relative_fault(value: &str) -> Option<String> {
533 if value.is_empty() {
534 return Some("empty".into());
535 }
536 if value.starts_with('/') || value.starts_with('\\') || value.contains(':') {
537 return Some("absolute".into());
538 }
539 if value.contains('\0') {
540 return Some("contains a NUL".into());
541 }
542 for component in value.split(['/', '\\']) {
543 if component == ".." {
544 return Some("climbs out with '..'".into());
545 }
546 }
547 if value.split(['/', '\\']).all(|c| c.is_empty() || c == ".") {
548 return Some("names no directory".into());
549 }
550 None
551}
552
553fn parse_exports(raw: &[RawExport], origin: &str) -> Result<Vec<ExportDecl>, PinError> {
558 let mut decls: Vec<ExportDecl> = Vec::with_capacity(raw.len());
559 for e in raw {
560 let kind = ExportKind::from_str(&e.kind).map_err(|()| PinError::UnknownExportKind {
561 path: origin.to_string(),
562 kind: e.kind.clone(),
563 expected: ExportKind::ALL
564 .iter()
565 .map(|k| k.as_str())
566 .collect::<Vec<_>>()
567 .join(", "),
568 })?;
569 if let Some(why) = contained_relative_fault(&e.out) {
570 return Err(PinError::ExportOutEscapes {
571 path: origin.to_string(),
572 out: e.out.clone(),
573 why,
574 });
575 }
576 if let Some(first) = decls.iter().find(|d| d.out == e.out) {
577 return Err(PinError::DuplicateExportOut {
578 path: origin.to_string(),
579 out: e.out.clone(),
580 first: first.kind.to_string(),
581 second: kind.to_string(),
582 });
583 }
584 if let Some(select) = &e.select {
585 if select.is_empty() {
586 return Err(PinError::EmptyExportSelect {
587 path: origin.to_string(),
588 out: e.out.clone(),
589 });
590 }
591 for name in select {
592 let plain = !name.is_empty()
593 && name != "."
594 && name != ".."
595 && !name.contains('/')
596 && !name.contains('\\')
597 && !name.contains('\0');
598 if !plain {
599 return Err(PinError::ExportSelectIsAPath {
600 path: origin.to_string(),
601 out: e.out.clone(),
602 name: name.clone(),
603 });
604 }
605 }
606 }
607 let env = match &e.env {
608 None => None,
609 Some(raw_env) => {
610 if !kind.is_sourced() {
611 return Err(PinError::ExportEnvNotSourced {
612 path: origin.to_string(),
613 out: e.out.clone(),
614 kind: kind.to_string(),
615 sourced: ExportKind::ALL
616 .iter()
617 .filter(|k| k.is_sourced())
618 .map(|k| k.as_str())
619 .collect::<Vec<_>>()
620 .join(", "),
621 });
622 }
623 if let Some(why) = contained_relative_fault(&raw_env.script) {
624 return Err(PinError::ExportScriptEscapes {
625 path: origin.to_string(),
626 out: e.out.clone(),
627 script: raw_env.script.clone(),
628 why,
629 });
630 }
631 let Some(order) = &raw_env.path else {
636 return Err(PinError::ExportEnvNeedsShimOrder {
637 path: origin.to_string(),
638 out: e.out.clone(),
639 });
640 };
641 let path = ShimOrder::from_str(order).map_err(|()| PinError::UnknownShimOrder {
642 path: origin.to_string(),
643 out: e.out.clone(),
644 found: order.clone(),
645 })?;
646 Some(ExportEnv {
647 script: raw_env.script.clone(),
648 path,
649 })
650 }
651 };
652 decls.push(ExportDecl {
653 kind,
654 out: e.out.clone(),
655 select: e.select.clone(),
656 env,
657 });
658 }
659 Ok(decls)
660}
661
662#[derive(Debug, PartialEq, Eq)]
664pub enum DeclaredExportStatus {
665 Current,
667 Missing,
674 Stale { stamped: String, current: String },
676 KindMismatch { declared: String, stamped: String },
680 Unreadable(String),
684}
685
686impl DeclaredExportStatus {
687 pub fn is_current(&self) -> bool {
688 matches!(self, DeclaredExportStatus::Current)
689 }
690}
691
692pub fn check_declared_export(
698 decl: &ExportDecl,
699 project_root: &Path,
700 current_manifest_digest: &str,
701) -> DeclaredExportStatus {
702 use crate::exportstamp::{ExportStampError, ExportStatus, read_stamp, status};
703 let dir = decl.dir(project_root);
704 match read_stamp(&dir) {
705 Err(ExportStampError::Missing(_)) => DeclaredExportStatus::Missing,
706 Err(other) => DeclaredExportStatus::Unreadable(other.to_string()),
707 Ok(stamp) => {
708 if stamp.kind != decl.kind.as_str() {
709 return DeclaredExportStatus::KindMismatch {
710 declared: decl.kind.as_str().to_string(),
711 stamped: stamp.kind,
712 };
713 }
714 match status(&stamp, current_manifest_digest) {
715 ExportStatus::Current => DeclaredExportStatus::Current,
716 ExportStatus::Stale { stamped, current } => {
717 DeclaredExportStatus::Stale { stamped, current }
718 }
719 }
720 }
721 }
722}
723
724pub fn env_lines(pin: &Pin, project_root: &Path, shim_env: Option<&Path>) -> Vec<String> {
735 let mut lines = Vec::new();
736 let sourced = |order: ShimOrder, lines: &mut Vec<String>| {
737 for decl in pin
738 .exports
739 .iter()
740 .filter(|d| d.env.as_ref().is_some_and(|e| e.path == order))
741 {
742 if let Some(script) = decl.env_script(project_root) {
743 lines.push(format!(
744 "# {} export {} — declared {} (REQ-EXPORTDECL-001 clause 5)",
745 decl.kind,
746 decl.out,
747 order.as_str()
748 ));
749 lines.push(format!(". \"{}\"", script.display()));
750 }
751 }
752 };
753 sourced(ShimOrder::AfterShims, &mut lines);
755 if let Some(env) = shim_env {
756 lines.push("# varve's shims".to_string());
757 lines.push(format!(". \"{}\"", env.display()));
758 }
759 sourced(ShimOrder::BeforeShims, &mut lines);
762 lines
763}
764
765#[derive(Debug, PartialEq, Eq)]
768pub enum ShadowDeclaration<'a> {
769 Expected(&'a ExportDecl),
773 ContradictsDeclaration(&'a ExportDecl),
777 Undeclared,
779}
780
781fn is_within(dir: &Path, path: &Path) -> bool {
786 let real = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
787 path.starts_with(dir) || real(path).starts_with(real(dir))
788}
789
790pub fn classify_shadowing<'a>(
797 pin: &'a Pin,
798 project_root: &Path,
799 found: &Path,
800) -> ShadowDeclaration<'a> {
801 for decl in &pin.exports {
802 let Some(env) = &decl.env else {
803 continue;
806 };
807 if is_within(&decl.dir(project_root), found) {
808 return match env.path {
809 ShimOrder::BeforeShims => ShadowDeclaration::Expected(decl),
810 ShimOrder::AfterShims => ShadowDeclaration::ContradictsDeclaration(decl),
811 };
812 }
813 }
814 ShadowDeclaration::Undeclared
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820
821 const FULL: &str = r#"
822manifest-version = 1
823
824[toolchain]
825channel = "qualified"
826layer = "2026.07.0"
827digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
828tools = ["rivet", "synth"]
829"#;
830
831 #[test]
833 fn parses_a_complete_pin() {
834 let pin = Pin::parse(FULL, "varve.toml").unwrap();
835 assert_eq!(pin.channel, Channel::Qualified);
836 assert_eq!(pin.layer, LayerId::from_str("2026.07.0").unwrap());
837 assert_eq!(
838 pin.digest.as_deref(),
839 Some("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
840 );
841 assert_eq!(
844 pin.tools
845 .as_deref()
846 .map(|t| t.iter().map(ToolSelector::to_string).collect::<Vec<_>>()),
847 Some(vec!["rivet".to_string(), "synth".to_string()])
848 );
849 assert!(
850 pin.tools
851 .as_deref()
852 .unwrap()
853 .iter()
854 .all(|t| t.realm.is_none()),
855 "a bare name carries no realm choice"
856 );
857 }
858
859 #[test]
861 fn digest_and_tools_are_optional() {
862 let pin = Pin::parse(
863 "manifest-version = 1\n[toolchain]\nchannel = \"rolling\"\nlayer = \"2026.08.0\"\n",
864 "varve.toml",
865 )
866 .unwrap();
867 assert_eq!(pin.channel, Channel::Rolling);
868 assert_eq!(pin.digest, None);
869 assert_eq!(pin.tools, None);
870 }
871
872 #[test]
874 fn rejects_unsupported_manifest_version() {
875 let err = Pin::parse(
876 "manifest-version = 2\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
877 "varve.toml",
878 )
879 .unwrap_err();
880 assert!(
881 matches!(err, PinError::UnsupportedManifestVersion { found: 2, .. }),
882 "got: {err}"
883 );
884 }
885
886 #[test]
888 fn rejects_two_part_layer_with_the_grammar_guidance() {
889 let err = Pin::parse(
890 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07\"\n",
891 "varve.toml",
892 )
893 .unwrap_err();
894 let PinError::Layer { source, .. } = &err else {
895 panic!("got: {err}");
896 };
897 assert!(matches!(source, LayerIdError::MissingPatch(_)));
898 assert!(
900 source.to_string().contains("three-part"),
901 "the chain must teach the grammar: {source}"
902 );
903 }
904
905 #[test]
907 fn rejects_unknown_keys_instead_of_ignoring_them() {
908 let err = Pin::parse(
909 "manifest-version = 1\nsurprise = true\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
910 "varve.toml",
911 )
912 .unwrap_err();
913 assert!(matches!(err, PinError::Toml { .. }), "got: {err}");
914 }
915
916 #[test]
918 fn rejects_unknown_channel() {
919 let err = Pin::parse(
920 "manifest-version = 1\n[toolchain]\nchannel = \"latest\"\nlayer = \"2026.07.0\"\n",
921 "varve.toml",
922 )
923 .unwrap_err();
924 assert!(matches!(err, PinError::Toml { .. }), "got: {err}");
925 }
926
927 #[test]
929 fn rejects_malformed_digest() {
930 for bad in [
933 "sha256:short",
934 "md5:aaaa",
935 "aaaaaaaa",
936 "sha256:GGGG",
937 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
938 ] {
939 let toml = format!(
940 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ndigest = \"{bad}\"\n"
941 );
942 let err = Pin::parse(&toml, "varve.toml").unwrap_err();
943 assert!(
944 matches!(err, PinError::MalformedDigest { .. }),
945 "input {bad:?} got: {err}"
946 );
947 }
948 }
949
950 #[test]
952 fn rejects_a_tool_name_that_is_a_path() {
953 for hostile in [
962 "/usr/bin/id",
963 "../../usr/bin/id",
964 "sub/dir/deeper",
965 "/rivet",
966 "acme/",
967 "../rivet",
968 "./rivet",
969 "acme/..",
970 "../acme/rivet",
971 "..",
972 ".",
973 "",
974 "C:\\Windows\\system32\\cmd.exe",
975 "acme\\rivet",
976 ] {
977 let content = format!(
978 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"{}\"]\n",
979 hostile.replace('\\', "\\\\")
980 );
981 assert!(
982 Pin::parse(&content, "varve.toml").is_err(),
983 "tools entry {hostile:?} must be refused — it escapes the layer"
984 );
985 }
986 let ok = "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"rivet\", \"synth-c\", \"cargo_x\"]\n";
988 assert!(Pin::parse(ok, "varve.toml").is_ok());
989 }
990
991 #[test]
993 fn tools_accepts_a_realm_qualifier_beside_a_bare_name() {
994 let pin = Pin::parse(
998 "manifest-version = 1\n[toolchain]\nrealm = \"pulseengine\"\nchannel = \"qualified\"\n\
999 layer = \"2026.09.0\"\ntools = [\"bytecodealliance/wasm-tools\", \"rivet\"]\n",
1000 "varve.toml",
1001 )
1002 .unwrap();
1003 let tools = pin.tools.unwrap();
1004 assert_eq!(tools[0].realm.as_deref(), Some("bytecodealliance"));
1005 assert_eq!(tools[0].name, "wasm-tools");
1006 assert_eq!(tools[0].to_string(), "bytecodealliance/wasm-tools");
1007 assert_eq!(tools[1].realm, None);
1008 assert_eq!(tools[1].name, "rivet");
1009 }
1010
1011 #[test]
1013 fn the_refusal_for_a_path_shows_both_forms_that_are_accepted() {
1014 let err = Pin::parse(
1018 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n\
1019 tools = [\"/usr/bin/id\"]\n",
1020 "varve.toml",
1021 )
1022 .unwrap_err();
1023 let msg = err.to_string();
1024 assert!(matches!(err, PinError::ToolNameIsAPath { .. }), "{msg}");
1025 assert!(
1026 msg.contains("tools = [\"rivet\"]")
1027 && msg.contains("tools = [\"bytecodealliance/wasm-tools\"]"),
1028 "both accepted forms must be shown: {msg}"
1029 );
1030 }
1031
1032 #[test]
1034 fn rejects_empty_tools_list() {
1035 let err = Pin::parse(
1036 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = []\n",
1037 "varve.toml",
1038 )
1039 .unwrap_err();
1040 assert!(matches!(err, PinError::EmptyTools { .. }), "got: {err}");
1041 }
1042
1043 #[test]
1045 fn errors_name_the_offending_file() {
1046 let err = Pin::parse("nonsense", "proj/sub/varve.toml").unwrap_err();
1047 assert!(
1048 err.to_string().contains("proj/sub/varve.toml"),
1049 "diagnostic must carry the path: {err}"
1050 );
1051 }
1052}
1053
1054#[cfg(test)]
1055mod export_tests {
1056 use super::*;
1057 use crate::exportstamp::{ExportStamp, write_stamp};
1058
1059 const HEAD: &str =
1060 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.08.0\"\n";
1061
1062 fn parse(exports: &str) -> Result<Pin, PinError> {
1063 Pin::parse(&format!("{HEAD}{exports}"), "varve.toml")
1064 }
1065
1066 const DECLARED: &str = r#"
1069[[export]]
1070kind = "cargo"
1071out = "vendor/registry"
1072
1073[[export]]
1074kind = "vsix"
1075out = ".vscode/varve-extensions"
1076select = ["rust-lang.rust-analyzer", "vadimcn.vscode-lldb"]
1077
1078[[export]]
1079kind = "sdk"
1080out = "toolchains/poky"
1081select = ["poky-cortexa53"]
1082
1083[export.env]
1084script = "environment-setup-cortexa53-poky-linux"
1085path = "before-shims"
1086"#;
1087
1088 #[test]
1090 fn a_project_declares_its_exports_in_the_pin_it_already_has() {
1091 let pin = parse(DECLARED).unwrap();
1095 assert_eq!(pin.exports.len(), 3);
1096 assert_eq!(pin.exports[0].kind, ExportKind::Cargo);
1097 assert_eq!(pin.exports[0].out, "vendor/registry");
1098 assert_eq!(
1099 pin.exports[0].select, None,
1100 "no subset means the whole layer"
1101 );
1102 assert_eq!(pin.exports[0].env, None);
1103
1104 assert_eq!(pin.exports[1].kind, ExportKind::Vsix);
1106 assert_eq!(
1107 pin.exports[1].select.as_deref(),
1108 Some(
1109 &[
1110 "rust-lang.rust-analyzer".to_string(),
1111 "vadimcn.vscode-lldb".to_string()
1112 ][..]
1113 )
1114 );
1115
1116 let sdk = &pin.exports[2];
1118 assert_eq!(sdk.kind, ExportKind::Sdk);
1119 let env = sdk.env.as_ref().expect("an sdk is entered, not pointed at");
1120 assert_eq!(env.script, "environment-setup-cortexa53-poky-linux");
1121 assert_eq!(env.path, ShimOrder::BeforeShims);
1122
1123 let root = Path::new("/repo");
1126 assert_eq!(sdk.dir(root), Path::new("/repo/toolchains/poky"));
1127 assert_eq!(
1128 sdk.env_script(root).unwrap(),
1129 Path::new("/repo/toolchains/poky/environment-setup-cortexa53-poky-linux")
1130 );
1131 assert_eq!(pin.exports[0].env_script(root), None);
1132
1133 assert!(Pin::parse(HEAD, "varve.toml").unwrap().exports.is_empty());
1136 }
1137
1138 #[test]
1140 fn the_declared_kind_is_one_varve_can_actually_produce() {
1141 let err = parse("[[export]]\nkind = \"npm\"\nout = \"x\"\n").unwrap_err();
1146 let msg = err.to_string();
1147 assert!(matches!(err, PinError::UnknownExportKind { .. }), "{msg}");
1148 for known in ExportKind::ALL {
1149 assert!(
1150 msg.contains(known.as_str()),
1151 "the refusal must list {known}, or the author has nothing to correct to: {msg}"
1152 );
1153 }
1154 for (kind, wire) in [
1157 (ExportKind::Cargo, "cargo"),
1158 (ExportKind::CratesVendor, "crates-vendor"),
1159 (ExportKind::BazelRegistry, "bazel-registry"),
1160 (ExportKind::BazelDistdir, "bazel-distdir"),
1161 (ExportKind::Vsix, "vsix"),
1162 (ExportKind::Sdk, "sdk"),
1163 ] {
1164 assert_eq!(kind.as_str(), wire);
1165 assert_eq!(ExportKind::from_str(wire).unwrap(), kind);
1166 }
1167 assert_eq!(ExportKind::ALL.len(), 6, "ALL must list every variant");
1168 }
1169
1170 #[test]
1172 fn a_destination_that_leaves_the_repository_is_refused() {
1173 for bad in [
1179 "/etc",
1180 "../outside",
1181 "a/../../outside",
1182 "",
1183 ".",
1184 "./",
1185 "C:\\x",
1186 ] {
1187 let err = parse(&format!(
1188 "[[export]]\nkind = \"cargo\"\nout = \"{}\"\n",
1189 bad.replace('\\', "\\\\")
1190 ))
1191 .unwrap_err();
1192 assert!(
1193 matches!(err, PinError::ExportOutEscapes { .. }),
1194 "out {bad:?} must be refused, got: {err}"
1195 );
1196 }
1197 for good in ["vendor", "vendor/registry", "a/b/c"] {
1198 assert!(
1199 parse(&format!("[[export]]\nkind = \"cargo\"\nout = \"{good}\"\n")).is_ok(),
1200 "{good} is an ordinary export directory"
1201 );
1202 }
1203 }
1204
1205 #[test]
1207 fn two_exports_may_not_share_one_directory() {
1208 let err = parse(
1213 "[[export]]\nkind = \"cargo\"\nout = \"vendor\"\n\
1214 [[export]]\nkind = \"vsix\"\nout = \"vendor\"\n",
1215 )
1216 .unwrap_err();
1217 assert!(
1218 matches!(err, PinError::DuplicateExportOut { .. }),
1219 "got: {err}"
1220 );
1221 let msg = err.to_string();
1222 assert!(
1223 msg.contains("cargo") && msg.contains("vsix"),
1224 "names both: {msg}"
1225 );
1226 }
1227
1228 #[test]
1230 fn a_subset_selection_names_payloads_not_paths() {
1231 for bad in ["../evil", "a/b", "", ".", "..", "a\\b"] {
1235 let err = parse(&format!(
1236 "[[export]]\nkind = \"cargo\"\nout = \"v\"\nselect = [\"{}\"]\n",
1237 bad.replace('\\', "\\\\")
1238 ))
1239 .unwrap_err();
1240 assert!(
1241 matches!(err, PinError::ExportSelectIsAPath { .. }),
1242 "select {bad:?} must be refused, got: {err}"
1243 );
1244 }
1245 let err = parse("[[export]]\nkind = \"cargo\"\nout = \"v\"\nselect = []\n").unwrap_err();
1248 assert!(
1249 matches!(err, PinError::EmptyExportSelect { .. }),
1250 "got: {err}"
1251 );
1252 }
1253
1254 #[test]
1256 fn an_environment_must_say_where_it_sits_relative_to_the_shims() {
1257 let err = parse(
1264 "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"env-setup\"\n",
1265 )
1266 .unwrap_err();
1267 assert!(
1268 matches!(err, PinError::ExportEnvNeedsShimOrder { .. }),
1269 "got: {err}"
1270 );
1271 let msg = err.to_string();
1272 assert!(msg.contains("before-shims"), "offers the answers: {msg}");
1273 assert!(msg.contains("after-shims"), "offers the answers: {msg}");
1274 assert!(
1275 msg.contains("REQ-SHADOW-001"),
1276 "says WHY it is needed, not just that it is: {msg}"
1277 );
1278
1279 for (value, want) in [
1281 ("before-shims", ShimOrder::BeforeShims),
1282 ("after-shims", ShimOrder::AfterShims),
1283 ] {
1284 let pin = parse(&format!(
1285 "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"e\"\npath = \"{value}\"\n"
1286 ))
1287 .unwrap();
1288 assert_eq!(pin.exports[0].env.as_ref().unwrap().path, want);
1289 assert_eq!(want.as_str(), value);
1290 }
1291 let err = parse(
1292 "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"e\"\npath = \"first\"\n",
1293 )
1294 .unwrap_err();
1295 assert!(
1296 matches!(err, PinError::UnknownShimOrder { .. }),
1297 "got: {err}"
1298 );
1299 }
1300
1301 #[test]
1303 fn only_an_export_that_is_sourced_may_declare_an_environment() {
1304 let err = parse(
1308 "[[export]]\nkind = \"cargo\"\nout = \"v\"\n[export.env]\nscript = \"e\"\npath = \"after-shims\"\n",
1309 )
1310 .unwrap_err();
1311 assert!(
1312 matches!(err, PinError::ExportEnvNotSourced { .. }),
1313 "got: {err}"
1314 );
1315 assert!(
1316 err.to_string().contains("sdk"),
1317 "names what IS sourced: {err}"
1318 );
1319 assert!(ExportKind::Sdk.is_sourced());
1320 for pointed in ExportKind::ALL.iter().filter(|k| **k != ExportKind::Sdk) {
1321 assert!(
1322 !pointed.is_sourced(),
1323 "{pointed} is consumed by pointing at it, not by sourcing it"
1324 );
1325 }
1326 let err = parse(
1328 "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"../../etc/profile\"\npath = \"after-shims\"\n",
1329 )
1330 .unwrap_err();
1331 assert!(
1332 matches!(err, PinError::ExportScriptEscapes { .. }),
1333 "got: {err}"
1334 );
1335 }
1336
1337 fn stamped(dir: &std::path::Path, kind: &str, digest: &str) {
1338 write_stamp(
1339 dir,
1340 &ExportStamp {
1341 layer: "2026.08.0".into(),
1342 manifest_digest: digest.into(),
1343 kind: kind.into(),
1344 },
1345 )
1346 .unwrap();
1347 }
1348
1349 #[test]
1351 fn every_declared_export_is_checked_and_an_absent_one_fails() {
1352 let tmp = tempfile::tempdir().unwrap();
1357 let root = tmp.path();
1358 let pin = parse(DECLARED).unwrap();
1359 let current = "sha256:aaaa";
1360
1361 for decl in &pin.exports {
1363 assert_eq!(
1364 check_declared_export(decl, root, current),
1365 DeclaredExportStatus::Missing,
1366 "{} must fail while it does not exist",
1367 decl.out
1368 );
1369 }
1370
1371 for decl in &pin.exports {
1373 stamped(&decl.dir(root), decl.kind.as_str(), current);
1374 let got = check_declared_export(decl, root, current);
1375 assert!(
1376 got.is_current(),
1377 "{} should be fresh, got {got:?}",
1378 decl.out
1379 );
1380 }
1381
1382 let moved = "sha256:bbbb";
1384 assert_eq!(
1385 check_declared_export(&pin.exports[0], root, moved),
1386 DeclaredExportStatus::Stale {
1387 stamped: current.into(),
1388 current: moved.into(),
1389 }
1390 );
1391
1392 let vsix = &pin.exports[1];
1396 stamped(&vsix.dir(root), "cargo", current);
1397 assert_eq!(
1398 check_declared_export(vsix, root, current),
1399 DeclaredExportStatus::KindMismatch {
1400 declared: "vsix".into(),
1401 stamped: "cargo".into(),
1402 }
1403 );
1404
1405 let cargo = &pin.exports[0];
1408 std::fs::write(
1409 cargo.dir(root).join(crate::exportstamp::STAMP_FILE),
1410 b"{not json",
1411 )
1412 .unwrap();
1413 assert!(matches!(
1414 check_declared_export(cargo, root, current),
1415 DeclaredExportStatus::Unreadable(_)
1416 ));
1417 }
1418
1419 #[test]
1421 fn is_current_is_false_for_every_status_that_is_not_current() {
1422 assert!(DeclaredExportStatus::Current.is_current());
1428 for status in [
1429 DeclaredExportStatus::Missing,
1430 DeclaredExportStatus::Stale {
1431 stamped: "sha256:aaaa".into(),
1432 current: "sha256:bbbb".into(),
1433 },
1434 DeclaredExportStatus::KindMismatch {
1435 declared: "vsix".into(),
1436 stamped: "cargo".into(),
1437 },
1438 DeclaredExportStatus::Unreadable("truncated".into()),
1439 ] {
1440 assert!(
1441 !status.is_current(),
1442 "{status:?} must not report itself current"
1443 );
1444 }
1445 }
1446
1447 #[test]
1449 fn an_export_reached_through_a_symlink_is_the_same_export() {
1450 let tmp = tempfile::tempdir().unwrap();
1458 let real_dir = tmp.path().join("real/export");
1459 std::fs::create_dir_all(real_dir.join("bin")).unwrap();
1463 std::fs::write(real_dir.join("bin/gcc"), b"#!/bin/sh\n").unwrap();
1464 let link = tmp.path().join("link");
1465 #[cfg(unix)]
1466 std::os::unix::fs::symlink(tmp.path().join("real"), &link).unwrap();
1467 #[cfg(not(unix))]
1468 return;
1469
1470 let through_link = link.join("export/bin/gcc");
1472 assert!(
1473 !through_link.starts_with(&real_dir),
1474 "the fixture must be lexically outside, or it proves nothing"
1475 );
1476 assert!(
1477 is_within(&real_dir, &through_link),
1478 "an export reached through a symlinked checkout is the same export"
1479 );
1480
1481 assert!(!is_within(&real_dir, &tmp.path().join("elsewhere/bin/gcc")));
1484 }
1485
1486 #[test]
1488 fn a_declared_sdk_environment_is_not_reported_as_a_hijack() {
1489 let root = Path::new("/repo");
1494 let pin = parse(DECLARED).unwrap();
1495 let sdk_gcc = Path::new("/repo/toolchains/poky/sysroots/x86_64/usr/bin/gcc");
1496 match classify_shadowing(&pin, root, sdk_gcc) {
1497 ShadowDeclaration::Expected(d) => assert_eq!(d.out, "toolchains/poky"),
1498 other => panic!("a declared before-shims SDK must be expected, got {other:?}"),
1499 }
1500
1501 assert_eq!(
1504 classify_shadowing(&pin, root, Path::new("/usr/local/bin/gcc")),
1505 ShadowDeclaration::Undeclared
1506 );
1507 assert_eq!(
1510 classify_shadowing(&pin, root, Path::new("/repo/vendor/registry/gcc")),
1511 ShadowDeclaration::Undeclared
1512 );
1513
1514 let after = parse(
1518 "[[export]]\nkind = \"sdk\"\nout = \"toolchains/poky\"\n[export.env]\nscript = \"e\"\npath = \"after-shims\"\n",
1519 )
1520 .unwrap();
1521 match classify_shadowing(&after, root, sdk_gcc) {
1522 ShadowDeclaration::ContradictsDeclaration(d) => {
1523 assert_eq!(d.out, "toolchains/poky")
1524 }
1525 other => panic!("expected ContradictsDeclaration, got {other:?}"),
1526 }
1527 }
1528
1529 #[test]
1531 fn one_command_sources_the_whole_environment_in_the_declared_path_order() {
1532 let root = Path::new("/repo");
1539 let shim_env = Path::new("/home/u/.varve/env");
1540 let pin = parse(
1541 "[[export]]\nkind = \"cargo\"\nout = \"vendor\"\n\
1542 [[export]]\nkind = \"sdk\"\nout = \"early\"\n\
1543 [export.env]\nscript = \"env-setup-early\"\npath = \"before-shims\"\n\
1544 [[export]]\nkind = \"sdk\"\nout = \"late\"\n\
1545 [export.env]\nscript = \"env-setup-late\"\npath = \"after-shims\"\n",
1546 )
1547 .unwrap();
1548 let sourced: Vec<String> = env_lines(&pin, root, Some(shim_env))
1549 .into_iter()
1550 .filter(|l| l.starts_with(". "))
1551 .collect();
1552 assert_eq!(
1553 sourced,
1554 vec![
1555 ". \"/repo/late/env-setup-late\"".to_string(),
1556 ". \"/home/u/.varve/env\"".to_string(),
1557 ". \"/repo/early/env-setup-early\"".to_string(),
1558 ],
1559 "after-shims is sourced FIRST so the shims land ahead of it"
1560 );
1561 assert!(
1563 !env_lines(&pin, root, Some(shim_env))
1564 .join("\n")
1565 .contains("vendor")
1566 );
1567 let no_shims: Vec<String> = env_lines(&pin, root, None)
1569 .into_iter()
1570 .filter(|l| l.starts_with(". "))
1571 .collect();
1572 assert_eq!(no_shims.len(), 2);
1573 assert!(!no_shims.iter().any(|l| l.contains(".varve/env")));
1574 }
1575}