Skip to main content

varve_core/
pin.rs

1//! The pin — `varve.toml`, the human-written half of the two manifests.
2//!
3//! Checked into the consuming repo, discovered by walking up from the working
4//! directory, reviewed like code. It names the layer a project is frozen on;
5//! it is a *preference*, where the layer manifest is *evidence*. Conflating
6//! the two is how toolchains drift (see `docs/manifest-format.md`).
7//!
8//! Parsing is strict: unknown keys, a missing patch component, or a malformed
9//! digest are hard errors carrying corrective guidance — a qualified pin that
10//! half-parses is worse than one that fails loudly.
11//!
12//! The pin also declares the project's EXPORTS (REQ-EXPORTDECL-001). The pin
13//! says which layer a project consumes; an `[[export]]` entry says HOW it
14//! consumes it. They belong in one file because `verify` already reads it: a
15//! second discovery path is a second thing to get wrong, and an export that
16//! lives only in a CI script is an export nothing ever checks.
17
18use std::path::{Path, PathBuf};
19use std::str::FromStr;
20
21use serde::Deserialize;
22
23use crate::layer::{LayerId, LayerIdError};
24
25/// The release channel a pin selects.
26///
27/// `qualified` names a line with a stated support window and qualification
28/// evidence attached; `rolling` has neither and may move.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum Channel {
32    Qualified,
33    Rolling,
34}
35
36impl Channel {
37    /// The wire string, matching the signed manifest annotation and the pin.
38    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    /// The SAME vocabulary a pin accepts, exposed so the produce side can
49    /// refuse a channel no pin could ever name (REQ-PRODUCER-001). Deriving
50    /// both from one enum is what keeps them from drifting apart.
51    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/// Which export adapter an `[[export]]` entry names (REQ-EXPORTDECL-001
61/// clause 2).
62///
63/// The wire strings are exactly the ones the adapters already write into
64/// `.varve-export.json`, so a declaration and the stamp it is checked against
65/// are comparable without a translation table — a table is a place for the two
66/// to drift, and drift here would make `verify` pass on a directory produced by
67/// a different adapter entirely.
68#[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    /// Is this payload CONSUMED by entering an environment rather than by
91    /// pointing at a path (clause 4)?
92    ///
93    /// A Yocto SDK is sourced: `environment-setup-*` sets CC, SYSROOT and
94    /// CFLAGS and prepends its own bin to PATH. A local Cargo registry or a
95    /// distdir is not — a config file points at it. Only a sourced export may
96    /// carry an `[export.env]`, because on anything else the block would be
97    /// accepted, ignored, and believed.
98    pub fn is_sourced(self) -> bool {
99        matches!(self, ExportKind::Sdk)
100    }
101
102    /// Every kind, for diagnostics that must say what WOULD have worked.
103    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/// Where a sourced environment sits relative to varve's shims on PATH
131/// (clause 5).
132///
133/// This is not bookkeeping. An SDK's `environment-setup-*` PREPENDS its own bin
134/// to PATH, which shadows the shims — precisely the condition REQ-SHADOW-001
135/// detects. Undeclared, `verify` either catches a genuine hijack or fires on a
136/// legitimate setup, and the spurious failure is the worse of the two: a check
137/// that cries wolf is a check people switch off.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ShimOrder {
140    /// The environment's bin comes FIRST: its compiler wins over the pinned
141    /// one, and the project has said so on purpose.
142    BeforeShims,
143    /// varve's shims come first: the pinned tools win, and anything of theirs
144    /// resolving into this export contradicts the declaration.
145    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/// How a sourced export's environment is entered (clauses 4 and 5).
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct ExportEnv {
171    /// The script to source, relative to the export's `out` directory.
172    pub script: String,
173    /// Where it sits relative to varve's shims once sourced.
174    pub path: ShimOrder,
175}
176
177/// One declared export: the adapter, the destination, optionally a subset of
178/// the layer, and — for a sourced payload — how its environment is entered.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct ExportDecl {
181    pub kind: ExportKind,
182    /// Destination, relative to the directory holding `varve.toml`.
183    pub out: String,
184    /// A SUBSET of the layer's payloads by name. `None` means all of them —
185    /// a project exporting one extension or three crates into a dedicated
186    /// area is the ordinary case, not a special one (clause 2).
187    pub select: Option<Vec<String>>,
188    pub env: Option<ExportEnv>,
189}
190
191impl ExportDecl {
192    /// The absolute destination, resolved against the directory holding
193    /// `varve.toml`. Relative in the file so the declaration travels with the
194    /// repository; absolute here because everything downstream — the stamp, the
195    /// SDK relocation budget, the PATH comparison — needs a real path.
196    pub fn dir(&self, project_root: &Path) -> PathBuf {
197        project_root.join(&self.out)
198    }
199
200    /// The script `varve env` sources for this export, if it has one.
201    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/// One entry of a pin's `tools` list (REQ-REALM2-001 clause 4a).
209///
210/// A bare name — `"rivet"` — means "whichever layer of this composition
211/// provides it", and stays exactly what it always was where nothing collides.
212/// A REALM-QUALIFIED name — `"bytecodealliance/wasm-tools"` — names the realm
213/// that must provide it, which is the only thing that can settle a collision
214/// between two realms shipping one name. Filtering by name never could: the
215/// collision IS one name.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct ToolSelector {
218    /// The realm the pin chose, or `None` for a bare name.
219    pub realm: Option<String>,
220    /// The dispatchable name, always bare.
221    pub name: String,
222}
223
224impl ToolSelector {
225    /// Parse one `tools` entry. `None` where the string is neither a plain
226    /// name nor `realm/name` — a path, an empty half, or a deeper path.
227    ///
228    /// The check is deliberately whole-string rather than "does it contain a
229    /// slash": `/usr/bin/id` and `../x` must stay refused, because a tool name
230    /// indexes the verified layer's `bin/` and `Path::join` with an absolute
231    /// path REPLACES the base.
232    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/// A parsed, validated pin.
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct Pin {
266    /// Optional trust universe (REQ-REALM-001). When named, the realm's
267    /// registry and trust root are AUTHORITATIVE for this project.
268    pub realm: Option<String>,
269    pub channel: Channel,
270    pub layer: LayerId,
271    /// Optional exact manifest digest. When present it wins over the name:
272    /// a name resolving to a different digest is a hard failure (DD-005's
273    /// lever available at the pin level).
274    pub digest: Option<String>,
275    /// Optional restriction to a subset of the layer's tools, each optionally
276    /// realm-qualified. `None` means every tool in the composition.
277    pub tools: Option<Vec<ToolSelector>>,
278    /// The exports this project declares (REQ-EXPORTDECL-001). Declared means
279    /// checked: `verify` looks at every one of these without being told.
280    pub exports: Vec<ExportDecl>,
281}
282
283/// Why a pin failed to parse or validate.
284#[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    // Display carries only the location; the cause prints once via the
301    // #[source] chain (varve#7 — the anyhow alternate formatter was
302    // printing it twice).
303    #[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    /// `[[export]]` entries, in declaration order.
411    #[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    /// Kept as `Option<String>` rather than a required typed field so the
429    /// refusal can explain WHY the answer is needed. Serde's own "missing field
430    /// `path`" is true and useless: the reader has to know that omitting it
431    /// makes `verify` guess between a legitimate SDK and a hijack.
432    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    /// Parse and validate pin content. `origin` names the source (a path, in
448    /// diagnostics) — errors must tell the reader *which* file is wrong.
449    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                // A tool name indexes the verified layer's `bin/`. `Path::join`
487                // with an absolute path REPLACES the base, and `..` walks out
488                // of it, so anything but a plain name (or one realm qualifier
489                // ahead of a plain name) would escape the layer — the opposite
490                // of "a pin resolves exactly or the command fails".
491                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    /// Read and parse a pin file from disk.
516    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
525/// Is this a relative path that stays inside its base? Returns the fault, or
526/// `None` when it is usable.
527///
528/// The rule the whole file already applies to a tool name, applied to a
529/// directory: an absolute path REPLACES the base in `Path::join`, and `..`
530/// walks out of it, so either would let a declaration written in a repository
531/// address something outside the checkout.
532fn 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
553/// Validate the declared exports as a set (REQ-EXPORTDECL-001 clauses 1, 2, 4
554/// and 5). Nothing here is best-effort: an entry that cannot be acted on is a
555/// hard failure, because the whole point of declaring an export is that
556/// something checks it.
557fn 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                // Clause 5: this is REQUIRED, not defaulted. A default would be
632                // a guess about PATH order made on the project's behalf, and
633                // being wrong in either direction is what this clause exists to
634                // prevent.
635                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/// The verdict for ONE declared export (clause 3).
663#[derive(Debug, PartialEq, Eq)]
664pub enum DeclaredExportStatus {
665    /// The stamp names the layer the pin resolves to, by the declared adapter.
666    Current,
667    /// The declared directory carries no stamp — never generated, deleted, or
668    /// not a varve export at all.
669    ///
670    /// This is a FAILURE, not a warning. "I forgot to generate it" and "it is
671    /// stale" are the same severity to anyone relying on the export, and
672    /// distinguishing them mainly helps the person who made the mistake.
673    Missing,
674    /// The stamp was produced from a different layer than the pin now resolves.
675    Stale { stamped: String, current: String },
676    /// The directory was produced by a DIFFERENT adapter than the one declared.
677    /// A `cargo` declaration checked against a `vsix` stamp would otherwise
678    /// pass on freshness while the declared thing was never produced at all.
679    KindMismatch { declared: String, stamped: String },
680    /// The stamp exists but could not be read or parsed. Distinct from
681    /// `Missing`, because "re-run the export" is not the fix for a permissions
682    /// fault or a truncated file.
683    Unreadable(String),
684}
685
686impl DeclaredExportStatus {
687    pub fn is_current(&self) -> bool {
688        matches!(self, DeclaredExportStatus::Current)
689    }
690}
691
692/// Check one declared export against the layer the pin currently resolves to.
693///
694/// `verify` runs this over EVERY declared export without being asked — that is
695/// the whole of clause 3. `--export <DIR>` remains for a directory nobody has
696/// declared yet; a declared one is checked whether or not anyone remembers it.
697pub 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
724/// Shell lines that enter every declared environment AND varve's shims in one
725/// go (clause 4).
726///
727/// The ordering is the part worth reading twice, because it INVERTS. Sourcing a
728/// script PREPENDS its bin to PATH, so whatever is sourced LAST ends up first
729/// on PATH and wins. An export declared `before-shims` — its compiler is meant
730/// to win — must therefore be sourced AFTER the shims, and one declared
731/// `after-shims` must be sourced BEFORE them. Emitting them in declaration
732/// order would produce exactly the PATH the project said it did not want, and
733/// `verify` would then report the shadowing the project had declared away.
734pub 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 FIRST so the shims, sourced after, land ahead of them on PATH.
754    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 LAST so this environment's bin lands ahead of the shims — which
760    // is what `before-shims` asked for.
761    sourced(ShimOrder::BeforeShims, &mut lines);
762    lines
763}
764
765/// What a declared export says about a binary PATH resolves ahead of a shim
766/// (clause 5).
767#[derive(Debug, PartialEq, Eq)]
768pub enum ShadowDeclaration<'a> {
769    /// Inside an export declared to sit BEFORE the shims. Expected, and
770    /// `verify` must not fail on it — a check that fires on the setup the
771    /// project deliberately configured is one people switch off.
772    Expected(&'a ExportDecl),
773    /// Inside an export declared to sit AFTER the shims. The declaration and
774    /// the actual PATH disagree, which is a real fault with a precise fix —
775    /// and a far better message than the generic hijack report.
776    ContradictsDeclaration(&'a ExportDecl),
777    /// Inside no declared export: the ordinary hijack REQ-SHADOW-001 is for.
778    Undeclared,
779}
780
781/// Is `path` inside `dir`? Canonicalised where both exist, because an export
782/// directory reached through a symlinked checkout is the same directory, and
783/// lexically otherwise — a declared export that has not been generated yet
784/// cannot be canonicalised and must still be recognisable.
785fn 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
790/// Classify a shadowing binary against the project's declared exports.
791///
792/// An SDK's `environment-setup-*` prepends its own bin to PATH, so its compiler
793/// shadows varve's shims. Whether that is a hijack or the point depends entirely
794/// on what the project declared — which is why clause 5 makes the declaration
795/// mandatory rather than inferred.
796pub 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            // An export nobody sources puts nothing on PATH, so a binary found
804            // inside one is not explained by it.
805            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    // rivet: verifies REQ-PIN-001
832    #[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        // The compatibility surface: a bare `tools` list still parses to bare
842        // selectors, realm `None`, in the order written.
843        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    // rivet: verifies REQ-PIN-001
860    #[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    // rivet: verifies REQ-PIN-001
873    #[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    // rivet: verifies REQ-PATCH-001
887    #[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        // The guidance lives in the SOURCE (printed once via the chain).
899        assert!(
900            source.to_string().contains("three-part"),
901            "the chain must teach the grammar: {source}"
902        );
903    }
904
905    // rivet: verifies REQ-PIN-001
906    #[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    // rivet: verifies REQ-PIN-001
917    #[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    // rivet: verifies REQ-PIN-001
928    #[test]
929    fn rejects_malformed_digest() {
930        // Includes a wrong-length PURE-HEX digest: length and charset are
931        // independent checks and each must reject alone.
932        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    // rivet: verifies REQ-PIN-002
951    #[test]
952    fn rejects_a_tool_name_that_is_a_path() {
953        // A tool name is looked up INSIDE the verified layer. `Path::join`
954        // with an absolute path REPLACES the base, so an unchecked name would
955        // resolve outside the layer entirely — exactly the "never falls back
956        // to binaries on PATH" guarantee the docs make. Fail closed here.
957        //
958        // One `/` is now a REALM QUALIFIER (REQ-REALM2-001 clause 4a), so the
959        // list below is what remains path-shaped: an absolute path, a deeper
960        // path, a half that is empty or relative, a Windows path.
961        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        // Ordinary names still parse.
987        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    // rivet: verifies REQ-REALM2-001
992    #[test]
993    fn tools_accepts_a_realm_qualifier_beside_a_bare_name() {
994        // Clause 4a, at the schema. The pin is a COMPATIBILITY SURFACE: the
995        // qualified and bare forms must coexist in one list, because a realm
996        // collision is about one name and the rest of the list is unaffected.
997        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    // rivet: verifies REQ-REALM2-001
1012    #[test]
1013    fn the_refusal_for_a_path_shows_both_forms_that_are_accepted() {
1014        // The old message said "Name the tool only" — which, from v0.29.0, is
1015        // no longer the whole truth, and a reader hitting a collision would be
1016        // sent back to the form that cannot express their fix.
1017        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    // rivet: verifies REQ-PIN-001
1033    #[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    // rivet: verifies REQ-PIN-001
1044    #[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    /// A project declaring the five adapters that exist plus the sourced one
1067    /// this release adds — the shape a real repository would carry.
1068    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    // rivet: verifies REQ-EXPORTDECL-001
1089    #[test]
1090    fn a_project_declares_its_exports_in_the_pin_it_already_has() {
1091        // Clause 1: one file, and `verify` already reads it. The set of exports
1092        // used to live in a CI script or a shell history, which is why an
1093        // export nobody named never went stale — nothing looked at it.
1094        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        // Clause 2: the adapter, the destination, and a SUBSET of the layer.
1105        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        // Clauses 4 and 5 on the sourced one.
1117        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        // Declared relative, resolved absolute against the pin's directory, so
1124        // the file means the same thing on every machine.
1125        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        // A pin with no exports is still a pin: this is additive, and every
1134        // varve.toml already in the world has none.
1135        assert!(Pin::parse(HEAD, "varve.toml").unwrap().exports.is_empty());
1136    }
1137
1138    // rivet: verifies REQ-EXPORTDECL-001
1139    #[test]
1140    fn the_declared_kind_is_one_varve_can_actually_produce() {
1141        // An adapter varve does not have is a declaration nothing will ever
1142        // satisfy — and, worse, one `verify` would have to skip, which is the
1143        // "only checks what it is told about" failure this requirement exists
1144        // to close.
1145        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        // Every kind's wire string is the one the adapters already stamp, so a
1155        // declaration and a stamp compare directly.
1156        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    // rivet: verifies REQ-EXPORTDECL-001
1171    #[test]
1172    fn a_destination_that_leaves_the_repository_is_refused() {
1173        // The declaration is checked into a repository and resolved against the
1174        // directory holding varve.toml. An absolute path REPLACES that base and
1175        // `..` walks out of it, so either would let a committed file address
1176        // something outside the checkout — and `verify` would then report on a
1177        // directory nobody reviewing the pin could see.
1178        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    // rivet: verifies REQ-EXPORTDECL-001
1206    #[test]
1207    fn two_exports_may_not_share_one_directory() {
1208        // Each adapter writes ONE `.varve-export.json`. Two into one directory
1209        // means the second overwrites the first's stamp, after which `verify`
1210        // checks one export twice and the other never — a gate that reports
1211        // green on something it has not looked at.
1212        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    // rivet: verifies REQ-EXPORTDECL-001
1229    #[test]
1230    fn a_subset_selection_names_payloads_not_paths() {
1231        // Clause 2's subset indexes the VERIFIED layer, exactly as `tools` does
1232        // in the toolchain block, so the same rule applies: a name, never a
1233        // path, or the selection resolves outside the layer.
1234        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        // Present but empty is a mistake with a specific correction: omitting
1246        // the key means the whole layer, so an empty list can only be a typo.
1247        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    // rivet: verifies REQ-EXPORTDECL-001
1255    #[test]
1256    fn an_environment_must_say_where_it_sits_relative_to_the_shims() {
1257        // Clause 5, and the reason these two requirements ship together. An
1258        // SDK's environment-setup prepends its own bin to PATH and shadows
1259        // varve's shims — the exact condition REQ-SHADOW-001 detects. Left
1260        // undeclared, `verify` must either miss a genuine hijack or fire on a
1261        // legitimate setup, and the spurious failure is worse: a check that
1262        // cries wolf is a check people switch off.
1263        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        // Both answers parse, and nothing else does.
1280        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    // rivet: verifies REQ-EXPORTDECL-001
1302    #[test]
1303    fn only_an_export_that_is_sourced_may_declare_an_environment() {
1304        // Clause 4 distinguishes a payload ENTERED as an environment from one
1305        // POINTED at. A local Cargo registry is pointed at by a config file; an
1306        // `[export.env]` on it would be accepted, ignored, and believed.
1307        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        // …and the script itself must stay inside the export directory.
1327        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    // rivet: verifies REQ-EXPORTDECL-001
1350    #[test]
1351    fn every_declared_export_is_checked_and_an_absent_one_fails() {
1352        // Clause 3. Declared means checked — no `--export` argument, no CI
1353        // script listing directories. And "I forgot to generate it" and "it is
1354        // stale" are the same severity to anyone relying on the export, so a
1355        // missing directory FAILS rather than warns.
1356        let tmp = tempfile::tempdir().unwrap();
1357        let root = tmp.path();
1358        let pin = parse(DECLARED).unwrap();
1359        let current = "sha256:aaaa";
1360
1361        // Nothing generated yet: every declared export fails, none is skipped.
1362        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        // Generated against the current pin: fresh.
1372        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        // The pin moves on and the exports do not.
1383        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        // A directory produced by a DIFFERENT adapter is not the declared
1393        // export, however fresh its stamp is — the declared thing was never
1394        // produced at all.
1395        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        // A stamp that exists but is unreadable is its own verdict: "re-run the
1406        // export" is not the fix for a truncated file.
1407        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    // rivet: verifies REQ-EXPORTDECL-001
1420    #[test]
1421    fn is_current_is_false_for_every_status_that_is_not_current() {
1422        // `is_current` was asserted TRUE in one place and false in none, so
1423        // replacing its whole body with `true` survived the mutation gate: a
1424        // stale, missing, mismatched or unreadable export would have reported
1425        // itself fresh and `verify` would have exited 0 on the drift the
1426        // declaration exists to catch. Found by cargo mutants in CI.
1427        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    // rivet: verifies REQ-EXPORTDECL-001, REQ-SHADOW-001
1448    #[test]
1449    fn an_export_reached_through_a_symlink_is_the_same_export() {
1450        // `is_within` is `lexical || canonical`, and every existing test
1451        // satisfied BOTH halves, so flipping it to `&&` survived. The `||` is
1452        // load-bearing in exactly one direction: a checkout reached through a
1453        // symlink gives a path that does NOT lexically start with the declared
1454        // directory but canonicalises into it. Under `&&` such an export reads
1455        // as undeclared, and a declared SDK's compiler gets reported as a
1456        // hijack — the cry-wolf failure clause 5 exists to prevent.
1457        let tmp = tempfile::tempdir().unwrap();
1458        let real_dir = tmp.path().join("real/export");
1459        // The binary must really exist: `canonicalize` resolves whole paths,
1460        // so a fixture that only creates directories tests the not-generated
1461        // branch instead of the symlink one.
1462        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        // Reached through the symlink: lexically outside, canonically inside.
1471        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        // …and a genuinely unrelated path is still outside, so the fix is
1482        // containment rather than `is_within` answering true for everything.
1483        assert!(!is_within(&real_dir, &tmp.path().join("elsewhere/bin/gcc")));
1484    }
1485
1486    // rivet: verifies REQ-EXPORTDECL-001, REQ-SHADOW-001
1487    #[test]
1488    fn a_declared_sdk_environment_is_not_reported_as_a_hijack() {
1489        // Clause 5's payoff. The SDK's `aarch64-poky-linux-gcc` really is ahead
1490        // of varve's shims on PATH — the project said so. Reporting that as a
1491        // hijack is the cry-wolf failure; reporting an UNdeclared one as fine
1492        // is the miss. The declaration is what tells them apart.
1493        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        // Anywhere else is the ordinary hijack REQ-SHADOW-001 exists for — a
1502        // distro gcc, a `cargo install`ed tool, a stale shim directory.
1503        assert_eq!(
1504            classify_shadowing(&pin, root, Path::new("/usr/local/bin/gcc")),
1505            ShadowDeclaration::Undeclared
1506        );
1507        // …and so is a binary inside an export that is not sourced at all: a
1508        // vendor directory puts nothing on PATH, so it explains nothing.
1509        assert_eq!(
1510            classify_shadowing(&pin, root, Path::new("/repo/vendor/registry/gcc")),
1511            ShadowDeclaration::Undeclared
1512        );
1513
1514        // Declared the OTHER way, the same binary is a real fault: the project
1515        // said varve's tools win and PATH says otherwise. Distinct verdict,
1516        // because the fix is distinct — fix PATH, or fix the declaration.
1517        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    // rivet: verifies REQ-EXPORTDECL-001
1530    #[test]
1531    fn one_command_sources_the_whole_environment_in_the_declared_path_order() {
1532        // Clause 4, and the ordering INVERTS — which is the whole reason this
1533        // is computed rather than written by hand. Sourcing a script PREPENDS
1534        // its bin to PATH, so whatever is sourced LAST wins. An export declared
1535        // `before-shims` must therefore be sourced AFTER the shims; emitting
1536        // them in declaration order would produce exactly the PATH the project
1537        // said it did not want, and `verify` would then flag it.
1538        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        // A cargo export contributes nothing: it is pointed at, not entered.
1562        assert!(
1563            !env_lines(&pin, root, Some(shim_env))
1564                .join("\n")
1565                .contains("vendor")
1566        );
1567        // Without a shim env there is still an environment to enter.
1568        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}