use anyhow::Result;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
static WARNED_UNRESOLVABLE_PROTECTED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
pub(crate) fn note_unresolvable_protected(entry: &str) -> bool {
let seen = WARNED_UNRESOLVABLE_PROTECTED.get_or_init(|| Mutex::new(HashSet::new()));
seen.lock()
.map(|mut s| s.insert(entry.to_string()))
.unwrap_or(true)
}
pub(crate) const SYSTEM_PROTECTED: &[&str] = &[
"/", "/home", "/etc", "/usr", "/var", "/boot", "/nix", "/run", "/sys", "/dev", "/proc",
];
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn check_safe_to_delete(path: &Path, user_protected: &[String]) -> Result<PathBuf> {
let canon = match path.canonicalize() {
Ok(p) => p,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(path.to_path_buf());
}
Err(e) => anyhow::bail!(
"cannot canonicalize {}: {} — refusing to delete",
path.display(),
e
),
};
if let Ok(meta) = std::fs::symlink_metadata(path) {
if meta.file_type().is_symlink() {
anyhow::bail!(
"refusing to delete symlink {} — use target directly",
path.display()
);
}
}
let canon_str = canon.display().to_string();
for prot in SYSTEM_PROTECTED {
if is_protected_ancestor(&canon_str, prot) {
anyhow::bail!(
"refusing to delete protected path {} (under system root {})",
canon.display(),
prot
);
}
}
for user_prot in user_protected {
let prot_canon = match Path::new(user_prot).canonicalize() {
Ok(p) => p.display().to_string(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if note_unresolvable_protected(user_prot) {
eprintln!(
"⚠️ protected_paths entry does not resolve, so it is protecting NOTHING: {}",
user_prot
);
}
continue;
}
Err(e) => anyhow::bail!(
"cannot canonicalize user-protected path {}: {} — refusing",
user_prot,
e
),
};
if is_protected_ancestor(&canon_str, &prot_canon) {
anyhow::bail!(
"refusing to delete protected path {} (under user-protected path {})",
canon.display(),
user_prot
);
}
}
Ok(canon)
}
pub(crate) const SAFE_TMP_ROOTS: &[&str] = &["/tmp"];
pub(crate) fn check_safe_tmp_root(path: &Path) -> Result<PathBuf> {
let metadata = std::fs::symlink_metadata(path).map_err(|e| {
anyhow::anyhow!(
"cannot inspect configured tmp root {}: {} — refusing to scan",
path.display(),
e
)
})?;
if metadata.file_type().is_symlink() {
anyhow::bail!(
"refusing configured tmp root {} because it is a symlink",
path.display()
);
}
if !metadata.is_dir() {
anyhow::bail!(
"refusing configured tmp root {} because it is not a directory",
path.display()
);
}
let canon = path.canonicalize().map_err(|e| {
anyhow::anyhow!(
"cannot canonicalize configured tmp root {}: {} — refusing to scan",
path.display(),
e
)
})?;
if !SAFE_TMP_ROOTS
.iter()
.any(|root| canon.starts_with(Path::new(root)))
{
anyhow::bail!(
"refusing configured tmp root {}: it must be /tmp (or a descendant)",
canon.display()
);
}
Ok(canon)
}
pub(crate) fn check_safe_to_delete_guard(
path: &Path,
user_protected: &[String],
) -> Result<PathBuf> {
let canon = match path.canonicalize() {
Ok(p) => p,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(path.to_path_buf());
}
Err(e) => anyhow::bail!(
"cannot canonicalize {}: {} — refusing to delete",
path.display(),
e
),
};
if let Ok(meta) = std::fs::symlink_metadata(path) {
if meta.file_type().is_symlink() {
anyhow::bail!(
"refusing to delete symlink {} — use target directly",
path.display()
);
}
}
let canon_str = canon.display().to_string();
for prot in SYSTEM_PROTECTED {
if canon_str == *prot {
anyhow::bail!(
"refusing guard cleanup of protected system path {}",
canon.display()
);
}
}
for user_prot in user_protected {
let prot_canon = match Path::new(user_prot).canonicalize() {
Ok(p) => p.display().to_string(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let first = note_unresolvable_protected(user_prot);
if first {
eprintln!(
"⚠️ protected_paths entry does not resolve, so it is protecting NOTHING: {}",
user_prot
);
}
continue;
}
Err(e) => anyhow::bail!(
"cannot canonicalize user-protected path {}: {} — refusing",
user_prot,
e
),
};
if is_protected_ancestor(&canon_str, &prot_canon) {
anyhow::bail!(
"refusing to delete protected path {} (under user-protected path {})",
canon.display(),
user_prot
);
}
}
Ok(canon)
}
pub(crate) fn check_safe_to_delete_tmp_entry(
path: &Path,
tmp_root: &Path,
user_protected: &[String],
) -> Result<PathBuf> {
let canon = check_safe_to_delete_guard(path, user_protected)?;
if !canon.starts_with(tmp_root) {
anyhow::bail!(
"refusing to delete tmp entry {}: resolved path is outside validated tmp root {}",
canon.display(),
tmp_root.display()
);
}
Ok(canon)
}
pub(crate) fn is_protected_ancestor(path: &str, protected: &str) -> bool {
if path == protected {
return true;
}
if protected == "/" {
return path == "/";
}
let prefix = if protected.ends_with('/') {
protected.to_string()
} else {
format!("{}/", protected)
};
path.starts_with(&prefix)
}
pub(crate) const SHIPPED_GUARD_UNIT: &str = include_str!("../dracon-system-guard.service");
pub(crate) fn unit_readwrite_paths(unit: &str, home: &Path) -> Vec<PathBuf> {
let mut granted = Vec::new();
for line in unit.lines() {
let line = line.trim();
let Some(value) = line.strip_prefix("ReadWritePaths=") else {
continue;
};
for token in value.split_whitespace() {
let token = token.strip_prefix('-').unwrap_or(token);
granted.push(expand_unit_path(token, home));
}
}
granted
}
fn expand_unit_path(token: &str, home: &Path) -> PathBuf {
if let Some(rest) = token.strip_prefix("%h") {
return home.join(rest.trim_start_matches('/'));
}
PathBuf::from(token)
}
pub(crate) fn unit_grants_write(unit: &str, home: &Path, path: &Path) -> bool {
let canon_path = canonical_or_literal(path);
unit_readwrite_paths(unit, home)
.iter()
.any(|granted| canon_path.starts_with(canonical_or_literal(granted)))
}
fn canonical_or_literal(path: &Path) -> PathBuf {
if !path.is_absolute() {
return path.to_path_buf();
}
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
pub(crate) fn uncovered_storage_roots(
unit: &str,
guard: &crate::GuardPolicy,
) -> Vec<(&'static str, PathBuf)> {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
let cold_root = guard.relocate_cold_root.trim();
let mut roots: Vec<(&'static str, PathBuf)> = vec![
("quarantine_dir", crate::quarantine_root(guard)),
("relocate_cold_root", crate::expand_tilde(cold_root)),
];
if let Some(log_file) = crate::resolve_guard_log_path(&guard.guard_log_file) {
roots.push(("guard_log_file", log_file));
}
if let Some(configured) = crate::effective_log_dirs(&guard.log_dirs) {
for entry in configured.split(',') {
let entry = entry.trim();
if !entry.is_empty() {
roots.push(("log_dirs", crate::expand_tilde(entry)));
}
}
}
let marker = guard.sync_freeze_marker.trim();
if marker.is_empty() {
roots.push((
"sync_freeze_marker",
PathBuf::from(crate::default_sync_freeze_marker()),
));
} else {
roots.push(("sync_freeze_marker", crate::resolve_policy_path(marker)));
}
roots
.into_iter()
.filter(|(_, path)| !path.as_os_str().is_empty() && path != &PathBuf::from("."))
.filter(|(_, path)| !unit_grants_write(unit, &home, path))
.collect()
}
pub(crate) fn warn_uncovered_storage_roots(guard: &crate::GuardPolicy) {
for (key, path) in uncovered_storage_roots(SHIPPED_GUARD_UNIT, guard) {
eprintln!(
"⚠️ {key} = {} is not in the guard unit's ReadWritePaths — writes there \
fail EROFS under ProtectSystem=strict. Add: -{} to ReadWritePaths= in \
dracon-system-guard.service, then reinstall the unit.",
path.display(),
path.display()
);
}
}