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/// A parsed, validated pin.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct Pin {
211    /// Optional trust universe (REQ-REALM-001). When named, the realm's
212    /// registry and trust root are AUTHORITATIVE for this project.
213    pub realm: Option<String>,
214    pub channel: Channel,
215    pub layer: LayerId,
216    /// Optional exact manifest digest. When present it wins over the name:
217    /// a name resolving to a different digest is a hard failure (DD-005's
218    /// lever available at the pin level).
219    pub digest: Option<String>,
220    /// Optional restriction to a subset of the layer's tools. `None` means
221    /// every tool in the layer.
222    pub tools: Option<Vec<String>>,
223    /// The exports this project declares (REQ-EXPORTDECL-001). Declared means
224    /// checked: `verify` looks at every one of these without being told.
225    pub exports: Vec<ExportDecl>,
226}
227
228/// Why a pin failed to parse or validate.
229#[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    // Display carries only the location; the cause prints once via the
246    // #[source] chain (varve#7 — the anyhow alternate formatter was
247    // printing it twice).
248    #[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    /// `[[export]]` entries, in declaration order.
355    #[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    /// Kept as `Option<String>` rather than a required typed field so the
373    /// refusal can explain WHY the answer is needed. Serde's own "missing field
374    /// `path`" is true and useless: the reader has to know that omitting it
375    /// makes `verify` guess between a legitimate SDK and a hijack.
376    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    /// Parse and validate pin content. `origin` names the source (a path, in
392    /// diagnostics) — errors must tell the reader *which* file is wrong.
393    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            // A tool name indexes the verified layer's `bin/`. `Path::join`
429            // with an absolute path REPLACES the base, and `..` walks out of
430            // it, so anything but a plain name would escape the layer — the
431            // opposite of "a pin resolves exactly or the command fails".
432            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    /// Read and parse a pin file from disk.
459    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
468/// Is this a relative path that stays inside its base? Returns the fault, or
469/// `None` when it is usable.
470///
471/// The rule the whole file already applies to a tool name, applied to a
472/// directory: an absolute path REPLACES the base in `Path::join`, and `..`
473/// walks out of it, so either would let a declaration written in a repository
474/// address something outside the checkout.
475fn 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
496/// Validate the declared exports as a set (REQ-EXPORTDECL-001 clauses 1, 2, 4
497/// and 5). Nothing here is best-effort: an entry that cannot be acted on is a
498/// hard failure, because the whole point of declaring an export is that
499/// something checks it.
500fn 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                // Clause 5: this is REQUIRED, not defaulted. A default would be
575                // a guess about PATH order made on the project's behalf, and
576                // being wrong in either direction is what this clause exists to
577                // prevent.
578                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/// The verdict for ONE declared export (clause 3).
606#[derive(Debug, PartialEq, Eq)]
607pub enum DeclaredExportStatus {
608    /// The stamp names the layer the pin resolves to, by the declared adapter.
609    Current,
610    /// The declared directory carries no stamp — never generated, deleted, or
611    /// not a varve export at all.
612    ///
613    /// This is a FAILURE, not a warning. "I forgot to generate it" and "it is
614    /// stale" are the same severity to anyone relying on the export, and
615    /// distinguishing them mainly helps the person who made the mistake.
616    Missing,
617    /// The stamp was produced from a different layer than the pin now resolves.
618    Stale { stamped: String, current: String },
619    /// The directory was produced by a DIFFERENT adapter than the one declared.
620    /// A `cargo` declaration checked against a `vsix` stamp would otherwise
621    /// pass on freshness while the declared thing was never produced at all.
622    KindMismatch { declared: String, stamped: String },
623    /// The stamp exists but could not be read or parsed. Distinct from
624    /// `Missing`, because "re-run the export" is not the fix for a permissions
625    /// fault or a truncated file.
626    Unreadable(String),
627}
628
629impl DeclaredExportStatus {
630    pub fn is_current(&self) -> bool {
631        matches!(self, DeclaredExportStatus::Current)
632    }
633}
634
635/// Check one declared export against the layer the pin currently resolves to.
636///
637/// `verify` runs this over EVERY declared export without being asked — that is
638/// the whole of clause 3. `--export <DIR>` remains for a directory nobody has
639/// declared yet; a declared one is checked whether or not anyone remembers it.
640pub 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
667/// Shell lines that enter every declared environment AND varve's shims in one
668/// go (clause 4).
669///
670/// The ordering is the part worth reading twice, because it INVERTS. Sourcing a
671/// script PREPENDS its bin to PATH, so whatever is sourced LAST ends up first
672/// on PATH and wins. An export declared `before-shims` — its compiler is meant
673/// to win — must therefore be sourced AFTER the shims, and one declared
674/// `after-shims` must be sourced BEFORE them. Emitting them in declaration
675/// order would produce exactly the PATH the project said it did not want, and
676/// `verify` would then report the shadowing the project had declared away.
677pub 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 FIRST so the shims, sourced after, land ahead of them on PATH.
697    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 LAST so this environment's bin lands ahead of the shims — which
703    // is what `before-shims` asked for.
704    sourced(ShimOrder::BeforeShims, &mut lines);
705    lines
706}
707
708/// What a declared export says about a binary PATH resolves ahead of a shim
709/// (clause 5).
710#[derive(Debug, PartialEq, Eq)]
711pub enum ShadowDeclaration<'a> {
712    /// Inside an export declared to sit BEFORE the shims. Expected, and
713    /// `verify` must not fail on it — a check that fires on the setup the
714    /// project deliberately configured is one people switch off.
715    Expected(&'a ExportDecl),
716    /// Inside an export declared to sit AFTER the shims. The declaration and
717    /// the actual PATH disagree, which is a real fault with a precise fix —
718    /// and a far better message than the generic hijack report.
719    ContradictsDeclaration(&'a ExportDecl),
720    /// Inside no declared export: the ordinary hijack REQ-SHADOW-001 is for.
721    Undeclared,
722}
723
724/// Is `path` inside `dir`? Canonicalised where both exist, because an export
725/// directory reached through a symlinked checkout is the same directory, and
726/// lexically otherwise — a declared export that has not been generated yet
727/// cannot be canonicalised and must still be recognisable.
728fn 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
733/// Classify a shadowing binary against the project's declared exports.
734///
735/// An SDK's `environment-setup-*` prepends its own bin to PATH, so its compiler
736/// shadows varve's shims. Whether that is a hijack or the point depends entirely
737/// on what the project declared — which is why clause 5 makes the declaration
738/// mandatory rather than inferred.
739pub 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            // An export nobody sources puts nothing on PATH, so a binary found
747            // inside one is not explained by it.
748            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    // rivet: verifies REQ-PIN-001
775    #[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    // rivet: verifies REQ-PIN-001
791    #[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    // rivet: verifies REQ-PIN-001
804    #[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    // rivet: verifies REQ-PATCH-001
818    #[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        // The guidance lives in the SOURCE (printed once via the chain).
830        assert!(
831            source.to_string().contains("three-part"),
832            "the chain must teach the grammar: {source}"
833        );
834    }
835
836    // rivet: verifies REQ-PIN-001
837    #[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    // rivet: verifies REQ-PIN-001
848    #[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    // rivet: verifies REQ-PIN-001
859    #[test]
860    fn rejects_malformed_digest() {
861        // Includes a wrong-length PURE-HEX digest: length and charset are
862        // independent checks and each must reject alone.
863        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    // rivet: verifies REQ-PIN-002
882    #[test]
883    fn rejects_a_tool_name_that_is_a_path() {
884        // A tool name is looked up INSIDE the verified layer. `Path::join`
885        // with an absolute path REPLACES the base, so an unchecked name would
886        // resolve outside the layer entirely — exactly the "never falls back
887        // to binaries on PATH" guarantee the docs make. Fail closed here.
888        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        // Ordinary names still parse.
907        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    // rivet: verifies REQ-PIN-001
912    #[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    // rivet: verifies REQ-PIN-001
923    #[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    /// A project declaring the five adapters that exist plus the sourced one
946    /// this release adds — the shape a real repository would carry.
947    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    // rivet: verifies REQ-EXPORTDECL-001
968    #[test]
969    fn a_project_declares_its_exports_in_the_pin_it_already_has() {
970        // Clause 1: one file, and `verify` already reads it. The set of exports
971        // used to live in a CI script or a shell history, which is why an
972        // export nobody named never went stale — nothing looked at it.
973        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        // Clause 2: the adapter, the destination, and a SUBSET of the layer.
984        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        // Clauses 4 and 5 on the sourced one.
996        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        // Declared relative, resolved absolute against the pin's directory, so
1003        // the file means the same thing on every machine.
1004        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        // A pin with no exports is still a pin: this is additive, and every
1013        // varve.toml already in the world has none.
1014        assert!(Pin::parse(HEAD, "varve.toml").unwrap().exports.is_empty());
1015    }
1016
1017    // rivet: verifies REQ-EXPORTDECL-001
1018    #[test]
1019    fn the_declared_kind_is_one_varve_can_actually_produce() {
1020        // An adapter varve does not have is a declaration nothing will ever
1021        // satisfy — and, worse, one `verify` would have to skip, which is the
1022        // "only checks what it is told about" failure this requirement exists
1023        // to close.
1024        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        // Every kind's wire string is the one the adapters already stamp, so a
1034        // declaration and a stamp compare directly.
1035        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    // rivet: verifies REQ-EXPORTDECL-001
1050    #[test]
1051    fn a_destination_that_leaves_the_repository_is_refused() {
1052        // The declaration is checked into a repository and resolved against the
1053        // directory holding varve.toml. An absolute path REPLACES that base and
1054        // `..` walks out of it, so either would let a committed file address
1055        // something outside the checkout — and `verify` would then report on a
1056        // directory nobody reviewing the pin could see.
1057        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    // rivet: verifies REQ-EXPORTDECL-001
1085    #[test]
1086    fn two_exports_may_not_share_one_directory() {
1087        // Each adapter writes ONE `.varve-export.json`. Two into one directory
1088        // means the second overwrites the first's stamp, after which `verify`
1089        // checks one export twice and the other never — a gate that reports
1090        // green on something it has not looked at.
1091        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    // rivet: verifies REQ-EXPORTDECL-001
1108    #[test]
1109    fn a_subset_selection_names_payloads_not_paths() {
1110        // Clause 2's subset indexes the VERIFIED layer, exactly as `tools` does
1111        // in the toolchain block, so the same rule applies: a name, never a
1112        // path, or the selection resolves outside the layer.
1113        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        // Present but empty is a mistake with a specific correction: omitting
1125        // the key means the whole layer, so an empty list can only be a typo.
1126        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    // rivet: verifies REQ-EXPORTDECL-001
1134    #[test]
1135    fn an_environment_must_say_where_it_sits_relative_to_the_shims() {
1136        // Clause 5, and the reason these two requirements ship together. An
1137        // SDK's environment-setup prepends its own bin to PATH and shadows
1138        // varve's shims — the exact condition REQ-SHADOW-001 detects. Left
1139        // undeclared, `verify` must either miss a genuine hijack or fire on a
1140        // legitimate setup, and the spurious failure is worse: a check that
1141        // cries wolf is a check people switch off.
1142        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        // Both answers parse, and nothing else does.
1159        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    // rivet: verifies REQ-EXPORTDECL-001
1181    #[test]
1182    fn only_an_export_that_is_sourced_may_declare_an_environment() {
1183        // Clause 4 distinguishes a payload ENTERED as an environment from one
1184        // POINTED at. A local Cargo registry is pointed at by a config file; an
1185        // `[export.env]` on it would be accepted, ignored, and believed.
1186        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        // …and the script itself must stay inside the export directory.
1206        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    // rivet: verifies REQ-EXPORTDECL-001
1229    #[test]
1230    fn every_declared_export_is_checked_and_an_absent_one_fails() {
1231        // Clause 3. Declared means checked — no `--export` argument, no CI
1232        // script listing directories. And "I forgot to generate it" and "it is
1233        // stale" are the same severity to anyone relying on the export, so a
1234        // missing directory FAILS rather than warns.
1235        let tmp = tempfile::tempdir().unwrap();
1236        let root = tmp.path();
1237        let pin = parse(DECLARED).unwrap();
1238        let current = "sha256:aaaa";
1239
1240        // Nothing generated yet: every declared export fails, none is skipped.
1241        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        // Generated against the current pin: fresh.
1251        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        // The pin moves on and the exports do not.
1262        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        // A directory produced by a DIFFERENT adapter is not the declared
1272        // export, however fresh its stamp is — the declared thing was never
1273        // produced at all.
1274        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        // A stamp that exists but is unreadable is its own verdict: "re-run the
1285        // export" is not the fix for a truncated file.
1286        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    // rivet: verifies REQ-EXPORTDECL-001
1299    #[test]
1300    fn is_current_is_false_for_every_status_that_is_not_current() {
1301        // `is_current` was asserted TRUE in one place and false in none, so
1302        // replacing its whole body with `true` survived the mutation gate: a
1303        // stale, missing, mismatched or unreadable export would have reported
1304        // itself fresh and `verify` would have exited 0 on the drift the
1305        // declaration exists to catch. Found by cargo mutants in CI.
1306        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    // rivet: verifies REQ-EXPORTDECL-001, REQ-SHADOW-001
1327    #[test]
1328    fn an_export_reached_through_a_symlink_is_the_same_export() {
1329        // `is_within` is `lexical || canonical`, and every existing test
1330        // satisfied BOTH halves, so flipping it to `&&` survived. The `||` is
1331        // load-bearing in exactly one direction: a checkout reached through a
1332        // symlink gives a path that does NOT lexically start with the declared
1333        // directory but canonicalises into it. Under `&&` such an export reads
1334        // as undeclared, and a declared SDK's compiler gets reported as a
1335        // hijack — the cry-wolf failure clause 5 exists to prevent.
1336        let tmp = tempfile::tempdir().unwrap();
1337        let real_dir = tmp.path().join("real/export");
1338        // The binary must really exist: `canonicalize` resolves whole paths,
1339        // so a fixture that only creates directories tests the not-generated
1340        // branch instead of the symlink one.
1341        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        // Reached through the symlink: lexically outside, canonically inside.
1350        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        // …and a genuinely unrelated path is still outside, so the fix is
1361        // containment rather than `is_within` answering true for everything.
1362        assert!(!is_within(&real_dir, &tmp.path().join("elsewhere/bin/gcc")));
1363    }
1364
1365    // rivet: verifies REQ-EXPORTDECL-001, REQ-SHADOW-001
1366    #[test]
1367    fn a_declared_sdk_environment_is_not_reported_as_a_hijack() {
1368        // Clause 5's payoff. The SDK's `aarch64-poky-linux-gcc` really is ahead
1369        // of varve's shims on PATH — the project said so. Reporting that as a
1370        // hijack is the cry-wolf failure; reporting an UNdeclared one as fine
1371        // is the miss. The declaration is what tells them apart.
1372        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        // Anywhere else is the ordinary hijack REQ-SHADOW-001 exists for — a
1381        // distro gcc, a `cargo install`ed tool, a stale shim directory.
1382        assert_eq!(
1383            classify_shadowing(&pin, root, Path::new("/usr/local/bin/gcc")),
1384            ShadowDeclaration::Undeclared
1385        );
1386        // …and so is a binary inside an export that is not sourced at all: a
1387        // vendor directory puts nothing on PATH, so it explains nothing.
1388        assert_eq!(
1389            classify_shadowing(&pin, root, Path::new("/repo/vendor/registry/gcc")),
1390            ShadowDeclaration::Undeclared
1391        );
1392
1393        // Declared the OTHER way, the same binary is a real fault: the project
1394        // said varve's tools win and PATH says otherwise. Distinct verdict,
1395        // because the fix is distinct — fix PATH, or fix the declaration.
1396        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    // rivet: verifies REQ-EXPORTDECL-001
1409    #[test]
1410    fn one_command_sources_the_whole_environment_in_the_declared_path_order() {
1411        // Clause 4, and the ordering INVERTS — which is the whole reason this
1412        // is computed rather than written by hand. Sourcing a script PREPENDS
1413        // its bin to PATH, so whatever is sourced LAST wins. An export declared
1414        // `before-shims` must therefore be sourced AFTER the shims; emitting
1415        // them in declaration order would produce exactly the PATH the project
1416        // said it did not want, and `verify` would then flag it.
1417        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        // A cargo export contributes nothing: it is pointed at, not entered.
1441        assert!(
1442            !env_lines(&pin, root, Some(shim_env))
1443                .join("\n")
1444                .contains("vendor")
1445        );
1446        // Without a shim env there is still an environment to enter.
1447        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}