use std::ffi::OsStr;
use std::io::{ErrorKind, Write};
use std::path::{Component, Path, PathBuf};
use thiserror::Error;
use crate::path::{sanitize_path_for_error, validate_path_segment};
use crate::untrusted::sanitize_untrusted_inline;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfinementTarget<'a> {
Directory(&'a OsStr),
File(&'a OsStr),
}
#[derive(Debug, Error)]
pub enum ConfinementError {
#[error("segment must be a single non-empty path segment: {segment:?}")]
InvalidSegment {
segment: String,
},
#[error("segment directory must not be a symlink: {path}")]
SegmentIsSymlink {
path: String,
},
#[error("resolved path escapes the confined directory: {path}")]
Escape {
path: String,
},
#[error("path component is not a directory: {path}")]
NotADirectory {
path: String,
},
#[error("path exists as the wrong kind of entry: {path}")]
WrongTargetKind {
path: String,
},
#[error("failed to create directory {path}: {source}")]
CreateDir {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to resolve confined path: {0}")]
Io(#[from] std::io::Error),
}
pub async fn resolve_confined_path(
base_dir: &Path,
segment: &str,
relative_dirs: &Path,
target: Option<ConfinementTarget<'_>>,
) -> Result<PathBuf, ConfinementError> {
let component =
validate_path_segment(segment).ok_or_else(|| ConfinementError::InvalidSegment {
segment: sanitize_untrusted_inline(segment),
})?;
tokio::fs::create_dir_all(base_dir)
.await
.map_err(|source| ConfinementError::CreateDir {
path: sanitize_path_for_error(base_dir),
source,
})?;
let canonical_root = tokio::fs::canonicalize(base_dir).await?;
let segment_dir = resolve_segment_dir(&canonical_root, component).await?;
let mut current = segment_dir.clone();
for dir_component in relative_dirs.components() {
current.push(dir_component);
resolve_lenient_component(&mut current, &segment_dir).await?;
}
match target {
None => Ok(current),
Some(target) => resolve_terminal(current, &segment_dir, target).await,
}
}
fn validate_existing_segment_dir(
segment_dir: &Path,
meta: &std::fs::Metadata,
) -> Result<(), ConfinementError> {
if meta.file_type().is_symlink() {
return Err(ConfinementError::SegmentIsSymlink {
path: sanitize_path_for_error(segment_dir),
});
}
if !meta.is_dir() {
return Err(ConfinementError::NotADirectory {
path: sanitize_path_for_error(segment_dir),
});
}
Ok(())
}
async fn resolve_segment_dir(
canonical_root: &Path,
component: Component<'_>,
) -> Result<PathBuf, ConfinementError> {
let mut segment_dir = canonical_root.to_path_buf();
segment_dir.push(component);
if !segment_dir.starts_with(canonical_root) {
return Err(ConfinementError::Escape {
path: sanitize_path_for_error(&segment_dir),
});
}
if let Ok(meta) = tokio::fs::symlink_metadata(&segment_dir).await {
validate_existing_segment_dir(&segment_dir, &meta)?;
} else if let Err(source) = tokio::fs::create_dir(&segment_dir).await {
if source.kind() != ErrorKind::AlreadyExists {
return Err(ConfinementError::CreateDir {
path: sanitize_path_for_error(&segment_dir),
source,
});
}
let meta = tokio::fs::symlink_metadata(&segment_dir).await?;
validate_existing_segment_dir(&segment_dir, &meta)?;
}
Ok(segment_dir)
}
async fn validate_existing_lenient_component(
current: &mut PathBuf,
segment_dir: &Path,
) -> Result<(), ConfinementError> {
let resolved = tokio::fs::canonicalize(¤t).await?;
if !resolved.starts_with(segment_dir) {
return Err(ConfinementError::Escape {
path: sanitize_path_for_error(current),
});
}
if !tokio::fs::metadata(&resolved).await?.is_dir() {
return Err(ConfinementError::NotADirectory {
path: sanitize_path_for_error(current),
});
}
*current = resolved;
Ok(())
}
async fn resolve_lenient_component(
current: &mut PathBuf,
segment_dir: &Path,
) -> Result<(), ConfinementError> {
if !current.starts_with(segment_dir) {
return Err(ConfinementError::Escape {
path: sanitize_path_for_error(current),
});
}
match tokio::fs::symlink_metadata(¤t).await {
Ok(_) => validate_existing_lenient_component(current, segment_dir).await,
Err(_) => match tokio::fs::create_dir(¤t).await {
Ok(()) => Ok(()),
Err(source) if source.kind() == ErrorKind::AlreadyExists => {
validate_existing_lenient_component(current, segment_dir).await
}
Err(source) => Err(ConfinementError::CreateDir {
path: sanitize_path_for_error(current),
source,
}),
},
}
}
async fn resolve_terminal(
mut current: PathBuf,
segment_dir: &Path,
target: ConfinementTarget<'_>,
) -> Result<PathBuf, ConfinementError> {
match target {
ConfinementTarget::Directory(name) => {
current.push(name);
if !current.starts_with(segment_dir) {
return Err(ConfinementError::Escape {
path: sanitize_path_for_error(¤t),
});
}
if let Ok(meta) = tokio::fs::symlink_metadata(¤t).await {
if meta.file_type().is_symlink() {
return Err(ConfinementError::Escape {
path: sanitize_path_for_error(¤t),
});
}
let resolved = tokio::fs::canonicalize(¤t).await?;
if !resolved.starts_with(segment_dir) {
return Err(ConfinementError::Escape {
path: sanitize_path_for_error(¤t),
});
}
if !meta.is_dir() {
return Err(ConfinementError::WrongTargetKind {
path: sanitize_path_for_error(¤t),
});
}
current = resolved;
}
Ok(current)
}
ConfinementTarget::File(name) => {
let final_path = current.join(name);
if let Ok(meta) = tokio::fs::symlink_metadata(&final_path).await {
if meta.file_type().is_symlink() {
return Err(ConfinementError::Escape {
path: sanitize_path_for_error(&final_path),
});
}
if meta.is_dir() {
return Err(ConfinementError::WrongTargetKind {
path: sanitize_path_for_error(&final_path),
});
}
}
Ok(final_path)
}
}
}
pub async fn write_confined_file(path: &Path, content: &[u8]) -> Result<(), ConfinementError> {
let path = path.to_path_buf();
let content = content.to_vec();
tokio::task::spawn_blocking(move || write_confined_file_blocking(&path, &content))
.await
.map_err(std::io::Error::other)??;
Ok(())
}
fn write_confined_file_blocking(path: &Path, content: &[u8]) -> std::io::Result<()> {
let mut file = open_confined_write(path)?;
file.write_all(content)?;
file.flush()
}
pub fn open_confined_write(path: &Path) -> std::io::Result<std::fs::File> {
#[cfg(not(unix))]
if std::fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink()) {
return Err(std::io::Error::new(
ErrorKind::AlreadyExists,
"refusing to write through a pre-existing symlink",
));
}
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
options.open(path)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn target_none_returns_segment_dir_and_creates_nothing_beyond_it() {
let base = TempDir::new().unwrap();
let resolved = resolve_confined_path(base.path(), "my-server", Path::new(""), None)
.await
.unwrap();
let canonical_base = base.path().canonicalize().unwrap();
assert_eq!(resolved, canonical_base.join("my-server"));
}
#[tokio::test]
async fn leading_cur_dir_resolves_like_its_normalized_form() {
let base = TempDir::new().unwrap();
let with_cur_dir = resolve_confined_path(
base.path(),
"my-server",
Path::new("./nested"),
Some(ConfinementTarget::File(OsStr::new("out.txt"))),
)
.await
.unwrap();
let normalized = resolve_confined_path(
base.path(),
"my-server",
Path::new("nested"),
Some(ConfinementTarget::File(OsStr::new("out.txt"))),
)
.await
.unwrap();
assert_eq!(with_cur_dir, normalized);
}
#[tokio::test]
async fn segment_empty_is_rejected() {
let base = TempDir::new().unwrap();
let err = resolve_confined_path(base.path(), "", Path::new(""), None)
.await
.unwrap_err();
assert!(matches!(err, ConfinementError::InvalidSegment { .. }));
}
#[tokio::test]
async fn segment_with_parent_traversal_is_rejected() {
let base = TempDir::new().unwrap();
let err = resolve_confined_path(base.path(), "..", Path::new(""), None)
.await
.unwrap_err();
assert!(matches!(err, ConfinementError::InvalidSegment { .. }));
}
#[tokio::test]
async fn segment_with_hostile_characters_is_escaped_in_error() {
let base = TempDir::new().unwrap();
for (candidate, escaped) in [
("a/b&c", "a/b&c"),
("a/b<c", "a/b<c"),
("a/b>c", "a/b>c"),
] {
let err = resolve_confined_path(base.path(), candidate, Path::new(""), None)
.await
.unwrap_err();
let message = err.to_string();
assert!(!message.contains(candidate), "{message:?}");
assert!(message.contains(escaped), "{message:?}");
}
}
#[tokio::test]
async fn segment_with_emoji_is_left_unchanged_in_error() {
let base = TempDir::new().unwrap();
let err = resolve_confined_path(base.path(), "a/b\u{1F600}c", Path::new(""), None)
.await
.unwrap_err();
assert!(err.to_string().contains("a/b\u{1F600}c"));
}
#[tokio::test]
async fn segment_with_legitimate_non_ascii_is_left_unchanged_in_error() {
let base = TempDir::new().unwrap();
let err = resolve_confined_path(base.path(), "café/menu_日本語", Path::new(""), None)
.await
.unwrap_err();
assert!(err.to_string().contains("café/menu_日本語"));
}
#[tokio::test]
async fn segment_with_zwj_is_debug_escaped_in_error() {
let base = TempDir::new().unwrap();
let err = resolve_confined_path(base.path(), "a/b\u{200D}c", Path::new(""), None)
.await
.unwrap_err();
let message = err.to_string();
assert!(
!message.contains('\u{200D}'),
"raw ZWJ leaked into: {message}"
);
assert!(message.contains("\\u{200d}"), "message was: {message}");
}
#[tokio::test]
async fn segment_with_path_separator_is_rejected() {
let base = TempDir::new().unwrap();
let err = resolve_confined_path(base.path(), "a/b", Path::new(""), None)
.await
.unwrap_err();
assert!(matches!(err, ConfinementError::InvalidSegment { .. }));
}
#[tokio::test]
#[cfg(unix)]
async fn segment_dir_symlink_to_outside_is_rejected() {
let base = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
std::os::unix::fs::symlink(outside.path(), base.path().join("my-server")).unwrap();
let err = resolve_confined_path(base.path(), "my-server", Path::new(""), None)
.await
.unwrap_err();
assert!(matches!(err, ConfinementError::SegmentIsSymlink { .. }));
}
#[tokio::test]
#[cfg(unix)]
async fn segment_dir_symlink_to_sibling_is_rejected() {
let base = TempDir::new().unwrap();
tokio::fs::create_dir_all(base.path().join("server-a"))
.await
.unwrap();
std::os::unix::fs::symlink(base.path().join("server-a"), base.path().join("server-b"))
.unwrap();
let err = resolve_confined_path(base.path(), "server-b", Path::new(""), None)
.await
.unwrap_err();
assert!(matches!(err, ConfinementError::SegmentIsSymlink { .. }));
}
#[tokio::test]
#[cfg(unix)]
async fn segment_dir_that_is_a_regular_file_is_rejected() {
let base = TempDir::new().unwrap();
tokio::fs::write(base.path().join("my-server"), "oops")
.await
.unwrap();
let err = resolve_confined_path(base.path(), "my-server", Path::new(""), None)
.await
.unwrap_err();
assert!(matches!(err, ConfinementError::NotADirectory { .. }));
}
#[tokio::test]
#[cfg(unix)]
async fn lenient_walk_symlinked_intermediate_escape_is_rejected() {
let base = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let server_dir = base.path().join("my-server");
tokio::fs::create_dir_all(&server_dir).await.unwrap();
std::os::unix::fs::symlink(outside.path(), server_dir.join("escape")).unwrap();
let err = resolve_confined_path(base.path(), "my-server", Path::new("escape/custom"), None)
.await
.unwrap_err();
assert!(matches!(err, ConfinementError::Escape { .. }));
}
#[tokio::test]
#[cfg(unix)]
async fn lenient_walk_regular_file_intermediate_is_rejected() {
let base = TempDir::new().unwrap();
let server_dir = base.path().join("my-server");
tokio::fs::create_dir_all(&server_dir).await.unwrap();
tokio::fs::write(server_dir.join("not-a-dir"), "oops")
.await
.unwrap();
let err = resolve_confined_path(
base.path(),
"my-server",
Path::new("not-a-dir/custom"),
None,
)
.await
.unwrap_err();
assert!(matches!(err, ConfinementError::NotADirectory { .. }));
}
#[tokio::test]
#[cfg(unix)]
async fn lenient_walk_symlink_loop_surfaces_as_io() {
let base = TempDir::new().unwrap();
let server_dir = base.path().join("my-server");
tokio::fs::create_dir_all(&server_dir).await.unwrap();
std::os::unix::fs::symlink("a", server_dir.join("a")).unwrap();
let err = resolve_confined_path(base.path(), "my-server", Path::new("a/custom"), None)
.await
.unwrap_err();
assert!(matches!(err, ConfinementError::Io(_)));
}
#[tokio::test]
#[cfg(unix)]
async fn dangling_symlink_at_terminal_is_rejected_under_both_targets() {
let base = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let dangling_target = outside.path().join("does-not-exist");
let server_dir = base.path().join("my-server");
tokio::fs::create_dir_all(&server_dir).await.unwrap();
std::os::unix::fs::symlink(&dangling_target, server_dir.join("custom")).unwrap();
let dir_err = resolve_confined_path(
base.path(),
"my-server",
Path::new(""),
Some(ConfinementTarget::Directory(OsStr::new("custom"))),
)
.await
.unwrap_err();
assert!(matches!(dir_err, ConfinementError::Escape { .. }));
let file_err = resolve_confined_path(
base.path(),
"my-server",
Path::new(""),
Some(ConfinementTarget::File(OsStr::new("custom"))),
)
.await
.unwrap_err();
assert!(matches!(file_err, ConfinementError::Escape { .. }));
}
#[tokio::test]
#[cfg(unix)]
async fn symlink_to_existing_outside_file_at_terminal_is_rejected_under_both_targets() {
let base = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("real");
tokio::fs::write(&outside_file, "outside").await.unwrap();
let server_dir = base.path().join("my-server");
tokio::fs::create_dir_all(&server_dir).await.unwrap();
std::os::unix::fs::symlink(&outside_file, server_dir.join("custom")).unwrap();
let dir_err = resolve_confined_path(
base.path(),
"my-server",
Path::new(""),
Some(ConfinementTarget::Directory(OsStr::new("custom"))),
)
.await
.unwrap_err();
assert!(matches!(dir_err, ConfinementError::Escape { .. }));
let file_err = resolve_confined_path(
base.path(),
"my-server",
Path::new(""),
Some(ConfinementTarget::File(OsStr::new("custom"))),
)
.await
.unwrap_err();
assert!(matches!(file_err, ConfinementError::Escape { .. }));
}
#[tokio::test]
#[cfg(unix)]
async fn terminal_exists_as_the_other_kind_under_both_targets() {
let base = TempDir::new().unwrap();
let server_dir = base.path().join("my-server");
tokio::fs::create_dir_all(&server_dir).await.unwrap();
tokio::fs::write(server_dir.join("custom"), "oops")
.await
.unwrap();
let dir_err = resolve_confined_path(
base.path(),
"my-server",
Path::new(""),
Some(ConfinementTarget::Directory(OsStr::new("custom"))),
)
.await
.unwrap_err();
assert!(matches!(dir_err, ConfinementError::WrongTargetKind { .. }));
let other_server_dir = base.path().join("other-server");
tokio::fs::create_dir_all(other_server_dir.join("custom"))
.await
.unwrap();
let file_err = resolve_confined_path(
base.path(),
"other-server",
Path::new(""),
Some(ConfinementTarget::File(OsStr::new("custom"))),
)
.await
.unwrap_err();
assert!(matches!(file_err, ConfinementError::WrongTargetKind { .. }));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_first_time_segment_creation_both_succeed() {
let base = TempDir::new().unwrap();
let base_a = base.path().to_path_buf();
let base_b = base_a.clone();
let task_a = tokio::spawn(async move {
resolve_confined_path(&base_a, "my-server", Path::new(""), None).await
});
let task_b = tokio::spawn(async move {
resolve_confined_path(&base_b, "my-server", Path::new(""), None).await
});
let (a, b) = tokio::join!(task_a, task_b);
assert_eq!(a.unwrap().unwrap(), b.unwrap().unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_first_time_relative_dir_creation_both_succeed() {
let base = TempDir::new().unwrap();
tokio::fs::create_dir_all(base.path().join("my-server"))
.await
.unwrap();
let base_a = base.path().to_path_buf();
let base_b = base_a.clone();
let task_a = tokio::spawn(async move {
resolve_confined_path(&base_a, "my-server", Path::new("nested"), None).await
});
let task_b = tokio::spawn(async move {
resolve_confined_path(&base_b, "my-server", Path::new("nested"), None).await
});
let (a, b) = tokio::join!(task_a, task_b);
assert_eq!(a.unwrap().unwrap(), b.unwrap().unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn write_confined_file_survives_future_drop_without_partial_write() {
const OLD: &[u8] = b"old-content-should-survive-or-be-fully-replaced";
const NEW: &[u8] = b"new-content-0123456789";
for _ in 0..8 {
let base = TempDir::new().unwrap();
let path = base.path().join("SKILL.md");
tokio::fs::write(&path, OLD).await.unwrap();
let spawn_path = path.clone();
let handle = tokio::spawn(async move { write_confined_file(&spawn_path, NEW).await });
std::thread::sleep(std::time::Duration::from_micros(1));
handle.abort();
let _ = handle.await;
let mut content = tokio::fs::read(&path).await.unwrap();
for _ in 0..50 {
if content.as_slice() == OLD || content.as_slice() == NEW {
break;
}
std::thread::sleep(std::time::Duration::from_millis(2));
content = tokio::fs::read(&path).await.unwrap();
}
assert!(
content.as_slice() == OLD || content.as_slice() == NEW,
"partial/corrupt content observed: {content:?}"
);
}
}
#[tokio::test]
async fn write_confined_file_creates_new_file() {
let base = TempDir::new().unwrap();
let path = base.path().join("SKILL.md");
write_confined_file(&path, b"content").await.unwrap();
assert_eq!(tokio::fs::read(&path).await.unwrap(), b"content");
}
#[tokio::test]
async fn write_confined_file_overwrites_existing_regular_file() {
let base = TempDir::new().unwrap();
let path = base.path().join("SKILL.md");
tokio::fs::write(&path, b"old").await.unwrap();
write_confined_file(&path, b"new").await.unwrap();
assert_eq!(tokio::fs::read(&path).await.unwrap(), b"new");
}
#[tokio::test]
#[cfg(unix)]
async fn write_confined_file_rejects_a_symlink_planted_at_the_target() {
let base = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("real.md");
let confined_path = base.path().join("SKILL.md");
std::os::unix::fs::symlink(&outside_file, &confined_path).unwrap();
let err = write_confined_file(&confined_path, b"attacker-controlled")
.await
.unwrap_err();
assert!(matches!(err, ConfinementError::Io(_)));
assert!(!outside_file.exists());
}
#[cfg(windows)]
#[tokio::test]
async fn windows_root_relative_intermediate_cannot_escape_base() {
let base = TempDir::new().unwrap();
let err = resolve_confined_path(base.path(), "my-server", Path::new(r"\pwn\evil"), None)
.await
.unwrap_err();
assert!(matches!(err, ConfinementError::Escape { .. }));
}
}