1use std::path::Path;
13use std::str::FromStr;
14
15use serde::Deserialize;
16
17use crate::layer::{LayerId, LayerIdError};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum Channel {
26 Qualified,
27 Rolling,
28}
29
30impl Channel {
31 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 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#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Pin {
57 pub realm: Option<String>,
60 pub channel: Channel,
61 pub layer: LayerId,
62 pub digest: Option<String>,
66 pub tools: Option<Vec<String>>,
69}
70
71#[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 #[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 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 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 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 #[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 #[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 #[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 #[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 assert!(
277 source.to_string().contains("three-part"),
278 "the chain must teach the grammar: {source}"
279 );
280 }
281
282 #[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 #[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 #[test]
306 fn rejects_malformed_digest() {
307 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 #[test]
329 fn rejects_a_tool_name_that_is_a_path() {
330 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 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 #[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 #[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}