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(
299 "{path}: manifest-version {found} is not supported (this varve understands version {})",
300 crate::consumer::PIN_MANIFEST_VERSION
301 )]
302 UnsupportedManifestVersion { path: String, found: i64 },
303 #[error("{path}: invalid layer identifier")]
307 Layer {
308 path: String,
309 #[source]
310 source: LayerIdError,
311 },
312 #[error(
313 "{path}: digest '{found}' is not a valid digest: expected 'sha256:' followed by 64 hex characters"
314 )]
315 MalformedDigest { path: String, found: String },
316 #[error(
317 "{path}: tools entry {name:?} is neither a tool name nor a realm-qualified one — \
318 a tool is looked up INSIDE the verified composition, so a path would resolve \
319 outside it. Write either tools = [\"rivet\"] or, where two realms ship one name, \
320 tools = [\"bytecodealliance/wasm-tools\"]."
321 )]
322 ToolNameIsAPath { path: String, name: String },
323 #[error("{path}: tools list is present but empty — omit it to select every tool in the layer")]
324 EmptyTools { path: String },
325 #[error(
326 "{path}: export kind {kind:?} is not one this varve can produce — expected one of {expected}"
327 )]
328 UnknownExportKind {
329 path: String,
330 kind: String,
331 expected: String,
332 },
333 #[error(
334 "{path}: export destination {out:?} is not usable ({why}) — an export directory is \
335 RELATIVE to the directory holding varve.toml, so the declaration travels with the \
336 repository and means the same thing on every machine"
337 )]
338 ExportOutEscapes {
339 path: String,
340 out: String,
341 why: String,
342 },
343 #[error(
344 "{path}: exports {first:?} and {second:?} both write to {out:?} — the second would \
345 overwrite the first's stamp, and `verify` would then check one export twice while \
346 never checking the other at all"
347 )]
348 DuplicateExportOut {
349 path: String,
350 out: String,
351 first: String,
352 second: String,
353 },
354 #[error(
355 "{path}: export to {out:?} has an empty select list — omit it to export the whole layer"
356 )]
357 EmptyExportSelect { path: String, out: String },
358 #[error(
359 "{path}: export to {out:?} selects {name:?}, which is not a plain payload name — a \
360 selection indexes the VERIFIED layer, so a path would reach outside it"
361 )]
362 ExportSelectIsAPath {
363 path: String,
364 out: String,
365 name: String,
366 },
367 #[error(
368 "{path}: export to {out:?} is a {kind} export, which is consumed by POINTING at it, not \
369 by sourcing it — an [export.env] here would be accepted, ignored, and believed. Only \
370 these kinds are entered as an environment: {sourced}"
371 )]
372 ExportEnvNotSourced {
373 path: String,
374 out: String,
375 kind: String,
376 sourced: String,
377 },
378 #[error(
379 "{path}: export to {out:?} declares an environment but not where it sits relative to \
380 varve's shims. Add `path = \"before-shims\"` if this environment's bin is meant to win \
381 on PATH, or `path = \"after-shims\"` if varve's pinned tools are. Undeclared, `verify` \
382 cannot tell a legitimate sourced SDK from a hijacked PATH (REQ-SHADOW-001), and \
383 guessing wrong either misses a real one or cries wolf on a correct setup"
384 )]
385 ExportEnvNeedsShimOrder { path: String, out: String },
386 #[error(
387 "{path}: export to {out:?} declares path = {found:?} — expected \"before-shims\" or \
388 \"after-shims\""
389 )]
390 UnknownShimOrder {
391 path: String,
392 out: String,
393 found: String,
394 },
395 #[error(
396 "{path}: export to {out:?} sources {script:?}, which is not usable ({why}) — the script \
397 is relative to the export directory, and it must stay inside it"
398 )]
399 ExportScriptEscapes {
400 path: String,
401 out: String,
402 script: String,
403 why: String,
404 },
405}
406
407#[derive(Deserialize)]
408#[serde(deny_unknown_fields)]
409struct RawPin {
410 #[serde(rename = "manifest-version")]
411 manifest_version: i64,
412 toolchain: RawToolchain,
413 #[serde(default, rename = "export")]
415 exports: Vec<RawExport>,
416}
417
418#[derive(Deserialize)]
419#[serde(deny_unknown_fields)]
420struct RawExport {
421 kind: String,
422 out: String,
423 select: Option<Vec<String>>,
424 env: Option<RawExportEnv>,
425}
426
427#[derive(Deserialize)]
428#[serde(deny_unknown_fields)]
429struct RawExportEnv {
430 script: String,
431 path: Option<String>,
436}
437
438#[derive(Deserialize)]
439#[serde(deny_unknown_fields)]
440struct RawToolchain {
441 #[serde(default)]
442 realm: Option<String>,
443 channel: Channel,
444 layer: String,
445 digest: Option<String>,
446 tools: Option<Vec<String>>,
447}
448
449impl Pin {
450 pub fn parse(content: &str, origin: &str) -> Result<Self, PinError> {
453 let raw: RawPin = toml::from_str(content).map_err(|source| PinError::Toml {
454 path: origin.to_string(),
455 source: Box::new(source),
456 })?;
457 if raw.manifest_version != crate::consumer::PIN_MANIFEST_VERSION {
463 return Err(PinError::UnsupportedManifestVersion {
464 path: origin.to_string(),
465 found: raw.manifest_version,
466 });
467 }
468 let layer = LayerId::from_str(&raw.toolchain.layer).map_err(|source| PinError::Layer {
469 path: origin.to_string(),
470 source,
471 })?;
472 if let Some(digest) = &raw.toolchain.digest {
473 let hex = digest
474 .strip_prefix("sha256:")
475 .ok_or_else(|| PinError::MalformedDigest {
476 path: origin.to_string(),
477 found: digest.clone(),
478 })?;
479 if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
480 return Err(PinError::MalformedDigest {
481 path: origin.to_string(),
482 found: digest.clone(),
483 });
484 }
485 }
486 let tools = match &raw.toolchain.tools {
487 None => None,
488 Some(entries) => {
489 if entries.is_empty() {
490 return Err(PinError::EmptyTools {
491 path: origin.to_string(),
492 });
493 }
494 let mut selectors = Vec::with_capacity(entries.len());
500 for entry in entries {
501 let Some(selector) = ToolSelector::parse(entry) else {
502 return Err(PinError::ToolNameIsAPath {
503 path: origin.to_string(),
504 name: entry.clone(),
505 });
506 };
507 selectors.push(selector);
508 }
509 Some(selectors)
510 }
511 };
512 let exports = parse_exports(&raw.exports, origin)?;
513 Ok(Pin {
514 realm: raw.toolchain.realm,
515 channel: raw.toolchain.channel,
516 layer,
517 digest: raw.toolchain.digest,
518 tools,
519 exports,
520 })
521 }
522
523 pub fn load(path: &Path) -> Result<Self, PinError> {
525 let content = std::fs::read_to_string(path).map_err(|source| PinError::Io {
526 path: path.display().to_string(),
527 source,
528 })?;
529 Self::parse(&content, &path.display().to_string())
530 }
531}
532
533fn contained_relative_fault(value: &str) -> Option<String> {
541 if value.is_empty() {
542 return Some("empty".into());
543 }
544 if value.starts_with('/') || value.starts_with('\\') || value.contains(':') {
545 return Some("absolute".into());
546 }
547 if value.contains('\0') {
548 return Some("contains a NUL".into());
549 }
550 for component in value.split(['/', '\\']) {
551 if component == ".." {
552 return Some("climbs out with '..'".into());
553 }
554 }
555 if value.split(['/', '\\']).all(|c| c.is_empty() || c == ".") {
556 return Some("names no directory".into());
557 }
558 None
559}
560
561fn parse_exports(raw: &[RawExport], origin: &str) -> Result<Vec<ExportDecl>, PinError> {
566 let mut decls: Vec<ExportDecl> = Vec::with_capacity(raw.len());
567 for e in raw {
568 let kind = ExportKind::from_str(&e.kind).map_err(|()| PinError::UnknownExportKind {
569 path: origin.to_string(),
570 kind: e.kind.clone(),
571 expected: ExportKind::ALL
572 .iter()
573 .map(|k| k.as_str())
574 .collect::<Vec<_>>()
575 .join(", "),
576 })?;
577 if let Some(why) = contained_relative_fault(&e.out) {
578 return Err(PinError::ExportOutEscapes {
579 path: origin.to_string(),
580 out: e.out.clone(),
581 why,
582 });
583 }
584 if let Some(first) = decls.iter().find(|d| d.out == e.out) {
585 return Err(PinError::DuplicateExportOut {
586 path: origin.to_string(),
587 out: e.out.clone(),
588 first: first.kind.to_string(),
589 second: kind.to_string(),
590 });
591 }
592 if let Some(select) = &e.select {
593 if select.is_empty() {
594 return Err(PinError::EmptyExportSelect {
595 path: origin.to_string(),
596 out: e.out.clone(),
597 });
598 }
599 for name in select {
600 let plain = !name.is_empty()
601 && name != "."
602 && name != ".."
603 && !name.contains('/')
604 && !name.contains('\\')
605 && !name.contains('\0');
606 if !plain {
607 return Err(PinError::ExportSelectIsAPath {
608 path: origin.to_string(),
609 out: e.out.clone(),
610 name: name.clone(),
611 });
612 }
613 }
614 }
615 let env = match &e.env {
616 None => None,
617 Some(raw_env) => {
618 if !kind.is_sourced() {
619 return Err(PinError::ExportEnvNotSourced {
620 path: origin.to_string(),
621 out: e.out.clone(),
622 kind: kind.to_string(),
623 sourced: ExportKind::ALL
624 .iter()
625 .filter(|k| k.is_sourced())
626 .map(|k| k.as_str())
627 .collect::<Vec<_>>()
628 .join(", "),
629 });
630 }
631 if let Some(why) = contained_relative_fault(&raw_env.script) {
632 return Err(PinError::ExportScriptEscapes {
633 path: origin.to_string(),
634 out: e.out.clone(),
635 script: raw_env.script.clone(),
636 why,
637 });
638 }
639 let Some(order) = &raw_env.path else {
644 return Err(PinError::ExportEnvNeedsShimOrder {
645 path: origin.to_string(),
646 out: e.out.clone(),
647 });
648 };
649 let path = ShimOrder::from_str(order).map_err(|()| PinError::UnknownShimOrder {
650 path: origin.to_string(),
651 out: e.out.clone(),
652 found: order.clone(),
653 })?;
654 Some(ExportEnv {
655 script: raw_env.script.clone(),
656 path,
657 })
658 }
659 };
660 decls.push(ExportDecl {
661 kind,
662 out: e.out.clone(),
663 select: e.select.clone(),
664 env,
665 });
666 }
667 Ok(decls)
668}
669
670#[derive(Debug, PartialEq, Eq)]
672pub enum DeclaredExportStatus {
673 Current,
675 Missing,
682 Stale { stamped: String, current: String },
684 KindMismatch { declared: String, stamped: String },
688 Unreadable(String),
692}
693
694impl DeclaredExportStatus {
695 pub fn is_current(&self) -> bool {
696 matches!(self, DeclaredExportStatus::Current)
697 }
698}
699
700pub fn check_declared_export(
706 decl: &ExportDecl,
707 project_root: &Path,
708 current_manifest_digest: &str,
709) -> DeclaredExportStatus {
710 use crate::exportstamp::{ExportStampError, ExportStatus, read_stamp, status};
711 let dir = decl.dir(project_root);
712 match read_stamp(&dir) {
713 Err(ExportStampError::Missing(_)) => DeclaredExportStatus::Missing,
714 Err(other) => DeclaredExportStatus::Unreadable(other.to_string()),
715 Ok(stamp) => {
716 if stamp.kind != decl.kind.as_str() {
717 return DeclaredExportStatus::KindMismatch {
718 declared: decl.kind.as_str().to_string(),
719 stamped: stamp.kind,
720 };
721 }
722 match status(&stamp, current_manifest_digest) {
723 ExportStatus::Current => DeclaredExportStatus::Current,
724 ExportStatus::Stale { stamped, current } => {
725 DeclaredExportStatus::Stale { stamped, current }
726 }
727 }
728 }
729 }
730}
731
732pub fn env_lines(pin: &Pin, project_root: &Path, shim_env: Option<&Path>) -> Vec<String> {
743 let mut lines = Vec::new();
744 let sourced = |order: ShimOrder, lines: &mut Vec<String>| {
745 for decl in pin
746 .exports
747 .iter()
748 .filter(|d| d.env.as_ref().is_some_and(|e| e.path == order))
749 {
750 if let Some(script) = decl.env_script(project_root) {
751 lines.push(format!(
752 "# {} export {} — declared {} (REQ-EXPORTDECL-001 clause 5)",
753 decl.kind,
754 decl.out,
755 order.as_str()
756 ));
757 lines.push(format!(". \"{}\"", script.display()));
758 }
759 }
760 };
761 sourced(ShimOrder::AfterShims, &mut lines);
763 if let Some(env) = shim_env {
764 lines.push("# varve's shims".to_string());
765 lines.push(format!(". \"{}\"", env.display()));
766 }
767 sourced(ShimOrder::BeforeShims, &mut lines);
770 lines
771}
772
773#[derive(Debug, PartialEq, Eq)]
776pub enum ShadowDeclaration<'a> {
777 Expected(&'a ExportDecl),
781 ContradictsDeclaration(&'a ExportDecl),
785 Undeclared,
787}
788
789fn is_within(dir: &Path, path: &Path) -> bool {
794 let real = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
795 path.starts_with(dir) || real(path).starts_with(real(dir))
796}
797
798pub fn classify_shadowing<'a>(
805 pin: &'a Pin,
806 project_root: &Path,
807 found: &Path,
808) -> ShadowDeclaration<'a> {
809 for decl in &pin.exports {
810 let Some(env) = &decl.env else {
811 continue;
814 };
815 if is_within(&decl.dir(project_root), found) {
816 return match env.path {
817 ShimOrder::BeforeShims => ShadowDeclaration::Expected(decl),
818 ShimOrder::AfterShims => ShadowDeclaration::ContradictsDeclaration(decl),
819 };
820 }
821 }
822 ShadowDeclaration::Undeclared
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828
829 const FULL: &str = r#"
830manifest-version = 1
831
832[toolchain]
833channel = "qualified"
834layer = "2026.07.0"
835digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
836tools = ["rivet", "synth"]
837"#;
838
839 #[test]
841 fn parses_a_complete_pin() {
842 let pin = Pin::parse(FULL, "varve.toml").unwrap();
843 assert_eq!(pin.channel, Channel::Qualified);
844 assert_eq!(pin.layer, LayerId::from_str("2026.07.0").unwrap());
845 assert_eq!(
846 pin.digest.as_deref(),
847 Some("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
848 );
849 assert_eq!(
852 pin.tools
853 .as_deref()
854 .map(|t| t.iter().map(ToolSelector::to_string).collect::<Vec<_>>()),
855 Some(vec!["rivet".to_string(), "synth".to_string()])
856 );
857 assert!(
858 pin.tools
859 .as_deref()
860 .unwrap()
861 .iter()
862 .all(|t| t.realm.is_none()),
863 "a bare name carries no realm choice"
864 );
865 }
866
867 #[test]
869 fn digest_and_tools_are_optional() {
870 let pin = Pin::parse(
871 "manifest-version = 1\n[toolchain]\nchannel = \"rolling\"\nlayer = \"2026.08.0\"\n",
872 "varve.toml",
873 )
874 .unwrap();
875 assert_eq!(pin.channel, Channel::Rolling);
876 assert_eq!(pin.digest, None);
877 assert_eq!(pin.tools, None);
878 }
879
880 #[test]
882 fn rejects_unsupported_manifest_version() {
883 let err = Pin::parse(
884 "manifest-version = 2\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
885 "varve.toml",
886 )
887 .unwrap_err();
888 assert!(
889 matches!(err, PinError::UnsupportedManifestVersion { found: 2, .. }),
890 "got: {err}"
891 );
892 }
893
894 #[test]
896 fn rejects_two_part_layer_with_the_grammar_guidance() {
897 let err = Pin::parse(
898 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07\"\n",
899 "varve.toml",
900 )
901 .unwrap_err();
902 let PinError::Layer { source, .. } = &err else {
903 panic!("got: {err}");
904 };
905 assert!(matches!(source, LayerIdError::MissingPatch(_)));
906 assert!(
908 source.to_string().contains("three-part"),
909 "the chain must teach the grammar: {source}"
910 );
911 }
912
913 #[test]
915 fn rejects_unknown_keys_instead_of_ignoring_them() {
916 let err = Pin::parse(
917 "manifest-version = 1\nsurprise = true\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
918 "varve.toml",
919 )
920 .unwrap_err();
921 assert!(matches!(err, PinError::Toml { .. }), "got: {err}");
922 }
923
924 #[test]
926 fn rejects_unknown_channel() {
927 let err = Pin::parse(
928 "manifest-version = 1\n[toolchain]\nchannel = \"latest\"\nlayer = \"2026.07.0\"\n",
929 "varve.toml",
930 )
931 .unwrap_err();
932 assert!(matches!(err, PinError::Toml { .. }), "got: {err}");
933 }
934
935 #[test]
937 fn rejects_malformed_digest() {
938 for bad in [
941 "sha256:short",
942 "md5:aaaa",
943 "aaaaaaaa",
944 "sha256:GGGG",
945 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
946 ] {
947 let toml = format!(
948 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ndigest = \"{bad}\"\n"
949 );
950 let err = Pin::parse(&toml, "varve.toml").unwrap_err();
951 assert!(
952 matches!(err, PinError::MalformedDigest { .. }),
953 "input {bad:?} got: {err}"
954 );
955 }
956 }
957
958 #[test]
960 fn rejects_a_tool_name_that_is_a_path() {
961 for hostile in [
970 "/usr/bin/id",
971 "../../usr/bin/id",
972 "sub/dir/deeper",
973 "/rivet",
974 "acme/",
975 "../rivet",
976 "./rivet",
977 "acme/..",
978 "../acme/rivet",
979 "..",
980 ".",
981 "",
982 "C:\\Windows\\system32\\cmd.exe",
983 "acme\\rivet",
984 ] {
985 let content = format!(
986 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"{}\"]\n",
987 hostile.replace('\\', "\\\\")
988 );
989 assert!(
990 Pin::parse(&content, "varve.toml").is_err(),
991 "tools entry {hostile:?} must be refused — it escapes the layer"
992 );
993 }
994 let ok = "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"rivet\", \"synth-c\", \"cargo_x\"]\n";
996 assert!(Pin::parse(ok, "varve.toml").is_ok());
997 }
998
999 #[test]
1001 fn tools_accepts_a_realm_qualifier_beside_a_bare_name() {
1002 let pin = Pin::parse(
1006 "manifest-version = 1\n[toolchain]\nrealm = \"pulseengine\"\nchannel = \"qualified\"\n\
1007 layer = \"2026.09.0\"\ntools = [\"bytecodealliance/wasm-tools\", \"rivet\"]\n",
1008 "varve.toml",
1009 )
1010 .unwrap();
1011 let tools = pin.tools.unwrap();
1012 assert_eq!(tools[0].realm.as_deref(), Some("bytecodealliance"));
1013 assert_eq!(tools[0].name, "wasm-tools");
1014 assert_eq!(tools[0].to_string(), "bytecodealliance/wasm-tools");
1015 assert_eq!(tools[1].realm, None);
1016 assert_eq!(tools[1].name, "rivet");
1017 }
1018
1019 #[test]
1021 fn the_refusal_for_a_path_shows_both_forms_that_are_accepted() {
1022 let err = Pin::parse(
1026 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n\
1027 tools = [\"/usr/bin/id\"]\n",
1028 "varve.toml",
1029 )
1030 .unwrap_err();
1031 let msg = err.to_string();
1032 assert!(matches!(err, PinError::ToolNameIsAPath { .. }), "{msg}");
1033 assert!(
1034 msg.contains("tools = [\"rivet\"]")
1035 && msg.contains("tools = [\"bytecodealliance/wasm-tools\"]"),
1036 "both accepted forms must be shown: {msg}"
1037 );
1038 }
1039
1040 #[test]
1042 fn rejects_empty_tools_list() {
1043 let err = Pin::parse(
1044 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = []\n",
1045 "varve.toml",
1046 )
1047 .unwrap_err();
1048 assert!(matches!(err, PinError::EmptyTools { .. }), "got: {err}");
1049 }
1050
1051 #[test]
1053 fn errors_name_the_offending_file() {
1054 let err = Pin::parse("nonsense", "proj/sub/varve.toml").unwrap_err();
1055 assert!(
1056 err.to_string().contains("proj/sub/varve.toml"),
1057 "diagnostic must carry the path: {err}"
1058 );
1059 }
1060}
1061
1062#[cfg(test)]
1063mod export_tests {
1064 use super::*;
1065 use crate::exportstamp::{ExportStamp, write_stamp};
1066
1067 const HEAD: &str =
1068 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.08.0\"\n";
1069
1070 fn parse(exports: &str) -> Result<Pin, PinError> {
1071 Pin::parse(&format!("{HEAD}{exports}"), "varve.toml")
1072 }
1073
1074 const DECLARED: &str = r#"
1077[[export]]
1078kind = "cargo"
1079out = "vendor/registry"
1080
1081[[export]]
1082kind = "vsix"
1083out = ".vscode/varve-extensions"
1084select = ["rust-lang.rust-analyzer", "vadimcn.vscode-lldb"]
1085
1086[[export]]
1087kind = "sdk"
1088out = "toolchains/poky"
1089select = ["poky-cortexa53"]
1090
1091[export.env]
1092script = "environment-setup-cortexa53-poky-linux"
1093path = "before-shims"
1094"#;
1095
1096 #[test]
1098 fn a_project_declares_its_exports_in_the_pin_it_already_has() {
1099 let pin = parse(DECLARED).unwrap();
1103 assert_eq!(pin.exports.len(), 3);
1104 assert_eq!(pin.exports[0].kind, ExportKind::Cargo);
1105 assert_eq!(pin.exports[0].out, "vendor/registry");
1106 assert_eq!(
1107 pin.exports[0].select, None,
1108 "no subset means the whole layer"
1109 );
1110 assert_eq!(pin.exports[0].env, None);
1111
1112 assert_eq!(pin.exports[1].kind, ExportKind::Vsix);
1114 assert_eq!(
1115 pin.exports[1].select.as_deref(),
1116 Some(
1117 &[
1118 "rust-lang.rust-analyzer".to_string(),
1119 "vadimcn.vscode-lldb".to_string()
1120 ][..]
1121 )
1122 );
1123
1124 let sdk = &pin.exports[2];
1126 assert_eq!(sdk.kind, ExportKind::Sdk);
1127 let env = sdk.env.as_ref().expect("an sdk is entered, not pointed at");
1128 assert_eq!(env.script, "environment-setup-cortexa53-poky-linux");
1129 assert_eq!(env.path, ShimOrder::BeforeShims);
1130
1131 let root = Path::new("/repo");
1134 assert_eq!(sdk.dir(root), Path::new("/repo/toolchains/poky"));
1135 assert_eq!(
1136 sdk.env_script(root).unwrap(),
1137 Path::new("/repo/toolchains/poky/environment-setup-cortexa53-poky-linux")
1138 );
1139 assert_eq!(pin.exports[0].env_script(root), None);
1140
1141 assert!(Pin::parse(HEAD, "varve.toml").unwrap().exports.is_empty());
1144 }
1145
1146 #[test]
1148 fn the_declared_kind_is_one_varve_can_actually_produce() {
1149 let err = parse("[[export]]\nkind = \"npm\"\nout = \"x\"\n").unwrap_err();
1154 let msg = err.to_string();
1155 assert!(matches!(err, PinError::UnknownExportKind { .. }), "{msg}");
1156 for known in ExportKind::ALL {
1157 assert!(
1158 msg.contains(known.as_str()),
1159 "the refusal must list {known}, or the author has nothing to correct to: {msg}"
1160 );
1161 }
1162 for (kind, wire) in [
1165 (ExportKind::Cargo, "cargo"),
1166 (ExportKind::CratesVendor, "crates-vendor"),
1167 (ExportKind::BazelRegistry, "bazel-registry"),
1168 (ExportKind::BazelDistdir, "bazel-distdir"),
1169 (ExportKind::Vsix, "vsix"),
1170 (ExportKind::Sdk, "sdk"),
1171 ] {
1172 assert_eq!(kind.as_str(), wire);
1173 assert_eq!(ExportKind::from_str(wire).unwrap(), kind);
1174 }
1175 assert_eq!(ExportKind::ALL.len(), 6, "ALL must list every variant");
1176 }
1177
1178 #[test]
1180 fn a_destination_that_leaves_the_repository_is_refused() {
1181 for bad in [
1187 "/etc",
1188 "../outside",
1189 "a/../../outside",
1190 "",
1191 ".",
1192 "./",
1193 "C:\\x",
1194 ] {
1195 let err = parse(&format!(
1196 "[[export]]\nkind = \"cargo\"\nout = \"{}\"\n",
1197 bad.replace('\\', "\\\\")
1198 ))
1199 .unwrap_err();
1200 assert!(
1201 matches!(err, PinError::ExportOutEscapes { .. }),
1202 "out {bad:?} must be refused, got: {err}"
1203 );
1204 }
1205 for good in ["vendor", "vendor/registry", "a/b/c"] {
1206 assert!(
1207 parse(&format!("[[export]]\nkind = \"cargo\"\nout = \"{good}\"\n")).is_ok(),
1208 "{good} is an ordinary export directory"
1209 );
1210 }
1211 }
1212
1213 #[test]
1215 fn two_exports_may_not_share_one_directory() {
1216 let err = parse(
1221 "[[export]]\nkind = \"cargo\"\nout = \"vendor\"\n\
1222 [[export]]\nkind = \"vsix\"\nout = \"vendor\"\n",
1223 )
1224 .unwrap_err();
1225 assert!(
1226 matches!(err, PinError::DuplicateExportOut { .. }),
1227 "got: {err}"
1228 );
1229 let msg = err.to_string();
1230 assert!(
1231 msg.contains("cargo") && msg.contains("vsix"),
1232 "names both: {msg}"
1233 );
1234 }
1235
1236 #[test]
1238 fn a_subset_selection_names_payloads_not_paths() {
1239 for bad in ["../evil", "a/b", "", ".", "..", "a\\b"] {
1243 let err = parse(&format!(
1244 "[[export]]\nkind = \"cargo\"\nout = \"v\"\nselect = [\"{}\"]\n",
1245 bad.replace('\\', "\\\\")
1246 ))
1247 .unwrap_err();
1248 assert!(
1249 matches!(err, PinError::ExportSelectIsAPath { .. }),
1250 "select {bad:?} must be refused, got: {err}"
1251 );
1252 }
1253 let err = parse("[[export]]\nkind = \"cargo\"\nout = \"v\"\nselect = []\n").unwrap_err();
1256 assert!(
1257 matches!(err, PinError::EmptyExportSelect { .. }),
1258 "got: {err}"
1259 );
1260 }
1261
1262 #[test]
1264 fn an_environment_must_say_where_it_sits_relative_to_the_shims() {
1265 let err = parse(
1272 "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"env-setup\"\n",
1273 )
1274 .unwrap_err();
1275 assert!(
1276 matches!(err, PinError::ExportEnvNeedsShimOrder { .. }),
1277 "got: {err}"
1278 );
1279 let msg = err.to_string();
1280 assert!(msg.contains("before-shims"), "offers the answers: {msg}");
1281 assert!(msg.contains("after-shims"), "offers the answers: {msg}");
1282 assert!(
1283 msg.contains("REQ-SHADOW-001"),
1284 "says WHY it is needed, not just that it is: {msg}"
1285 );
1286
1287 for (value, want) in [
1289 ("before-shims", ShimOrder::BeforeShims),
1290 ("after-shims", ShimOrder::AfterShims),
1291 ] {
1292 let pin = parse(&format!(
1293 "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"e\"\npath = \"{value}\"\n"
1294 ))
1295 .unwrap();
1296 assert_eq!(pin.exports[0].env.as_ref().unwrap().path, want);
1297 assert_eq!(want.as_str(), value);
1298 }
1299 let err = parse(
1300 "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"e\"\npath = \"first\"\n",
1301 )
1302 .unwrap_err();
1303 assert!(
1304 matches!(err, PinError::UnknownShimOrder { .. }),
1305 "got: {err}"
1306 );
1307 }
1308
1309 #[test]
1311 fn only_an_export_that_is_sourced_may_declare_an_environment() {
1312 let err = parse(
1316 "[[export]]\nkind = \"cargo\"\nout = \"v\"\n[export.env]\nscript = \"e\"\npath = \"after-shims\"\n",
1317 )
1318 .unwrap_err();
1319 assert!(
1320 matches!(err, PinError::ExportEnvNotSourced { .. }),
1321 "got: {err}"
1322 );
1323 assert!(
1324 err.to_string().contains("sdk"),
1325 "names what IS sourced: {err}"
1326 );
1327 assert!(ExportKind::Sdk.is_sourced());
1328 for pointed in ExportKind::ALL.iter().filter(|k| **k != ExportKind::Sdk) {
1329 assert!(
1330 !pointed.is_sourced(),
1331 "{pointed} is consumed by pointing at it, not by sourcing it"
1332 );
1333 }
1334 let err = parse(
1336 "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"../../etc/profile\"\npath = \"after-shims\"\n",
1337 )
1338 .unwrap_err();
1339 assert!(
1340 matches!(err, PinError::ExportScriptEscapes { .. }),
1341 "got: {err}"
1342 );
1343 }
1344
1345 fn stamped(dir: &std::path::Path, kind: &str, digest: &str) {
1346 write_stamp(
1347 dir,
1348 &ExportStamp {
1349 layer: "2026.08.0".into(),
1350 manifest_digest: digest.into(),
1351 kind: kind.into(),
1352 },
1353 )
1354 .unwrap();
1355 }
1356
1357 #[test]
1359 fn every_declared_export_is_checked_and_an_absent_one_fails() {
1360 let tmp = tempfile::tempdir().unwrap();
1365 let root = tmp.path();
1366 let pin = parse(DECLARED).unwrap();
1367 let current = "sha256:aaaa";
1368
1369 for decl in &pin.exports {
1371 assert_eq!(
1372 check_declared_export(decl, root, current),
1373 DeclaredExportStatus::Missing,
1374 "{} must fail while it does not exist",
1375 decl.out
1376 );
1377 }
1378
1379 for decl in &pin.exports {
1381 stamped(&decl.dir(root), decl.kind.as_str(), current);
1382 let got = check_declared_export(decl, root, current);
1383 assert!(
1384 got.is_current(),
1385 "{} should be fresh, got {got:?}",
1386 decl.out
1387 );
1388 }
1389
1390 let moved = "sha256:bbbb";
1392 assert_eq!(
1393 check_declared_export(&pin.exports[0], root, moved),
1394 DeclaredExportStatus::Stale {
1395 stamped: current.into(),
1396 current: moved.into(),
1397 }
1398 );
1399
1400 let vsix = &pin.exports[1];
1404 stamped(&vsix.dir(root), "cargo", current);
1405 assert_eq!(
1406 check_declared_export(vsix, root, current),
1407 DeclaredExportStatus::KindMismatch {
1408 declared: "vsix".into(),
1409 stamped: "cargo".into(),
1410 }
1411 );
1412
1413 let cargo = &pin.exports[0];
1416 std::fs::write(
1417 cargo.dir(root).join(crate::exportstamp::STAMP_FILE),
1418 b"{not json",
1419 )
1420 .unwrap();
1421 assert!(matches!(
1422 check_declared_export(cargo, root, current),
1423 DeclaredExportStatus::Unreadable(_)
1424 ));
1425 }
1426
1427 #[test]
1429 fn is_current_is_false_for_every_status_that_is_not_current() {
1430 assert!(DeclaredExportStatus::Current.is_current());
1436 for status in [
1437 DeclaredExportStatus::Missing,
1438 DeclaredExportStatus::Stale {
1439 stamped: "sha256:aaaa".into(),
1440 current: "sha256:bbbb".into(),
1441 },
1442 DeclaredExportStatus::KindMismatch {
1443 declared: "vsix".into(),
1444 stamped: "cargo".into(),
1445 },
1446 DeclaredExportStatus::Unreadable("truncated".into()),
1447 ] {
1448 assert!(
1449 !status.is_current(),
1450 "{status:?} must not report itself current"
1451 );
1452 }
1453 }
1454
1455 #[test]
1457 fn an_export_reached_through_a_symlink_is_the_same_export() {
1458 let tmp = tempfile::tempdir().unwrap();
1466 let real_dir = tmp.path().join("real/export");
1467 std::fs::create_dir_all(real_dir.join("bin")).unwrap();
1471 std::fs::write(real_dir.join("bin/gcc"), b"#!/bin/sh\n").unwrap();
1472 let link = tmp.path().join("link");
1473 #[cfg(unix)]
1474 std::os::unix::fs::symlink(tmp.path().join("real"), &link).unwrap();
1475 #[cfg(not(unix))]
1476 return;
1477
1478 let through_link = link.join("export/bin/gcc");
1480 assert!(
1481 !through_link.starts_with(&real_dir),
1482 "the fixture must be lexically outside, or it proves nothing"
1483 );
1484 assert!(
1485 is_within(&real_dir, &through_link),
1486 "an export reached through a symlinked checkout is the same export"
1487 );
1488
1489 assert!(!is_within(&real_dir, &tmp.path().join("elsewhere/bin/gcc")));
1492 }
1493
1494 #[test]
1496 fn a_declared_sdk_environment_is_not_reported_as_a_hijack() {
1497 let root = Path::new("/repo");
1502 let pin = parse(DECLARED).unwrap();
1503 let sdk_gcc = Path::new("/repo/toolchains/poky/sysroots/x86_64/usr/bin/gcc");
1504 match classify_shadowing(&pin, root, sdk_gcc) {
1505 ShadowDeclaration::Expected(d) => assert_eq!(d.out, "toolchains/poky"),
1506 other => panic!("a declared before-shims SDK must be expected, got {other:?}"),
1507 }
1508
1509 assert_eq!(
1512 classify_shadowing(&pin, root, Path::new("/usr/local/bin/gcc")),
1513 ShadowDeclaration::Undeclared
1514 );
1515 assert_eq!(
1518 classify_shadowing(&pin, root, Path::new("/repo/vendor/registry/gcc")),
1519 ShadowDeclaration::Undeclared
1520 );
1521
1522 let after = parse(
1526 "[[export]]\nkind = \"sdk\"\nout = \"toolchains/poky\"\n[export.env]\nscript = \"e\"\npath = \"after-shims\"\n",
1527 )
1528 .unwrap();
1529 match classify_shadowing(&after, root, sdk_gcc) {
1530 ShadowDeclaration::ContradictsDeclaration(d) => {
1531 assert_eq!(d.out, "toolchains/poky")
1532 }
1533 other => panic!("expected ContradictsDeclaration, got {other:?}"),
1534 }
1535 }
1536
1537 #[test]
1539 fn one_command_sources_the_whole_environment_in_the_declared_path_order() {
1540 let root = Path::new("/repo");
1547 let shim_env = Path::new("/home/u/.varve/env");
1548 let pin = parse(
1549 "[[export]]\nkind = \"cargo\"\nout = \"vendor\"\n\
1550 [[export]]\nkind = \"sdk\"\nout = \"early\"\n\
1551 [export.env]\nscript = \"env-setup-early\"\npath = \"before-shims\"\n\
1552 [[export]]\nkind = \"sdk\"\nout = \"late\"\n\
1553 [export.env]\nscript = \"env-setup-late\"\npath = \"after-shims\"\n",
1554 )
1555 .unwrap();
1556 let sourced: Vec<String> = env_lines(&pin, root, Some(shim_env))
1557 .into_iter()
1558 .filter(|l| l.starts_with(". "))
1559 .collect();
1560 assert_eq!(
1561 sourced,
1562 vec![
1563 ". \"/repo/late/env-setup-late\"".to_string(),
1564 ". \"/home/u/.varve/env\"".to_string(),
1565 ". \"/repo/early/env-setup-early\"".to_string(),
1566 ],
1567 "after-shims is sourced FIRST so the shims land ahead of it"
1568 );
1569 assert!(
1571 !env_lines(&pin, root, Some(shim_env))
1572 .join("\n")
1573 .contains("vendor")
1574 );
1575 let no_shims: Vec<String> = env_lines(&pin, root, None)
1577 .into_iter()
1578 .filter(|l| l.starts_with(". "))
1579 .collect();
1580 assert_eq!(no_shims.len(), 2);
1581 assert!(!no_shims.iter().any(|l| l.contains(".varve/env")));
1582 }
1583}
1584
1585#[cfg(test)]
1586mod version_message_tests {
1587 use super::*;
1588
1589 #[test]
1598 fn the_unsupported_version_error_names_the_version_this_build_enforces() {
1599 let newer = format!(
1600 "manifest-version = {}\n[toolchain]\nchannel = \"rolling\"\nlayer = \"2026.09.1\"\n",
1601 crate::consumer::PIN_MANIFEST_VERSION + 1
1602 );
1603 let msg = Pin::parse(&newer, "varve.toml")
1604 .expect_err("a newer pin must be refused")
1605 .to_string();
1606 assert!(
1607 msg.contains(&format!(
1608 "understands version {}",
1609 crate::consumer::PIN_MANIFEST_VERSION
1610 )),
1611 "the message must name the enforced constant, got: {msg}"
1612 );
1613 assert!(
1614 msg.contains(&format!(
1615 "manifest-version {}",
1616 crate::consumer::PIN_MANIFEST_VERSION + 1
1617 )),
1618 "and the version it refused, got: {msg}"
1619 );
1620 }
1621}