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)]
210pub struct Pin {
211 pub realm: Option<String>,
214 pub channel: Channel,
215 pub layer: LayerId,
216 pub digest: Option<String>,
220 pub tools: Option<Vec<String>>,
223 pub exports: Vec<ExportDecl>,
226}
227
228#[derive(Debug, thiserror::Error)]
230pub enum PinError {
231 #[error("failed to read {path}")]
232 Io {
233 path: String,
234 #[source]
235 source: std::io::Error,
236 },
237 #[error("{path}: not valid varve.toml")]
238 Toml {
239 path: String,
240 #[source]
241 source: Box<toml::de::Error>,
242 },
243 #[error("{path}: manifest-version {found} is not supported (this varve understands version 1)")]
244 UnsupportedManifestVersion { path: String, found: i64 },
245 #[error("{path}: invalid layer identifier")]
249 Layer {
250 path: String,
251 #[source]
252 source: LayerIdError,
253 },
254 #[error(
255 "{path}: digest '{found}' is not a valid digest: expected 'sha256:' followed by 64 hex characters"
256 )]
257 MalformedDigest { path: String, found: String },
258 #[error(
259 "{path}: tool name {name:?} is not a plain name — a tool is looked up INSIDE \
260 the pinned layer, so a path would resolve outside it. Name the tool only, \
261 e.g. tools = [\"rivet\"]."
262 )]
263 ToolNameIsAPath { path: String, name: String },
264 #[error("{path}: tools list is present but empty — omit it to select every tool in the layer")]
265 EmptyTools { path: String },
266 #[error(
267 "{path}: export kind {kind:?} is not one this varve can produce — expected one of {expected}"
268 )]
269 UnknownExportKind {
270 path: String,
271 kind: String,
272 expected: String,
273 },
274 #[error(
275 "{path}: export destination {out:?} is not usable ({why}) — an export directory is \
276 RELATIVE to the directory holding varve.toml, so the declaration travels with the \
277 repository and means the same thing on every machine"
278 )]
279 ExportOutEscapes {
280 path: String,
281 out: String,
282 why: String,
283 },
284 #[error(
285 "{path}: exports {first:?} and {second:?} both write to {out:?} — the second would \
286 overwrite the first's stamp, and `verify` would then check one export twice while \
287 never checking the other at all"
288 )]
289 DuplicateExportOut {
290 path: String,
291 out: String,
292 first: String,
293 second: String,
294 },
295 #[error(
296 "{path}: export to {out:?} has an empty select list — omit it to export the whole layer"
297 )]
298 EmptyExportSelect { path: String, out: String },
299 #[error(
300 "{path}: export to {out:?} selects {name:?}, which is not a plain payload name — a \
301 selection indexes the VERIFIED layer, so a path would reach outside it"
302 )]
303 ExportSelectIsAPath {
304 path: String,
305 out: String,
306 name: String,
307 },
308 #[error(
309 "{path}: export to {out:?} is a {kind} export, which is consumed by POINTING at it, not \
310 by sourcing it — an [export.env] here would be accepted, ignored, and believed. Only \
311 these kinds are entered as an environment: {sourced}"
312 )]
313 ExportEnvNotSourced {
314 path: String,
315 out: String,
316 kind: String,
317 sourced: String,
318 },
319 #[error(
320 "{path}: export to {out:?} declares an environment but not where it sits relative to \
321 varve's shims. Add `path = \"before-shims\"` if this environment's bin is meant to win \
322 on PATH, or `path = \"after-shims\"` if varve's pinned tools are. Undeclared, `verify` \
323 cannot tell a legitimate sourced SDK from a hijacked PATH (REQ-SHADOW-001), and \
324 guessing wrong either misses a real one or cries wolf on a correct setup"
325 )]
326 ExportEnvNeedsShimOrder { path: String, out: String },
327 #[error(
328 "{path}: export to {out:?} declares path = {found:?} — expected \"before-shims\" or \
329 \"after-shims\""
330 )]
331 UnknownShimOrder {
332 path: String,
333 out: String,
334 found: String,
335 },
336 #[error(
337 "{path}: export to {out:?} sources {script:?}, which is not usable ({why}) — the script \
338 is relative to the export directory, and it must stay inside it"
339 )]
340 ExportScriptEscapes {
341 path: String,
342 out: String,
343 script: String,
344 why: String,
345 },
346}
347
348#[derive(Deserialize)]
349#[serde(deny_unknown_fields)]
350struct RawPin {
351 #[serde(rename = "manifest-version")]
352 manifest_version: i64,
353 toolchain: RawToolchain,
354 #[serde(default, rename = "export")]
356 exports: Vec<RawExport>,
357}
358
359#[derive(Deserialize)]
360#[serde(deny_unknown_fields)]
361struct RawExport {
362 kind: String,
363 out: String,
364 select: Option<Vec<String>>,
365 env: Option<RawExportEnv>,
366}
367
368#[derive(Deserialize)]
369#[serde(deny_unknown_fields)]
370struct RawExportEnv {
371 script: String,
372 path: Option<String>,
377}
378
379#[derive(Deserialize)]
380#[serde(deny_unknown_fields)]
381struct RawToolchain {
382 #[serde(default)]
383 realm: Option<String>,
384 channel: Channel,
385 layer: String,
386 digest: Option<String>,
387 tools: Option<Vec<String>>,
388}
389
390impl Pin {
391 pub fn parse(content: &str, origin: &str) -> Result<Self, PinError> {
394 let raw: RawPin = toml::from_str(content).map_err(|source| PinError::Toml {
395 path: origin.to_string(),
396 source: Box::new(source),
397 })?;
398 if raw.manifest_version != 1 {
399 return Err(PinError::UnsupportedManifestVersion {
400 path: origin.to_string(),
401 found: raw.manifest_version,
402 });
403 }
404 let layer = LayerId::from_str(&raw.toolchain.layer).map_err(|source| PinError::Layer {
405 path: origin.to_string(),
406 source,
407 })?;
408 if let Some(digest) = &raw.toolchain.digest {
409 let hex = digest
410 .strip_prefix("sha256:")
411 .ok_or_else(|| PinError::MalformedDigest {
412 path: origin.to_string(),
413 found: digest.clone(),
414 })?;
415 if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
416 return Err(PinError::MalformedDigest {
417 path: origin.to_string(),
418 found: digest.clone(),
419 });
420 }
421 }
422 if let Some(tools) = &raw.toolchain.tools {
423 if tools.is_empty() {
424 return Err(PinError::EmptyTools {
425 path: origin.to_string(),
426 });
427 }
428 for name in tools {
433 let plain = !name.is_empty()
434 && name != "."
435 && name != ".."
436 && !name.contains('/')
437 && !name.contains('\\')
438 && !name.contains('\0');
439 if !plain {
440 return Err(PinError::ToolNameIsAPath {
441 path: origin.to_string(),
442 name: name.clone(),
443 });
444 }
445 }
446 }
447 let exports = parse_exports(&raw.exports, origin)?;
448 Ok(Pin {
449 realm: raw.toolchain.realm,
450 channel: raw.toolchain.channel,
451 layer,
452 digest: raw.toolchain.digest,
453 tools: raw.toolchain.tools,
454 exports,
455 })
456 }
457
458 pub fn load(path: &Path) -> Result<Self, PinError> {
460 let content = std::fs::read_to_string(path).map_err(|source| PinError::Io {
461 path: path.display().to_string(),
462 source,
463 })?;
464 Self::parse(&content, &path.display().to_string())
465 }
466}
467
468fn contained_relative_fault(value: &str) -> Option<String> {
476 if value.is_empty() {
477 return Some("empty".into());
478 }
479 if value.starts_with('/') || value.starts_with('\\') || value.contains(':') {
480 return Some("absolute".into());
481 }
482 if value.contains('\0') {
483 return Some("contains a NUL".into());
484 }
485 for component in value.split(['/', '\\']) {
486 if component == ".." {
487 return Some("climbs out with '..'".into());
488 }
489 }
490 if value.split(['/', '\\']).all(|c| c.is_empty() || c == ".") {
491 return Some("names no directory".into());
492 }
493 None
494}
495
496fn parse_exports(raw: &[RawExport], origin: &str) -> Result<Vec<ExportDecl>, PinError> {
501 let mut decls: Vec<ExportDecl> = Vec::with_capacity(raw.len());
502 for e in raw {
503 let kind = ExportKind::from_str(&e.kind).map_err(|()| PinError::UnknownExportKind {
504 path: origin.to_string(),
505 kind: e.kind.clone(),
506 expected: ExportKind::ALL
507 .iter()
508 .map(|k| k.as_str())
509 .collect::<Vec<_>>()
510 .join(", "),
511 })?;
512 if let Some(why) = contained_relative_fault(&e.out) {
513 return Err(PinError::ExportOutEscapes {
514 path: origin.to_string(),
515 out: e.out.clone(),
516 why,
517 });
518 }
519 if let Some(first) = decls.iter().find(|d| d.out == e.out) {
520 return Err(PinError::DuplicateExportOut {
521 path: origin.to_string(),
522 out: e.out.clone(),
523 first: first.kind.to_string(),
524 second: kind.to_string(),
525 });
526 }
527 if let Some(select) = &e.select {
528 if select.is_empty() {
529 return Err(PinError::EmptyExportSelect {
530 path: origin.to_string(),
531 out: e.out.clone(),
532 });
533 }
534 for name in select {
535 let plain = !name.is_empty()
536 && name != "."
537 && name != ".."
538 && !name.contains('/')
539 && !name.contains('\\')
540 && !name.contains('\0');
541 if !plain {
542 return Err(PinError::ExportSelectIsAPath {
543 path: origin.to_string(),
544 out: e.out.clone(),
545 name: name.clone(),
546 });
547 }
548 }
549 }
550 let env = match &e.env {
551 None => None,
552 Some(raw_env) => {
553 if !kind.is_sourced() {
554 return Err(PinError::ExportEnvNotSourced {
555 path: origin.to_string(),
556 out: e.out.clone(),
557 kind: kind.to_string(),
558 sourced: ExportKind::ALL
559 .iter()
560 .filter(|k| k.is_sourced())
561 .map(|k| k.as_str())
562 .collect::<Vec<_>>()
563 .join(", "),
564 });
565 }
566 if let Some(why) = contained_relative_fault(&raw_env.script) {
567 return Err(PinError::ExportScriptEscapes {
568 path: origin.to_string(),
569 out: e.out.clone(),
570 script: raw_env.script.clone(),
571 why,
572 });
573 }
574 let Some(order) = &raw_env.path else {
579 return Err(PinError::ExportEnvNeedsShimOrder {
580 path: origin.to_string(),
581 out: e.out.clone(),
582 });
583 };
584 let path = ShimOrder::from_str(order).map_err(|()| PinError::UnknownShimOrder {
585 path: origin.to_string(),
586 out: e.out.clone(),
587 found: order.clone(),
588 })?;
589 Some(ExportEnv {
590 script: raw_env.script.clone(),
591 path,
592 })
593 }
594 };
595 decls.push(ExportDecl {
596 kind,
597 out: e.out.clone(),
598 select: e.select.clone(),
599 env,
600 });
601 }
602 Ok(decls)
603}
604
605#[derive(Debug, PartialEq, Eq)]
607pub enum DeclaredExportStatus {
608 Current,
610 Missing,
617 Stale { stamped: String, current: String },
619 KindMismatch { declared: String, stamped: String },
623 Unreadable(String),
627}
628
629impl DeclaredExportStatus {
630 pub fn is_current(&self) -> bool {
631 matches!(self, DeclaredExportStatus::Current)
632 }
633}
634
635pub fn check_declared_export(
641 decl: &ExportDecl,
642 project_root: &Path,
643 current_manifest_digest: &str,
644) -> DeclaredExportStatus {
645 use crate::exportstamp::{ExportStampError, ExportStatus, read_stamp, status};
646 let dir = decl.dir(project_root);
647 match read_stamp(&dir) {
648 Err(ExportStampError::Missing(_)) => DeclaredExportStatus::Missing,
649 Err(other) => DeclaredExportStatus::Unreadable(other.to_string()),
650 Ok(stamp) => {
651 if stamp.kind != decl.kind.as_str() {
652 return DeclaredExportStatus::KindMismatch {
653 declared: decl.kind.as_str().to_string(),
654 stamped: stamp.kind,
655 };
656 }
657 match status(&stamp, current_manifest_digest) {
658 ExportStatus::Current => DeclaredExportStatus::Current,
659 ExportStatus::Stale { stamped, current } => {
660 DeclaredExportStatus::Stale { stamped, current }
661 }
662 }
663 }
664 }
665}
666
667pub fn env_lines(pin: &Pin, project_root: &Path, shim_env: Option<&Path>) -> Vec<String> {
678 let mut lines = Vec::new();
679 let sourced = |order: ShimOrder, lines: &mut Vec<String>| {
680 for decl in pin
681 .exports
682 .iter()
683 .filter(|d| d.env.as_ref().is_some_and(|e| e.path == order))
684 {
685 if let Some(script) = decl.env_script(project_root) {
686 lines.push(format!(
687 "# {} export {} — declared {} (REQ-EXPORTDECL-001 clause 5)",
688 decl.kind,
689 decl.out,
690 order.as_str()
691 ));
692 lines.push(format!(". \"{}\"", script.display()));
693 }
694 }
695 };
696 sourced(ShimOrder::AfterShims, &mut lines);
698 if let Some(env) = shim_env {
699 lines.push("# varve's shims".to_string());
700 lines.push(format!(". \"{}\"", env.display()));
701 }
702 sourced(ShimOrder::BeforeShims, &mut lines);
705 lines
706}
707
708#[derive(Debug, PartialEq, Eq)]
711pub enum ShadowDeclaration<'a> {
712 Expected(&'a ExportDecl),
716 ContradictsDeclaration(&'a ExportDecl),
720 Undeclared,
722}
723
724fn is_within(dir: &Path, path: &Path) -> bool {
729 let real = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
730 path.starts_with(dir) || real(path).starts_with(real(dir))
731}
732
733pub fn classify_shadowing<'a>(
740 pin: &'a Pin,
741 project_root: &Path,
742 found: &Path,
743) -> ShadowDeclaration<'a> {
744 for decl in &pin.exports {
745 let Some(env) = &decl.env else {
746 continue;
749 };
750 if is_within(&decl.dir(project_root), found) {
751 return match env.path {
752 ShimOrder::BeforeShims => ShadowDeclaration::Expected(decl),
753 ShimOrder::AfterShims => ShadowDeclaration::ContradictsDeclaration(decl),
754 };
755 }
756 }
757 ShadowDeclaration::Undeclared
758}
759
760#[cfg(test)]
761mod tests {
762 use super::*;
763
764 const FULL: &str = r#"
765manifest-version = 1
766
767[toolchain]
768channel = "qualified"
769layer = "2026.07.0"
770digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
771tools = ["rivet", "synth"]
772"#;
773
774 #[test]
776 fn parses_a_complete_pin() {
777 let pin = Pin::parse(FULL, "varve.toml").unwrap();
778 assert_eq!(pin.channel, Channel::Qualified);
779 assert_eq!(pin.layer, LayerId::from_str("2026.07.0").unwrap());
780 assert_eq!(
781 pin.digest.as_deref(),
782 Some("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
783 );
784 assert_eq!(
785 pin.tools.as_deref(),
786 Some(&["rivet".to_string(), "synth".to_string()][..])
787 );
788 }
789
790 #[test]
792 fn digest_and_tools_are_optional() {
793 let pin = Pin::parse(
794 "manifest-version = 1\n[toolchain]\nchannel = \"rolling\"\nlayer = \"2026.08.0\"\n",
795 "varve.toml",
796 )
797 .unwrap();
798 assert_eq!(pin.channel, Channel::Rolling);
799 assert_eq!(pin.digest, None);
800 assert_eq!(pin.tools, None);
801 }
802
803 #[test]
805 fn rejects_unsupported_manifest_version() {
806 let err = Pin::parse(
807 "manifest-version = 2\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
808 "varve.toml",
809 )
810 .unwrap_err();
811 assert!(
812 matches!(err, PinError::UnsupportedManifestVersion { found: 2, .. }),
813 "got: {err}"
814 );
815 }
816
817 #[test]
819 fn rejects_two_part_layer_with_the_grammar_guidance() {
820 let err = Pin::parse(
821 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07\"\n",
822 "varve.toml",
823 )
824 .unwrap_err();
825 let PinError::Layer { source, .. } = &err else {
826 panic!("got: {err}");
827 };
828 assert!(matches!(source, LayerIdError::MissingPatch(_)));
829 assert!(
831 source.to_string().contains("three-part"),
832 "the chain must teach the grammar: {source}"
833 );
834 }
835
836 #[test]
838 fn rejects_unknown_keys_instead_of_ignoring_them() {
839 let err = Pin::parse(
840 "manifest-version = 1\nsurprise = true\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
841 "varve.toml",
842 )
843 .unwrap_err();
844 assert!(matches!(err, PinError::Toml { .. }), "got: {err}");
845 }
846
847 #[test]
849 fn rejects_unknown_channel() {
850 let err = Pin::parse(
851 "manifest-version = 1\n[toolchain]\nchannel = \"latest\"\nlayer = \"2026.07.0\"\n",
852 "varve.toml",
853 )
854 .unwrap_err();
855 assert!(matches!(err, PinError::Toml { .. }), "got: {err}");
856 }
857
858 #[test]
860 fn rejects_malformed_digest() {
861 for bad in [
864 "sha256:short",
865 "md5:aaaa",
866 "aaaaaaaa",
867 "sha256:GGGG",
868 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
869 ] {
870 let toml = format!(
871 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ndigest = \"{bad}\"\n"
872 );
873 let err = Pin::parse(&toml, "varve.toml").unwrap_err();
874 assert!(
875 matches!(err, PinError::MalformedDigest { .. }),
876 "input {bad:?} got: {err}"
877 );
878 }
879 }
880
881 #[test]
883 fn rejects_a_tool_name_that_is_a_path() {
884 for hostile in [
889 "/usr/bin/id",
890 "../../usr/bin/id",
891 "sub/dir",
892 "..",
893 ".",
894 "",
895 "C:\\Windows\\system32\\cmd.exe",
896 ] {
897 let content = format!(
898 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"{}\"]\n",
899 hostile.replace('\\', "\\\\")
900 );
901 assert!(
902 Pin::parse(&content, "varve.toml").is_err(),
903 "tool name {hostile:?} must be refused — it escapes the layer"
904 );
905 }
906 let ok = "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"rivet\", \"synth-c\", \"cargo_x\"]\n";
908 assert!(Pin::parse(ok, "varve.toml").is_ok());
909 }
910
911 #[test]
913 fn rejects_empty_tools_list() {
914 let err = Pin::parse(
915 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = []\n",
916 "varve.toml",
917 )
918 .unwrap_err();
919 assert!(matches!(err, PinError::EmptyTools { .. }), "got: {err}");
920 }
921
922 #[test]
924 fn errors_name_the_offending_file() {
925 let err = Pin::parse("nonsense", "proj/sub/varve.toml").unwrap_err();
926 assert!(
927 err.to_string().contains("proj/sub/varve.toml"),
928 "diagnostic must carry the path: {err}"
929 );
930 }
931}
932
933#[cfg(test)]
934mod export_tests {
935 use super::*;
936 use crate::exportstamp::{ExportStamp, write_stamp};
937
938 const HEAD: &str =
939 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.08.0\"\n";
940
941 fn parse(exports: &str) -> Result<Pin, PinError> {
942 Pin::parse(&format!("{HEAD}{exports}"), "varve.toml")
943 }
944
945 const DECLARED: &str = r#"
948[[export]]
949kind = "cargo"
950out = "vendor/registry"
951
952[[export]]
953kind = "vsix"
954out = ".vscode/varve-extensions"
955select = ["rust-lang.rust-analyzer", "vadimcn.vscode-lldb"]
956
957[[export]]
958kind = "sdk"
959out = "toolchains/poky"
960select = ["poky-cortexa53"]
961
962[export.env]
963script = "environment-setup-cortexa53-poky-linux"
964path = "before-shims"
965"#;
966
967 #[test]
969 fn a_project_declares_its_exports_in_the_pin_it_already_has() {
970 let pin = parse(DECLARED).unwrap();
974 assert_eq!(pin.exports.len(), 3);
975 assert_eq!(pin.exports[0].kind, ExportKind::Cargo);
976 assert_eq!(pin.exports[0].out, "vendor/registry");
977 assert_eq!(
978 pin.exports[0].select, None,
979 "no subset means the whole layer"
980 );
981 assert_eq!(pin.exports[0].env, None);
982
983 assert_eq!(pin.exports[1].kind, ExportKind::Vsix);
985 assert_eq!(
986 pin.exports[1].select.as_deref(),
987 Some(
988 &[
989 "rust-lang.rust-analyzer".to_string(),
990 "vadimcn.vscode-lldb".to_string()
991 ][..]
992 )
993 );
994
995 let sdk = &pin.exports[2];
997 assert_eq!(sdk.kind, ExportKind::Sdk);
998 let env = sdk.env.as_ref().expect("an sdk is entered, not pointed at");
999 assert_eq!(env.script, "environment-setup-cortexa53-poky-linux");
1000 assert_eq!(env.path, ShimOrder::BeforeShims);
1001
1002 let root = Path::new("/repo");
1005 assert_eq!(sdk.dir(root), Path::new("/repo/toolchains/poky"));
1006 assert_eq!(
1007 sdk.env_script(root).unwrap(),
1008 Path::new("/repo/toolchains/poky/environment-setup-cortexa53-poky-linux")
1009 );
1010 assert_eq!(pin.exports[0].env_script(root), None);
1011
1012 assert!(Pin::parse(HEAD, "varve.toml").unwrap().exports.is_empty());
1015 }
1016
1017 #[test]
1019 fn the_declared_kind_is_one_varve_can_actually_produce() {
1020 let err = parse("[[export]]\nkind = \"npm\"\nout = \"x\"\n").unwrap_err();
1025 let msg = err.to_string();
1026 assert!(matches!(err, PinError::UnknownExportKind { .. }), "{msg}");
1027 for known in ExportKind::ALL {
1028 assert!(
1029 msg.contains(known.as_str()),
1030 "the refusal must list {known}, or the author has nothing to correct to: {msg}"
1031 );
1032 }
1033 for (kind, wire) in [
1036 (ExportKind::Cargo, "cargo"),
1037 (ExportKind::CratesVendor, "crates-vendor"),
1038 (ExportKind::BazelRegistry, "bazel-registry"),
1039 (ExportKind::BazelDistdir, "bazel-distdir"),
1040 (ExportKind::Vsix, "vsix"),
1041 (ExportKind::Sdk, "sdk"),
1042 ] {
1043 assert_eq!(kind.as_str(), wire);
1044 assert_eq!(ExportKind::from_str(wire).unwrap(), kind);
1045 }
1046 assert_eq!(ExportKind::ALL.len(), 6, "ALL must list every variant");
1047 }
1048
1049 #[test]
1051 fn a_destination_that_leaves_the_repository_is_refused() {
1052 for bad in [
1058 "/etc",
1059 "../outside",
1060 "a/../../outside",
1061 "",
1062 ".",
1063 "./",
1064 "C:\\x",
1065 ] {
1066 let err = parse(&format!(
1067 "[[export]]\nkind = \"cargo\"\nout = \"{}\"\n",
1068 bad.replace('\\', "\\\\")
1069 ))
1070 .unwrap_err();
1071 assert!(
1072 matches!(err, PinError::ExportOutEscapes { .. }),
1073 "out {bad:?} must be refused, got: {err}"
1074 );
1075 }
1076 for good in ["vendor", "vendor/registry", "a/b/c"] {
1077 assert!(
1078 parse(&format!("[[export]]\nkind = \"cargo\"\nout = \"{good}\"\n")).is_ok(),
1079 "{good} is an ordinary export directory"
1080 );
1081 }
1082 }
1083
1084 #[test]
1086 fn two_exports_may_not_share_one_directory() {
1087 let err = parse(
1092 "[[export]]\nkind = \"cargo\"\nout = \"vendor\"\n\
1093 [[export]]\nkind = \"vsix\"\nout = \"vendor\"\n",
1094 )
1095 .unwrap_err();
1096 assert!(
1097 matches!(err, PinError::DuplicateExportOut { .. }),
1098 "got: {err}"
1099 );
1100 let msg = err.to_string();
1101 assert!(
1102 msg.contains("cargo") && msg.contains("vsix"),
1103 "names both: {msg}"
1104 );
1105 }
1106
1107 #[test]
1109 fn a_subset_selection_names_payloads_not_paths() {
1110 for bad in ["../evil", "a/b", "", ".", "..", "a\\b"] {
1114 let err = parse(&format!(
1115 "[[export]]\nkind = \"cargo\"\nout = \"v\"\nselect = [\"{}\"]\n",
1116 bad.replace('\\', "\\\\")
1117 ))
1118 .unwrap_err();
1119 assert!(
1120 matches!(err, PinError::ExportSelectIsAPath { .. }),
1121 "select {bad:?} must be refused, got: {err}"
1122 );
1123 }
1124 let err = parse("[[export]]\nkind = \"cargo\"\nout = \"v\"\nselect = []\n").unwrap_err();
1127 assert!(
1128 matches!(err, PinError::EmptyExportSelect { .. }),
1129 "got: {err}"
1130 );
1131 }
1132
1133 #[test]
1135 fn an_environment_must_say_where_it_sits_relative_to_the_shims() {
1136 let err = parse(
1143 "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"env-setup\"\n",
1144 )
1145 .unwrap_err();
1146 assert!(
1147 matches!(err, PinError::ExportEnvNeedsShimOrder { .. }),
1148 "got: {err}"
1149 );
1150 let msg = err.to_string();
1151 assert!(msg.contains("before-shims"), "offers the answers: {msg}");
1152 assert!(msg.contains("after-shims"), "offers the answers: {msg}");
1153 assert!(
1154 msg.contains("REQ-SHADOW-001"),
1155 "says WHY it is needed, not just that it is: {msg}"
1156 );
1157
1158 for (value, want) in [
1160 ("before-shims", ShimOrder::BeforeShims),
1161 ("after-shims", ShimOrder::AfterShims),
1162 ] {
1163 let pin = parse(&format!(
1164 "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"e\"\npath = \"{value}\"\n"
1165 ))
1166 .unwrap();
1167 assert_eq!(pin.exports[0].env.as_ref().unwrap().path, want);
1168 assert_eq!(want.as_str(), value);
1169 }
1170 let err = parse(
1171 "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"e\"\npath = \"first\"\n",
1172 )
1173 .unwrap_err();
1174 assert!(
1175 matches!(err, PinError::UnknownShimOrder { .. }),
1176 "got: {err}"
1177 );
1178 }
1179
1180 #[test]
1182 fn only_an_export_that_is_sourced_may_declare_an_environment() {
1183 let err = parse(
1187 "[[export]]\nkind = \"cargo\"\nout = \"v\"\n[export.env]\nscript = \"e\"\npath = \"after-shims\"\n",
1188 )
1189 .unwrap_err();
1190 assert!(
1191 matches!(err, PinError::ExportEnvNotSourced { .. }),
1192 "got: {err}"
1193 );
1194 assert!(
1195 err.to_string().contains("sdk"),
1196 "names what IS sourced: {err}"
1197 );
1198 assert!(ExportKind::Sdk.is_sourced());
1199 for pointed in ExportKind::ALL.iter().filter(|k| **k != ExportKind::Sdk) {
1200 assert!(
1201 !pointed.is_sourced(),
1202 "{pointed} is consumed by pointing at it, not by sourcing it"
1203 );
1204 }
1205 let err = parse(
1207 "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"../../etc/profile\"\npath = \"after-shims\"\n",
1208 )
1209 .unwrap_err();
1210 assert!(
1211 matches!(err, PinError::ExportScriptEscapes { .. }),
1212 "got: {err}"
1213 );
1214 }
1215
1216 fn stamped(dir: &std::path::Path, kind: &str, digest: &str) {
1217 write_stamp(
1218 dir,
1219 &ExportStamp {
1220 layer: "2026.08.0".into(),
1221 manifest_digest: digest.into(),
1222 kind: kind.into(),
1223 },
1224 )
1225 .unwrap();
1226 }
1227
1228 #[test]
1230 fn every_declared_export_is_checked_and_an_absent_one_fails() {
1231 let tmp = tempfile::tempdir().unwrap();
1236 let root = tmp.path();
1237 let pin = parse(DECLARED).unwrap();
1238 let current = "sha256:aaaa";
1239
1240 for decl in &pin.exports {
1242 assert_eq!(
1243 check_declared_export(decl, root, current),
1244 DeclaredExportStatus::Missing,
1245 "{} must fail while it does not exist",
1246 decl.out
1247 );
1248 }
1249
1250 for decl in &pin.exports {
1252 stamped(&decl.dir(root), decl.kind.as_str(), current);
1253 let got = check_declared_export(decl, root, current);
1254 assert!(
1255 got.is_current(),
1256 "{} should be fresh, got {got:?}",
1257 decl.out
1258 );
1259 }
1260
1261 let moved = "sha256:bbbb";
1263 assert_eq!(
1264 check_declared_export(&pin.exports[0], root, moved),
1265 DeclaredExportStatus::Stale {
1266 stamped: current.into(),
1267 current: moved.into(),
1268 }
1269 );
1270
1271 let vsix = &pin.exports[1];
1275 stamped(&vsix.dir(root), "cargo", current);
1276 assert_eq!(
1277 check_declared_export(vsix, root, current),
1278 DeclaredExportStatus::KindMismatch {
1279 declared: "vsix".into(),
1280 stamped: "cargo".into(),
1281 }
1282 );
1283
1284 let cargo = &pin.exports[0];
1287 std::fs::write(
1288 cargo.dir(root).join(crate::exportstamp::STAMP_FILE),
1289 b"{not json",
1290 )
1291 .unwrap();
1292 assert!(matches!(
1293 check_declared_export(cargo, root, current),
1294 DeclaredExportStatus::Unreadable(_)
1295 ));
1296 }
1297
1298 #[test]
1300 fn is_current_is_false_for_every_status_that_is_not_current() {
1301 assert!(DeclaredExportStatus::Current.is_current());
1307 for status in [
1308 DeclaredExportStatus::Missing,
1309 DeclaredExportStatus::Stale {
1310 stamped: "sha256:aaaa".into(),
1311 current: "sha256:bbbb".into(),
1312 },
1313 DeclaredExportStatus::KindMismatch {
1314 declared: "vsix".into(),
1315 stamped: "cargo".into(),
1316 },
1317 DeclaredExportStatus::Unreadable("truncated".into()),
1318 ] {
1319 assert!(
1320 !status.is_current(),
1321 "{status:?} must not report itself current"
1322 );
1323 }
1324 }
1325
1326 #[test]
1328 fn an_export_reached_through_a_symlink_is_the_same_export() {
1329 let tmp = tempfile::tempdir().unwrap();
1337 let real_dir = tmp.path().join("real/export");
1338 std::fs::create_dir_all(real_dir.join("bin")).unwrap();
1342 std::fs::write(real_dir.join("bin/gcc"), b"#!/bin/sh\n").unwrap();
1343 let link = tmp.path().join("link");
1344 #[cfg(unix)]
1345 std::os::unix::fs::symlink(tmp.path().join("real"), &link).unwrap();
1346 #[cfg(not(unix))]
1347 return;
1348
1349 let through_link = link.join("export/bin/gcc");
1351 assert!(
1352 !through_link.starts_with(&real_dir),
1353 "the fixture must be lexically outside, or it proves nothing"
1354 );
1355 assert!(
1356 is_within(&real_dir, &through_link),
1357 "an export reached through a symlinked checkout is the same export"
1358 );
1359
1360 assert!(!is_within(&real_dir, &tmp.path().join("elsewhere/bin/gcc")));
1363 }
1364
1365 #[test]
1367 fn a_declared_sdk_environment_is_not_reported_as_a_hijack() {
1368 let root = Path::new("/repo");
1373 let pin = parse(DECLARED).unwrap();
1374 let sdk_gcc = Path::new("/repo/toolchains/poky/sysroots/x86_64/usr/bin/gcc");
1375 match classify_shadowing(&pin, root, sdk_gcc) {
1376 ShadowDeclaration::Expected(d) => assert_eq!(d.out, "toolchains/poky"),
1377 other => panic!("a declared before-shims SDK must be expected, got {other:?}"),
1378 }
1379
1380 assert_eq!(
1383 classify_shadowing(&pin, root, Path::new("/usr/local/bin/gcc")),
1384 ShadowDeclaration::Undeclared
1385 );
1386 assert_eq!(
1389 classify_shadowing(&pin, root, Path::new("/repo/vendor/registry/gcc")),
1390 ShadowDeclaration::Undeclared
1391 );
1392
1393 let after = parse(
1397 "[[export]]\nkind = \"sdk\"\nout = \"toolchains/poky\"\n[export.env]\nscript = \"e\"\npath = \"after-shims\"\n",
1398 )
1399 .unwrap();
1400 match classify_shadowing(&after, root, sdk_gcc) {
1401 ShadowDeclaration::ContradictsDeclaration(d) => {
1402 assert_eq!(d.out, "toolchains/poky")
1403 }
1404 other => panic!("expected ContradictsDeclaration, got {other:?}"),
1405 }
1406 }
1407
1408 #[test]
1410 fn one_command_sources_the_whole_environment_in_the_declared_path_order() {
1411 let root = Path::new("/repo");
1418 let shim_env = Path::new("/home/u/.varve/env");
1419 let pin = parse(
1420 "[[export]]\nkind = \"cargo\"\nout = \"vendor\"\n\
1421 [[export]]\nkind = \"sdk\"\nout = \"early\"\n\
1422 [export.env]\nscript = \"env-setup-early\"\npath = \"before-shims\"\n\
1423 [[export]]\nkind = \"sdk\"\nout = \"late\"\n\
1424 [export.env]\nscript = \"env-setup-late\"\npath = \"after-shims\"\n",
1425 )
1426 .unwrap();
1427 let sourced: Vec<String> = env_lines(&pin, root, Some(shim_env))
1428 .into_iter()
1429 .filter(|l| l.starts_with(". "))
1430 .collect();
1431 assert_eq!(
1432 sourced,
1433 vec![
1434 ". \"/repo/late/env-setup-late\"".to_string(),
1435 ". \"/home/u/.varve/env\"".to_string(),
1436 ". \"/repo/early/env-setup-early\"".to_string(),
1437 ],
1438 "after-shims is sourced FIRST so the shims land ahead of it"
1439 );
1440 assert!(
1442 !env_lines(&pin, root, Some(shim_env))
1443 .join("\n")
1444 .contains("vendor")
1445 );
1446 let no_shims: Vec<String> = env_lines(&pin, root, None)
1448 .into_iter()
1449 .filter(|l| l.starts_with(". "))
1450 .collect();
1451 assert_eq!(no_shims.len(), 2);
1452 assert!(!no_shims.iter().any(|l| l.contains(".varve/env")));
1453 }
1454}