use crate::config::SafetyConfig;
use anyhow::Result;
use std::path::{Path, PathBuf};
#[cfg(target_os = "linux")]
const ELOOP: i32 = 40;
#[cfg(target_os = "macos")]
const ELOOP: i32 = 62;
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
const ELOOP: i32 = -1;
#[cfg(target_os = "linux")]
const O_NOFOLLOW: i32 = 0o0400000;
#[cfg(target_os = "macos")]
const O_NOFOLLOW: i32 = 0x0100;
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
const O_NOFOLLOW: i32 = 0;
#[cfg(unix)]
fn open_nofollow_and_resolve(path: &Path) -> std::io::Result<PathBuf> {
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::AsRawFd;
let fd = std::fs::OpenOptions::new()
.read(true)
.custom_flags(O_NOFOLLOW)
.open(path)?;
let raw_fd = fd.as_raw_fd();
let fd_path = format!("/proc/self/fd/{}", raw_fd);
let proc_path = Path::new(&fd_path);
if proc_path.exists() {
return std::fs::read_link(proc_path);
}
#[cfg(target_os = "macos")]
{
const F_GETPATH: i32 = 50;
const MAXPATHLEN: usize = 1024;
let mut buf = vec![0u8; MAXPATHLEN];
let ret = unsafe {
extern "C" {
fn fcntl(fd: i32, cmd: i32, ...) -> i32;
}
fcntl(raw_fd, F_GETPATH, buf.as_mut_ptr())
};
if ret != -1 {
let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
buf.truncate(len);
return Ok(PathBuf::from(std::ffi::OsString::from(
String::from_utf8_lossy(&buf).into_owned(),
)));
}
}
path.canonicalize()
}
#[cfg(not(unix))]
fn open_nofollow_and_resolve(path: &Path) -> std::io::Result<PathBuf> {
path.canonicalize()
}
#[derive(Clone)]
pub struct PathValidator {
config: SafetyConfig,
working_dir: PathBuf,
}
impl PathValidator {
pub fn new(config: &SafetyConfig, working_dir: PathBuf) -> Self {
Self {
config: config.clone(),
working_dir,
}
}
pub fn validate(&self, path: &str) -> Result<()> {
if path.contains('\0') {
anyhow::bail!("Path contains null bytes");
}
let suspicious_unicode: &[(char, &str)] = &[
('\u{FF0E}', "fullwidth full stop (.)"),
('\u{FF0F}', "fullwidth solidus (/)"),
('\u{FF3C}', "fullwidth reverse solidus (\\)"),
('\u{2024}', "one dot leader (.)"),
('\u{FE52}', "small full stop (.)"),
('\u{2025}', "two dot leader (..)"),
('\u{2026}', "horizontal ellipsis (...)"),
('\u{29F8}', "big solidus (/)"),
('\u{2044}', "fraction slash (/)"),
('\u{2215}', "division slash (/)"),
('\u{FE68}', "small reverse solidus (\\)"),
];
for (ch, description) in suspicious_unicode {
if path.contains(*ch) {
anyhow::bail!(
"Path contains suspicious Unicode character: {} (U+{:04X}) - possible homoglyph bypass attempt",
description,
*ch as u32
);
}
}
for component in path.split(&['/', '\\'][..]) {
if component.is_empty() {
continue;
}
let has_non_ascii = !component.is_ascii();
let has_dots = component.contains('.');
if has_non_ascii && has_dots && component.len() <= 10 {
anyhow::bail!(
"Path component '{}' contains suspicious mix of ASCII and non-ASCII characters",
component
);
}
}
let path_buf = Path::new(path);
let resolved = if path_buf.is_absolute() {
path_buf.to_path_buf()
} else {
self.working_dir.join(path_buf)
};
let canonical = match open_nofollow_and_resolve(&resolved) {
Ok(real_path) => real_path,
Err(e) if e.raw_os_error() == Some(ELOOP) => {
let safe_target = self.check_symlink_safety(&resolved)?;
safe_target
.canonicalize()
.unwrap_or_else(|_| normalize_path(&safe_target))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if let Some(parent) = resolved.parent() {
match open_nofollow_and_resolve(parent) {
Ok(real_parent) => {
real_parent.join(resolved.file_name().unwrap_or_default())
}
Err(e) if e.raw_os_error() == Some(ELOOP) => {
let safe_parent = self.check_symlink_safety(parent)?;
safe_parent
.canonicalize()
.unwrap_or_else(|_| normalize_path(&safe_parent))
.join(resolved.file_name().unwrap_or_default())
}
Err(_) => normalize_path(&resolved),
}
} else {
normalize_path(&resolved)
}
}
Err(_) => resolved
.canonicalize()
.unwrap_or_else(|_| normalize_path(&resolved)),
};
let canonical_str = strip_unc_prefix(&canonical.to_string_lossy());
if path.contains("..") {
let original_parent = self
.working_dir
.canonicalize()
.unwrap_or_else(|_| self.working_dir.clone());
let is_within_working_dir = canonical.starts_with(&original_parent);
let is_explicitly_allowed = self.is_path_in_allowed_list(&canonical_str, path)?;
if !is_within_working_dir && !is_explicitly_allowed {
anyhow::bail!(
"Path traversal detected: {} resolves to {}",
path,
canonical_str
);
}
}
for pattern in &self.config.denied_paths {
let glob_pattern = glob::Pattern::new(pattern)?;
if glob_pattern.matches(&canonical_str) {
anyhow::bail!("Path matches denied pattern: {}", pattern);
}
if glob_pattern.matches(path) {
anyhow::bail!("Path matches denied pattern: {}", pattern);
}
for component in canonical.components() {
if let std::path::Component::Normal(name) = component {
let name_str = name.to_string_lossy();
if !pattern.contains('/')
&& !pattern.contains('\\')
&& glob_pattern.matches(&name_str)
{
anyhow::bail!("Path component matches denied pattern: {}", pattern);
}
}
}
}
if !self.config.allowed_paths.is_empty()
&& !self.is_path_in_allowed_list(&canonical_str, path)?
{
anyhow::bail!("Path not in allowed list: {}", canonical_str);
}
Ok(())
}
pub fn is_path_in_allowed_list(
&self,
canonical_str: &str,
_original_path: &str,
) -> Result<bool> {
let working_dir_canonical = strip_unc_prefix(
&self
.working_dir
.canonicalize()
.unwrap_or_else(|_| self.working_dir.clone())
.to_string_lossy(),
);
let canonical_normalized = canonical_str.replace('\\', "/");
let working_dir_normalized = working_dir_canonical.replace('\\', "/");
for pattern in &self.config.allowed_paths {
let expanded_pattern = if pattern.starts_with("./") || pattern == "." {
let suffix = pattern.strip_prefix("./").unwrap_or("");
format!("{}/{}", working_dir_normalized, suffix)
} else {
pattern.replace('\\', "/")
};
let pattern_normalized = pattern.replace('\\', "/");
if glob::Pattern::new(&expanded_pattern)?.matches(&canonical_normalized)
|| glob::Pattern::new(&pattern_normalized)?.matches(&canonical_normalized)
{
return Ok(true);
}
if cfg!(target_os = "windows") && pattern_normalized.starts_with('/') {
if let Some(drive_prefix) = canonical_normalized.get(..2) {
if drive_prefix.ends_with(':') {
let win_pattern = format!("{}{}", drive_prefix, pattern_normalized);
if glob::Pattern::new(&win_pattern)?.matches(&canonical_normalized) {
return Ok(true);
}
}
}
}
if pattern == "./**" && canonical_normalized.starts_with(&working_dir_normalized) {
return Ok(true);
}
}
Ok(false)
}
pub fn check_symlink_safety(&self, path: &Path) -> Result<PathBuf> {
let mut current = path.to_path_buf();
let mut visited = std::collections::HashSet::new();
let max_depth = 40;
for _ in 0..max_depth {
if !current.is_symlink() {
break;
}
let current_str = current.to_string_lossy().to_string();
if visited.contains(¤t_str) {
anyhow::bail!("Symlink loop detected: {}", path.display());
}
visited.insert(current_str);
let target = std::fs::read_link(¤t)?;
let resolved_target = if target.is_absolute() {
target
} else {
current.parent().unwrap_or(Path::new("/")).join(&target)
};
let target_str = resolved_target.to_string_lossy();
let dangerous_targets = [
"/etc/passwd",
"/etc/shadow",
"/etc/sudoers",
"/root/",
"/proc/",
"/sys/",
];
for dangerous in &dangerous_targets {
if target_str.starts_with(dangerous) {
anyhow::bail!(
"Symlink points to protected system path: {} -> {}",
path.display(),
target_str
);
}
}
current = resolved_target;
}
if visited.len() >= max_depth {
anyhow::bail!(
"Symlink chain too deep (possible attack): {}",
path.display()
);
}
Ok(current)
}
}
fn strip_unc_prefix(path: &str) -> String {
if cfg!(target_os = "windows") {
path.strip_prefix(r"\\?\").unwrap_or(path).to_string()
} else {
path.to_string()
}
}
pub fn normalize_path(path: &Path) -> PathBuf {
let mut components = Vec::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
if !components.is_empty() {
components.pop();
}
}
std::path::Component::CurDir => {}
c => components.push(c),
}
}
components.iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_config(allowed: Vec<&str>, denied: Vec<&str>) -> SafetyConfig {
SafetyConfig {
allowed_paths: allowed.into_iter().map(|s| s.to_string()).collect(),
denied_paths: denied.into_iter().map(|s| s.to_string()).collect(),
protected_branches: vec![],
require_confirmation: vec![],
strict_permissions: false,
permissions: vec![],
}
}
#[test]
fn test_normalize_simple_absolute() {
let path = normalize_path(Path::new("/foo/bar/baz"));
assert_eq!(path, PathBuf::from("/foo/bar/baz"));
}
#[test]
fn test_normalize_with_dot() {
let path = normalize_path(Path::new("/foo/./bar"));
assert_eq!(path, PathBuf::from("/foo/bar"));
}
#[test]
fn test_normalize_with_dotdot() {
let path = normalize_path(Path::new("/foo/bar/../baz"));
assert_eq!(path, PathBuf::from("/foo/baz"));
}
#[test]
fn test_normalize_multiple_dotdot() {
let path = normalize_path(Path::new("/foo/bar/baz/../../qux"));
assert_eq!(path, PathBuf::from("/foo/qux"));
}
#[test]
fn test_normalize_dotdot_at_root() {
let path = normalize_path(Path::new("/foo/../.."));
assert_eq!(path, PathBuf::from(""));
}
#[test]
fn test_normalize_relative() {
let path = normalize_path(Path::new("foo/./bar/../baz"));
assert_eq!(path, PathBuf::from("foo/baz"));
}
#[test]
fn test_strip_unc_prefix_normal_path() {
assert_eq!(strip_unc_prefix("/foo/bar"), "/foo/bar");
}
#[test]
fn test_strip_unc_prefix_empty() {
assert_eq!(strip_unc_prefix(""), "");
}
#[test]
fn test_allowed_list_empty() {
let config = make_config(vec![], vec![]);
let cwd = std::env::current_dir().unwrap();
let validator = PathValidator::new(&config, cwd);
assert!(!validator
.is_path_in_allowed_list("/some/path", "/some/path")
.unwrap());
}
#[test]
fn test_allowed_list_absolute_glob() {
let config = make_config(vec!["/tmp/**"], vec![]);
let cwd = std::env::current_dir().unwrap();
let validator = PathValidator::new(&config, cwd);
assert!(validator
.is_path_in_allowed_list("/tmp/foo/bar", "/tmp/foo/bar")
.unwrap());
assert!(!validator
.is_path_in_allowed_list("/etc/passwd", "/etc/passwd")
.unwrap());
}
#[test]
fn test_allowed_list_relative_glob() {
let config = make_config(vec!["./**"], vec![]);
let cwd = std::env::current_dir().unwrap();
let cwd_str = cwd.to_string_lossy();
let validator = PathValidator::new(&config, cwd.clone());
let test_path = format!("{}/src/main.rs", cwd_str);
assert!(validator
.is_path_in_allowed_list(&test_path, "./src/main.rs")
.unwrap());
}
#[test]
fn test_validate_denied_env_file() {
let config = make_config(vec![], vec!["**/.env"]);
let cwd = std::env::current_dir().unwrap();
let validator = PathValidator::new(&config, cwd);
let result = validator.validate(".env");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("denied pattern"));
}
#[test]
fn test_validate_denied_ssh() {
let config = make_config(vec![], vec!["**/.ssh/**"]);
let cwd = std::env::current_dir().unwrap();
let validator = PathValidator::new(&config, cwd);
let result = validator.validate("/home/user/.ssh/id_rsa");
assert!(result.is_err());
}
#[test]
fn test_validate_allowed_path() {
let config = make_config(vec![], vec![]);
let cwd = std::env::current_dir().unwrap();
let validator = PathValidator::new(&config, cwd.clone());
let result = validator.validate("src/main.rs");
assert!(result.is_ok());
}
#[test]
fn test_validate_denied_secrets_dir() {
let config = make_config(vec![], vec!["**/secrets/**"]);
let cwd = std::env::current_dir().unwrap();
let validator = PathValidator::new(&config, cwd);
let result = validator.validate("config/secrets/api_key.txt");
assert!(result.is_err());
}
#[test]
fn test_validate_path_traversal_detected() {
let config = make_config(vec![], vec![]);
let cwd = std::env::current_dir().unwrap();
let validator = PathValidator::new(&config, cwd);
let result = validator.validate("../../../../etc/passwd");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Path traversal") || err_msg.contains("denied"),
"Expected traversal or denied error, got: {}",
err_msg
);
}
#[test]
fn test_validate_not_in_allowed_list() {
let config = make_config(vec!["/allowed/**"], vec![]);
let cwd = std::env::current_dir().unwrap();
let validator = PathValidator::new(&config, cwd);
let result = validator.validate("/not-allowed/file.txt");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("not in allowed list"));
}
#[test]
fn test_validate_env_local_denied() {
let config = make_config(vec![], vec!["**/.env.local"]);
let cwd = std::env::current_dir().unwrap();
let validator = PathValidator::new(&config, cwd);
let result = validator.validate(".env.local");
assert!(result.is_err());
}
#[test]
fn test_validate_null_byte_rejected() {
let config = make_config(vec![], vec![]);
let cwd = std::env::current_dir().unwrap();
let validator = PathValidator::new(&config, cwd);
let result = validator.validate("safe_path\0/etc/passwd");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("null bytes"));
}
#[test]
fn test_validate_null_byte_at_end_rejected() {
let config = make_config(vec![], vec![]);
let cwd = std::env::current_dir().unwrap();
let validator = PathValidator::new(&config, cwd);
let result = validator.validate("some/file.txt\0");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("null bytes"));
}
#[test]
fn test_no_test_mode_bypass() {
unsafe {
std::env::set_var("SELFWARE_TEST_MODE", "1");
}
let config = make_config(vec![], vec!["**/.env"]);
let cwd = std::env::current_dir().unwrap();
let validator = PathValidator::new(&config, cwd);
let result = validator.validate(".env");
assert!(
result.is_err(),
"SELFWARE_TEST_MODE must not bypass path validation"
);
assert!(
result.unwrap_err().to_string().contains("denied pattern"),
"Expected 'denied pattern' error"
);
unsafe {
std::env::remove_var("SELFWARE_TEST_MODE");
}
}
}