use std::io;
use std::path::{Component, Path, PathBuf};
const BASE_SUBDIR: &str = ".car/memory";
pub fn resolve(path: &str) -> io::Result<PathBuf> {
if path.trim().is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"memory path is empty",
));
}
let supplied = Path::new(path);
if supplied
.components()
.any(|c| matches!(c, Component::ParentDir))
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"memory path must not contain `..` segments",
));
}
let base = ensure_base()?;
let candidate = if supplied.is_absolute() {
supplied.to_path_buf()
} else {
base.join(supplied)
};
if candidate.file_name().is_none() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"memory path must include a filename, not just a directory",
));
}
let parent = candidate.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"memory path has no parent directory",
)
})?;
std::fs::create_dir_all(parent)?;
let real_parent = parent.canonicalize()?;
let real_base = base.canonicalize()?;
if !real_parent.starts_with(&real_base) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"memory path resolved outside the sandbox \
(resolved parent {} is not under {})",
real_parent.display(),
real_base.display(),
),
));
}
let file_name = candidate.file_name().expect("checked above");
Ok(real_parent.join(file_name))
}
pub fn ensure_base() -> io::Result<PathBuf> {
let home = home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no HOME / USERPROFILE"))?;
let base = home.join(BASE_SUBDIR);
std::fs::create_dir_all(&base)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700));
}
Ok(base)
}
fn home_dir() -> Option<PathBuf> {
if let Some(h) = std::env::var_os("HOME") {
return Some(PathBuf::from(h));
}
if let Some(h) = std::env::var_os("USERPROFILE") {
return Some(PathBuf::from(h));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn isolated_home() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::TempDir::new().unwrap();
std::env::set_var("HOME", tmp.path());
let base = tmp.path().join(BASE_SUBDIR);
std::fs::create_dir_all(&base).unwrap();
(tmp, base)
}
#[test]
fn relative_path_lands_under_base() {
let _g = crate::env_test_lock();
let (_tmp, base) = isolated_home();
let resolved = resolve("snapshot.json").unwrap();
assert_eq!(resolved.parent().unwrap(), base.canonicalize().unwrap());
assert_eq!(resolved.file_name().unwrap(), "snapshot.json");
}
#[test]
fn nested_relative_path_creates_subdir() {
let _g = crate::env_test_lock();
let (_tmp, base) = isolated_home();
let resolved = resolve("project-a/snap.json").unwrap();
assert!(resolved.starts_with(&base.canonicalize().unwrap()));
assert!(resolved.parent().unwrap().exists());
}
#[test]
fn parent_segment_is_rejected_even_when_safe() {
let _g = crate::env_test_lock();
let (_tmp, _base) = isolated_home();
let err = resolve("../../etc/passwd").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(err.to_string().contains(".."));
}
#[test]
fn absolute_path_outside_sandbox_is_rejected() {
let _g = crate::env_test_lock();
let (_tmp, _base) = isolated_home();
let err = resolve("/etc/hosts").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(err.to_string().contains("sandbox"));
}
#[test]
fn empty_path_is_rejected() {
let _g = crate::env_test_lock();
let (_tmp, _base) = isolated_home();
assert!(resolve("").is_err());
assert!(resolve(" ").is_err());
}
#[test]
fn directory_only_path_is_rejected() {
let _g = crate::env_test_lock();
let (_tmp, _base) = isolated_home();
let err = resolve("/").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[test]
#[cfg(unix)]
fn symlink_pointing_outside_sandbox_is_rejected() {
let _g = crate::env_test_lock();
let (_tmp, base) = isolated_home();
let link = base.join("escape");
std::os::unix::fs::symlink("/etc", &link).unwrap();
let err = resolve("escape/passwd").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
}