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(
299        "{path}: manifest-version {found} is not supported (this varve understands version {})",
300        crate::consumer::PIN_MANIFEST_VERSION
301    )]
302    UnsupportedManifestVersion { path: String, found: i64 },
303    // Display carries only the location; the cause prints once via the
304    // #[source] chain (varve#7 — the anyhow alternate formatter was
305    // printing it twice).
306    #[error("{path}: invalid layer identifier")]
307    Layer {
308        path: String,
309        #[source]
310        source: LayerIdError,
311    },
312    #[error(
313        "{path}: digest '{found}' is not a valid digest: expected 'sha256:' followed by 64 hex characters"
314    )]
315    MalformedDigest { path: String, found: String },
316    #[error(
317        "{path}: tools entry {name:?} is neither a tool name nor a realm-qualified one — \
318         a tool is looked up INSIDE the verified composition, so a path would resolve \
319         outside it. Write either tools = [\"rivet\"] or, where two realms ship one name, \
320         tools = [\"bytecodealliance/wasm-tools\"]."
321    )]
322    ToolNameIsAPath { path: String, name: String },
323    #[error("{path}: tools list is present but empty — omit it to select every tool in the layer")]
324    EmptyTools { path: String },
325    #[error(
326        "{path}: export kind {kind:?} is not one this varve can produce — expected one of {expected}"
327    )]
328    UnknownExportKind {
329        path: String,
330        kind: String,
331        expected: String,
332    },
333    #[error(
334        "{path}: export destination {out:?} is not usable ({why}) — an export directory is \
335         RELATIVE to the directory holding varve.toml, so the declaration travels with the \
336         repository and means the same thing on every machine"
337    )]
338    ExportOutEscapes {
339        path: String,
340        out: String,
341        why: String,
342    },
343    #[error(
344        "{path}: exports {first:?} and {second:?} both write to {out:?} — the second would \
345         overwrite the first's stamp, and `verify` would then check one export twice while \
346         never checking the other at all"
347    )]
348    DuplicateExportOut {
349        path: String,
350        out: String,
351        first: String,
352        second: String,
353    },
354    #[error(
355        "{path}: export to {out:?} has an empty select list — omit it to export the whole layer"
356    )]
357    EmptyExportSelect { path: String, out: String },
358    #[error(
359        "{path}: export to {out:?} selects {name:?}, which is not a plain payload name — a \
360         selection indexes the VERIFIED layer, so a path would reach outside it"
361    )]
362    ExportSelectIsAPath {
363        path: String,
364        out: String,
365        name: String,
366    },
367    #[error(
368        "{path}: export to {out:?} is a {kind} export, which is consumed by POINTING at it, not \
369         by sourcing it — an [export.env] here would be accepted, ignored, and believed. Only \
370         these kinds are entered as an environment: {sourced}"
371    )]
372    ExportEnvNotSourced {
373        path: String,
374        out: String,
375        kind: String,
376        sourced: String,
377    },
378    #[error(
379        "{path}: export to {out:?} declares an environment but not where it sits relative to \
380         varve's shims. Add `path = \"before-shims\"` if this environment's bin is meant to win \
381         on PATH, or `path = \"after-shims\"` if varve's pinned tools are. Undeclared, `verify` \
382         cannot tell a legitimate sourced SDK from a hijacked PATH (REQ-SHADOW-001), and \
383         guessing wrong either misses a real one or cries wolf on a correct setup"
384    )]
385    ExportEnvNeedsShimOrder { path: String, out: String },
386    #[error(
387        "{path}: export to {out:?} declares path = {found:?} — expected \"before-shims\" or \
388         \"after-shims\""
389    )]
390    UnknownShimOrder {
391        path: String,
392        out: String,
393        found: String,
394    },
395    #[error(
396        "{path}: export to {out:?} sources {script:?}, which is not usable ({why}) — the script \
397         is relative to the export directory, and it must stay inside it"
398    )]
399    ExportScriptEscapes {
400        path: String,
401        out: String,
402        script: String,
403        why: String,
404    },
405}
406
407#[derive(Deserialize)]
408#[serde(deny_unknown_fields)]
409struct RawPin {
410    #[serde(rename = "manifest-version")]
411    manifest_version: i64,
412    toolchain: RawToolchain,
413    /// `[[export]]` entries, in declaration order.
414    #[serde(default, rename = "export")]
415    exports: Vec<RawExport>,
416}
417
418#[derive(Deserialize)]
419#[serde(deny_unknown_fields)]
420struct RawExport {
421    kind: String,
422    out: String,
423    select: Option<Vec<String>>,
424    env: Option<RawExportEnv>,
425}
426
427#[derive(Deserialize)]
428#[serde(deny_unknown_fields)]
429struct RawExportEnv {
430    script: String,
431    /// Kept as `Option<String>` rather than a required typed field so the
432    /// refusal can explain WHY the answer is needed. Serde's own "missing field
433    /// `path`" is true and useless: the reader has to know that omitting it
434    /// makes `verify` guess between a legitimate SDK and a hijack.
435    path: Option<String>,
436}
437
438#[derive(Deserialize)]
439#[serde(deny_unknown_fields)]
440struct RawToolchain {
441    #[serde(default)]
442    realm: Option<String>,
443    channel: Channel,
444    layer: String,
445    digest: Option<String>,
446    tools: Option<Vec<String>>,
447}
448
449impl Pin {
450    /// Parse and validate pin content. `origin` names the source (a path, in
451    /// diagnostics) — errors must tell the reader *which* file is wrong.
452    pub fn parse(content: &str, origin: &str) -> Result<Self, PinError> {
453        let raw: RawPin = toml::from_str(content).map_err(|source| PinError::Toml {
454            path: origin.to_string(),
455            source: Box::new(source),
456        })?;
457        // The constant, not a literal: `consumer::PIN_MANIFEST_VERSION` is
458        // published so a consumer can record what it understands as a fact
459        // (REQ-CONSUMERAPI-001 clause 5). A second copy of the number here
460        // would let the published value drift from the enforced one, which is
461        // worse than not publishing it at all.
462        if raw.manifest_version != crate::consumer::PIN_MANIFEST_VERSION {
463            return Err(PinError::UnsupportedManifestVersion {
464                path: origin.to_string(),
465                found: raw.manifest_version,
466            });
467        }
468        let layer = LayerId::from_str(&raw.toolchain.layer).map_err(|source| PinError::Layer {
469            path: origin.to_string(),
470            source,
471        })?;
472        if let Some(digest) = &raw.toolchain.digest {
473            let hex = digest
474                .strip_prefix("sha256:")
475                .ok_or_else(|| PinError::MalformedDigest {
476                    path: origin.to_string(),
477                    found: digest.clone(),
478                })?;
479            if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
480                return Err(PinError::MalformedDigest {
481                    path: origin.to_string(),
482                    found: digest.clone(),
483                });
484            }
485        }
486        let tools = match &raw.toolchain.tools {
487            None => None,
488            Some(entries) => {
489                if entries.is_empty() {
490                    return Err(PinError::EmptyTools {
491                        path: origin.to_string(),
492                    });
493                }
494                // A tool name indexes the verified layer's `bin/`. `Path::join`
495                // with an absolute path REPLACES the base, and `..` walks out
496                // of it, so anything but a plain name (or one realm qualifier
497                // ahead of a plain name) would escape the layer — the opposite
498                // of "a pin resolves exactly or the command fails".
499                let mut selectors = Vec::with_capacity(entries.len());
500                for entry in entries {
501                    let Some(selector) = ToolSelector::parse(entry) else {
502                        return Err(PinError::ToolNameIsAPath {
503                            path: origin.to_string(),
504                            name: entry.clone(),
505                        });
506                    };
507                    selectors.push(selector);
508                }
509                Some(selectors)
510            }
511        };
512        let exports = parse_exports(&raw.exports, origin)?;
513        Ok(Pin {
514            realm: raw.toolchain.realm,
515            channel: raw.toolchain.channel,
516            layer,
517            digest: raw.toolchain.digest,
518            tools,
519            exports,
520        })
521    }
522
523    /// Read and parse a pin file from disk.
524    pub fn load(path: &Path) -> Result<Self, PinError> {
525        let content = std::fs::read_to_string(path).map_err(|source| PinError::Io {
526            path: path.display().to_string(),
527            source,
528        })?;
529        Self::parse(&content, &path.display().to_string())
530    }
531}
532
533/// Is this a relative path that stays inside its base? Returns the fault, or
534/// `None` when it is usable.
535///
536/// The rule the whole file already applies to a tool name, applied to a
537/// directory: an absolute path REPLACES the base in `Path::join`, and `..`
538/// walks out of it, so either would let a declaration written in a repository
539/// address something outside the checkout.
540fn contained_relative_fault(value: &str) -> Option<String> {
541    if value.is_empty() {
542        return Some("empty".into());
543    }
544    if value.starts_with('/') || value.starts_with('\\') || value.contains(':') {
545        return Some("absolute".into());
546    }
547    if value.contains('\0') {
548        return Some("contains a NUL".into());
549    }
550    for component in value.split(['/', '\\']) {
551        if component == ".." {
552            return Some("climbs out with '..'".into());
553        }
554    }
555    if value.split(['/', '\\']).all(|c| c.is_empty() || c == ".") {
556        return Some("names no directory".into());
557    }
558    None
559}
560
561/// Validate the declared exports as a set (REQ-EXPORTDECL-001 clauses 1, 2, 4
562/// and 5). Nothing here is best-effort: an entry that cannot be acted on is a
563/// hard failure, because the whole point of declaring an export is that
564/// something checks it.
565fn parse_exports(raw: &[RawExport], origin: &str) -> Result<Vec<ExportDecl>, PinError> {
566    let mut decls: Vec<ExportDecl> = Vec::with_capacity(raw.len());
567    for e in raw {
568        let kind = ExportKind::from_str(&e.kind).map_err(|()| PinError::UnknownExportKind {
569            path: origin.to_string(),
570            kind: e.kind.clone(),
571            expected: ExportKind::ALL
572                .iter()
573                .map(|k| k.as_str())
574                .collect::<Vec<_>>()
575                .join(", "),
576        })?;
577        if let Some(why) = contained_relative_fault(&e.out) {
578            return Err(PinError::ExportOutEscapes {
579                path: origin.to_string(),
580                out: e.out.clone(),
581                why,
582            });
583        }
584        if let Some(first) = decls.iter().find(|d| d.out == e.out) {
585            return Err(PinError::DuplicateExportOut {
586                path: origin.to_string(),
587                out: e.out.clone(),
588                first: first.kind.to_string(),
589                second: kind.to_string(),
590            });
591        }
592        if let Some(select) = &e.select {
593            if select.is_empty() {
594                return Err(PinError::EmptyExportSelect {
595                    path: origin.to_string(),
596                    out: e.out.clone(),
597                });
598            }
599            for name in select {
600                let plain = !name.is_empty()
601                    && name != "."
602                    && name != ".."
603                    && !name.contains('/')
604                    && !name.contains('\\')
605                    && !name.contains('\0');
606                if !plain {
607                    return Err(PinError::ExportSelectIsAPath {
608                        path: origin.to_string(),
609                        out: e.out.clone(),
610                        name: name.clone(),
611                    });
612                }
613            }
614        }
615        let env = match &e.env {
616            None => None,
617            Some(raw_env) => {
618                if !kind.is_sourced() {
619                    return Err(PinError::ExportEnvNotSourced {
620                        path: origin.to_string(),
621                        out: e.out.clone(),
622                        kind: kind.to_string(),
623                        sourced: ExportKind::ALL
624                            .iter()
625                            .filter(|k| k.is_sourced())
626                            .map(|k| k.as_str())
627                            .collect::<Vec<_>>()
628                            .join(", "),
629                    });
630                }
631                if let Some(why) = contained_relative_fault(&raw_env.script) {
632                    return Err(PinError::ExportScriptEscapes {
633                        path: origin.to_string(),
634                        out: e.out.clone(),
635                        script: raw_env.script.clone(),
636                        why,
637                    });
638                }
639                // Clause 5: this is REQUIRED, not defaulted. A default would be
640                // a guess about PATH order made on the project's behalf, and
641                // being wrong in either direction is what this clause exists to
642                // prevent.
643                let Some(order) = &raw_env.path else {
644                    return Err(PinError::ExportEnvNeedsShimOrder {
645                        path: origin.to_string(),
646                        out: e.out.clone(),
647                    });
648                };
649                let path = ShimOrder::from_str(order).map_err(|()| PinError::UnknownShimOrder {
650                    path: origin.to_string(),
651                    out: e.out.clone(),
652                    found: order.clone(),
653                })?;
654                Some(ExportEnv {
655                    script: raw_env.script.clone(),
656                    path,
657                })
658            }
659        };
660        decls.push(ExportDecl {
661            kind,
662            out: e.out.clone(),
663            select: e.select.clone(),
664            env,
665        });
666    }
667    Ok(decls)
668}
669
670/// The verdict for ONE declared export (clause 3).
671#[derive(Debug, PartialEq, Eq)]
672pub enum DeclaredExportStatus {
673    /// The stamp names the layer the pin resolves to, by the declared adapter.
674    Current,
675    /// The declared directory carries no stamp — never generated, deleted, or
676    /// not a varve export at all.
677    ///
678    /// This is a FAILURE, not a warning. "I forgot to generate it" and "it is
679    /// stale" are the same severity to anyone relying on the export, and
680    /// distinguishing them mainly helps the person who made the mistake.
681    Missing,
682    /// The stamp was produced from a different layer than the pin now resolves.
683    Stale { stamped: String, current: String },
684    /// The directory was produced by a DIFFERENT adapter than the one declared.
685    /// A `cargo` declaration checked against a `vsix` stamp would otherwise
686    /// pass on freshness while the declared thing was never produced at all.
687    KindMismatch { declared: String, stamped: String },
688    /// The stamp exists but could not be read or parsed. Distinct from
689    /// `Missing`, because "re-run the export" is not the fix for a permissions
690    /// fault or a truncated file.
691    Unreadable(String),
692}
693
694impl DeclaredExportStatus {
695    pub fn is_current(&self) -> bool {
696        matches!(self, DeclaredExportStatus::Current)
697    }
698}
699
700/// Check one declared export against the layer the pin currently resolves to.
701///
702/// `verify` runs this over EVERY declared export without being asked — that is
703/// the whole of clause 3. `--export <DIR>` remains for a directory nobody has
704/// declared yet; a declared one is checked whether or not anyone remembers it.
705pub fn check_declared_export(
706    decl: &ExportDecl,
707    project_root: &Path,
708    current_manifest_digest: &str,
709) -> DeclaredExportStatus {
710    use crate::exportstamp::{ExportStampError, ExportStatus, read_stamp, status};
711    let dir = decl.dir(project_root);
712    match read_stamp(&dir) {
713        Err(ExportStampError::Missing(_)) => DeclaredExportStatus::Missing,
714        Err(other) => DeclaredExportStatus::Unreadable(other.to_string()),
715        Ok(stamp) => {
716            if stamp.kind != decl.kind.as_str() {
717                return DeclaredExportStatus::KindMismatch {
718                    declared: decl.kind.as_str().to_string(),
719                    stamped: stamp.kind,
720                };
721            }
722            match status(&stamp, current_manifest_digest) {
723                ExportStatus::Current => DeclaredExportStatus::Current,
724                ExportStatus::Stale { stamped, current } => {
725                    DeclaredExportStatus::Stale { stamped, current }
726                }
727            }
728        }
729    }
730}
731
732/// Shell lines that enter every declared environment AND varve's shims in one
733/// go (clause 4).
734///
735/// The ordering is the part worth reading twice, because it INVERTS. Sourcing a
736/// script PREPENDS its bin to PATH, so whatever is sourced LAST ends up first
737/// on PATH and wins. An export declared `before-shims` — its compiler is meant
738/// to win — must therefore be sourced AFTER the shims, and one declared
739/// `after-shims` must be sourced BEFORE them. Emitting them in declaration
740/// order would produce exactly the PATH the project said it did not want, and
741/// `verify` would then report the shadowing the project had declared away.
742pub fn env_lines(pin: &Pin, project_root: &Path, shim_env: Option<&Path>) -> Vec<String> {
743    let mut lines = Vec::new();
744    let sourced = |order: ShimOrder, lines: &mut Vec<String>| {
745        for decl in pin
746            .exports
747            .iter()
748            .filter(|d| d.env.as_ref().is_some_and(|e| e.path == order))
749        {
750            if let Some(script) = decl.env_script(project_root) {
751                lines.push(format!(
752                    "# {} export {} — declared {} (REQ-EXPORTDECL-001 clause 5)",
753                    decl.kind,
754                    decl.out,
755                    order.as_str()
756                ));
757                lines.push(format!(". \"{}\"", script.display()));
758            }
759        }
760    };
761    // Sourced FIRST so the shims, sourced after, land ahead of them on PATH.
762    sourced(ShimOrder::AfterShims, &mut lines);
763    if let Some(env) = shim_env {
764        lines.push("# varve's shims".to_string());
765        lines.push(format!(". \"{}\"", env.display()));
766    }
767    // Sourced LAST so this environment's bin lands ahead of the shims — which
768    // is what `before-shims` asked for.
769    sourced(ShimOrder::BeforeShims, &mut lines);
770    lines
771}
772
773/// What a declared export says about a binary PATH resolves ahead of a shim
774/// (clause 5).
775#[derive(Debug, PartialEq, Eq)]
776pub enum ShadowDeclaration<'a> {
777    /// Inside an export declared to sit BEFORE the shims. Expected, and
778    /// `verify` must not fail on it — a check that fires on the setup the
779    /// project deliberately configured is one people switch off.
780    Expected(&'a ExportDecl),
781    /// Inside an export declared to sit AFTER the shims. The declaration and
782    /// the actual PATH disagree, which is a real fault with a precise fix —
783    /// and a far better message than the generic hijack report.
784    ContradictsDeclaration(&'a ExportDecl),
785    /// Inside no declared export: the ordinary hijack REQ-SHADOW-001 is for.
786    Undeclared,
787}
788
789/// Is `path` inside `dir`? Canonicalised where both exist, because an export
790/// directory reached through a symlinked checkout is the same directory, and
791/// lexically otherwise — a declared export that has not been generated yet
792/// cannot be canonicalised and must still be recognisable.
793fn is_within(dir: &Path, path: &Path) -> bool {
794    let real = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
795    path.starts_with(dir) || real(path).starts_with(real(dir))
796}
797
798/// Classify a shadowing binary against the project's declared exports.
799///
800/// An SDK's `environment-setup-*` prepends its own bin to PATH, so its compiler
801/// shadows varve's shims. Whether that is a hijack or the point depends entirely
802/// on what the project declared — which is why clause 5 makes the declaration
803/// mandatory rather than inferred.
804pub fn classify_shadowing<'a>(
805    pin: &'a Pin,
806    project_root: &Path,
807    found: &Path,
808) -> ShadowDeclaration<'a> {
809    for decl in &pin.exports {
810        let Some(env) = &decl.env else {
811            // An export nobody sources puts nothing on PATH, so a binary found
812            // inside one is not explained by it.
813            continue;
814        };
815        if is_within(&decl.dir(project_root), found) {
816            return match env.path {
817                ShimOrder::BeforeShims => ShadowDeclaration::Expected(decl),
818                ShimOrder::AfterShims => ShadowDeclaration::ContradictsDeclaration(decl),
819            };
820        }
821    }
822    ShadowDeclaration::Undeclared
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    const FULL: &str = r#"
830manifest-version = 1
831
832[toolchain]
833channel = "qualified"
834layer   = "2026.07.0"
835digest  = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
836tools   = ["rivet", "synth"]
837"#;
838
839    // rivet: verifies REQ-PIN-001
840    #[test]
841    fn parses_a_complete_pin() {
842        let pin = Pin::parse(FULL, "varve.toml").unwrap();
843        assert_eq!(pin.channel, Channel::Qualified);
844        assert_eq!(pin.layer, LayerId::from_str("2026.07.0").unwrap());
845        assert_eq!(
846            pin.digest.as_deref(),
847            Some("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
848        );
849        // The compatibility surface: a bare `tools` list still parses to bare
850        // selectors, realm `None`, in the order written.
851        assert_eq!(
852            pin.tools
853                .as_deref()
854                .map(|t| t.iter().map(ToolSelector::to_string).collect::<Vec<_>>()),
855            Some(vec!["rivet".to_string(), "synth".to_string()])
856        );
857        assert!(
858            pin.tools
859                .as_deref()
860                .unwrap()
861                .iter()
862                .all(|t| t.realm.is_none()),
863            "a bare name carries no realm choice"
864        );
865    }
866
867    // rivet: verifies REQ-PIN-001
868    #[test]
869    fn digest_and_tools_are_optional() {
870        let pin = Pin::parse(
871            "manifest-version = 1\n[toolchain]\nchannel = \"rolling\"\nlayer = \"2026.08.0\"\n",
872            "varve.toml",
873        )
874        .unwrap();
875        assert_eq!(pin.channel, Channel::Rolling);
876        assert_eq!(pin.digest, None);
877        assert_eq!(pin.tools, None);
878    }
879
880    // rivet: verifies REQ-PIN-001
881    #[test]
882    fn rejects_unsupported_manifest_version() {
883        let err = Pin::parse(
884            "manifest-version = 2\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
885            "varve.toml",
886        )
887        .unwrap_err();
888        assert!(
889            matches!(err, PinError::UnsupportedManifestVersion { found: 2, .. }),
890            "got: {err}"
891        );
892    }
893
894    // rivet: verifies REQ-PATCH-001
895    #[test]
896    fn rejects_two_part_layer_with_the_grammar_guidance() {
897        let err = Pin::parse(
898            "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07\"\n",
899            "varve.toml",
900        )
901        .unwrap_err();
902        let PinError::Layer { source, .. } = &err else {
903            panic!("got: {err}");
904        };
905        assert!(matches!(source, LayerIdError::MissingPatch(_)));
906        // The guidance lives in the SOURCE (printed once via the chain).
907        assert!(
908            source.to_string().contains("three-part"),
909            "the chain must teach the grammar: {source}"
910        );
911    }
912
913    // rivet: verifies REQ-PIN-001
914    #[test]
915    fn rejects_unknown_keys_instead_of_ignoring_them() {
916        let err = Pin::parse(
917            "manifest-version = 1\nsurprise = true\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
918            "varve.toml",
919        )
920        .unwrap_err();
921        assert!(matches!(err, PinError::Toml { .. }), "got: {err}");
922    }
923
924    // rivet: verifies REQ-PIN-001
925    #[test]
926    fn rejects_unknown_channel() {
927        let err = Pin::parse(
928            "manifest-version = 1\n[toolchain]\nchannel = \"latest\"\nlayer = \"2026.07.0\"\n",
929            "varve.toml",
930        )
931        .unwrap_err();
932        assert!(matches!(err, PinError::Toml { .. }), "got: {err}");
933    }
934
935    // rivet: verifies REQ-PIN-001
936    #[test]
937    fn rejects_malformed_digest() {
938        // Includes a wrong-length PURE-HEX digest: length and charset are
939        // independent checks and each must reject alone.
940        for bad in [
941            "sha256:short",
942            "md5:aaaa",
943            "aaaaaaaa",
944            "sha256:GGGG",
945            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
946        ] {
947            let toml = format!(
948                "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ndigest = \"{bad}\"\n"
949            );
950            let err = Pin::parse(&toml, "varve.toml").unwrap_err();
951            assert!(
952                matches!(err, PinError::MalformedDigest { .. }),
953                "input {bad:?} got: {err}"
954            );
955        }
956    }
957
958    // rivet: verifies REQ-PIN-002
959    #[test]
960    fn rejects_a_tool_name_that_is_a_path() {
961        // A tool name is looked up INSIDE the verified layer. `Path::join`
962        // with an absolute path REPLACES the base, so an unchecked name would
963        // resolve outside the layer entirely — exactly the "never falls back
964        // to binaries on PATH" guarantee the docs make. Fail closed here.
965        //
966        // One `/` is now a REALM QUALIFIER (REQ-REALM2-001 clause 4a), so the
967        // list below is what remains path-shaped: an absolute path, a deeper
968        // path, a half that is empty or relative, a Windows path.
969        for hostile in [
970            "/usr/bin/id",
971            "../../usr/bin/id",
972            "sub/dir/deeper",
973            "/rivet",
974            "acme/",
975            "../rivet",
976            "./rivet",
977            "acme/..",
978            "../acme/rivet",
979            "..",
980            ".",
981            "",
982            "C:\\Windows\\system32\\cmd.exe",
983            "acme\\rivet",
984        ] {
985            let content = format!(
986                "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"{}\"]\n",
987                hostile.replace('\\', "\\\\")
988            );
989            assert!(
990                Pin::parse(&content, "varve.toml").is_err(),
991                "tools entry {hostile:?} must be refused — it escapes the layer"
992            );
993        }
994        // Ordinary names still parse.
995        let ok = "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"rivet\", \"synth-c\", \"cargo_x\"]\n";
996        assert!(Pin::parse(ok, "varve.toml").is_ok());
997    }
998
999    // rivet: verifies REQ-REALM2-001
1000    #[test]
1001    fn tools_accepts_a_realm_qualifier_beside_a_bare_name() {
1002        // Clause 4a, at the schema. The pin is a COMPATIBILITY SURFACE: the
1003        // qualified and bare forms must coexist in one list, because a realm
1004        // collision is about one name and the rest of the list is unaffected.
1005        let pin = Pin::parse(
1006            "manifest-version = 1\n[toolchain]\nrealm = \"pulseengine\"\nchannel = \"qualified\"\n\
1007             layer = \"2026.09.0\"\ntools = [\"bytecodealliance/wasm-tools\", \"rivet\"]\n",
1008            "varve.toml",
1009        )
1010        .unwrap();
1011        let tools = pin.tools.unwrap();
1012        assert_eq!(tools[0].realm.as_deref(), Some("bytecodealliance"));
1013        assert_eq!(tools[0].name, "wasm-tools");
1014        assert_eq!(tools[0].to_string(), "bytecodealliance/wasm-tools");
1015        assert_eq!(tools[1].realm, None);
1016        assert_eq!(tools[1].name, "rivet");
1017    }
1018
1019    // rivet: verifies REQ-REALM2-001
1020    #[test]
1021    fn the_refusal_for_a_path_shows_both_forms_that_are_accepted() {
1022        // The old message said "Name the tool only" — which, from v0.29.0, is
1023        // no longer the whole truth, and a reader hitting a collision would be
1024        // sent back to the form that cannot express their fix.
1025        let err = Pin::parse(
1026            "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n\
1027             tools = [\"/usr/bin/id\"]\n",
1028            "varve.toml",
1029        )
1030        .unwrap_err();
1031        let msg = err.to_string();
1032        assert!(matches!(err, PinError::ToolNameIsAPath { .. }), "{msg}");
1033        assert!(
1034            msg.contains("tools = [\"rivet\"]")
1035                && msg.contains("tools = [\"bytecodealliance/wasm-tools\"]"),
1036            "both accepted forms must be shown: {msg}"
1037        );
1038    }
1039
1040    // rivet: verifies REQ-PIN-001
1041    #[test]
1042    fn rejects_empty_tools_list() {
1043        let err = Pin::parse(
1044            "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = []\n",
1045            "varve.toml",
1046        )
1047        .unwrap_err();
1048        assert!(matches!(err, PinError::EmptyTools { .. }), "got: {err}");
1049    }
1050
1051    // rivet: verifies REQ-PIN-001
1052    #[test]
1053    fn errors_name_the_offending_file() {
1054        let err = Pin::parse("nonsense", "proj/sub/varve.toml").unwrap_err();
1055        assert!(
1056            err.to_string().contains("proj/sub/varve.toml"),
1057            "diagnostic must carry the path: {err}"
1058        );
1059    }
1060}
1061
1062#[cfg(test)]
1063mod export_tests {
1064    use super::*;
1065    use crate::exportstamp::{ExportStamp, write_stamp};
1066
1067    const HEAD: &str =
1068        "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.08.0\"\n";
1069
1070    fn parse(exports: &str) -> Result<Pin, PinError> {
1071        Pin::parse(&format!("{HEAD}{exports}"), "varve.toml")
1072    }
1073
1074    /// A project declaring the five adapters that exist plus the sourced one
1075    /// this release adds — the shape a real repository would carry.
1076    const DECLARED: &str = r#"
1077[[export]]
1078kind = "cargo"
1079out  = "vendor/registry"
1080
1081[[export]]
1082kind = "vsix"
1083out  = ".vscode/varve-extensions"
1084select = ["rust-lang.rust-analyzer", "vadimcn.vscode-lldb"]
1085
1086[[export]]
1087kind = "sdk"
1088out  = "toolchains/poky"
1089select = ["poky-cortexa53"]
1090
1091[export.env]
1092script = "environment-setup-cortexa53-poky-linux"
1093path   = "before-shims"
1094"#;
1095
1096    // rivet: verifies REQ-EXPORTDECL-001
1097    #[test]
1098    fn a_project_declares_its_exports_in_the_pin_it_already_has() {
1099        // Clause 1: one file, and `verify` already reads it. The set of exports
1100        // used to live in a CI script or a shell history, which is why an
1101        // export nobody named never went stale — nothing looked at it.
1102        let pin = parse(DECLARED).unwrap();
1103        assert_eq!(pin.exports.len(), 3);
1104        assert_eq!(pin.exports[0].kind, ExportKind::Cargo);
1105        assert_eq!(pin.exports[0].out, "vendor/registry");
1106        assert_eq!(
1107            pin.exports[0].select, None,
1108            "no subset means the whole layer"
1109        );
1110        assert_eq!(pin.exports[0].env, None);
1111
1112        // Clause 2: the adapter, the destination, and a SUBSET of the layer.
1113        assert_eq!(pin.exports[1].kind, ExportKind::Vsix);
1114        assert_eq!(
1115            pin.exports[1].select.as_deref(),
1116            Some(
1117                &[
1118                    "rust-lang.rust-analyzer".to_string(),
1119                    "vadimcn.vscode-lldb".to_string()
1120                ][..]
1121            )
1122        );
1123
1124        // Clauses 4 and 5 on the sourced one.
1125        let sdk = &pin.exports[2];
1126        assert_eq!(sdk.kind, ExportKind::Sdk);
1127        let env = sdk.env.as_ref().expect("an sdk is entered, not pointed at");
1128        assert_eq!(env.script, "environment-setup-cortexa53-poky-linux");
1129        assert_eq!(env.path, ShimOrder::BeforeShims);
1130
1131        // Declared relative, resolved absolute against the pin's directory, so
1132        // the file means the same thing on every machine.
1133        let root = Path::new("/repo");
1134        assert_eq!(sdk.dir(root), Path::new("/repo/toolchains/poky"));
1135        assert_eq!(
1136            sdk.env_script(root).unwrap(),
1137            Path::new("/repo/toolchains/poky/environment-setup-cortexa53-poky-linux")
1138        );
1139        assert_eq!(pin.exports[0].env_script(root), None);
1140
1141        // A pin with no exports is still a pin: this is additive, and every
1142        // varve.toml already in the world has none.
1143        assert!(Pin::parse(HEAD, "varve.toml").unwrap().exports.is_empty());
1144    }
1145
1146    // rivet: verifies REQ-EXPORTDECL-001
1147    #[test]
1148    fn the_declared_kind_is_one_varve_can_actually_produce() {
1149        // An adapter varve does not have is a declaration nothing will ever
1150        // satisfy — and, worse, one `verify` would have to skip, which is the
1151        // "only checks what it is told about" failure this requirement exists
1152        // to close.
1153        let err = parse("[[export]]\nkind = \"npm\"\nout = \"x\"\n").unwrap_err();
1154        let msg = err.to_string();
1155        assert!(matches!(err, PinError::UnknownExportKind { .. }), "{msg}");
1156        for known in ExportKind::ALL {
1157            assert!(
1158                msg.contains(known.as_str()),
1159                "the refusal must list {known}, or the author has nothing to correct to: {msg}"
1160            );
1161        }
1162        // Every kind's wire string is the one the adapters already stamp, so a
1163        // declaration and a stamp compare directly.
1164        for (kind, wire) in [
1165            (ExportKind::Cargo, "cargo"),
1166            (ExportKind::CratesVendor, "crates-vendor"),
1167            (ExportKind::BazelRegistry, "bazel-registry"),
1168            (ExportKind::BazelDistdir, "bazel-distdir"),
1169            (ExportKind::Vsix, "vsix"),
1170            (ExportKind::Sdk, "sdk"),
1171        ] {
1172            assert_eq!(kind.as_str(), wire);
1173            assert_eq!(ExportKind::from_str(wire).unwrap(), kind);
1174        }
1175        assert_eq!(ExportKind::ALL.len(), 6, "ALL must list every variant");
1176    }
1177
1178    // rivet: verifies REQ-EXPORTDECL-001
1179    #[test]
1180    fn a_destination_that_leaves_the_repository_is_refused() {
1181        // The declaration is checked into a repository and resolved against the
1182        // directory holding varve.toml. An absolute path REPLACES that base and
1183        // `..` walks out of it, so either would let a committed file address
1184        // something outside the checkout — and `verify` would then report on a
1185        // directory nobody reviewing the pin could see.
1186        for bad in [
1187            "/etc",
1188            "../outside",
1189            "a/../../outside",
1190            "",
1191            ".",
1192            "./",
1193            "C:\\x",
1194        ] {
1195            let err = parse(&format!(
1196                "[[export]]\nkind = \"cargo\"\nout = \"{}\"\n",
1197                bad.replace('\\', "\\\\")
1198            ))
1199            .unwrap_err();
1200            assert!(
1201                matches!(err, PinError::ExportOutEscapes { .. }),
1202                "out {bad:?} must be refused, got: {err}"
1203            );
1204        }
1205        for good in ["vendor", "vendor/registry", "a/b/c"] {
1206            assert!(
1207                parse(&format!("[[export]]\nkind = \"cargo\"\nout = \"{good}\"\n")).is_ok(),
1208                "{good} is an ordinary export directory"
1209            );
1210        }
1211    }
1212
1213    // rivet: verifies REQ-EXPORTDECL-001
1214    #[test]
1215    fn two_exports_may_not_share_one_directory() {
1216        // Each adapter writes ONE `.varve-export.json`. Two into one directory
1217        // means the second overwrites the first's stamp, after which `verify`
1218        // checks one export twice and the other never — a gate that reports
1219        // green on something it has not looked at.
1220        let err = parse(
1221            "[[export]]\nkind = \"cargo\"\nout = \"vendor\"\n\
1222             [[export]]\nkind = \"vsix\"\nout = \"vendor\"\n",
1223        )
1224        .unwrap_err();
1225        assert!(
1226            matches!(err, PinError::DuplicateExportOut { .. }),
1227            "got: {err}"
1228        );
1229        let msg = err.to_string();
1230        assert!(
1231            msg.contains("cargo") && msg.contains("vsix"),
1232            "names both: {msg}"
1233        );
1234    }
1235
1236    // rivet: verifies REQ-EXPORTDECL-001
1237    #[test]
1238    fn a_subset_selection_names_payloads_not_paths() {
1239        // Clause 2's subset indexes the VERIFIED layer, exactly as `tools` does
1240        // in the toolchain block, so the same rule applies: a name, never a
1241        // path, or the selection resolves outside the layer.
1242        for bad in ["../evil", "a/b", "", ".", "..", "a\\b"] {
1243            let err = parse(&format!(
1244                "[[export]]\nkind = \"cargo\"\nout = \"v\"\nselect = [\"{}\"]\n",
1245                bad.replace('\\', "\\\\")
1246            ))
1247            .unwrap_err();
1248            assert!(
1249                matches!(err, PinError::ExportSelectIsAPath { .. }),
1250                "select {bad:?} must be refused, got: {err}"
1251            );
1252        }
1253        // Present but empty is a mistake with a specific correction: omitting
1254        // the key means the whole layer, so an empty list can only be a typo.
1255        let err = parse("[[export]]\nkind = \"cargo\"\nout = \"v\"\nselect = []\n").unwrap_err();
1256        assert!(
1257            matches!(err, PinError::EmptyExportSelect { .. }),
1258            "got: {err}"
1259        );
1260    }
1261
1262    // rivet: verifies REQ-EXPORTDECL-001
1263    #[test]
1264    fn an_environment_must_say_where_it_sits_relative_to_the_shims() {
1265        // Clause 5, and the reason these two requirements ship together. An
1266        // SDK's environment-setup prepends its own bin to PATH and shadows
1267        // varve's shims — the exact condition REQ-SHADOW-001 detects. Left
1268        // undeclared, `verify` must either miss a genuine hijack or fire on a
1269        // legitimate setup, and the spurious failure is worse: a check that
1270        // cries wolf is a check people switch off.
1271        let err = parse(
1272            "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"env-setup\"\n",
1273        )
1274        .unwrap_err();
1275        assert!(
1276            matches!(err, PinError::ExportEnvNeedsShimOrder { .. }),
1277            "got: {err}"
1278        );
1279        let msg = err.to_string();
1280        assert!(msg.contains("before-shims"), "offers the answers: {msg}");
1281        assert!(msg.contains("after-shims"), "offers the answers: {msg}");
1282        assert!(
1283            msg.contains("REQ-SHADOW-001"),
1284            "says WHY it is needed, not just that it is: {msg}"
1285        );
1286
1287        // Both answers parse, and nothing else does.
1288        for (value, want) in [
1289            ("before-shims", ShimOrder::BeforeShims),
1290            ("after-shims", ShimOrder::AfterShims),
1291        ] {
1292            let pin = parse(&format!(
1293                "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"e\"\npath = \"{value}\"\n"
1294            ))
1295            .unwrap();
1296            assert_eq!(pin.exports[0].env.as_ref().unwrap().path, want);
1297            assert_eq!(want.as_str(), value);
1298        }
1299        let err = parse(
1300            "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"e\"\npath = \"first\"\n",
1301        )
1302        .unwrap_err();
1303        assert!(
1304            matches!(err, PinError::UnknownShimOrder { .. }),
1305            "got: {err}"
1306        );
1307    }
1308
1309    // rivet: verifies REQ-EXPORTDECL-001
1310    #[test]
1311    fn only_an_export_that_is_sourced_may_declare_an_environment() {
1312        // Clause 4 distinguishes a payload ENTERED as an environment from one
1313        // POINTED at. A local Cargo registry is pointed at by a config file; an
1314        // `[export.env]` on it would be accepted, ignored, and believed.
1315        let err = parse(
1316            "[[export]]\nkind = \"cargo\"\nout = \"v\"\n[export.env]\nscript = \"e\"\npath = \"after-shims\"\n",
1317        )
1318        .unwrap_err();
1319        assert!(
1320            matches!(err, PinError::ExportEnvNotSourced { .. }),
1321            "got: {err}"
1322        );
1323        assert!(
1324            err.to_string().contains("sdk"),
1325            "names what IS sourced: {err}"
1326        );
1327        assert!(ExportKind::Sdk.is_sourced());
1328        for pointed in ExportKind::ALL.iter().filter(|k| **k != ExportKind::Sdk) {
1329            assert!(
1330                !pointed.is_sourced(),
1331                "{pointed} is consumed by pointing at it, not by sourcing it"
1332            );
1333        }
1334        // …and the script itself must stay inside the export directory.
1335        let err = parse(
1336            "[[export]]\nkind = \"sdk\"\nout = \"t\"\n[export.env]\nscript = \"../../etc/profile\"\npath = \"after-shims\"\n",
1337        )
1338        .unwrap_err();
1339        assert!(
1340            matches!(err, PinError::ExportScriptEscapes { .. }),
1341            "got: {err}"
1342        );
1343    }
1344
1345    fn stamped(dir: &std::path::Path, kind: &str, digest: &str) {
1346        write_stamp(
1347            dir,
1348            &ExportStamp {
1349                layer: "2026.08.0".into(),
1350                manifest_digest: digest.into(),
1351                kind: kind.into(),
1352            },
1353        )
1354        .unwrap();
1355    }
1356
1357    // rivet: verifies REQ-EXPORTDECL-001
1358    #[test]
1359    fn every_declared_export_is_checked_and_an_absent_one_fails() {
1360        // Clause 3. Declared means checked — no `--export` argument, no CI
1361        // script listing directories. And "I forgot to generate it" and "it is
1362        // stale" are the same severity to anyone relying on the export, so a
1363        // missing directory FAILS rather than warns.
1364        let tmp = tempfile::tempdir().unwrap();
1365        let root = tmp.path();
1366        let pin = parse(DECLARED).unwrap();
1367        let current = "sha256:aaaa";
1368
1369        // Nothing generated yet: every declared export fails, none is skipped.
1370        for decl in &pin.exports {
1371            assert_eq!(
1372                check_declared_export(decl, root, current),
1373                DeclaredExportStatus::Missing,
1374                "{} must fail while it does not exist",
1375                decl.out
1376            );
1377        }
1378
1379        // Generated against the current pin: fresh.
1380        for decl in &pin.exports {
1381            stamped(&decl.dir(root), decl.kind.as_str(), current);
1382            let got = check_declared_export(decl, root, current);
1383            assert!(
1384                got.is_current(),
1385                "{} should be fresh, got {got:?}",
1386                decl.out
1387            );
1388        }
1389
1390        // The pin moves on and the exports do not.
1391        let moved = "sha256:bbbb";
1392        assert_eq!(
1393            check_declared_export(&pin.exports[0], root, moved),
1394            DeclaredExportStatus::Stale {
1395                stamped: current.into(),
1396                current: moved.into(),
1397            }
1398        );
1399
1400        // A directory produced by a DIFFERENT adapter is not the declared
1401        // export, however fresh its stamp is — the declared thing was never
1402        // produced at all.
1403        let vsix = &pin.exports[1];
1404        stamped(&vsix.dir(root), "cargo", current);
1405        assert_eq!(
1406            check_declared_export(vsix, root, current),
1407            DeclaredExportStatus::KindMismatch {
1408                declared: "vsix".into(),
1409                stamped: "cargo".into(),
1410            }
1411        );
1412
1413        // A stamp that exists but is unreadable is its own verdict: "re-run the
1414        // export" is not the fix for a truncated file.
1415        let cargo = &pin.exports[0];
1416        std::fs::write(
1417            cargo.dir(root).join(crate::exportstamp::STAMP_FILE),
1418            b"{not json",
1419        )
1420        .unwrap();
1421        assert!(matches!(
1422            check_declared_export(cargo, root, current),
1423            DeclaredExportStatus::Unreadable(_)
1424        ));
1425    }
1426
1427    // rivet: verifies REQ-EXPORTDECL-001
1428    #[test]
1429    fn is_current_is_false_for_every_status_that_is_not_current() {
1430        // `is_current` was asserted TRUE in one place and false in none, so
1431        // replacing its whole body with `true` survived the mutation gate: a
1432        // stale, missing, mismatched or unreadable export would have reported
1433        // itself fresh and `verify` would have exited 0 on the drift the
1434        // declaration exists to catch. Found by cargo mutants in CI.
1435        assert!(DeclaredExportStatus::Current.is_current());
1436        for status in [
1437            DeclaredExportStatus::Missing,
1438            DeclaredExportStatus::Stale {
1439                stamped: "sha256:aaaa".into(),
1440                current: "sha256:bbbb".into(),
1441            },
1442            DeclaredExportStatus::KindMismatch {
1443                declared: "vsix".into(),
1444                stamped: "cargo".into(),
1445            },
1446            DeclaredExportStatus::Unreadable("truncated".into()),
1447        ] {
1448            assert!(
1449                !status.is_current(),
1450                "{status:?} must not report itself current"
1451            );
1452        }
1453    }
1454
1455    // rivet: verifies REQ-EXPORTDECL-001, REQ-SHADOW-001
1456    #[test]
1457    fn an_export_reached_through_a_symlink_is_the_same_export() {
1458        // `is_within` is `lexical || canonical`, and every existing test
1459        // satisfied BOTH halves, so flipping it to `&&` survived. The `||` is
1460        // load-bearing in exactly one direction: a checkout reached through a
1461        // symlink gives a path that does NOT lexically start with the declared
1462        // directory but canonicalises into it. Under `&&` such an export reads
1463        // as undeclared, and a declared SDK's compiler gets reported as a
1464        // hijack — the cry-wolf failure clause 5 exists to prevent.
1465        let tmp = tempfile::tempdir().unwrap();
1466        let real_dir = tmp.path().join("real/export");
1467        // The binary must really exist: `canonicalize` resolves whole paths,
1468        // so a fixture that only creates directories tests the not-generated
1469        // branch instead of the symlink one.
1470        std::fs::create_dir_all(real_dir.join("bin")).unwrap();
1471        std::fs::write(real_dir.join("bin/gcc"), b"#!/bin/sh\n").unwrap();
1472        let link = tmp.path().join("link");
1473        #[cfg(unix)]
1474        std::os::unix::fs::symlink(tmp.path().join("real"), &link).unwrap();
1475        #[cfg(not(unix))]
1476        return;
1477
1478        // Reached through the symlink: lexically outside, canonically inside.
1479        let through_link = link.join("export/bin/gcc");
1480        assert!(
1481            !through_link.starts_with(&real_dir),
1482            "the fixture must be lexically outside, or it proves nothing"
1483        );
1484        assert!(
1485            is_within(&real_dir, &through_link),
1486            "an export reached through a symlinked checkout is the same export"
1487        );
1488
1489        // …and a genuinely unrelated path is still outside, so the fix is
1490        // containment rather than `is_within` answering true for everything.
1491        assert!(!is_within(&real_dir, &tmp.path().join("elsewhere/bin/gcc")));
1492    }
1493
1494    // rivet: verifies REQ-EXPORTDECL-001, REQ-SHADOW-001
1495    #[test]
1496    fn a_declared_sdk_environment_is_not_reported_as_a_hijack() {
1497        // Clause 5's payoff. The SDK's `aarch64-poky-linux-gcc` really is ahead
1498        // of varve's shims on PATH — the project said so. Reporting that as a
1499        // hijack is the cry-wolf failure; reporting an UNdeclared one as fine
1500        // is the miss. The declaration is what tells them apart.
1501        let root = Path::new("/repo");
1502        let pin = parse(DECLARED).unwrap();
1503        let sdk_gcc = Path::new("/repo/toolchains/poky/sysroots/x86_64/usr/bin/gcc");
1504        match classify_shadowing(&pin, root, sdk_gcc) {
1505            ShadowDeclaration::Expected(d) => assert_eq!(d.out, "toolchains/poky"),
1506            other => panic!("a declared before-shims SDK must be expected, got {other:?}"),
1507        }
1508
1509        // Anywhere else is the ordinary hijack REQ-SHADOW-001 exists for — a
1510        // distro gcc, a `cargo install`ed tool, a stale shim directory.
1511        assert_eq!(
1512            classify_shadowing(&pin, root, Path::new("/usr/local/bin/gcc")),
1513            ShadowDeclaration::Undeclared
1514        );
1515        // …and so is a binary inside an export that is not sourced at all: a
1516        // vendor directory puts nothing on PATH, so it explains nothing.
1517        assert_eq!(
1518            classify_shadowing(&pin, root, Path::new("/repo/vendor/registry/gcc")),
1519            ShadowDeclaration::Undeclared
1520        );
1521
1522        // Declared the OTHER way, the same binary is a real fault: the project
1523        // said varve's tools win and PATH says otherwise. Distinct verdict,
1524        // because the fix is distinct — fix PATH, or fix the declaration.
1525        let after = parse(
1526            "[[export]]\nkind = \"sdk\"\nout = \"toolchains/poky\"\n[export.env]\nscript = \"e\"\npath = \"after-shims\"\n",
1527        )
1528        .unwrap();
1529        match classify_shadowing(&after, root, sdk_gcc) {
1530            ShadowDeclaration::ContradictsDeclaration(d) => {
1531                assert_eq!(d.out, "toolchains/poky")
1532            }
1533            other => panic!("expected ContradictsDeclaration, got {other:?}"),
1534        }
1535    }
1536
1537    // rivet: verifies REQ-EXPORTDECL-001
1538    #[test]
1539    fn one_command_sources_the_whole_environment_in_the_declared_path_order() {
1540        // Clause 4, and the ordering INVERTS — which is the whole reason this
1541        // is computed rather than written by hand. Sourcing a script PREPENDS
1542        // its bin to PATH, so whatever is sourced LAST wins. An export declared
1543        // `before-shims` must therefore be sourced AFTER the shims; emitting
1544        // them in declaration order would produce exactly the PATH the project
1545        // said it did not want, and `verify` would then flag it.
1546        let root = Path::new("/repo");
1547        let shim_env = Path::new("/home/u/.varve/env");
1548        let pin = parse(
1549            "[[export]]\nkind = \"cargo\"\nout = \"vendor\"\n\
1550             [[export]]\nkind = \"sdk\"\nout = \"early\"\n\
1551             [export.env]\nscript = \"env-setup-early\"\npath = \"before-shims\"\n\
1552             [[export]]\nkind = \"sdk\"\nout = \"late\"\n\
1553             [export.env]\nscript = \"env-setup-late\"\npath = \"after-shims\"\n",
1554        )
1555        .unwrap();
1556        let sourced: Vec<String> = env_lines(&pin, root, Some(shim_env))
1557            .into_iter()
1558            .filter(|l| l.starts_with(". "))
1559            .collect();
1560        assert_eq!(
1561            sourced,
1562            vec![
1563                ". \"/repo/late/env-setup-late\"".to_string(),
1564                ". \"/home/u/.varve/env\"".to_string(),
1565                ". \"/repo/early/env-setup-early\"".to_string(),
1566            ],
1567            "after-shims is sourced FIRST so the shims land ahead of it"
1568        );
1569        // A cargo export contributes nothing: it is pointed at, not entered.
1570        assert!(
1571            !env_lines(&pin, root, Some(shim_env))
1572                .join("\n")
1573                .contains("vendor")
1574        );
1575        // Without a shim env there is still an environment to enter.
1576        let no_shims: Vec<String> = env_lines(&pin, root, None)
1577            .into_iter()
1578            .filter(|l| l.starts_with(". "))
1579            .collect();
1580        assert_eq!(no_shims.len(), 2);
1581        assert!(!no_shims.iter().any(|l| l.contains(".varve/env")));
1582    }
1583}
1584
1585#[cfg(test)]
1586mod version_message_tests {
1587    use super::*;
1588
1589    /// The refusal must name the version this build actually understands.
1590    ///
1591    /// The check reads `consumer::PIN_MANIFEST_VERSION`; the message used to
1592    /// hardcode "version 1" beside it. A clean-room review set the constant to
1593    /// 7 and got "manifest-version 1 is not supported (this varve understands
1594    /// version 1)" — a self-contradictory diagnostic, in the one place a
1595    /// consumer is being told what this build supports.
1596    // rivet: verifies REQ-CONSUMERAPI-001
1597    #[test]
1598    fn the_unsupported_version_error_names_the_version_this_build_enforces() {
1599        let newer = format!(
1600            "manifest-version = {}\n[toolchain]\nchannel = \"rolling\"\nlayer = \"2026.09.1\"\n",
1601            crate::consumer::PIN_MANIFEST_VERSION + 1
1602        );
1603        let msg = Pin::parse(&newer, "varve.toml")
1604            .expect_err("a newer pin must be refused")
1605            .to_string();
1606        assert!(
1607            msg.contains(&format!(
1608                "understands version {}",
1609                crate::consumer::PIN_MANIFEST_VERSION
1610            )),
1611            "the message must name the enforced constant, got: {msg}"
1612        );
1613        assert!(
1614            msg.contains(&format!(
1615                "manifest-version {}",
1616                crate::consumer::PIN_MANIFEST_VERSION + 1
1617            )),
1618            "and the version it refused, got: {msg}"
1619        );
1620    }
1621}