use std::path::{Component, Path, PathBuf};
use nix::fcntl::OFlag;
fn symlink_refusal_notice(path: &Path) -> String {
format!(
"shep serve: refused {} — a symlink is not permitted in the docroot; \
pass --follow-symlinks to allow it (this reopens the check-then-open \
race refusing symlinks closes)",
path.display(),
)
}
pub async fn contain(root: &Path, segments: &[String], follow_symlinks: bool) -> Option<PathBuf> {
let mut joined = root.to_path_buf();
for segment in segments {
let mut components = Path::new(segment).components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) => {}
_ => return None,
}
joined.push(segment);
if !follow_symlinks {
match tokio::fs::symlink_metadata(&joined).await {
Ok(metadata) if metadata.file_type().is_symlink() => {
eprintln!("{}", symlink_refusal_notice(&joined));
return None;
}
Ok(_) => {}
Err(_) => return None,
}
}
}
if follow_symlinks {
let canonical = tokio::fs::canonicalize(&joined).await.ok()?;
return canonical.starts_with(root).then_some(canonical);
}
joined.starts_with(root).then_some(joined)
}
pub async fn open_regular(path: &Path) -> Option<(tokio::fs::File, u64)> {
let file = tokio::fs::OpenOptions::new()
.read(true)
.custom_flags(OFlag::O_NOFOLLOW.bits() | OFlag::O_NONBLOCK.bits())
.open(path)
.await
.ok()?;
let metadata = file.metadata().await.ok()?;
if !metadata.is_file() {
return None;
}
Some((file, metadata.len()))
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
#[tokio::test]
async fn a_symlinked_leaf_pointing_outside_the_root_is_not_contained() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("www");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("ok.txt"), b"served").unwrap();
let secret = dir.path().join("secret.txt");
std::fs::write(&secret, b"not served").unwrap();
std::os::unix::fs::symlink(&secret, root.join("escape.txt")).unwrap();
let root = std::fs::canonicalize(&root).unwrap();
assert!(
contain(&root, &["ok.txt".to_string()], false)
.await
.is_some()
);
assert!(
contain(&root, &["escape.txt".to_string()], false)
.await
.is_none()
);
}
#[tokio::test]
async fn a_symlinked_intermediate_directory_is_not_contained() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("www")).unwrap();
std::fs::create_dir(dir.path().join("elsewhere")).unwrap();
std::fs::write(dir.path().join("elsewhere/x"), b"not served").unwrap();
std::fs::write(dir.path().join("www/ok.txt"), b"served").unwrap();
let root = std::fs::canonicalize(dir.path().join("www")).unwrap();
std::os::unix::fs::symlink(dir.path().join("elsewhere"), root.join("link")).unwrap();
assert!(
contain(&root, &["ok.txt".to_string()], false)
.await
.is_some()
);
assert!(
contain(&root, &["link".to_string(), "x".to_string()], false)
.await
.is_none()
);
}
#[tokio::test]
async fn open_regular_refuses_a_symlink_the_walk_never_saw() {
let dir = tempfile::tempdir().unwrap();
let secret = dir.path().join("secret.txt");
std::fs::write(&secret, b"not served").unwrap();
let ordinary = dir.path().join("ok.txt");
std::fs::write(&ordinary, b"served").unwrap();
let link = dir.path().join("escape.txt");
std::os::unix::fs::symlink(&secret, &link).unwrap();
assert!(open_regular(&ordinary).await.is_some(), "positive control");
assert!(open_regular(&link).await.is_none());
}
#[tokio::test]
async fn a_fifo_is_refused_without_blocking() {
let dir = tempfile::tempdir().unwrap();
let fifo = dir.path().join("pipe");
nix::unistd::mkfifo(
&fifo,
nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
)
.unwrap();
let opened = tokio::time::timeout(Duration::from_secs(5), open_regular(&fifo))
.await
.expect("opening a fifo must not block");
assert!(opened.is_none(), "a fifo is not a regular file");
}
#[tokio::test]
async fn a_sibling_whose_name_extends_the_roots_name_is_not_contained() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("www")).unwrap();
std::fs::create_dir(dir.path().join("www-secret")).unwrap();
std::fs::write(dir.path().join("www-secret/x"), b"x").unwrap();
let root = std::fs::canonicalize(dir.path().join("www")).unwrap();
assert!(
contain(
&root,
&["..".to_string(), "www-secret".to_string(), "x".to_string()],
false
)
.await
.is_none()
);
}
#[test]
fn symlink_refusal_notice_names_the_path_and_the_flag() {
let notice = symlink_refusal_notice(Path::new("/srv/www/current"));
assert!(notice.contains("/srv/www/current"), "{notice}");
assert!(notice.contains("--follow-symlinks"), "{notice}");
}
#[tokio::test]
async fn an_in_docroot_deploy_symlink_is_contained_only_with_follow_symlinks() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("www");
std::fs::create_dir_all(root.join("releases/2026-08-15")).unwrap();
std::fs::write(root.join("releases/2026-08-15/index.html"), b"home").unwrap();
std::os::unix::fs::symlink(root.join("releases/2026-08-15"), root.join("current")).unwrap();
let root = std::fs::canonicalize(&root).unwrap();
let target = vec!["current".to_string(), "index.html".to_string()];
assert!(
contain(&root, &target, false).await.is_none(),
"default mode still refuses it"
);
assert!(
contain(&root, &target, true).await.is_some(),
"the flag is what lets it through"
);
}
#[tokio::test]
async fn follow_symlinks_still_refuses_a_symlink_that_escapes_the_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("www");
std::fs::create_dir(&root).unwrap();
let secret = dir.path().join("secret.txt");
std::fs::write(&secret, b"not served").unwrap();
std::os::unix::fs::symlink(&secret, root.join("escape.txt")).unwrap();
let root = std::fs::canonicalize(&root).unwrap();
assert!(
contain(&root, &["escape.txt".to_string()], true)
.await
.is_none(),
"the flag permits a symlink component; it does not permit escaping the root"
);
}
}