use std::path::{Path, PathBuf};
const DEFAULT_PREFIXES: &[&str] = &["/tmp", "/var/tmp", "/home"];
pub struct PathContext {
pub allowed_prefixes: &'static [&'static str],
pub require_exists: bool,
pub require_file: bool,
}
impl Default for PathContext {
fn default() -> Self {
Self {
allowed_prefixes: &[],
require_exists: true,
require_file: true,
}
}
}
fn get_allowed_prefixes(ctx: &PathContext) -> Vec<String> {
let mut prefixes: Vec<String> = DEFAULT_PREFIXES.iter().map(|s| s.to_string()).collect();
if let Ok(env_paths) = std::env::var("RUNTIMO_ALLOWED_PATHS") {
for p in env_paths.split(':').filter(|s| !s.is_empty()) {
let trimmed = p.trim().to_string();
if !prefixes.contains(&trimmed) {
prefixes.push(trimmed);
}
}
}
for p in ctx.allowed_prefixes {
let trimmed = p.trim().to_string();
if !prefixes.contains(&trimmed) {
prefixes.push(trimmed);
}
}
prefixes
}
pub fn validate_path(path_str: &str, ctx: &PathContext) -> Result<PathBuf, String> {
if path_str.is_empty() {
return Err("path is empty".to_string());
}
if path_str.contains("..") {
return Err("path traversal not allowed".to_string());
}
let path = Path::new(path_str);
if ctx.require_exists && !path.exists() {
return Err(format!("path does not exist: {}", path_str));
}
let resolved = if path.exists() {
path.canonicalize()
.map_err(|e| format!("canonicalize failed: {}", e))?
} else {
let parent = path.parent().unwrap_or(Path::new("/"));
if parent.exists() {
let canonical_parent = parent
.canonicalize()
.map_err(|e| format!("canonicalize parent failed: {}", e))?;
let filename = path
.file_name()
.ok_or_else(|| "invalid filename".to_string())?;
canonical_parent.join(filename)
} else {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| format!("cannot resolve relative path: {}", e))?
.join(path)
}
}
};
if ctx.require_file && resolved.exists() && !resolved.is_file() {
return Err(format!("not a file: {}", resolved.display()));
}
let resolved_str = resolved.to_string_lossy();
let allowed = get_allowed_prefixes(ctx);
if !allowed
.iter()
.any(|prefix| resolved_str.starts_with(prefix.as_str()))
{
return Err(format!(
"path outside allowed directories: {} (allowed: {})",
resolved.display(),
allowed.join(", ")
));
}
Ok(resolved)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty_path() {
let ctx = PathContext::default();
assert!(validate_path("", &ctx).is_err());
}
#[test]
fn rejects_traversal() {
let ctx = PathContext::default();
assert!(validate_path("/tmp/../etc/passwd", &ctx).is_err());
}
#[test]
fn accepts_existing_tmp_file() {
let p = std::env::temp_dir().join("runtimo_val_test.txt");
std::fs::write(&p, "test").ok();
let ctx = PathContext::default();
let result = validate_path(p.to_str().unwrap(), &ctx);
assert!(result.is_ok(), "expected Ok, got {:?}", result);
std::fs::remove_file(&p).ok();
}
#[test]
fn accepts_nonexistent_tmp_file_for_writes() {
let ctx = PathContext {
require_exists: false,
require_file: false,
..Default::default()
};
let result = validate_path("/tmp/runtimo_new_file_test.txt", &ctx);
assert!(result.is_ok(), "expected Ok, got {:?}", result);
}
#[test]
fn rejects_write_outside_allowed() {
let ctx = PathContext {
require_exists: false,
require_file: false,
..Default::default()
};
let result = validate_path("/etc/shadow", &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("outside allowed"));
}
#[test]
fn rejects_symlink_escape() {
let link_path = std::env::temp_dir().join("runtimo_symlink_test");
let _ = std::fs::remove_file(&link_path);
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
if symlink("/etc/hostname", &link_path).is_ok() {
let ctx = PathContext::default();
let result = validate_path(link_path.to_str().unwrap(), &ctx);
assert!(result.is_err(), "symlink escape should be rejected");
std::fs::remove_file(&link_path).ok();
}
}
}
#[test]
fn env_var_extends_allowed_prefixes() {
let ctx = PathContext {
require_exists: false,
require_file: false,
..Default::default()
};
assert!(validate_path("/srv/myapp/config", &ctx).is_err());
std::env::set_var("RUNTIMO_ALLOWED_PATHS", "/srv:/opt");
assert!(validate_path("/srv/myapp/config", &ctx).is_ok());
assert!(validate_path("/opt/tools/bin", &ctx).is_ok());
std::env::remove_var("RUNTIMO_ALLOWED_PATHS");
assert!(validate_path("/srv/myapp/config", &ctx).is_err());
}
#[test]
fn error_message_shows_allowed_prefixes() {
let ctx = PathContext {
require_exists: false,
require_file: false,
..Default::default()
};
let err = validate_path("/etc/shadow", &ctx).unwrap_err();
assert!(err.contains("/tmp"), "error should list /tmp as allowed");
assert!(err.contains("/home"), "error should list /home as allowed");
}
}