use camino::Utf8PathBuf;
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use thiserror::Error;
thread_local! {
static THREAD_HOME: RefCell<Option<Utf8PathBuf>> = const { RefCell::new(None) };
}
#[cfg(unix)]
pub fn link_count(path: &Path) -> Result<u32, std::io::Error> {
use std::os::unix::fs::MetadataExt;
let metadata = path.metadata()?;
Ok(metadata.nlink() as u32)
}
#[cfg(windows)]
pub fn link_count(path: &Path) -> Result<u32, std::io::Error> {
use std::fs::File;
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
};
let file = File::open(path)?;
let handle = HANDLE(file.as_raw_handle());
let mut file_info = BY_HANDLE_FILE_INFORMATION::default();
let result = unsafe { GetFileInformationByHandle(handle, &mut file_info) };
match result {
Ok(()) => Ok(file_info.nNumberOfLinks),
Err(e) => Err(std::io::Error::other(format!(
"GetFileInformationByHandle failed: {e}"
))),
}
}
#[derive(Error, Debug, Clone, PartialEq, Eq)]
pub enum SandboxError {
#[error("Sandbox root does not exist: {path}")]
RootNotFound { path: String },
#[error("Sandbox root is not a directory: {path}")]
RootNotDirectory { path: String },
#[error("Failed to canonicalize sandbox root '{path}': {reason}")]
RootCanonicalizationFailed { path: String, reason: String },
#[error("Path contains parent directory traversal: {path}")]
ParentTraversal { path: String },
#[error("Absolute path not allowed: {path}")]
AbsolutePath { path: String },
#[error("Path escapes sandbox root: {path} resolves outside {root}")]
EscapeAttempt { path: String, root: String },
#[error("Symlink not allowed: {path}")]
SymlinkNotAllowed { path: String },
#[error("Hardlink not allowed: {path}")]
HardlinkNotAllowed { path: String },
#[error("Failed to canonicalize path '{path}': {reason}")]
PathCanonicalizationFailed { path: String, reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SandboxConfig {
pub allow_symlinks: bool,
pub allow_hardlinks: bool,
}
impl SandboxConfig {
#[must_use]
pub fn permissive() -> Self {
Self {
allow_symlinks: true,
allow_hardlinks: true,
}
}
}
#[derive(Debug, Clone)]
pub struct SandboxRoot {
root: PathBuf,
config: SandboxConfig,
}
impl SandboxRoot {
pub fn new(root: impl AsRef<Path>, config: SandboxConfig) -> Result<Self, SandboxError> {
let root_path = root.as_ref();
if !root_path.exists() {
return Err(SandboxError::RootNotFound {
path: root_path.display().to_string(),
});
}
if !root_path.is_dir() {
return Err(SandboxError::RootNotDirectory {
path: root_path.display().to_string(),
});
}
let canonical =
root_path
.canonicalize()
.map_err(|e| SandboxError::RootCanonicalizationFailed {
path: root_path.display().to_string(),
reason: e.to_string(),
})?;
Ok(Self {
root: canonical,
config,
})
}
pub fn new_default(root: impl AsRef<Path>) -> Result<Self, SandboxError> {
Self::new(root, SandboxConfig::default())
}
pub fn join(&self, rel: impl AsRef<Path>) -> Result<SandboxPath, SandboxError> {
let rel_path = rel.as_ref();
if rel_path.is_absolute() {
return Err(SandboxError::AbsolutePath {
path: rel_path.display().to_string(),
});
}
if rel_path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(SandboxError::ParentTraversal {
path: rel_path.display().to_string(),
});
}
let full_path = self.root.join(rel_path);
if !self.config.allow_symlinks {
self.check_symlinks_in_path(&full_path)?;
}
if full_path.exists() {
let canonical =
full_path
.canonicalize()
.map_err(|e| SandboxError::PathCanonicalizationFailed {
path: full_path.display().to_string(),
reason: e.to_string(),
})?;
if !canonical.starts_with(&self.root) {
return Err(SandboxError::EscapeAttempt {
path: rel_path.display().to_string(),
root: self.root.display().to_string(),
});
}
if !self.config.allow_hardlinks {
self.check_hardlink(&canonical)?;
}
Ok(SandboxPath {
full: canonical,
rel: rel_path.to_path_buf(),
})
} else {
if self.config.allow_symlinks {
self.validate_ancestor_within_sandbox(&full_path, rel_path)?;
}
Ok(SandboxPath {
full: full_path,
rel: rel_path.to_path_buf(),
})
}
}
fn check_symlinks_in_path(&self, path: &Path) -> Result<(), SandboxError> {
let mut current = PathBuf::new();
for component in path.components() {
current.push(component);
if current.exists() {
if current
.symlink_metadata()
.map(|m| m.is_symlink())
.unwrap_or(false)
{
return Err(SandboxError::SymlinkNotAllowed {
path: current.display().to_string(),
});
}
}
}
Ok(())
}
fn check_hardlink(&self, path: &Path) -> Result<(), SandboxError> {
if path.is_file() {
match link_count(path) {
Ok(count) if count > 1 => {
return Err(SandboxError::HardlinkNotAllowed {
path: path.display().to_string(),
});
}
Ok(_) => {
}
Err(_) => {
return Err(SandboxError::HardlinkNotAllowed {
path: path.display().to_string(),
});
}
}
}
Ok(())
}
fn validate_ancestor_within_sandbox(
&self,
full_path: &Path,
rel_path: &Path,
) -> Result<(), SandboxError> {
let mut ancestor = full_path.to_path_buf();
while !ancestor.exists() {
if !ancestor.pop() {
return Ok(());
}
}
let canonical_ancestor =
ancestor
.canonicalize()
.map_err(|e| SandboxError::PathCanonicalizationFailed {
path: ancestor.display().to_string(),
reason: e.to_string(),
})?;
if !canonical_ancestor.starts_with(&self.root) {
return Err(SandboxError::EscapeAttempt {
path: rel_path.display().to_string(),
root: self.root.display().to_string(),
});
}
Ok(())
}
#[must_use]
pub fn as_path(&self) -> &Path {
&self.root
}
#[must_use]
pub fn config(&self) -> &SandboxConfig {
&self.config
}
}
#[derive(Debug, Clone)]
pub struct SandboxPath {
full: PathBuf,
rel: PathBuf,
}
impl SandboxPath {
#[must_use]
pub fn as_path(&self) -> &Path {
&self.full
}
#[must_use]
pub fn relative(&self) -> &Path {
&self.rel
}
#[must_use]
pub fn to_path_buf(&self) -> PathBuf {
self.full.clone()
}
#[must_use]
pub fn relative_to_path_buf(&self) -> PathBuf {
self.rel.clone()
}
}
impl AsRef<Path> for SandboxPath {
fn as_ref(&self) -> &Path {
&self.full
}
}
#[must_use]
pub fn xchecker_home() -> Utf8PathBuf {
if let Some(tl) = THREAD_HOME.with(|tl| tl.borrow().clone()) {
return tl;
}
if let Ok(p) = std::env::var("XCHECKER_HOME") {
return Utf8PathBuf::from(p);
}
Utf8PathBuf::from(".xchecker")
}
#[must_use]
pub fn spec_root(spec_id: &str) -> Utf8PathBuf {
xchecker_home().join("specs").join(spec_id)
}
#[must_use]
pub fn cache_dir() -> Utf8PathBuf {
xchecker_home().join("cache")
}
pub fn ensure_dir_all<P: AsRef<std::path::Path>>(p: P) -> std::io::Result<()> {
match std::fs::create_dir_all(&p) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(e) => Err(e),
}
}
#[cfg(any(test, feature = "test-utils"))]
#[cfg_attr(not(test), allow(dead_code))]
#[must_use]
pub fn with_isolated_home() -> tempfile::TempDir {
let td = tempfile::TempDir::new().expect("create temp home");
let p = Utf8PathBuf::from_path_buf(td.path().to_path_buf()).unwrap();
THREAD_HOME.with(|tl| *tl.borrow_mut() = Some(p.clone()));
#[cfg(feature = "test-utils")]
{
xchecker_lock::set_thread_home_for_tests(p);
}
td
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_dir() -> TempDir {
TempDir::new().expect("Failed to create temp dir")
}
#[test]
fn test_sandbox_root_new_valid_directory() {
let temp = create_test_dir();
let root = SandboxRoot::new(temp.path(), SandboxConfig::default());
assert!(root.is_ok());
let root = root.unwrap();
assert!(root.as_path().is_absolute());
}
#[test]
fn test_sandbox_root_new_nonexistent_path() {
let result = SandboxRoot::new(
"/nonexistent/path/that/does/not/exist",
SandboxConfig::default(),
);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
SandboxError::RootNotFound { .. }
));
}
#[test]
fn test_sandbox_root_new_file_not_directory() {
let temp = create_test_dir();
let file_path = temp.path().join("file.txt");
std::fs::write(&file_path, "content").unwrap();
let result = SandboxRoot::new(&file_path, SandboxConfig::default());
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
SandboxError::RootNotDirectory { .. }
));
}
#[test]
fn test_sandbox_root_new_default() {
let temp = create_test_dir();
let root = SandboxRoot::new_default(temp.path());
assert!(root.is_ok());
}
#[test]
fn test_sandbox_join_simple_relative_path() {
let temp = create_test_dir();
let subdir = temp.path().join("subdir");
std::fs::create_dir(&subdir).unwrap();
let file = subdir.join("file.txt");
std::fs::write(&file, "content").unwrap();
let root = SandboxRoot::new_default(temp.path()).unwrap();
let result = root.join("subdir/file.txt");
assert!(result.is_ok());
let sandbox_path = result.unwrap();
assert_eq!(sandbox_path.relative(), Path::new("subdir/file.txt"));
}
#[test]
fn test_sandbox_join_nonexistent_path_allowed() {
let temp = create_test_dir();
let root = SandboxRoot::new_default(temp.path()).unwrap();
let result = root.join("new/path/to/file.txt");
assert!(result.is_ok());
}
#[test]
fn test_sandbox_join_rejects_parent_traversal() {
let temp = create_test_dir();
let root = SandboxRoot::new_default(temp.path()).unwrap();
let result = root.join("../escape");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
SandboxError::ParentTraversal { .. }
));
}
#[test]
fn test_sandbox_join_rejects_hidden_parent_traversal() {
let temp = create_test_dir();
let root = SandboxRoot::new_default(temp.path()).unwrap();
let result = root.join("subdir/../../../escape");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
SandboxError::ParentTraversal { .. }
));
}
#[test]
fn test_sandbox_join_rejects_parent_at_end() {
let temp = create_test_dir();
let root = SandboxRoot::new_default(temp.path()).unwrap();
let result = root.join("subdir/..");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
SandboxError::ParentTraversal { .. }
));
}
#[test]
fn test_sandbox_join_rejects_absolute_path() {
let temp = create_test_dir();
let root = SandboxRoot::new_default(temp.path()).unwrap();
#[cfg(unix)]
let result = root.join("/etc/passwd");
#[cfg(windows)]
let result = root.join("C:\\Windows\\System32");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
SandboxError::AbsolutePath { .. }
));
}
#[cfg(unix)]
#[test]
fn test_sandbox_join_rejects_symlink_by_default() {
let temp = create_test_dir();
let target = temp.path().join("target.txt");
std::fs::write(&target, "content").unwrap();
let link = temp.path().join("link.txt");
std::os::unix::fs::symlink(&target, &link).unwrap();
let root = SandboxRoot::new_default(temp.path()).unwrap();
let result = root.join("link.txt");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
SandboxError::SymlinkNotAllowed { .. }
));
}
#[cfg(unix)]
#[test]
fn test_sandbox_join_allows_symlink_when_configured() {
let temp = create_test_dir();
let target = temp.path().join("target.txt");
std::fs::write(&target, "content").unwrap();
let link = temp.path().join("link.txt");
std::os::unix::fs::symlink(&target, &link).unwrap();
let config = SandboxConfig::permissive();
let root = SandboxRoot::new(temp.path(), config).unwrap();
let result = root.join("link.txt");
assert!(result.is_ok());
}
#[cfg(unix)]
#[test]
fn test_sandbox_join_rejects_symlink_escape() {
let temp = create_test_dir();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("secret.txt");
std::fs::write(&outside_file, "secret").unwrap();
let link = temp.path().join("escape_link");
std::os::unix::fs::symlink(&outside_file, &link).unwrap();
let config = SandboxConfig::permissive();
let root = SandboxRoot::new(temp.path(), config).unwrap();
let result = root.join("escape_link");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
SandboxError::EscapeAttempt { .. }
));
}
#[cfg(unix)]
#[test]
fn test_sandbox_join_rejects_symlink_dir_escape_via_nonexistent_path() {
let temp = create_test_dir();
let outside = TempDir::new().unwrap();
let outside_dir = outside.path().join("attacker_controlled");
std::fs::create_dir(&outside_dir).unwrap();
let escape_link = temp.path().join("escape_dir");
std::os::unix::fs::symlink(&outside_dir, &escape_link).unwrap();
let config = SandboxConfig::permissive();
let root = SandboxRoot::new(temp.path(), config).unwrap();
let result = root.join("escape_dir/nonexistent_malicious_file.txt");
assert!(
result.is_err(),
"Expected escape to be detected for non-existent path through symlinked directory"
);
assert!(matches!(
result.unwrap_err(),
SandboxError::EscapeAttempt { .. }
));
}
#[cfg(unix)]
#[test]
fn test_sandbox_join_allows_safe_symlink_dir_with_nonexistent_path() {
let temp = create_test_dir();
let inside_dir = temp.path().join("real_subdir");
std::fs::create_dir(&inside_dir).unwrap();
let safe_link = temp.path().join("link_to_subdir");
std::os::unix::fs::symlink(&inside_dir, &safe_link).unwrap();
let config = SandboxConfig::permissive();
let root = SandboxRoot::new(temp.path(), config).unwrap();
let result = root.join("link_to_subdir/new_file.txt");
assert!(
result.is_ok(),
"Expected safe symlink with non-existent path to succeed"
);
}
#[cfg(unix)]
#[test]
fn test_sandbox_join_rejects_hardlink_by_default() {
let temp = create_test_dir();
let original = temp.path().join("original.txt");
std::fs::write(&original, "content").unwrap();
let hardlink = temp.path().join("hardlink.txt");
std::fs::hard_link(&original, &hardlink).unwrap();
let root = SandboxRoot::new_default(temp.path()).unwrap();
let result = root.join("hardlink.txt");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
SandboxError::HardlinkNotAllowed { .. }
));
}
#[cfg(unix)]
#[test]
fn test_sandbox_join_allows_hardlink_when_configured() {
let temp = create_test_dir();
let original = temp.path().join("original.txt");
std::fs::write(&original, "content").unwrap();
let hardlink = temp.path().join("hardlink.txt");
std::fs::hard_link(&original, &hardlink).unwrap();
let config = SandboxConfig::permissive();
let root = SandboxRoot::new(temp.path(), config).unwrap();
let result = root.join("hardlink.txt");
assert!(result.is_ok());
}
#[test]
fn test_sandbox_path_as_path() {
let temp = create_test_dir();
let file = temp.path().join("file.txt");
std::fs::write(&file, "content").unwrap();
let root = SandboxRoot::new_default(temp.path()).unwrap();
let sandbox_path = root.join("file.txt").unwrap();
assert!(sandbox_path.as_path().ends_with("file.txt"));
assert!(sandbox_path.as_path().is_absolute());
}
#[test]
fn test_sandbox_path_relative() {
let temp = create_test_dir();
let subdir = temp.path().join("a/b/c");
std::fs::create_dir_all(&subdir).unwrap();
let file = subdir.join("file.txt");
std::fs::write(&file, "content").unwrap();
let root = SandboxRoot::new_default(temp.path()).unwrap();
let sandbox_path = root.join("a/b/c/file.txt").unwrap();
assert_eq!(sandbox_path.relative(), Path::new("a/b/c/file.txt"));
}
#[test]
fn test_sandbox_path_to_path_buf() {
let temp = create_test_dir();
let file = temp.path().join("file.txt");
std::fs::write(&file, "content").unwrap();
let root = SandboxRoot::new_default(temp.path()).unwrap();
let sandbox_path = root.join("file.txt").unwrap();
let path_buf = sandbox_path.to_path_buf();
assert!(path_buf.is_absolute());
assert!(path_buf.ends_with("file.txt"));
}
#[test]
fn test_sandbox_path_as_ref() {
let temp = create_test_dir();
let file = temp.path().join("file.txt");
std::fs::write(&file, "content").unwrap();
let root = SandboxRoot::new_default(temp.path()).unwrap();
let sandbox_path = root.join("file.txt").unwrap();
let path_ref: &Path = sandbox_path.as_ref();
assert!(path_ref.ends_with("file.txt"));
}
#[test]
fn test_sandbox_config_default() {
let config = SandboxConfig::default();
assert!(!config.allow_symlinks);
assert!(!config.allow_hardlinks);
}
#[test]
fn test_sandbox_config_permissive() {
let config = SandboxConfig::permissive();
assert!(config.allow_symlinks);
assert!(config.allow_hardlinks);
}
#[test]
fn test_sandbox_error_display() {
let err = SandboxError::ParentTraversal {
path: "../escape".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("parent directory traversal"));
assert!(msg.contains("../escape"));
}
#[test]
fn test_sandbox_error_equality() {
let err1 = SandboxError::AbsolutePath {
path: "/etc/passwd".to_string(),
};
let err2 = SandboxError::AbsolutePath {
path: "/etc/passwd".to_string(),
};
assert_eq!(err1, err2);
}
}