use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::path::Path;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub link: Link,
#[serde(default)]
pub render: std::collections::BTreeMap<String, String>,
#[serde(default)]
pub hooks: std::collections::BTreeMap<String, crate::hooks::Hook>,
#[serde(default)]
pub data: Vec<String>,
#[serde(default)]
pub providers: crate::render::Providers,
#[serde(default)]
pub encrypted: std::collections::BTreeMap<String, String>,
#[serde(default)]
pub encryption: Option<crate::encrypted::Encryption>,
#[serde(default)]
pub modes: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Link {
#[serde(default)]
pub common: Vec<String>,
#[serde(default)]
pub darwin: Vec<String>,
#[serde(default)]
pub linux: Vec<String>,
#[serde(default)]
pub ignore: Vec<String>,
}
impl Manifest {
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to read manifest: {}", path.display()))?;
let m: Manifest = toml::from_str(&text)
.with_context(|| format!("failed to parse manifest: {}", path.display()))?;
m.validate()
.with_context(|| format!("invalid manifest: {}", path.display()))?;
Ok(m)
}
fn validate(&self) -> Result<()> {
let mut paths: Vec<(&str, &str)> = Vec::new();
for p in self
.link
.common
.iter()
.chain(&self.link.darwin)
.chain(&self.link.linux)
{
paths.push(("link", p));
}
for (out, src) in &self.render {
paths.push(("render output", out));
paths.push(("render template", src));
}
for (out, src) in &self.encrypted {
paths.push(("encrypted output", out));
paths.push(("encrypted source", src));
}
for p in self.modes.keys() {
paths.push(("modes", p));
}
for p in &self.data {
paths.push(("data", p));
}
for h in self.hooks.values() {
if let Some(cwd) = &h.cwd {
paths.push(("hook cwd", cwd));
}
for w in &h.when_changed {
paths.push(("hook when-changed", w));
}
}
for (what, p) in paths {
let path = Path::new(p);
if path.is_absolute() {
bail!("{what} `{p}` is an absolute path; declarations are relative to the repository root");
}
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
bail!("{what} `{p}` contains `..`; declarations may not leave the repository");
}
if p.is_empty() {
bail!("{what} has an empty path");
}
}
for (path, mode) in &self.modes {
let m = u32::from_str_radix(mode, 8)
.with_context(|| format!("mode `{mode}` for `{path}` is not octal"))?;
if mode.len() != 3 {
bail!("mode `{mode}` for `{path}` must be exactly three octal digits");
}
if m > 0o777 {
bail!("mode `{mode}` for `{path}` is out of range; use three octal digits");
}
}
Ok(())
}
pub fn targets(&self) -> Vec<&str> {
let mut out: Vec<&str> = self.link.common.iter().map(String::as_str).collect();
let os_specific = if cfg!(target_os = "macos") {
&self.link.darwin
} else {
&self.link.linux
};
out.extend(os_specific.iter().map(String::as_str));
out
}
pub fn is_template(&self, rel: &Path) -> bool {
self.render.values().any(|t| Path::new(t) == rel)
}
pub fn is_ciphertext(&self, rel: &Path) -> bool {
self.encrypted.values().any(|t| Path::new(t) == rel)
}
pub fn mode_for(&self, rel: &Path) -> Option<u32> {
self.modes
.iter()
.find(|(pat, _)| Path::new(pat.as_str()) == rel)
.and_then(|(_, m)| u32::from_str_radix(m, 8).ok())
}
pub fn is_ignored(&self, rel: &Path) -> bool {
self.link.ignore.iter().any(|pat| {
if let Some(ext) = pat.strip_prefix("*.") {
rel.extension().is_some_and(|e| e == ext)
} else {
rel.starts_with(pat)
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn load(toml: &str) -> Result<Manifest> {
let p = std::env::temp_dir().join(format!(
"sennit-manifest-{}.toml",
toml.len() as u64 * 31 + toml.bytes().map(u64::from).sum::<u64>()
));
std::fs::write(&p, toml).unwrap();
Manifest::load(&p)
}
#[test]
fn an_absolute_declaration_is_rejected() {
let e = load("[link]\ncommon = [\"/etc/passwd\"]\n").unwrap_err();
assert!(format!("{e:#}").contains("absolute"), "{e:#}");
}
#[test]
fn leaving_the_repository_is_rejected() {
let e = load("[link]\ncommon = [\"../secret\"]\n").unwrap_err();
assert!(format!("{e:#}").contains(".."), "{e:#}");
}
#[test]
fn a_template_path_that_escapes_is_rejected() {
let e = load("[link]\ncommon = []\n\n[render]\n\"a.conf\" = \"../a.tmpl\"\n").unwrap_err();
assert!(format!("{e:#}").contains(".."), "{e:#}");
}
#[test]
fn a_mode_that_is_not_octal_is_rejected() {
let e = load("[link]\ncommon = []\n\n[modes]\n\".npmrc\" = \"0o600\"\n").unwrap_err();
assert!(format!("{e:#}").contains("octal"), "{e:#}");
}
#[test]
fn a_misspelled_key_is_rejected() {
let e = load("[link]\ncommon = []\n\n[hooks.h]\nwhen_changed = [\"a\"]\nrun = \"true\"\n")
.unwrap_err();
assert!(format!("{e:#}").contains("when_changed"), "{e:#}");
}
#[test]
fn a_valid_manifest_loads() {
let m = load(
"[link]\ncommon = [\".config\"]\nignore = [\"*.tmpl\"]\n\n[modes]\n\".npmrc\" = \"600\"\n",
)
.unwrap();
assert_eq!(m.mode_for(Path::new(".npmrc")), Some(0o600));
}
fn manifest(ignore: &[&str]) -> Manifest {
Manifest {
link: Link {
common: vec![],
darwin: vec![],
linux: vec![],
ignore: ignore.iter().map(|s| s.to_string()).collect(),
},
render: Default::default(),
hooks: Default::default(),
providers: Default::default(),
encrypted: Default::default(),
encryption: None,
data: Default::default(),
modes: Default::default(),
}
}
#[test]
fn ignores_by_extension_glob() {
let m = manifest(&["*.tmpl"]);
assert!(m.is_ignored(Path::new(".config/starship.toml.tmpl")));
assert!(!m.is_ignored(Path::new(".config/starship.toml")));
}
#[test]
fn a_prefix_matches_whole_components_not_characters() {
let m = manifest(&["conf/nvim"]);
assert!(m.is_ignored(Path::new("conf/nvim/init.lua")));
assert!(m.is_ignored(Path::new("conf/nvim")));
assert!(!m.is_ignored(Path::new("conf/nvim-extra/x.lua")));
assert!(!m.is_ignored(Path::new("conf/nvimrc")));
}
#[test]
fn a_prefix_is_rooted_at_the_repository() {
let m = manifest(&["README.md"]);
assert!(m.is_ignored(Path::new("README.md")));
assert!(!m.is_ignored(Path::new("conf/README.md")));
}
#[test]
fn ignores_by_path_prefix() {
let m = manifest(&[".config/secret"]);
assert!(m.is_ignored(Path::new(".config/secret/token")));
assert!(m.is_ignored(Path::new(".config/secret")));
assert!(!m.is_ignored(Path::new(".config/public/token")));
}
fn with_modes(pairs: &[(&str, &str)]) -> Manifest {
let mut m = manifest(&[]);
for (k, v) in pairs {
m.modes.insert(k.to_string(), v.to_string());
}
m
}
#[test]
fn declared_templates_are_never_placed() {
let mut m = manifest(&[]);
m.render.insert("a.conf".into(), "a.conf.tmpl".into());
assert!(m.is_template(Path::new("a.conf.tmpl")));
assert!(!m.is_template(Path::new("a.conf")));
}
#[test]
fn declared_ciphertexts_are_never_placed() {
let mut m = manifest(&[]);
m.encrypted.insert("c.conf".into(), "c.conf.age".into());
assert!(m.is_ciphertext(Path::new("c.conf.age")));
assert!(!m.is_ciphertext(Path::new("c.conf")));
}
#[test]
fn mode_is_read_as_octal() {
let m = with_modes(&[(".npmrc", "600")]);
assert_eq!(m.mode_for(Path::new(".npmrc")), Some(0o600));
}
#[test]
fn a_declaration_applies_to_the_path_it_names() {
let m = with_modes(&[(".ssh", "700"), (".ssh/config", "600")]);
assert_eq!(m.mode_for(Path::new(".ssh")), Some(0o700));
assert_eq!(m.mode_for(Path::new(".ssh/config")), Some(0o600));
assert_eq!(m.mode_for(Path::new(".ssh/known_hosts")), None);
}
#[test]
fn a_mode_must_be_three_octal_digits() {
for bad in ["2755", "0600", "60", "6"] {
let toml = format!("[link]\ncommon = []\n\n[modes]\n\"bin/tool\" = \"{bad}\"\n");
let e = load(&toml).unwrap_err();
assert!(
format!("{e:#}").contains("three octal digits"),
"{bad}: {e:#}"
);
}
assert!(load("[link]\ncommon = []\n\n[modes]\n\"bin/tool\" = \"755\"\n").is_ok());
}
#[test]
fn a_data_file_that_escapes_is_rejected() {
let e = load("data = [\"../outside.toml\"]\n\n[link]\ncommon = []\n").unwrap_err();
assert!(format!("{e:#}").contains(".."), "{e:#}");
}
#[test]
fn a_hook_cwd_that_escapes_is_rejected() {
let e = load("[link]\ncommon = []\n\n[hooks.h]\nrun = \"true\"\ncwd = \"../elsewhere\"\n")
.unwrap_err();
assert!(format!("{e:#}").contains(".."), "{e:#}");
}
#[test]
fn a_declared_mode_overrides_the_read_only_default() {
let m = with_modes(&[(".npmrc", "600")]);
assert_eq!(m.mode_for(Path::new(".npmrc")), Some(0o600));
}
#[test]
fn no_declaration_means_no_opinion() {
let m = manifest(&[]);
assert_eq!(m.mode_for(Path::new(".npmrc")), None);
}
#[test]
fn empty_ignore_matches_nothing() {
let m = manifest(&[]);
assert!(!m.is_ignored(Path::new(".config/anything")));
}
}