use std::io;
use std::path::{Component, Path, PathBuf};
pub fn walk_no_follow<F>(root: &Path, mut visit: F) -> io::Result<()>
where
F: FnMut(&Path, &std::fs::Metadata) -> io::Result<()>,
{
let mut stack = vec![root.to_path_buf()];
while let Some(p) = stack.pop() {
let md = match std::fs::symlink_metadata(&p) {
Ok(md) => md,
Err(e) => {
tracing::debug!(path = %p.display(), error = %e, "lstat failed during safe walk");
continue;
}
};
if md.file_type().is_symlink() {
continue;
}
visit(&p, &md)?;
if md.is_dir() {
match std::fs::read_dir(&p) {
Ok(entries) => stack.extend(entries.flatten().map(|e| e.path())),
Err(e) => {
tracing::debug!(path = %p.display(), error = %e, "read_dir failed during safe walk");
}
}
}
}
Ok(())
}
#[cfg(unix)]
pub fn chgrp_setgid_tree(path: &Path, gid: nix::unistd::Gid, dir_mode: u32) {
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::create_dir_all(path) {
tracing::debug!(path = %path.display(), error = %e, "could not create dir for chgrp_setgid_tree");
return;
}
let _ = walk_no_follow(path, |p, md| {
if let Err(e) = nix::unistd::chown(p, None, Some(gid)) {
tracing::debug!(path = %p.display(), error = %e, "chgrp failed during tree normalize");
}
if md.is_dir() {
if let Err(e) = std::fs::set_permissions(p, std::fs::Permissions::from_mode(dir_mode)) {
tracing::debug!(path = %p.display(), error = %e, "chmod failed during tree normalize");
}
}
Ok(())
});
}
#[cfg(unix)]
pub fn chmod_tree_writable(root: &Path) {
use std::os::unix::fs::PermissionsExt;
let _ = walk_no_follow(root, |p, md| {
if md.is_dir() {
if let Err(e) = std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o700)) {
tracing::debug!(path = %p.display(), error = %e, "chmod-writable failed");
}
}
Ok(())
});
}
#[cfg(unix)]
pub fn chmod_tree_files(root: &Path, mode: u32) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
walk_no_follow(root, |p, md| {
if md.is_file() {
std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))
} else {
Ok(())
}
})
}
#[cfg(unix)]
pub fn chown_tree(root: &Path, uid: Option<u32>, gid: Option<u32>) -> io::Result<()> {
if uid.is_none() && gid.is_none() {
return Ok(());
}
let owner_uid = uid.map(nix::unistd::Uid::from_raw);
let owner_gid = gid.map(nix::unistd::Gid::from_raw);
walk_no_follow(root, |p, _md| {
nix::unistd::chown(p, owner_uid, owner_gid)
.map_err(|e| io::Error::other(format!("chown failed on {}: {e}", p.display())))
})
}
pub fn remove_path_no_follow(path: &Path) -> io::Result<()> {
let md = match std::fs::symlink_metadata(path) {
Ok(md) => md,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
let res = if md.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
};
match res {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
pub fn lremove_if_symlink(path: &Path) -> io::Result<()> {
match std::fs::symlink_metadata(path) {
Ok(md) if md.file_type().is_symlink() => std::fs::remove_file(path),
_ => Ok(()),
}
}
pub fn materialize_real_parent(rootfs: &Path, full_path: &Path) -> io::Result<()> {
let Some(parent) = full_path.parent() else {
return Ok(());
};
let Ok(rel) = parent.strip_prefix(rootfs) else {
return Ok(());
};
let mut cur = rootfs.to_path_buf();
for comp in rel.components() {
let Component::Normal(name) = comp else {
continue;
};
cur.push(name);
match std::fs::symlink_metadata(&cur) {
Ok(md) if md.file_type().is_symlink() => {
std::fs::remove_file(&cur)?;
std::fs::create_dir(&cur)?;
}
Ok(md) if md.is_dir() => {}
Ok(_) => {
std::fs::remove_file(&cur)?;
std::fs::create_dir(&cur)?;
}
Err(_) => {
std::fs::create_dir(&cur)?;
}
}
}
Ok(())
}
#[must_use]
pub fn relativize_abs_symlink(link_rel: &Path, abs_target: &Path) -> Option<PathBuf> {
if !abs_target.is_absolute() {
return None;
}
let parent = link_rel.parent()?;
let depth = parent
.components()
.filter(|c| matches!(c, Component::Normal(_)))
.count();
let target_rel: PathBuf = abs_target
.components()
.filter_map(|c| match c {
Component::Normal(n) => Some(n),
_ => None,
})
.collect();
let mut out = PathBuf::new();
for _ in 0..depth {
out.push("..");
}
if target_rel.as_os_str().is_empty() {
if out.as_os_str().is_empty() {
out.push(".");
}
} else {
out.push(&target_rel);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn rootfs_with_escaping_symlink(base: &Path) -> (PathBuf, PathBuf, PathBuf) {
let rootfs = base.join("rootfs");
let host_run = base.join("host_run");
std::fs::create_dir_all(rootfs.join("var")).unwrap();
std::fs::create_dir_all(&host_run).unwrap();
let sentinel = host_run.join("sshd");
std::fs::write(&sentinel, b"i am the host sshd runtime dir contents").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&host_run, rootfs.join("var").join("run")).unwrap();
(rootfs, host_run, sentinel)
}
#[cfg(unix)]
#[test]
fn chmod_tree_files_does_not_follow_symlink_out_of_root() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let (rootfs, host_run, sentinel) = rootfs_with_escaping_symlink(tmp.path());
let inside = rootfs.join("var").join("inside.txt");
std::fs::write(&inside, b"x").unwrap();
let before = std::fs::metadata(&sentinel).unwrap().permissions().mode() & 0o777;
chmod_tree_files(&rootfs, 0o600).unwrap();
let after = std::fs::metadata(&sentinel).unwrap().permissions().mode() & 0o777;
assert_eq!(before, after, "sentinel host file mode must be unchanged");
assert_eq!(
std::fs::metadata(&inside).unwrap().permissions().mode() & 0o777,
0o600
);
let _ = host_run;
}
#[cfg(unix)]
#[test]
fn walk_skips_symlinked_dir() {
let tmp = tempfile::tempdir().unwrap();
let (rootfs, _host_run, _sentinel) = rootfs_with_escaping_symlink(tmp.path());
let mut visited = Vec::new();
walk_no_follow(&rootfs, |p, _md| {
visited.push(p.to_path_buf());
Ok(())
})
.unwrap();
assert!(visited.iter().any(|p| p.ends_with("var")));
assert!(
!visited.iter().any(|p| p.ends_with("var/run")),
"symlink must not be visited"
);
assert!(
!visited.iter().any(|p| p.ends_with("sshd")),
"must not cross the symlink into the host dir"
);
}
#[test]
fn materialize_real_parent_replaces_escaping_symlink() {
let tmp = tempfile::tempdir().unwrap();
let (rootfs, _host_run, sentinel) = rootfs_with_escaping_symlink(tmp.path());
let target = rootfs.join("var").join("run").join("sshd");
materialize_real_parent(&rootfs, &target).unwrap();
let md = std::fs::symlink_metadata(rootfs.join("var").join("run")).unwrap();
assert!(md.is_dir(), "var/run must be a real dir now");
assert!(
!md.file_type().is_symlink(),
"var/run must not be a symlink"
);
std::fs::write(&target, b"contained").unwrap();
assert_eq!(
std::fs::read(&sentinel).unwrap(),
b"i am the host sshd runtime dir contents",
"host sentinel must be untouched"
);
assert!(target.exists());
}
#[test]
fn remove_path_no_follow_unlinks_symlink_not_target() {
let tmp = tempfile::tempdir().unwrap();
let (rootfs, _host_run, sentinel) = rootfs_with_escaping_symlink(tmp.path());
let link = rootfs.join("var").join("run");
remove_path_no_follow(&link).unwrap();
assert!(
std::fs::symlink_metadata(&link).is_err(),
"the symlink itself must be gone"
);
assert!(
sentinel.exists(),
"the symlink target (host file) must NOT be deleted"
);
}
#[test]
fn lremove_if_symlink_only_removes_links() {
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().join("real.txt");
std::fs::write(&real, b"keep").unwrap();
lremove_if_symlink(&real).unwrap();
assert!(real.exists(), "a real file must not be removed");
#[cfg(unix)]
{
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
lremove_if_symlink(&link).unwrap();
assert!(std::fs::symlink_metadata(&link).is_err(), "link removed");
assert!(real.exists(), "link target preserved");
}
}
#[test]
fn relativize_abs_symlink_confines_targets() {
assert_eq!(
relativize_abs_symlink(Path::new("var/run"), Path::new("/run")),
Some(PathBuf::from("../run"))
);
assert_eq!(
relativize_abs_symlink(Path::new("var/lock"), Path::new("/run/lock")),
Some(PathBuf::from("../run/lock"))
);
assert_eq!(
relativize_abs_symlink(Path::new("bin"), Path::new("/usr/bin")),
Some(PathBuf::from("usr/bin"))
);
assert_eq!(
relativize_abs_symlink(Path::new("a/b/c"), Path::new("/x")),
Some(PathBuf::from("../../x"))
);
assert_eq!(
relativize_abs_symlink(Path::new("here"), Path::new("/")),
Some(PathBuf::from("."))
);
assert_eq!(
relativize_abs_symlink(Path::new("a/here"), Path::new("/")),
Some(PathBuf::from(".."))
);
assert_eq!(
relativize_abs_symlink(Path::new("var/run"), Path::new("../run")),
None
);
}
}