use std::path::Path;
use std::str::FromStr;
use serde::Deserialize;
use crate::layer::{LayerId, LayerIdError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Channel {
Qualified,
Rolling,
}
impl Channel {
pub fn as_str(self) -> &'static str {
match self {
Channel::Qualified => "qualified",
Channel::Rolling => "rolling",
}
}
}
impl std::str::FromStr for Channel {
type Err = ();
fn from_str(s: &str) -> Result<Self, ()> {
match s {
"qualified" => Ok(Channel::Qualified),
"rolling" => Ok(Channel::Rolling),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pin {
pub realm: Option<String>,
pub channel: Channel,
pub layer: LayerId,
pub digest: Option<String>,
pub tools: Option<Vec<String>>,
}
#[derive(Debug, thiserror::Error)]
pub enum PinError {
#[error("failed to read {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("{path}: not valid varve.toml: {source}")]
Toml {
path: String,
#[source]
source: Box<toml::de::Error>,
},
#[error("{path}: manifest-version {found} is not supported (this varve understands version 1)")]
UnsupportedManifestVersion { path: String, found: i64 },
#[error("{path}: invalid layer identifier")]
Layer {
path: String,
#[source]
source: LayerIdError,
},
#[error(
"{path}: digest '{found}' is not a valid digest: expected 'sha256:' followed by 64 hex characters"
)]
MalformedDigest { path: String, found: String },
#[error(
"{path}: tool name {name:?} is not a plain name — a tool is looked up INSIDE \
the pinned layer, so a path would resolve outside it. Name the tool only, \
e.g. tools = [\"rivet\"]."
)]
ToolNameIsAPath { path: String, name: String },
#[error("{path}: tools list is present but empty — omit it to select every tool in the layer")]
EmptyTools { path: String },
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPin {
#[serde(rename = "manifest-version")]
manifest_version: i64,
toolchain: RawToolchain,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawToolchain {
#[serde(default)]
realm: Option<String>,
channel: Channel,
layer: String,
digest: Option<String>,
tools: Option<Vec<String>>,
}
impl Pin {
pub fn parse(content: &str, origin: &str) -> Result<Self, PinError> {
let raw: RawPin = toml::from_str(content).map_err(|source| PinError::Toml {
path: origin.to_string(),
source: Box::new(source),
})?;
if raw.manifest_version != 1 {
return Err(PinError::UnsupportedManifestVersion {
path: origin.to_string(),
found: raw.manifest_version,
});
}
let layer = LayerId::from_str(&raw.toolchain.layer).map_err(|source| PinError::Layer {
path: origin.to_string(),
source,
})?;
if let Some(digest) = &raw.toolchain.digest {
let hex = digest
.strip_prefix("sha256:")
.ok_or_else(|| PinError::MalformedDigest {
path: origin.to_string(),
found: digest.clone(),
})?;
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(PinError::MalformedDigest {
path: origin.to_string(),
found: digest.clone(),
});
}
}
if let Some(tools) = &raw.toolchain.tools {
if tools.is_empty() {
return Err(PinError::EmptyTools {
path: origin.to_string(),
});
}
for name in tools {
let plain = !name.is_empty()
&& name != "."
&& name != ".."
&& !name.contains('/')
&& !name.contains('\\')
&& !name.contains('\0');
if !plain {
return Err(PinError::ToolNameIsAPath {
path: origin.to_string(),
name: name.clone(),
});
}
}
}
Ok(Pin {
realm: raw.toolchain.realm,
channel: raw.toolchain.channel,
layer,
digest: raw.toolchain.digest,
tools: raw.toolchain.tools,
})
}
pub fn load(path: &Path) -> Result<Self, PinError> {
let content = std::fs::read_to_string(path).map_err(|source| PinError::Io {
path: path.display().to_string(),
source,
})?;
Self::parse(&content, &path.display().to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
const FULL: &str = r#"
manifest-version = 1
[toolchain]
channel = "qualified"
layer = "2026.07.0"
digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
tools = ["rivet", "synth"]
"#;
#[test]
fn parses_a_complete_pin() {
let pin = Pin::parse(FULL, "varve.toml").unwrap();
assert_eq!(pin.channel, Channel::Qualified);
assert_eq!(pin.layer, LayerId::from_str("2026.07.0").unwrap());
assert_eq!(
pin.digest.as_deref(),
Some("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
);
assert_eq!(
pin.tools.as_deref(),
Some(&["rivet".to_string(), "synth".to_string()][..])
);
}
#[test]
fn digest_and_tools_are_optional() {
let pin = Pin::parse(
"manifest-version = 1\n[toolchain]\nchannel = \"rolling\"\nlayer = \"2026.08.0\"\n",
"varve.toml",
)
.unwrap();
assert_eq!(pin.channel, Channel::Rolling);
assert_eq!(pin.digest, None);
assert_eq!(pin.tools, None);
}
#[test]
fn rejects_unsupported_manifest_version() {
let err = Pin::parse(
"manifest-version = 2\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
"varve.toml",
)
.unwrap_err();
assert!(
matches!(err, PinError::UnsupportedManifestVersion { found: 2, .. }),
"got: {err}"
);
}
#[test]
fn rejects_two_part_layer_with_the_grammar_guidance() {
let err = Pin::parse(
"manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07\"\n",
"varve.toml",
)
.unwrap_err();
let PinError::Layer { source, .. } = &err else {
panic!("got: {err}");
};
assert!(matches!(source, LayerIdError::MissingPatch(_)));
assert!(
source.to_string().contains("three-part"),
"the chain must teach the grammar: {source}"
);
}
#[test]
fn rejects_unknown_keys_instead_of_ignoring_them() {
let err = Pin::parse(
"manifest-version = 1\nsurprise = true\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
"varve.toml",
)
.unwrap_err();
assert!(matches!(err, PinError::Toml { .. }), "got: {err}");
}
#[test]
fn rejects_unknown_channel() {
let err = Pin::parse(
"manifest-version = 1\n[toolchain]\nchannel = \"latest\"\nlayer = \"2026.07.0\"\n",
"varve.toml",
)
.unwrap_err();
assert!(matches!(err, PinError::Toml { .. }), "got: {err}");
}
#[test]
fn rejects_malformed_digest() {
for bad in [
"sha256:short",
"md5:aaaa",
"aaaaaaaa",
"sha256:GGGG",
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
] {
let toml = format!(
"manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ndigest = \"{bad}\"\n"
);
let err = Pin::parse(&toml, "varve.toml").unwrap_err();
assert!(
matches!(err, PinError::MalformedDigest { .. }),
"input {bad:?} got: {err}"
);
}
}
#[test]
fn rejects_a_tool_name_that_is_a_path() {
for hostile in [
"/usr/bin/id",
"../../usr/bin/id",
"sub/dir",
"..",
".",
"",
"C:\\Windows\\system32\\cmd.exe",
] {
let content = format!(
"manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"{}\"]\n",
hostile.replace('\\', "\\\\")
);
assert!(
Pin::parse(&content, "varve.toml").is_err(),
"tool name {hostile:?} must be refused — it escapes the layer"
);
}
let ok = "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"rivet\", \"synth-c\", \"cargo_x\"]\n";
assert!(Pin::parse(ok, "varve.toml").is_ok());
}
#[test]
fn rejects_empty_tools_list() {
let err = Pin::parse(
"manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = []\n",
"varve.toml",
)
.unwrap_err();
assert!(matches!(err, PinError::EmptyTools { .. }), "got: {err}");
}
#[test]
fn errors_name_the_offending_file() {
let err = Pin::parse("nonsense", "proj/sub/varve.toml").unwrap_err();
assert!(
err.to_string().contains("proj/sub/varve.toml"),
"diagnostic must carry the path: {err}"
);
}
}