use std::path::{Component, Path, PathBuf};
pub(crate) async fn resolve_existing_path(
path: &str,
execution_root: Option<&Path>,
) -> Result<PathBuf, String> {
let Some(root) = execution_root else {
return Ok(Path::new(path).to_path_buf());
};
let root = canonical_root(root).await?;
let candidate = candidate_path(path, &root)?;
let canonical = tokio::fs::canonicalize(&candidate)
.await
.map_err(|error| format!("failed to resolve path {}: {error}", candidate.display()))?;
if canonical.starts_with(&root) {
Ok(canonical)
} else {
Err(format!(
"path {} escapes execution root {}",
candidate.display(),
root.display()
))
}
}
pub(crate) async fn resolve_writable_path(
path: &str,
execution_root: Option<&Path>,
) -> Result<PathBuf, String> {
let Some(root) = execution_root else {
return Ok(Path::new(path).to_path_buf());
};
let root = canonical_root(root).await?;
let candidate = candidate_path(path, &root)?;
let target_exists = tokio::fs::try_exists(&candidate).await.map_err(|error| {
format!(
"failed to check whether path exists {}: {error}",
candidate.display()
)
})?;
if target_exists {
let canonical = tokio::fs::canonicalize(&candidate)
.await
.map_err(|error| format!("failed to resolve path {}: {error}", candidate.display()))?;
return if canonical.starts_with(&root) {
Ok(canonical)
} else {
Err(format!(
"path {} escapes execution root {}",
candidate.display(),
root.display()
))
};
}
match tokio::fs::symlink_metadata(&candidate).await {
Ok(_) => return resolve_dangling_symlink(&candidate, &root).await,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"failed to check whether path exists {}: {error}",
candidate.display()
));
}
}
let ancestor = nearest_existing_ancestor(&candidate).await?;
let canonical_ancestor = tokio::fs::canonicalize(&ancestor)
.await
.map_err(|error| format!("failed to resolve path {}: {error}", ancestor.display()))?;
if canonical_ancestor.starts_with(&root) {
Ok(candidate)
} else {
Err(format!(
"path {} escapes execution root {}",
candidate.display(),
root.display()
))
}
}
async fn resolve_dangling_symlink(candidate: &Path, root: &Path) -> Result<PathBuf, String> {
let escape_error = || {
format!(
"path {} escapes execution root {}",
candidate.display(),
root.display()
)
};
let link_target = tokio::fs::read_link(candidate)
.await
.map_err(|error| format!("failed to resolve path {}: {error}", candidate.display()))?;
let resolved = if link_target.is_absolute() {
normalize(&link_target)
} else {
let parent = candidate.parent().unwrap_or(candidate);
normalize(&parent.join(link_target))
};
if tokio::fs::symlink_metadata(&resolved).await.is_ok() {
return Err(escape_error());
}
if !resolved.starts_with(root) {
return Err(escape_error());
}
let ancestor = nearest_existing_ancestor(&resolved).await?;
let canonical_ancestor = tokio::fs::canonicalize(&ancestor)
.await
.map_err(|error| format!("failed to resolve path {}: {error}", ancestor.display()))?;
if canonical_ancestor.starts_with(root) {
Ok(resolved)
} else {
Err(escape_error())
}
}
async fn canonical_root(root: &Path) -> Result<PathBuf, String> {
tokio::fs::canonicalize(root).await.map_err(|error| {
format!(
"failed to resolve execution root {}: {error}",
root.display()
)
})
}
fn candidate_path(path: &str, root: &Path) -> Result<PathBuf, String> {
let raw = Path::new(path);
if raw.is_absolute() {
return Ok(normalize(raw));
}
let candidate = normalize(&root.join(raw));
if candidate.starts_with(root) {
Ok(candidate)
} else {
Err(format!(
"path {} escapes execution root {}",
raw.display(),
root.display()
))
}
}
fn normalize(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
async fn nearest_existing_ancestor(path: &Path) -> Result<PathBuf, String> {
let mut current = path.parent().unwrap_or(path).to_path_buf();
loop {
match tokio::fs::try_exists(¤t).await {
Ok(true) => return Ok(current),
Ok(false) => {
if !current.pop() {
return Err(format!(
"failed to find existing ancestor for {}",
path.display()
));
}
}
Err(error) => {
return Err(format!(
"failed to check whether path exists {}: {error}",
current.display()
));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[tokio::test]
async fn resolve_writable_path_rejects_dangling_symlink_escaping_root() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("root");
tokio::fs::create_dir(&root).await.unwrap();
let outside_target = temp.path().join("outside").join("authorized_keys");
std::os::unix::fs::symlink(&outside_target, root.join("notes.txt")).unwrap();
let error = resolve_writable_path("notes.txt", Some(&root))
.await
.expect_err("dangling symlink escaping the execution root must be rejected");
assert!(
error.contains("escapes execution root"),
"unexpected error: {error}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn resolve_writable_path_rejects_relative_dangling_symlink_escaping_root() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("root");
tokio::fs::create_dir(&root).await.unwrap();
std::os::unix::fs::symlink("../outside.txt", root.join("notes.txt")).unwrap();
let error = resolve_writable_path("notes.txt", Some(&root))
.await
.expect_err("relative dangling symlink escaping the execution root must be rejected");
assert!(
error.contains("escapes execution root"),
"unexpected error: {error}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn resolve_writable_path_accepts_dangling_symlink_inside_root() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("root");
tokio::fs::create_dir(&root).await.unwrap();
std::os::unix::fs::symlink("inside.txt", root.join("notes.txt")).unwrap();
let resolved = resolve_writable_path("notes.txt", Some(&root))
.await
.expect("dangling symlink whose target stays inside the root is writable");
let canonical_root = tokio::fs::canonicalize(&root).await.unwrap();
assert!(
resolved.starts_with(&canonical_root),
"resolved path {} left the execution root",
resolved.display()
);
assert_eq!(resolved.file_name().unwrap(), "inside.txt");
}
}