use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_sennit")
}
struct Repo {
root: PathBuf,
home: PathBuf,
}
impl Repo {
fn new(name: &str) -> Self {
let base = std::env::temp_dir().join(format!("sennit-it-{name}"));
if base.exists() {
let _ = Command::new("chmod")
.arg("-R")
.arg("u+rwX")
.arg(&base)
.status();
}
let _ = std::fs::remove_dir_all(&base);
let root = base.join("repo");
let home = base.join("home");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&home).unwrap();
std::fs::write(root.join("packages.toml"), "[packages]\n").unwrap();
Repo { root, home }
}
fn write(&self, rel: &str, body: &str) {
let p = self.root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
}
fn write_home(&self, rel: &str, body: &str) {
let p = self.home.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
}
fn manifest(&self, body: &str) {
std::fs::write(self.root.join("sennit.toml"), body).unwrap();
}
fn run(&self, args: &[&str]) -> Output {
Command::new(bin())
.arg("--root")
.arg(&self.root)
.arg("--home")
.arg(&self.home)
.args(args)
.output()
.unwrap()
}
fn run_relative(&self, args: &[&str]) -> Output {
Command::new(bin())
.current_dir(&self.root)
.arg("--root")
.arg(".")
.arg("--home")
.arg(&self.home)
.args(args)
.output()
.unwrap()
}
fn home_path(&self, rel: &str) -> PathBuf {
self.home.join(rel)
}
fn root_path(&self, rel: &str) -> PathBuf {
self.root.join(rel)
}
}
fn ok(out: &Output) -> String {
assert!(
out.status.success(),
"command failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).to_string()
}
fn mode_of(p: &Path) -> u32 {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
}
#[test]
fn dry_run_writes_nothing() {
let r = Repo::new("dry-run");
r.manifest(
r#"
[link]
common = ["a.conf", "gen.conf"]
ignore = ["*.tmpl"]
[render]
"gen.conf" = "gen.conf.tmpl"
"#,
);
r.write("theme.toml", "bg = \"zzz\"\n");
r.write("a.conf", "plain\n");
r.write("gen.conf.tmpl", "x = {{ bg }}\n");
let out = ok(&r.run(&["apply", "--dry-run"]));
assert!(out.contains("nothing written"), "{out}");
assert!(!r.root_path("gen.conf").exists(), "dry-run wrote gen.conf");
assert_eq!(std::fs::read_dir(&r.home).unwrap().count(), 0);
assert!(out.contains("2 change(s)"), "{out}");
}
#[test]
fn a_relative_root_does_not_produce_a_self_link() {
let r = Repo::new("relative-root");
r.manifest("[link]\ncommon = [\"a.conf\"]\n");
r.write("a.conf", "from the repo\n");
ok(&r.run_relative(&["apply"]));
let target = std::fs::read_link(r.home_path("a.conf")).unwrap();
assert!(target.is_absolute(), "link target is relative: {target:?}");
assert_eq!(
std::fs::read_to_string(r.home_path("a.conf")).unwrap(),
"from the repo\n"
);
}
#[test]
fn a_backup_survives_a_later_apply() {
let r = Repo::new("backup-survives");
r.manifest("[link]\ncommon = [\"a.conf\"]\n");
r.write("a.conf", "repo\n");
r.write("b.conf", "repo b\n");
r.write_home("a.conf", "MINE\n");
ok(&r.run(&["apply"]));
r.manifest("[link]\ncommon = [\"a.conf\", \"b.conf\"]\n");
ok(&r.run(&["apply"]));
let out = ok(&r.run(&["rollback"]));
assert!(out.contains("1 file(s) restored"), "{out}");
assert_eq!(
std::fs::read_to_string(r.home_path("a.conf")).unwrap(),
"MINE\n"
);
}
#[test]
fn rollback_does_not_destroy_an_older_backup() {
let r = Repo::new("rollback-two");
r.manifest("[link]\ncommon = [\"a.conf\"]\n");
r.write("a.conf", "repo\n");
r.write_home("a.conf", "FIRST\n");
ok(&r.run(&["apply"]));
std::fs::remove_file(r.home_path("a.conf")).unwrap();
r.write_home("a.conf", "SECOND\n");
ok(&r.run(&["apply"]));
ok(&r.run(&["rollback"]));
assert_eq!(
std::fs::read_to_string(r.home_path("a.conf")).unwrap(),
"SECOND\n"
);
let first = std::fs::read_dir(&r.home)
.unwrap()
.filter_map(Result::ok)
.any(|e| std::fs::read_to_string(e.path()).unwrap_or_default() == "FIRST\n");
assert!(first, "the older backup was destroyed");
}
#[test]
fn a_failing_hook_does_not_strand_a_backup() {
let r = Repo::new("hook-fails");
r.manifest("[link]\ncommon = [\"a.conf\"]\n\n[hooks.boom]\nrun = \"exit 1\"\n");
r.write("a.conf", "repo\n");
r.write_home("a.conf", "MINE\n");
let out = r.run(&["apply"]);
assert!(!out.status.success(), "the failing hook should fail apply");
ok(&r.run(&["rollback"]));
assert_eq!(
std::fs::read_to_string(r.home_path("a.conf")).unwrap(),
"MINE\n"
);
}
#[test]
fn prune_leaves_a_foreign_link_alone() {
let r = Repo::new("prune-foreign");
r.manifest("[link]\ncommon = [\"a.conf\"]\n");
r.write("a.conf", "repo\n");
ok(&r.run(&["apply"]));
std::fs::remove_file(r.home_path("a.conf")).unwrap();
let elsewhere = r.home.join("elsewhere.conf");
std::fs::write(&elsewhere, "theirs\n").unwrap();
std::os::unix::fs::symlink(&elsewhere, r.home_path("a.conf")).unwrap();
r.manifest("[link]\ncommon = []\n");
let out = ok(&r.run(&["apply"]));
assert!(out.contains("left alone"), "{out}");
assert_eq!(
std::fs::read_link(r.home_path("a.conf")).unwrap(),
elsewhere
);
}
#[test]
fn prune_removes_a_link_it_made() {
let r = Repo::new("prune-own");
r.manifest("[link]\ncommon = [\"a.conf\"]\n");
r.write("a.conf", "repo\n");
ok(&r.run(&["apply"]));
assert!(r.home_path("a.conf").exists());
r.manifest("[link]\ncommon = []\n");
let out = ok(&r.run(&["apply"]));
assert!(out.contains("prune"), "{out}");
assert!(std::fs::symlink_metadata(r.home_path("a.conf")).is_err());
}
#[test]
fn a_template_is_never_placed_even_without_ignore() {
let r = Repo::new("no-ignore");
r.manifest(
r#"
[link]
common = ["conf"]
[render]
"conf/app.conf" = "conf/app.conf.tmpl"
"#,
);
r.write("theme.toml", "bg = \"zzz\"\n");
r.write("conf/app.conf.tmpl", "x = {{ bg }}\n");
ok(&r.run(&["apply"]));
assert!(
std::fs::symlink_metadata(r.home_path("conf/app.conf.tmpl")).is_err(),
"the template itself was placed in $HOME"
);
assert_eq!(
std::fs::read_to_string(r.home_path("conf/app.conf")).unwrap(),
"x = zzz\n"
);
}
#[test]
fn a_declared_mode_is_applied_to_a_plain_link() {
let r = Repo::new("modes");
r.manifest("[link]\ncommon = [\".npmrc\"]\n\n[modes]\n\".npmrc\" = \"600\"\n");
r.write(".npmrc", "token\n");
std::fs::set_permissions(
r.root_path(".npmrc"),
<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
)
.unwrap();
ok(&r.run(&["apply"]));
assert_eq!(mode_of(&r.root_path(".npmrc")), 0o600);
let out = r.run(&["verify"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn generated_files_are_read_only() {
let r = Repo::new("read-only");
r.manifest(
r#"
[link]
common = ["gen.conf"]
ignore = ["*.tmpl"]
[render]
"gen.conf" = "gen.conf.tmpl"
"#,
);
r.write("theme.toml", "bg = \"zzz\"\n");
r.write("gen.conf.tmpl", "x = {{ bg }}\n");
ok(&r.run(&["apply"]));
assert_eq!(mode_of(&r.root_path("gen.conf")), 0o444);
}
#[test]
fn a_deferred_output_is_not_linked() {
let r = Repo::new("deferred");
r.manifest(
r#"
[link]
common = ["secret.conf"]
ignore = ["*.tmpl"]
[render]
"secret.conf" = "secret.conf.tmpl"
[providers.never]
command = "false {}"
"#,
);
r.write("theme.toml", "bg = \"zzz\"\n");
r.write("secret.conf.tmpl", "token = {{ never://a/b }}\n");
let out = ok(&r.run(&["apply"]));
assert!(out.contains("deferred"), "{out}");
assert!(
std::fs::symlink_metadata(r.home_path("secret.conf")).is_err(),
"a link was made to a file that does not exist"
);
assert!(!out.contains("already up to date"), "{out}");
}
#[test]
fn a_secret_behind_a_false_condition_does_not_defer() {
let r = Repo::new("guarded-secret");
r.manifest(
r#"
[link]
common = ["gen.conf"]
ignore = ["*.tmpl"]
[render]
"gen.conf" = "gen.conf.tmpl"
[providers.never]
command = "false {}"
"#,
);
r.write("theme.toml", "bg = \"zzz\"\n");
r.write(
"gen.conf.tmpl",
"x = {{ bg }}\n{{ if sennit.os == \"no-such-os\" }}t = {{ never://a/b }}\n{{ end }}",
);
let out = ok(&r.run(&["apply"]));
assert!(!out.contains("deferred"), "{out}");
assert_eq!(
std::fs::read_to_string(r.home_path("gen.conf")).unwrap(),
"x = zzz\n"
);
}
#[test]
fn a_declaration_that_escapes_is_refused() {
let r = Repo::new("escape");
r.manifest("[link]\ncommon = [\"../outside\"]\n");
let out = r.run(&["apply"]);
assert!(!out.status.success());
assert!(
String::from_utf8_lossy(&out.stderr).contains(".."),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn an_unreadable_directory_fails_instead_of_pruning() {
use std::os::unix::fs::PermissionsExt;
if euid() == 0 {
return;
}
let r = Repo::new("unreadable");
r.manifest("[link]\ncommon = [\"conf\"]\n");
r.write("conf/a.conf", "a\n");
r.write("conf/b.conf", "b\n");
ok(&r.run(&["apply"]));
std::fs::set_permissions(r.root_path("conf"), std::fs::Permissions::from_mode(0o000)).unwrap();
let out = r.run(&["apply"]);
std::fs::set_permissions(r.root_path("conf"), std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(!out.status.success(), "an unreadable directory should fail");
assert!(std::fs::symlink_metadata(r.home_path("conf/a.conf")).is_ok());
assert!(std::fs::symlink_metadata(r.home_path("conf/b.conf")).is_ok());
}
fn euid() -> u32 {
Command::new("id")
.arg("-u")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.trim().parse().ok())
.unwrap_or(1000)
}
#[test]
fn an_unreadable_file_is_not_treated_as_removed() {
use std::os::unix::fs::PermissionsExt;
if euid() == 0 {
return;
}
let r = Repo::new("unreadable-file");
r.manifest("[link]\ncommon = [\"conf\"]\n");
r.write("conf/sub/b.conf", "b\n");
ok(&r.run(&["apply"]));
std::fs::set_permissions(
r.root_path("conf/sub"),
std::fs::Permissions::from_mode(0o600),
)
.unwrap();
let out = r.run(&["apply"]);
std::fs::set_permissions(
r.root_path("conf/sub"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
assert!(
!out.status.success(),
"an unreadable file should be an error, not a prune: {}",
String::from_utf8_lossy(&out.stdout)
);
assert!(
std::fs::symlink_metadata(r.home_path("conf/sub/b.conf")).is_ok(),
"the link was pruned"
);
}
#[test]
fn rollback_is_idempotent() {
let r = Repo::new("rollback-twice");
r.manifest("[link]\ncommon = [\"a.conf\"]\n");
r.write("a.conf", "repo\n");
for body in ["FIRST\n", "SECOND\n", "THIRD\n"] {
if r.home_path("a.conf").exists()
|| std::fs::symlink_metadata(r.home_path("a.conf")).is_ok()
{
std::fs::remove_file(r.home_path("a.conf")).unwrap();
}
r.write_home("a.conf", body);
ok(&r.run(&["apply"]));
}
ok(&r.run(&["rollback"]));
assert_eq!(
std::fs::read_to_string(r.home_path("a.conf")).unwrap(),
"THIRD\n"
);
let out = ok(&r.run(&["rollback"]));
assert!(out.contains("nothing to roll back"), "{out}");
assert_eq!(
std::fs::read_to_string(r.home_path("a.conf")).unwrap(),
"THIRD\n"
);
let bodies: Vec<String> = std::fs::read_dir(&r.home)
.unwrap()
.filter_map(Result::ok)
.filter_map(|e| std::fs::read_to_string(e.path()).ok())
.collect();
for want in ["FIRST\n", "SECOND\n"] {
assert!(bodies.iter().any(|b| b == want), "{want:?} was destroyed");
}
}
#[test]
fn a_generated_output_is_not_written_through_a_symlink() {
let r = Repo::new("symlink-output");
r.manifest(
r#"
[link]
common = ["gen.conf"]
ignore = ["*.tmpl"]
[render]
"gen.conf" = "gen.conf.tmpl"
"#,
);
r.write("theme.toml", "bg = \"zzz\"\n");
r.write("gen.conf.tmpl", "x = {{ bg }}\n");
let victim = r.home.parent().unwrap().join("victim");
std::fs::write(&victim, "PRECIOUS\n").unwrap();
std::os::unix::fs::symlink(&victim, r.root_path("gen.conf")).unwrap();
let out = r.run(&["apply"]);
assert!(
!out.status.success(),
"writing through a symlink should fail"
);
assert_eq!(std::fs::read_to_string(&victim).unwrap(), "PRECIOUS\n");
}
#[test]
fn dry_run_previews_what_apply_will_link() {
let r = Repo::new("dry-run-preview");
r.manifest(
r#"
[link]
common = ["a.conf", "gen.conf"]
ignore = ["*.tmpl"]
[render]
"gen.conf" = "gen.conf.tmpl"
"#,
);
r.write("theme.toml", "bg = \"zzz\"\n");
r.write("a.conf", "plain\n");
r.write("gen.conf.tmpl", "x = {{ bg }}\n");
let preview = ok(&r.run(&["apply", "--dry-run"]));
assert!(
!preview.contains("not generated yet"),
"dry-run said it cannot link a file it also said it would render:\n{preview}"
);
let real = ok(&r.run(&["apply"]));
assert!(real.contains("2 link(s) updated"), "{real}");
}
#[test]
fn a_directory_mode_that_locks_you_out_is_refused() {
let r = Repo::new("dir-mode");
r.manifest("[link]\ncommon = [\".ssh\"]\n\n[modes]\n\".ssh\" = \"600\"\n");
r.write(".ssh/config", "Host x\n");
let out = r.run(&["apply"]);
assert!(
!out.status.success(),
"600 on a directory should be refused"
);
use std::os::unix::fs::PermissionsExt;
assert_ne!(
std::fs::metadata(r.root_path(".ssh"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
#[test]
fn rollback_does_not_destroy_what_is_at_the_destination() {
let r = Repo::new("rollback-dest");
r.manifest("[link]\ncommon = [\"a.conf\"]\n");
r.write("a.conf", "repo\n");
r.write_home("a.conf", "ORIGINAL\n");
ok(&r.run(&["apply"]));
std::fs::remove_file(r.home_path("a.conf")).unwrap();
r.write_home("a.conf", "NEW WORK\n");
ok(&r.run(&["rollback"]));
assert_eq!(
std::fs::read_to_string(r.home_path("a.conf")).unwrap(),
"ORIGINAL\n"
);
let bodies: Vec<String> = std::fs::read_dir(&r.home)
.unwrap()
.filter_map(Result::ok)
.filter_map(|e| std::fs::read_to_string(e.path()).ok())
.collect();
assert!(
bodies.iter().any(|b| b == "NEW WORK\n"),
"what was at the destination was destroyed: {bodies:?}"
);
}
#[test]
fn a_backup_is_recorded_before_anything_else_can_fail() {
let r = Repo::new("backup-early");
r.manifest("[link]\ncommon = [\"a.conf\", \"zz\"]\n");
r.write("a.conf", "repo\n");
r.write("zz/b.conf", "b\n");
r.write_home("a.conf", "MINE\n");
r.write_home("zz", "not a directory\n");
let out = r.run(&["apply"]);
assert!(!out.status.success(), "apply should have failed");
ok(&r.run(&["rollback"]));
assert_eq!(
std::fs::read_to_string(r.home_path("a.conf")).unwrap(),
"MINE\n"
);
}
#[test]
fn a_generated_output_is_not_written_over_a_directory() {
let r = Repo::new("dir-output");
r.manifest(
r#"
[link]
common = ["gen.conf"]
ignore = ["*.tmpl"]
[render]
"gen.conf" = "gen.conf.tmpl"
"#,
);
r.write("theme.toml", "bg = \"zzz\"\n");
r.write("gen.conf.tmpl", "x = {{ bg }}\n");
std::fs::create_dir_all(r.root_path("gen.conf")).unwrap();
std::fs::write(r.root_path("gen.conf/inner"), "inner\n").unwrap();
let out = r.run(&["apply"]);
assert!(!out.status.success());
assert_eq!(
mode_of(&r.root_path("gen.conf")) & 0o700,
0o700,
"the directory was chmod-ed before the refusal"
);
assert!(r.root_path("gen.conf/inner").exists());
}
#[test]
fn no_backup_replaces_without_keeping_a_copy() {
let r = Repo::new("no-backup-apply");
r.manifest("[link]\ncommon = [\"a.conf\"]\n");
r.write("a.conf", "repo\n");
r.write_home("a.conf", "MINE\n");
let out = ok(&r.run(&["apply", "--no-backup"]));
assert!(out.contains("replace"), "{out}");
assert_eq!(
std::fs::read_to_string(r.home_path("a.conf")).unwrap(),
"repo\n"
);
let out = ok(&r.run(&["rollback"]));
assert!(out.contains("nothing to roll back"), "{out}");
}
#[test]
fn verify_fails_on_a_wrong_mode() {
use std::os::unix::fs::PermissionsExt;
let r = Repo::new("verify-mode");
r.manifest("[link]\ncommon = [\".npmrc\"]\n\n[modes]\n\".npmrc\" = \"600\"\n");
r.write(".npmrc", "token\n");
ok(&r.run(&["apply"]));
std::fs::set_permissions(
r.root_path(".npmrc"),
std::fs::Permissions::from_mode(0o644),
)
.unwrap();
let out = r.run(&["verify"]);
assert!(!out.status.success(), "verify should fail on a wrong mode");
assert!(
String::from_utf8_lossy(&out.stdout).contains("declared 600"),
"{}",
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn an_encrypted_file_is_decrypted_and_kept_private() {
let r = Repo::new("encrypted");
r.write("secret.age", "PLAINTEXT\n");
r.manifest(
r#"
[link]
common = ["secret"]
ignore = ["*.age"]
[encrypted]
"secret" = "secret.age"
[encryption]
command = "cat {}"
"#,
);
ok(&r.run(&["apply"]));
assert_eq!(
std::fs::read_to_string(r.home_path("secret")).unwrap(),
"PLAINTEXT\n"
);
assert_eq!(mode_of(&r.root_path("secret")), 0o400);
}
#[test]
fn an_encrypted_file_is_deferred_without_its_key() {
let r = Repo::new("encrypted-nokey");
r.write("secret.age", "PLAINTEXT\n");
r.manifest(
r#"
[link]
common = ["secret"]
ignore = ["*.age"]
[encrypted]
"secret" = "secret.age"
[encryption]
command = "cat {}"
identity = "/nonexistent/key.txt"
"#,
);
let out = ok(&r.run(&["apply"]));
assert!(out.contains("no decryption key"), "{out}");
assert!(std::fs::symlink_metadata(r.home_path("secret")).is_err());
}
#[test]
fn a_secret_is_fetched_with_secrets_and_written_private() {
let r = Repo::new("secrets");
r.manifest(
r#"
[link]
common = ["conf"]
ignore = ["*.tmpl"]
[render]
"conf" = "conf.tmpl"
[providers.fake]
command = "echo {}"
"#,
);
r.write("theme.toml", "bg = \"zzz\"\n");
r.write("conf.tmpl", "token = {{ fake://hello }}\n");
ok(&r.run(&["apply", "--secrets"]));
assert_eq!(
std::fs::read_to_string(r.home_path("conf")).unwrap(),
"token = hello\n"
);
assert_eq!(mode_of(&r.root_path("conf")), 0o400);
}
#[test]
fn an_undeclared_scheme_is_named() {
let r = Repo::new("unknown-scheme");
r.manifest(
r#"
[link]
common = ["conf"]
ignore = ["*.tmpl"]
[render]
"conf" = "conf.tmpl"
[providers.fake]
command = "echo {}"
"#,
);
r.write("theme.toml", "bg = \"zzz\"\n");
r.write("conf.tmpl", "token = {{ nosuch://x }}\n");
let out = r.run(&["apply", "--secrets"]);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("nosuch"), "{err}");
assert!(err.contains("fake"), "{err}");
}
#[test]
fn apply_does_not_claim_nothing_happened_after_running_a_hook() {
let r = Repo::new("hook-ran");
r.manifest("[link]\ncommon = [\"a.conf\"]\n\n[hooks.always]\nrun = \"true\"\n");
r.write("a.conf", "repo\n");
ok(&r.run(&["apply"]));
let out = ok(&r.run(&["apply"]));
assert!(out.contains("hook"), "{out}");
assert!(
!out.contains("already up to date"),
"a hook ran, so something happened:\n{out}"
);
}
#[test]
fn dry_run_refuses_a_directory_mode_that_locks_you_out() {
let r = Repo::new("dry-run-dir-mode");
r.manifest("[link]\ncommon = [\".ssh\"]\n\n[modes]\n\".ssh\" = \"600\"\n");
r.write(".ssh/config", "Host x\n");
let out = r.run(&["apply", "--dry-run"]);
assert!(
!out.status.success(),
"the preview should refuse what the real apply refuses"
);
}
#[test]
fn a_generated_output_cannot_escape_through_a_symlinked_parent() {
let r = Repo::new("symlink-parent");
let outside = r.home.parent().unwrap().join("outside");
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("gen.conf"), "PRECIOUS\n").unwrap();
r.manifest(
r#"
[link]
common = ["out"]
ignore = ["*.tmpl"]
[render]
"out/gen.conf" = "gen.conf.tmpl"
"#,
);
r.write("theme.toml", "bg = \"zzz\"\n");
r.write("gen.conf.tmpl", "x = {{ bg }}\n");
std::os::unix::fs::symlink(&outside, r.root_path("out")).unwrap();
let out = r.run(&["apply"]);
assert!(
!out.status.success(),
"writing outside the repo should fail"
);
assert_eq!(
std::fs::read_to_string(outside.join("gen.conf")).unwrap(),
"PRECIOUS\n"
);
}
#[test]
fn a_directory_mode_without_read_is_refused() {
let r = Repo::new("dir-mode-read");
r.manifest("[link]\ncommon = [\".ssh\"]\n\n[modes]\n\".ssh\" = \"300\"\n");
r.write(".ssh/config", "Host x\n");
let out = r.run(&["apply"]);
assert!(
!out.status.success(),
"300 on a directory should be refused"
);
assert_ne!(mode_of(&r.root_path(".ssh")) & 0o500, 0);
}
#[test]
fn links_are_recorded_even_if_a_later_step_fails() {
let r = Repo::new("record-before-modes");
r.manifest("[link]\ncommon = [\"a.conf\", \".ssh\"]\n\n[modes]\n\".ssh\" = \"600\"\n");
r.write("a.conf", "repo\n");
r.write(".ssh/config", "Host x\n");
let out = r.run(&["apply"]);
assert!(!out.status.success());
assert!(std::fs::symlink_metadata(r.home_path("a.conf")).is_ok());
r.manifest("[link]\ncommon = []\n");
let out = ok(&r.run(&["apply"]));
assert!(out.contains("prune"), "the link was never recorded:\n{out}");
assert!(std::fs::symlink_metadata(r.home_path("a.conf")).is_err());
}
#[test]
fn verify_does_not_approve_a_mode_it_could_not_read() {
use std::os::unix::fs::PermissionsExt;
if euid() == 0 {
return;
}
let r = Repo::new("verify-unreadable");
r.manifest("[link]\ncommon = []\n\n[modes]\n\"scripts/tool\" = \"600\"\n");
r.write("scripts/tool", "#!/bin/sh\n");
std::fs::set_permissions(
r.root_path("scripts"),
std::fs::Permissions::from_mode(0o600),
)
.unwrap();
let out = r.run(&["verify"]);
std::fs::set_permissions(
r.root_path("scripts"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
assert!(
!out.status.success(),
"verify approved a mode it could not read:\n{}",
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn a_link_placed_before_a_failure_is_still_recorded() {
let r = Repo::new("record-midloop");
r.manifest("[link]\ncommon = [\"a.conf\", \"zz\"]\n");
r.write("a.conf", "repo\n");
r.write("zz/b.conf", "b\n");
r.write_home("zz", "a file, not a directory\n");
let out = r.run(&["apply"]);
assert!(!out.status.success());
assert!(std::fs::symlink_metadata(r.home_path("a.conf")).is_ok());
r.manifest("[link]\ncommon = []\n");
let out = ok(&r.run(&["apply"]));
assert!(out.contains("prune"), "the link was never recorded:\n{out}");
assert!(std::fs::symlink_metadata(r.home_path("a.conf")).is_err());
}
#[test]
fn rollback_does_not_claim_to_restore_a_backup_that_is_gone() {
let r = Repo::new("rollback-gone");
r.manifest("[link]\ncommon = [\"a.conf\"]\n");
r.write("a.conf", "repo\n");
r.write_home("a.conf", "MINE\n");
ok(&r.run(&["apply"]));
std::fs::remove_file(r.home_path("a.conf.sennit-backup")).unwrap();
let out = ok(&r.run(&["rollback", "--dry-run"]));
assert!(!out.contains("1 file(s) would be restored"), "{out}");
let out = ok(&r.run(&["rollback"]));
assert!(!out.contains("1 file(s) restored"), "{out}");
assert!(
out.contains("already gone") || out.contains("nothing to put back"),
"{out}"
);
}
#[test]
fn ignore_does_not_swallow_a_sibling_with_a_shared_name_prefix() {
let r = Repo::new("ignore-sibling");
r.manifest("[link]\ncommon = [\"conf\"]\nignore = [\"conf/nvim\"]\n");
r.write("conf/nvim/init.lua", "-- ignored\n");
r.write("conf/nvim-extra/x.lua", "-- kept\n");
ok(&r.run(&["apply"]));
assert!(
std::fs::symlink_metadata(r.home_path("conf/nvim-extra/x.lua")).is_ok(),
"a sibling sharing the prefix was ignored"
);
assert!(std::fs::symlink_metadata(r.home_path("conf/nvim/init.lua")).is_err());
ok(&r.run(&["apply"]));
assert!(std::fs::symlink_metadata(r.home_path("conf/nvim-extra/x.lua")).is_ok());
}
#[test]
fn dry_run_calls_neither_a_provider_nor_a_decryption_command() {
let r = Repo::new("dry-run-no-exec");
let marker = r.home.parent().unwrap().join("called");
r.manifest(&format!(
r#"
[link]
common = ["gen.conf", "secret"]
ignore = ["*.tmpl", "*.age"]
[render]
"gen.conf" = "gen.conf.tmpl"
[encrypted]
"secret" = "secret.age"
[encryption]
command = "sh -c 'echo decrypt >> {m}; cat' {{}}"
[providers.fake]
command = "sh -c 'echo provider >> {m}; echo v'"
"#,
m = marker.display()
));
r.write("theme.toml", "bg = \"zzz\"\n");
r.write("gen.conf.tmpl", "t = {{ fake://a }}\n");
r.write("secret.age", "PLAIN\n");
ok(&r.run(&["apply", "--dry-run", "--secrets"]));
assert!(
!marker.exists(),
"--dry-run ran: {}",
std::fs::read_to_string(&marker).unwrap_or_default()
);
}
#[test]
fn overlapping_link_targets_do_not_break_the_first_apply() {
let r = Repo::new("overlap");
r.manifest("[link]\ncommon = [\"conf\", \"conf/nvim\"]\n");
r.write("conf/nvim/init.lua", "-- x\n");
r.write("conf/zzz.conf", "z\n");
let out = ok(&r.run(&["apply"]));
assert!(out.contains("2 link(s) updated"), "{out}");
assert!(std::fs::symlink_metadata(r.home_path("conf/nvim/init.lua")).is_ok());
assert!(std::fs::symlink_metadata(r.home_path("conf/zzz.conf")).is_ok());
}
#[test]
fn a_declared_mode_does_not_follow_a_symlink_out_of_the_repository() {
use std::os::unix::fs::PermissionsExt;
let r = Repo::new("mode-symlink");
let outside = r.home.parent().unwrap().join("secret");
std::fs::write(&outside, "key\n").unwrap();
std::fs::set_permissions(&outside, std::fs::Permissions::from_mode(0o600)).unwrap();
r.manifest("[link]\ncommon = []\n\n[modes]\n\"keylink\" = \"644\"\n");
std::os::unix::fs::symlink(&outside, r.root_path("keylink")).unwrap();
let out = r.run(&["apply"]);
assert!(
!out.status.success(),
"a symlinked mode target should be refused"
);
assert_eq!(mode_of(&outside), 0o600, "the link target was chmod-ed");
}
#[test]
fn an_unreadable_ancestor_does_not_prune() {
use std::os::unix::fs::PermissionsExt;
if euid() == 0 {
return;
}
let r = Repo::new("unreadable-ancestor");
r.manifest("[link]\ncommon = [\"conf/nvim\"]\n");
r.write("conf/nvim/init.lua", "-- x\n");
ok(&r.run(&["apply"]));
std::fs::set_permissions(r.root_path("conf"), std::fs::Permissions::from_mode(0o000)).unwrap();
let out = r.run(&["apply"]);
std::fs::set_permissions(r.root_path("conf"), std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(
!out.status.success(),
"an unreadable ancestor should be an error, not a prune:\n{}",
String::from_utf8_lossy(&out.stdout)
);
assert!(
std::fs::symlink_metadata(r.home_path("conf/nvim/init.lua")).is_ok(),
"the link was pruned"
);
}
#[test]
fn a_declared_mode_cannot_escape_through_a_symlinked_parent() {
use std::os::unix::fs::PermissionsExt;
let r = Repo::new("mode-symlink-parent");
let outside = r.home.parent().unwrap().join("outside");
std::fs::create_dir_all(&outside).unwrap();
let victim = outside.join("victim");
std::fs::write(&victim, "key\n").unwrap();
std::fs::set_permissions(&victim, std::fs::Permissions::from_mode(0o600)).unwrap();
r.manifest("[link]\ncommon = []\n\n[modes]\n\"out/victim\" = \"777\"\n");
std::os::unix::fs::symlink(&outside, r.root_path("out")).unwrap();
let out = r.run(&["apply"]);
assert!(
!out.status.success(),
"should refuse to chmod outside the repo"
);
assert_eq!(mode_of(&victim), 0o600, "the file outside was chmod-ed");
}
#[test]
fn commented_out_settings_are_not_requirements() {
let r = Repo::new("comments");
r.manifest("[link]\ncommon = []\n");
std::fs::write(r.root_path("packages.toml"), "[packages]\n").unwrap();
r.write(
".config/alacritty/alacritty.toml",
"[font.normal]\n# family = \"JetBrains Mono\"\nfamily = \"Hack Nerd Font Mono\"\n",
);
r.write(
".config/zed/settings.json",
"{\n // \"buffer_font_family\": \"Zed Plex Mono\",\n \"auto_install_extensions\": {\n // \"html\": true,\n \"toml\": true,\n },\n}\n",
);
let out = r.run(&["check"]);
let text = String::from_utf8_lossy(&out.stdout);
for phantom in ["JetBrains Mono", "Zed Plex Mono", "html"] {
assert!(
!text.contains(phantom),
"{phantom} came from a comment:\n{text}"
);
}
assert!(text.contains("Hack Nerd Font Mono"), "{text}");
assert!(text.contains("toml"), "{text}");
}
#[test]
fn a_declared_mode_on_a_deferred_output_does_not_fail_the_run() {
let r = Repo::new("mode-deferred");
r.manifest(
r#"
[link]
common = ["a.conf", "gh"]
ignore = ["*.tmpl"]
[render]
"gh/hosts.yml" = "hosts.yml.tmpl"
[modes]
"gh/hosts.yml" = "600"
[providers.never]
command = "false {}"
"#,
);
r.write("theme.toml", "bg = \"zzz\"\n");
r.write("a.conf", "plain\n");
r.write("hosts.yml.tmpl", "token = {{ never://a/b }}\n");
let preview = ok(&r.run(&["apply", "--dry-run"]));
assert!(preview.contains("nothing written"), "{preview}");
let out = ok(&r.run(&["apply"]));
assert!(out.contains("link(s) updated"), "{out}");
assert!(std::fs::symlink_metadata(r.home_path("a.conf")).is_ok());
}