use std::io::BufRead;
use std::path::{Component, Path, PathBuf};
#[cfg(windows)]
fn strip_windows_verbatim(path: PathBuf) -> PathBuf {
let text = path.as_os_str().to_string_lossy();
if let Some(stripped) = text.strip_prefix(r"\\?\UNC\") {
PathBuf::from(format!(r"\\{stripped}"))
} else if let Some(stripped) = text.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
path
}
}
#[cfg(not(windows))]
fn strip_windows_verbatim(path: PathBuf) -> PathBuf {
path
}
#[derive(Debug, PartialEq, Eq)]
pub enum GuardResult {
Allowed(PathBuf),
Denied(String),
Mismatch(String),
}
pub const HARNESS_SCRATCH_DIR: &str = "cosh";
pub struct PathGuard {
root: PathBuf,
allowlist: Option<Vec<PathBuf>>,
blocklist: Option<Vec<PathBuf>>,
}
impl PathGuard {
#[must_use]
pub fn new(root: &Path, allowlist: Option<&[PathBuf]>, blocklist: Option<&[PathBuf]>) -> Self {
Self {
root: root.to_path_buf(),
allowlist: allowlist.map(|l| l.to_vec()),
blocklist: blocklist.map(|l| l.to_vec()),
}
}
pub fn resolve(&self, path: &str) -> Result<PathBuf, String> {
let allowlist = self.allowlist.as_deref();
let blocklist = self.blocklist.as_deref();
match validate_path(path, &self.root, allowlist, blocklist) {
GuardResult::Allowed(normalized) => {
let Ok(root_canon) = self.root.canonicalize().map(strip_windows_verbatim) else {
return Err(format!(
"permission denied: `{path}` is outside the project directory"
));
};
let root_norm = normalize_path(&self.root, &self.root);
let in_root = normalized.starts_with(&root_norm);
let resolved = match normalized.canonicalize() {
Ok(canon) => strip_windows_verbatim(canon),
Err(_) => match normalized.parent() {
Some(parent) => match parent.canonicalize() {
Ok(parent_canon) => {
let file_name = normalized.file_name().unwrap_or_default();
strip_windows_verbatim(parent_canon.join(file_name))
}
Err(_) => {
if in_root {
normalized
} else {
return Err(format!(
"permission denied: `{path}` is outside the project directory"
));
}
}
},
None => {
return Err(format!(
"permission denied: `{path}` is outside the project directory"
));
}
},
};
if in_root && !resolved.starts_with(&root_canon) {
return Err(format!(
"permission denied: `{path}` is outside the project directory"
));
}
Ok(resolved)
}
GuardResult::Denied(reason) => Err(format!("permission denied: `{path}` — {reason}")),
GuardResult::Mismatch(msg) => Err(format!("permission denied: `{path}` — {msg}")),
}
}
#[must_use]
pub const fn root(&self) -> &PathBuf {
&self.root
}
#[must_use]
pub fn allowlist(&self) -> Option<&[PathBuf]> {
self.allowlist.as_deref()
}
#[must_use]
pub fn blocklist(&self) -> Option<&[PathBuf]> {
self.blocklist.as_deref()
}
pub fn add_allowlist_path(&mut self, path: PathBuf) {
let list = self.allowlist.get_or_insert_with(Vec::new);
if !list.contains(&path) {
list.push(path);
}
}
pub fn remove_allowlist_path(&mut self, path: &Path) {
if let Some(list) = self.allowlist.as_mut() {
list.retain(|p| p != path);
}
}
}
#[must_use]
pub fn normalize_path(path: &Path, root: &Path) -> PathBuf {
let absolute = if path.is_relative() {
root.join(path)
} else {
path.to_path_buf()
};
let mut out: Vec<Component> = Vec::new();
for c in absolute.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
if matches!(out.last(), Some(Component::Normal(_))) {
out.pop();
} else {
out.push(c);
}
}
other => out.push(other),
}
}
out.iter().collect()
}
#[must_use]
pub fn validate_path(
path: &str,
root: &Path,
allowlist: Option<&[PathBuf]>,
blocklist: Option<&[PathBuf]>,
) -> GuardResult {
let path = Path::new(path);
let normalized = normalize_path(path, root);
let root_norm = normalize_path(root, root);
let blocked = blocklist.is_some_and(|list| {
list.iter().any(|entry| {
let e = normalize_path(entry, root);
normalized.starts_with(&e) || normalized == e
})
});
let allowed = allowlist.is_some_and(|list| {
list.iter().any(|entry| {
let e = normalize_path(entry, root);
normalized == e
})
});
let in_root = normalized.starts_with(&root_norm);
let in_scratch = normalized.starts_with(normalize_path(
&std::env::temp_dir().join(HARNESS_SCRATCH_DIR),
root,
));
if blocked && allowed {
return GuardResult::Mismatch(
"Security Alert: path is in both blocklist and allowlist.".into(),
);
}
if blocked {
return GuardResult::Denied("path is in blocklist".into());
}
if !in_root && !allowed && !in_scratch {
return GuardResult::Denied("path is outside project root".into());
}
GuardResult::Allowed(normalized)
}
const AUTO_GENERATED_SCAN_LINES: usize = 10;
fn auto_generated_marker(line: &str) -> Option<&'static str> {
let lower = line.to_ascii_lowercase();
[
"do not edit",
"@generated",
"automatically generated",
"auto-generated",
"autogenerated",
]
.into_iter()
.find(|&marker| lower.contains(marker))
}
pub fn assert_editable_file(path: &Path) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
let Ok(file) = std::fs::File::open(path) else {
return Ok(());
};
let reader = std::io::BufReader::new(file);
for line in reader.lines().take(AUTO_GENERATED_SCAN_LINES) {
let Ok(line) = line else {
return Ok(());
};
if let Some(marker) = auto_generated_marker(&line) {
return Err(format!(
"refusing to modify `{}`: its header marks it as auto-generated \
(`{marker}`). Generated files are owned by a tool — your change \
would be overwritten on the next generation run. Edit the \
generator instead, or remove the marker from the file to force \
the write.",
path.display()
));
}
}
Ok(())
}
pub fn validate_asset_path(base_dir: &Path, asset_path: &str) -> Result<PathBuf, String> {
let requested = Path::new(asset_path);
if requested.is_absolute() {
return Err("absolute path not allowed, use a relative path".into());
}
if requested.components().any(|c| c == Component::ParentDir) {
return Err("path must not contain '..' (parent directory references)".into());
}
let base_canon = base_dir
.canonicalize()
.map_err(|e| format!("could not resolve base directory: {e}"))?;
let resolved = base_canon.join(asset_path);
let resolved_canon = resolved
.canonicalize()
.map_err(|e| format!("asset not found: {e}"))?;
if !resolved_canon.starts_with(&base_canon) {
return Err("path points outside the skill directory".into());
}
Ok(resolved_canon)
}
#[cfg(test)]
mod tests;