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
12use std::path::Path;
13use std::str::FromStr;
14
15use serde::Deserialize;
16
17use crate::layer::{LayerId, LayerIdError};
18
19/// The release channel a pin selects.
20///
21/// `qualified` names a line with a stated support window and qualification
22/// evidence attached; `rolling` has neither and may move.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum Channel {
26    Qualified,
27    Rolling,
28}
29
30impl Channel {
31    /// The wire string, matching the signed manifest annotation and the pin.
32    pub fn as_str(self) -> &'static str {
33        match self {
34            Channel::Qualified => "qualified",
35            Channel::Rolling => "rolling",
36        }
37    }
38}
39
40impl std::str::FromStr for Channel {
41    type Err = ();
42    /// The SAME vocabulary a pin accepts, exposed so the produce side can
43    /// refuse a channel no pin could ever name (REQ-PRODUCER-001). Deriving
44    /// both from one enum is what keeps them from drifting apart.
45    fn from_str(s: &str) -> Result<Self, ()> {
46        match s {
47            "qualified" => Ok(Channel::Qualified),
48            "rolling" => Ok(Channel::Rolling),
49            _ => Err(()),
50        }
51    }
52}
53
54/// A parsed, validated pin.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Pin {
57    /// Optional trust universe (REQ-REALM-001). When named, the realm's
58    /// registry and trust root are AUTHORITATIVE for this project.
59    pub realm: Option<String>,
60    pub channel: Channel,
61    pub layer: LayerId,
62    /// Optional exact manifest digest. When present it wins over the name:
63    /// a name resolving to a different digest is a hard failure (DD-005's
64    /// lever available at the pin level).
65    pub digest: Option<String>,
66    /// Optional restriction to a subset of the layer's tools. `None` means
67    /// every tool in the layer.
68    pub tools: Option<Vec<String>>,
69}
70
71/// Why a pin failed to parse or validate.
72#[derive(Debug, thiserror::Error)]
73pub enum PinError {
74    #[error("failed to read {path}: {source}")]
75    Io {
76        path: String,
77        #[source]
78        source: std::io::Error,
79    },
80    #[error("{path}: not valid varve.toml: {source}")]
81    Toml {
82        path: String,
83        #[source]
84        source: Box<toml::de::Error>,
85    },
86    #[error("{path}: manifest-version {found} is not supported (this varve understands version 1)")]
87    UnsupportedManifestVersion { path: String, found: i64 },
88    // Display carries only the location; the cause prints once via the
89    // #[source] chain (varve#7 — the anyhow alternate formatter was
90    // printing it twice).
91    #[error("{path}: invalid layer identifier")]
92    Layer {
93        path: String,
94        #[source]
95        source: LayerIdError,
96    },
97    #[error(
98        "{path}: digest '{found}' is not a valid digest: expected 'sha256:' followed by 64 hex characters"
99    )]
100    MalformedDigest { path: String, found: String },
101    #[error(
102        "{path}: tool name {name:?} is not a plain name — a tool is looked up INSIDE \
103         the pinned layer, so a path would resolve outside it. Name the tool only, \
104         e.g. tools = [\"rivet\"]."
105    )]
106    ToolNameIsAPath { path: String, name: String },
107    #[error("{path}: tools list is present but empty — omit it to select every tool in the layer")]
108    EmptyTools { path: String },
109}
110
111#[derive(Deserialize)]
112#[serde(deny_unknown_fields)]
113struct RawPin {
114    #[serde(rename = "manifest-version")]
115    manifest_version: i64,
116    toolchain: RawToolchain,
117}
118
119#[derive(Deserialize)]
120#[serde(deny_unknown_fields)]
121struct RawToolchain {
122    #[serde(default)]
123    realm: Option<String>,
124    channel: Channel,
125    layer: String,
126    digest: Option<String>,
127    tools: Option<Vec<String>>,
128}
129
130impl Pin {
131    /// Parse and validate pin content. `origin` names the source (a path, in
132    /// diagnostics) — errors must tell the reader *which* file is wrong.
133    pub fn parse(content: &str, origin: &str) -> Result<Self, PinError> {
134        let raw: RawPin = toml::from_str(content).map_err(|source| PinError::Toml {
135            path: origin.to_string(),
136            source: Box::new(source),
137        })?;
138        if raw.manifest_version != 1 {
139            return Err(PinError::UnsupportedManifestVersion {
140                path: origin.to_string(),
141                found: raw.manifest_version,
142            });
143        }
144        let layer = LayerId::from_str(&raw.toolchain.layer).map_err(|source| PinError::Layer {
145            path: origin.to_string(),
146            source,
147        })?;
148        if let Some(digest) = &raw.toolchain.digest {
149            let hex = digest
150                .strip_prefix("sha256:")
151                .ok_or_else(|| PinError::MalformedDigest {
152                    path: origin.to_string(),
153                    found: digest.clone(),
154                })?;
155            if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
156                return Err(PinError::MalformedDigest {
157                    path: origin.to_string(),
158                    found: digest.clone(),
159                });
160            }
161        }
162        if let Some(tools) = &raw.toolchain.tools {
163            if tools.is_empty() {
164                return Err(PinError::EmptyTools {
165                    path: origin.to_string(),
166                });
167            }
168            // A tool name indexes the verified layer's `bin/`. `Path::join`
169            // with an absolute path REPLACES the base, and `..` walks out of
170            // it, so anything but a plain name would escape the layer — the
171            // opposite of "a pin resolves exactly or the command fails".
172            for name in tools {
173                let plain = !name.is_empty()
174                    && name != "."
175                    && name != ".."
176                    && !name.contains('/')
177                    && !name.contains('\\')
178                    && !name.contains('\0');
179                if !plain {
180                    return Err(PinError::ToolNameIsAPath {
181                        path: origin.to_string(),
182                        name: name.clone(),
183                    });
184                }
185            }
186        }
187        Ok(Pin {
188            realm: raw.toolchain.realm,
189            channel: raw.toolchain.channel,
190            layer,
191            digest: raw.toolchain.digest,
192            tools: raw.toolchain.tools,
193        })
194    }
195
196    /// Read and parse a pin file from disk.
197    pub fn load(path: &Path) -> Result<Self, PinError> {
198        let content = std::fs::read_to_string(path).map_err(|source| PinError::Io {
199            path: path.display().to_string(),
200            source,
201        })?;
202        Self::parse(&content, &path.display().to_string())
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    const FULL: &str = r#"
211manifest-version = 1
212
213[toolchain]
214channel = "qualified"
215layer   = "2026.07.0"
216digest  = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
217tools   = ["rivet", "synth"]
218"#;
219
220    // rivet: verifies REQ-PIN-001
221    #[test]
222    fn parses_a_complete_pin() {
223        let pin = Pin::parse(FULL, "varve.toml").unwrap();
224        assert_eq!(pin.channel, Channel::Qualified);
225        assert_eq!(pin.layer, LayerId::from_str("2026.07.0").unwrap());
226        assert_eq!(
227            pin.digest.as_deref(),
228            Some("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
229        );
230        assert_eq!(
231            pin.tools.as_deref(),
232            Some(&["rivet".to_string(), "synth".to_string()][..])
233        );
234    }
235
236    // rivet: verifies REQ-PIN-001
237    #[test]
238    fn digest_and_tools_are_optional() {
239        let pin = Pin::parse(
240            "manifest-version = 1\n[toolchain]\nchannel = \"rolling\"\nlayer = \"2026.08.0\"\n",
241            "varve.toml",
242        )
243        .unwrap();
244        assert_eq!(pin.channel, Channel::Rolling);
245        assert_eq!(pin.digest, None);
246        assert_eq!(pin.tools, None);
247    }
248
249    // rivet: verifies REQ-PIN-001
250    #[test]
251    fn rejects_unsupported_manifest_version() {
252        let err = Pin::parse(
253            "manifest-version = 2\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
254            "varve.toml",
255        )
256        .unwrap_err();
257        assert!(
258            matches!(err, PinError::UnsupportedManifestVersion { found: 2, .. }),
259            "got: {err}"
260        );
261    }
262
263    // rivet: verifies REQ-PATCH-001
264    #[test]
265    fn rejects_two_part_layer_with_the_grammar_guidance() {
266        let err = Pin::parse(
267            "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07\"\n",
268            "varve.toml",
269        )
270        .unwrap_err();
271        let PinError::Layer { source, .. } = &err else {
272            panic!("got: {err}");
273        };
274        assert!(matches!(source, LayerIdError::MissingPatch(_)));
275        // The guidance lives in the SOURCE (printed once via the chain).
276        assert!(
277            source.to_string().contains("three-part"),
278            "the chain must teach the grammar: {source}"
279        );
280    }
281
282    // rivet: verifies REQ-PIN-001
283    #[test]
284    fn rejects_unknown_keys_instead_of_ignoring_them() {
285        let err = Pin::parse(
286            "manifest-version = 1\nsurprise = true\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
287            "varve.toml",
288        )
289        .unwrap_err();
290        assert!(matches!(err, PinError::Toml { .. }), "got: {err}");
291    }
292
293    // rivet: verifies REQ-PIN-001
294    #[test]
295    fn rejects_unknown_channel() {
296        let err = Pin::parse(
297            "manifest-version = 1\n[toolchain]\nchannel = \"latest\"\nlayer = \"2026.07.0\"\n",
298            "varve.toml",
299        )
300        .unwrap_err();
301        assert!(matches!(err, PinError::Toml { .. }), "got: {err}");
302    }
303
304    // rivet: verifies REQ-PIN-001
305    #[test]
306    fn rejects_malformed_digest() {
307        // Includes a wrong-length PURE-HEX digest: length and charset are
308        // independent checks and each must reject alone.
309        for bad in [
310            "sha256:short",
311            "md5:aaaa",
312            "aaaaaaaa",
313            "sha256:GGGG",
314            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
315        ] {
316            let toml = format!(
317                "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ndigest = \"{bad}\"\n"
318            );
319            let err = Pin::parse(&toml, "varve.toml").unwrap_err();
320            assert!(
321                matches!(err, PinError::MalformedDigest { .. }),
322                "input {bad:?} got: {err}"
323            );
324        }
325    }
326
327    // rivet: verifies REQ-PIN-002
328    #[test]
329    fn rejects_a_tool_name_that_is_a_path() {
330        // A tool name is looked up INSIDE the verified layer. `Path::join`
331        // with an absolute path REPLACES the base, so an unchecked name would
332        // resolve outside the layer entirely — exactly the "never falls back
333        // to binaries on PATH" guarantee the docs make. Fail closed here.
334        for hostile in [
335            "/usr/bin/id",
336            "../../usr/bin/id",
337            "sub/dir",
338            "..",
339            ".",
340            "",
341            "C:\\Windows\\system32\\cmd.exe",
342        ] {
343            let content = format!(
344                "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"{}\"]\n",
345                hostile.replace('\\', "\\\\")
346            );
347            assert!(
348                Pin::parse(&content, "varve.toml").is_err(),
349                "tool name {hostile:?} must be refused — it escapes the layer"
350            );
351        }
352        // Ordinary names still parse.
353        let ok = "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"rivet\", \"synth-c\", \"cargo_x\"]\n";
354        assert!(Pin::parse(ok, "varve.toml").is_ok());
355    }
356
357    // rivet: verifies REQ-PIN-001
358    #[test]
359    fn rejects_empty_tools_list() {
360        let err = Pin::parse(
361            "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = []\n",
362            "varve.toml",
363        )
364        .unwrap_err();
365        assert!(matches!(err, PinError::EmptyTools { .. }), "got: {err}");
366    }
367
368    // rivet: verifies REQ-PIN-001
369    #[test]
370    fn errors_name_the_offending_file() {
371        let err = Pin::parse("nonsense", "proj/sub/varve.toml").unwrap_err();
372        assert!(
373            err.to_string().contains("proj/sub/varve.toml"),
374            "diagnostic must carry the path: {err}"
375        );
376    }
377}