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 probes: std::collections::BTreeMap<String, crate::probe::Probe>,
#[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>,
#[serde(default)]
pub merge: std::collections::BTreeMap<String, Merge>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Merge {
pub from: 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 windows: 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)
.chain(&self.link.windows)
{
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 (out, m) in &self.merge {
paths.push(("merge output", out));
paths.push(("merge source", &m.from));
}
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()
|| path.has_root()
|| path
.components()
.any(|c| matches!(c, std::path::Component::Prefix(_)))
{
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");
}
}
self.validate_merge()?;
for (name, probe) in &self.probes {
probe.validate(name)?;
}
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(())
}
fn validate_merge(&self) -> Result<()> {
let outs: Vec<&str> = self.merge.keys().map(String::as_str).collect();
for (i, a) in outs.iter().enumerate() {
for b in &outs[i + 1..] {
let (pa, pb) = (Path::new(a), Path::new(b));
if pa.starts_with(pb) || pb.starts_with(pa) {
bail!("merge outputs `{a}` and `{b}` overlap");
}
}
}
for out in &outs {
let rel = Path::new(out);
if self
.link
.common
.iter()
.chain(&self.link.darwin)
.chain(&self.link.linux)
.chain(&self.link.windows)
.any(|t| Path::new(t) == rel)
{
bail!("merge output `{out}` is also declared under [link]; sennit would both link and rewrite it");
}
if self.render.contains_key(*out) || self.encrypted.contains_key(*out) {
bail!("merge output `{out}` is also a [render] or [encrypted] output");
}
}
for (out, m) in &self.merge {
if Path::new(&m.from) == Path::new(out.as_str()) {
bail!("merge output `{out}` is its own source");
}
}
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 if cfg!(windows) {
&self.link.windows
} 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 is_merge_source(&self, rel: &Path) -> bool {
self.merge.values().any(|m| Path::new(&m.from) == rel)
}
pub fn is_merge_output(&self, rel: &Path) -> bool {
self.merge.keys().any(|o| Path::new(o) == 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| crate::glob::Pattern::new(pat).matches(rel))
}
}
#[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![],
windows: vec![],
ignore: ignore.iter().map(|s| s.to_string()).collect(),
},
render: Default::default(),
hooks: Default::default(),
providers: Default::default(),
probes: Default::default(),
encrypted: Default::default(),
encryption: None,
data: Default::default(),
modes: Default::default(),
merge: 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")));
}
#[test]
fn ignore_takes_globs() {
let m = manifest(&[
"*.test.ts",
"**/node_modules",
".claude/worktrees",
"*.tar.gz",
]);
assert!(m.is_ignored(Path::new(".claude/hooks/a.test.ts")));
assert!(!m.is_ignored(Path::new(".claude/hooks/a.ts")));
assert!(m.is_ignored(Path::new(".claude/hooks/node_modules/x/y.js")));
assert!(m.is_ignored(Path::new(".claude/worktrees/a/b.md")));
assert!(!m.is_ignored(Path::new(".claude/worktrees-old/b.md")));
assert!(m.is_ignored(Path::new("pkg/x.tar.gz")));
}
#[test]
fn a_merge_source_is_never_placed_and_an_output_is_recognised() {
let m = load(
"[link]\ncommon = [\".claude\"]\n\n[merge.\".claude/settings.json\"]\nfrom = \".claude/settings.shared.json\"\n",
)
.unwrap();
assert!(m.is_merge_source(Path::new(".claude/settings.shared.json")));
assert!(!m.is_merge_source(Path::new(".claude/settings.json")));
assert!(m.is_merge_output(Path::new(".claude/settings.json")));
}
#[test]
fn a_merge_output_that_is_also_linked_is_rejected() {
let e = load(
"[link]\ncommon = [\".claude/settings.json\"]\n\n[merge.\".claude/settings.json\"]\nfrom = \"s.json\"\n",
)
.unwrap_err();
assert!(format!("{e:#}").contains("[link]"), "{e:#}");
}
#[test]
fn a_merge_output_that_is_also_rendered_is_rejected() {
let e = load(
"[link]\ncommon = []\n\n[render]\n\"a.json\" = \"a.json.tmpl\"\n\n[merge.\"a.json\"]\nfrom = \"s.json\"\n",
)
.unwrap_err();
assert!(format!("{e:#}").contains("[render]"), "{e:#}");
}
#[test]
fn overlapping_merge_outputs_are_rejected() {
let e = load(
"[link]\ncommon = []\n\n[merge.\"a\"]\nfrom = \"x.json\"\n\n[merge.\"a/b\"]\nfrom = \"y.json\"\n",
)
.unwrap_err();
assert!(format!("{e:#}").contains("overlap"), "{e:#}");
}
#[test]
fn a_merge_path_that_escapes_is_rejected() {
let e =
load("[link]\ncommon = []\n\n[merge.\"../x.json\"]\nfrom = \"s.json\"\n").unwrap_err();
assert!(format!("{e:#}").contains(".."), "{e:#}");
let e = load("[link]\ncommon = []\n\n[merge.\"x.json\"]\nfrom = \"/etc/s.json\"\n")
.unwrap_err();
assert!(format!("{e:#}").contains("absolute"), "{e:#}");
}
#[test]
fn an_unknown_merge_key_is_rejected() {
let e =
load("[link]\ncommon = []\n\n[merge.\"x.json\"]\nfrom = \"s.json\"\nown = [\"a\"]\n")
.unwrap_err();
assert!(format!("{e:#}").contains("own"), "{e:#}");
}
}