#![cfg(unix)]
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use std::process::Output;
const HELLO_SHA256: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
fn alint_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_alint"))
}
fn run(dir: &Path, args: &[&str]) -> Output {
std::process::Command::new(alint_bin())
.args(args)
.current_dir(dir)
.output()
.expect("spawn alint")
}
fn repo_with_symlink_out() -> (tempfile::TempDir, PathBuf, PathBuf) {
let base = tempfile::Builder::new()
.prefix("alint-confine-")
.tempdir()
.unwrap();
let repo = base.path().join("repo");
let outside = base.path().join("outside");
std::fs::create_dir_all(&repo).unwrap();
std::fs::create_dir_all(&outside).unwrap();
symlink(&outside, repo.join("link")).unwrap();
(base, repo, outside)
}
fn out_text(out: &Output) -> String {
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
#[test]
fn h1_pair_hash_symlink_target_is_confined_not_read() {
let (_base, repo, outside) = repo_with_symlink_out();
std::fs::write(repo.join("a.txt"), "hello").unwrap();
std::fs::write(outside.join("pin.txt"), format!("HASH = {HELLO_SHA256}\n")).unwrap();
let config = |allow: bool| {
format!(
"version: 1\n{}rules:\n - id: pin-check\n kind: pair_hash\n \
source: \"a.txt\"\n target: \"link/pin.txt\"\n algorithm: sha256\n \
format: contains\n level: error\n",
if allow {
"allow_out_of_root: true\n"
} else {
""
}
)
};
std::fs::write(repo.join(".alint.yml"), config(false)).unwrap();
let out = run(&repo, &["check", ".", "--format", "human"]);
assert_eq!(
out.status.code(),
Some(1),
"expected a violations exit; got {:?}\n{}",
out.status.code(),
out_text(&out)
);
assert!(
out_text(&out).contains("escapes the repo root"),
"expected an out-of-root escape violation, not a silent read:\n{}",
out_text(&out)
);
std::fs::write(repo.join(".alint.yml"), config(true)).unwrap();
let out = run(&repo, &["check", ".", "--format", "human"]);
assert_eq!(
out.status.code(),
Some(0),
"with allow_out_of_root the matching digest should pass:\n{}",
out_text(&out)
);
}
#[test]
fn m2_local_extends_through_in_tree_symlink_is_rejected() {
let (_base, repo, outside) = repo_with_symlink_out();
std::fs::write(
outside.join("secret.yml"),
"version: 1\nrules:\n - id: inherited\n kind: file_exists\n paths: X.md\n level: warning\n",
)
.unwrap();
std::fs::write(
repo.join(".alint.yml"),
"version: 1\nextends: [link/secret.yml]\nrules: []\n",
)
.unwrap();
let out = run(&repo, &["check", "."]);
assert_eq!(
out.status.code(),
Some(2),
"a symlinked extends escape must be a config error (exit 2); got {:?}\n{}",
out.status.code(),
out_text(&out)
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("outside the lint root"),
"expected an out-of-root extends rejection:\n{}",
String::from_utf8_lossy(&out.stderr)
);
std::fs::write(
repo.join(".alint.yml"),
"version: 1\nallow_out_of_root: true\nextends: [link/secret.yml]\nrules: []\n",
)
.unwrap();
let out = run(&repo, &["check", "."]);
assert_ne!(
out.status.code(),
Some(2),
"allow_out_of_root should lift the extends confinement:\n{}",
out_text(&out)
);
}
#[test]
fn fixer_file_create_out_of_root_target_is_refused() {
let base = tempfile::Builder::new()
.prefix("alint-fixesc-")
.tempdir()
.unwrap();
let repo = base.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
std::fs::write(
repo.join(".alint.yml"),
"version: 1\nrules:\n - id: seed\n kind: file_exists\n level: error\n \
paths: [\"../PWNED.txt\"]\n fix:\n file_create:\n \
path: \"../PWNED.txt\"\n content: \"pwned\\n\"\n",
)
.unwrap();
let out = run(&repo, &["fix", "."]);
let pwned = base.path().join("PWNED.txt");
assert!(
!pwned.exists(),
"fixer wrote OUTSIDE the repo root — confinement escape: {}\n{}",
pwned.display(),
out_text(&out)
);
let normalized = out_text(&out)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
assert!(
normalized.contains("escapes the repo root"),
"the refusal should say why; got:\n{}",
out_text(&out)
);
}
#[test]
fn fixer_file_create_out_of_root_allowed_with_top_level_opt_in() {
let base = tempfile::Builder::new()
.prefix("alint-fixok-")
.tempdir()
.unwrap();
let repo = base.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
std::fs::write(
repo.join(".alint.yml"),
"version: 1\nallow_out_of_root: true\nrules:\n - id: seed\n kind: file_exists\n \
level: error\n paths: [\"../ALLOWED.txt\"]\n fix:\n file_create:\n \
path: \"../ALLOWED.txt\"\n content: \"ok\\n\"\n",
)
.unwrap();
run(&repo, &["fix", "."]);
let allowed = base.path().join("ALLOWED.txt");
assert!(
allowed.exists(),
"top-level allow_out_of_root must permit the out-of-root create (escape hatch)"
);
}
#[test]
fn fixer_content_from_out_of_root_is_refused() {
let base = tempfile::Builder::new()
.prefix("alint-exfil-")
.tempdir()
.unwrap();
let repo = base.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
std::fs::write(base.path().join("secret.txt"), "TOPSECRET\n").unwrap();
std::fs::write(
repo.join(".alint.yml"),
"version: 1\nrules:\n - id: exfil\n kind: file_exists\n level: error\n \
paths: [\"stolen.txt\"]\n fix:\n file_create:\n \
path: \"stolen.txt\"\n content_from: \"../secret.txt\"\n",
)
.unwrap();
run(&repo, &["fix", "."]);
let stolen = repo.join("stolen.txt");
let leaked = std::fs::read_to_string(&stolen).unwrap_or_default();
assert!(
!leaked.contains("TOPSECRET"),
"content_from exfiltrated an out-of-root secret into the repo: {leaked:?}"
);
}