use std::path::{Path, PathBuf};
pub type IsDirectory<'a> = dyn Fn(&Path) -> bool + 'a;
pub fn on_disk(path: &Path) -> bool {
path.is_dir()
}
pub fn build(protected: &[PathBuf], pinned: &[PathBuf], is_directory: &IsDirectory) -> String {
let mut text = String::from(
"(version 1)\n\n; Everything not named below behaves normally.\n(allow default)\n",
);
let rules: Vec<String> = protected
.iter()
.map(|path| {
let form = if is_directory(path) {
"subpath"
} else {
"literal"
};
format!(" ({form} \"{}\")", escape(path))
})
.collect();
if !rules.is_empty() {
text.push_str("\n; Declared in agent.lock.\n(deny file-write*\n");
text.push_str(&rules.join("\n"));
text.push_str("\n)\n");
}
let ancestors: Vec<String> = pinned
.iter()
.map(|path| format!(" (literal \"{}\")", escape(path)))
.collect();
if !ancestors.is_empty() {
text.push_str(
"\n; The directories leading to them. Only unlink: they cannot be\n\
; renamed or removed, and everything inside them stays writable.\n\
(deny file-write-unlink\n",
);
text.push_str(&ancestors.join("\n"));
text.push_str("\n)\n");
}
text
}
fn escape(path: &Path) -> String {
let mut escaped = String::new();
for character in path.to_string_lossy().chars() {
if character == '"' || character == '\\' {
escaped.push('\\');
}
escaped.push(character);
}
escaped
}
#[cfg(test)]
mod tests {
use super::*;
fn directories(names: &[&str]) -> impl Fn(&Path) -> bool {
let names: Vec<PathBuf> = names.iter().map(PathBuf::from).collect();
move |path: &Path| names.iter().any(|name| name == path)
}
#[test]
fn a_file_is_literal_and_a_directory_is_a_subpath() {
let text = build(
&[PathBuf::from("/p/.env"), PathBuf::from("/p/config")],
&[],
&directories(&["/p/config"]),
);
assert!(text.contains("(literal \"/p/.env\")"), "{text}");
assert!(text.contains("(subpath \"/p/config\")"), "{text}");
assert!(text.contains("(deny file-write*"), "{text}");
}
#[test]
fn everything_else_is_left_alone() {
let text = build(&[PathBuf::from("/p/.env")], &[], &directories(&[]));
assert!(text.contains("(allow default)"), "{text}");
}
#[test]
fn ancestors_are_denied_as_nodes_not_as_subtrees() {
let text = build(
&[PathBuf::from("/p/src/index.tsx")],
&[PathBuf::from("/p"), PathBuf::from("/p/src")],
&directories(&["/p", "/p/src"]),
);
assert!(text.contains("(literal \"/p/src\")"), "{text}");
assert!(!text.contains("(subpath \"/p/src\")"), "{text}");
assert!(!text.contains("(subpath \"/p\")"), "{text}");
}
#[test]
fn ancestors_deny_only_unlink() {
let text = build(
&[PathBuf::from("/p/src/index.tsx")],
&[PathBuf::from("/p"), PathBuf::from("/p/src")],
&directories(&["/p", "/p/src"]),
);
let ancestors = text
.split("(deny ")
.find(|block| block.contains("\"/p/src\"") && !block.contains("index.tsx"))
.expect("the ancestor block should exist");
assert!(ancestors.starts_with("file-write-unlink"), "{text}");
assert!(text.contains("(deny file-write*"), "{text}");
}
#[test]
fn a_quote_in_a_path_cannot_end_the_string_early() {
let text = build(&[PathBuf::from("/p/we\"ird")], &[], &directories(&[]));
assert!(text.contains(r#"(literal "/p/we\"ird")"#), "{text}");
}
#[test]
fn a_policy_protecting_nothing_is_still_a_valid_profile() {
let text = build(&[], &[], &directories(&[]));
assert!(text.starts_with("(version 1)"), "{text}");
assert!(!text.contains("deny"), "{text}");
}
}