mod sanitize;
#[doc(inline)]
pub use sanitize::{
UnsafePathError, is_reserved_device_name, sanitize_path, sanitize_relative_path,
};
use std::{
fs, io,
path::{Path, PathBuf},
};
#[cfg(loom)]
use std::fs::File;
#[cfg(not(loom))]
use tokio::fs::File;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum SymlinkPolicy {
#[default]
RestrictToRoot,
Allow,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum CreatedFilePermissions {
#[default]
Default,
OwnerReadWrite,
}
pub async fn safe_open(path: impl AsRef<Path>) -> io::Result<File> {
OpenOptions::new().read(true).open(path).await
}
pub fn safe_open_sync(path: impl AsRef<Path>) -> io::Result<fs::File> {
OpenOptionsSync::new().read(true).open(path)
}
pub async fn safe_open_in(root: impl AsRef<Path>, path: impl AsRef<Path>) -> io::Result<File> {
OpenOptions::new()
.read(true)
.jail(root.as_ref())
.open(path)
.await
}
pub fn safe_open_in_sync(root: impl AsRef<Path>, path: impl AsRef<Path>) -> io::Result<fs::File> {
OpenOptionsSync::new()
.read(true)
.jail(root.as_ref())
.open(path)
}
pub async fn safe_path_in(root: impl AsRef<Path>, path: impl AsRef<Path>) -> io::Result<PathBuf> {
let root = root.as_ref();
let path = root.join(sanitize_relative_path(path)?);
let canonical_root = canonicalize_root(root).await?;
ensure_within_canonical_root(&canonical_root, &path).await?;
Ok(path)
}
pub fn safe_path_in_sync(root: impl AsRef<Path>, path: impl AsRef<Path>) -> io::Result<PathBuf> {
let root = root.as_ref();
let path = root.join(sanitize_relative_path(path)?);
let canonical_root = canonicalize_root_sync(root)?;
ensure_within_canonical_root_sync(&canonical_root, &path)?;
Ok(path)
}
pub async fn safe_create_dir_all_in(
root: impl AsRef<Path>,
path: impl AsRef<Path>,
) -> io::Result<()> {
let root = root.as_ref();
let path = root.join(sanitize_relative_path(path)?);
let canonical_root = canonicalize_root(root).await?;
ensure_within_canonical_root(&canonical_root, &path).await?;
#[cfg(loom)]
fs::create_dir_all(&path)?;
#[cfg(not(loom))]
tokio::fs::create_dir_all(&path).await?;
ensure_within_canonical_root(&canonical_root, &path).await
}
pub fn safe_create_dir_all_in_sync(
root: impl AsRef<Path>,
path: impl AsRef<Path>,
) -> io::Result<()> {
let root = root.as_ref();
let path = root.join(sanitize_relative_path(path)?);
let canonical_root = canonicalize_root_sync(root)?;
ensure_within_canonical_root_sync(&canonical_root, &path)?;
fs::create_dir_all(&path)?;
ensure_within_canonical_root_sync(&canonical_root, &path)
}
pub async fn safe_write_in(
root: impl AsRef<Path>,
path: impl AsRef<Path>,
contents: impl AsRef<[u8]>,
) -> io::Result<()> {
let root = root.as_ref();
let path = root.join(sanitize_relative_path(path)?);
let contents = contents.as_ref().to_owned();
let canonical_root = canonicalize_root(root).await?;
if let Some(parent) = path.parent() {
ensure_within_canonical_root(&canonical_root, parent).await?;
#[cfg(loom)]
fs::create_dir_all(parent)?;
#[cfg(not(loom))]
tokio::fs::create_dir_all(parent).await?;
ensure_within_canonical_root(&canonical_root, parent).await?;
}
ensure_within_canonical_root(&canonical_root, &path).await?;
#[cfg(loom)]
fs::write(&path, contents)?;
#[cfg(not(loom))]
tokio::fs::write(&path, contents).await?;
ensure_within_canonical_root(&canonical_root, &path).await
}
pub fn safe_write_in_sync(
root: impl AsRef<Path>,
path: impl AsRef<Path>,
contents: impl AsRef<[u8]>,
) -> io::Result<()> {
let root = root.as_ref();
let path = root.join(sanitize_relative_path(path)?);
let canonical_root = canonicalize_root_sync(root)?;
if let Some(parent) = path.parent() {
ensure_within_canonical_root_sync(&canonical_root, parent)?;
fs::create_dir_all(parent)?;
ensure_within_canonical_root_sync(&canonical_root, parent)?;
}
ensure_within_canonical_root_sync(&canonical_root, &path)?;
fs::write(&path, contents)?;
ensure_within_canonical_root_sync(&canonical_root, &path)
}
#[derive(Debug, Clone)]
pub struct OpenOptions {
inner: OpenOptionsInner,
}
impl OpenOptions {
#[must_use]
pub fn new() -> Self {
Self {
inner: OpenOptionsInner::new(),
}
}
pub fn read(&mut self, read: bool) -> &mut Self {
self.inner.read = read;
self
}
pub fn write(&mut self, write: bool) -> &mut Self {
self.inner.write = write;
self
}
pub fn append(&mut self, append: bool) -> &mut Self {
self.inner.append = append;
self
}
pub fn truncate(&mut self, truncate: bool) -> &mut Self {
self.inner.truncate = truncate;
self
}
pub fn create(&mut self, create: bool) -> &mut Self {
self.inner.create = create;
self
}
pub fn create_new(&mut self, create_new: bool) -> &mut Self {
self.inner.create_new = create_new;
self
}
pub fn jail(&mut self, root: impl Into<PathBuf>) -> &mut Self {
self.inner.jail = Some(root.into());
self
}
pub fn symlinks(&mut self, policy: SymlinkPolicy) -> &mut Self {
self.inner.symlinks = policy;
self
}
pub fn created_file_permissions(&mut self, permissions: CreatedFilePermissions) -> &mut Self {
self.inner.created_file_permissions = permissions;
self
}
pub async fn open(&self, path: impl AsRef<Path>) -> io::Result<File> {
let path = self.inner.resolve(path.as_ref()).await?;
#[cfg(loom)]
let file = self.inner.std_options().open(&path)?;
#[cfg(not(loom))]
let file = self.inner.tokio_options().open(&path).await?;
if let Some(root) = &self.inner.jail
&& self.inner.symlinks == SymlinkPolicy::RestrictToRoot
{
ensure_within_root(root, &path).await?;
}
Ok(file)
}
}
impl Default for OpenOptions {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct OpenOptionsSync {
inner: OpenOptionsInner,
}
impl OpenOptionsSync {
#[must_use]
pub fn new() -> Self {
Self {
inner: OpenOptionsInner::new(),
}
}
pub fn read(&mut self, read: bool) -> &mut Self {
self.inner.read = read;
self
}
pub fn write(&mut self, write: bool) -> &mut Self {
self.inner.write = write;
self
}
pub fn append(&mut self, append: bool) -> &mut Self {
self.inner.append = append;
self
}
pub fn truncate(&mut self, truncate: bool) -> &mut Self {
self.inner.truncate = truncate;
self
}
pub fn create(&mut self, create: bool) -> &mut Self {
self.inner.create = create;
self
}
pub fn create_new(&mut self, create_new: bool) -> &mut Self {
self.inner.create_new = create_new;
self
}
pub fn jail(&mut self, root: impl Into<PathBuf>) -> &mut Self {
self.inner.jail = Some(root.into());
self
}
pub fn symlinks(&mut self, policy: SymlinkPolicy) -> &mut Self {
self.inner.symlinks = policy;
self
}
pub fn created_file_permissions(&mut self, permissions: CreatedFilePermissions) -> &mut Self {
self.inner.created_file_permissions = permissions;
self
}
pub fn open(&self, path: impl AsRef<Path>) -> io::Result<fs::File> {
let path = self.inner.resolve_sync(path.as_ref())?;
let file = self.inner.std_options().open(&path)?;
if let Some(root) = &self.inner.jail
&& self.inner.symlinks == SymlinkPolicy::RestrictToRoot
{
ensure_within_root_sync(root, &path)?;
}
Ok(file)
}
}
impl Default for OpenOptionsSync {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
struct OpenOptionsInner {
read: bool,
write: bool,
append: bool,
truncate: bool,
create: bool,
create_new: bool,
jail: Option<PathBuf>,
symlinks: SymlinkPolicy,
created_file_permissions: CreatedFilePermissions,
}
impl OpenOptionsInner {
fn new() -> Self {
Self {
read: false,
write: false,
append: false,
truncate: false,
create: false,
create_new: false,
jail: None,
symlinks: SymlinkPolicy::RestrictToRoot,
created_file_permissions: CreatedFilePermissions::Default,
}
}
#[cfg(not(loom))]
fn tokio_options(&self) -> tokio::fs::OpenOptions {
let mut opts = tokio::fs::OpenOptions::new();
opts.read(self.read)
.write(self.write)
.append(self.append)
.truncate(self.truncate)
.create(self.create)
.create_new(self.create_new);
#[cfg(unix)]
if self.created_file_permissions == CreatedFilePermissions::OwnerReadWrite {
opts.mode(0o600);
}
opts
}
fn std_options(&self) -> fs::OpenOptions {
let mut opts = fs::OpenOptions::new();
opts.read(self.read)
.write(self.write)
.append(self.append)
.truncate(self.truncate)
.create(self.create)
.create_new(self.create_new);
#[cfg(unix)]
if self.created_file_permissions == CreatedFilePermissions::OwnerReadWrite {
use std::os::unix::fs::OpenOptionsExt as _;
opts.mode(0o600);
}
opts
}
async fn resolve(&self, path: &Path) -> io::Result<PathBuf> {
match &self.jail {
None => Ok(sanitize_path(path)?),
Some(root) => {
let full = root.join(sanitize_relative_path(path)?);
if self.symlinks == SymlinkPolicy::RestrictToRoot {
ensure_within_root(root, &full).await?;
}
Ok(full)
}
}
}
fn resolve_sync(&self, path: &Path) -> io::Result<PathBuf> {
match &self.jail {
None => Ok(sanitize_path(path)?),
Some(root) => {
let full = root.join(sanitize_relative_path(path)?);
if self.symlinks == SymlinkPolicy::RestrictToRoot {
ensure_within_root_sync(root, &full)?;
}
Ok(full)
}
}
}
}
async fn ensure_within_root(root: &Path, target: &Path) -> io::Result<()> {
let canonical_root = canonicalize_root(root).await?;
ensure_within_canonical_root(&canonical_root, target).await
}
#[cfg(loom)]
async fn canonicalize_root(root: &Path) -> io::Result<PathBuf> {
canonicalize_root_sync(root)
}
#[cfg(not(loom))]
async fn canonicalize_root(root: &Path) -> io::Result<PathBuf> {
tokio::fs::canonicalize(root).await
}
#[cfg(loom)]
async fn ensure_within_canonical_root(canonical_root: &Path, target: &Path) -> io::Result<()> {
ensure_within_canonical_root_sync(canonical_root, target)
}
#[cfg(not(loom))]
async fn ensure_within_canonical_root(canonical_root: &Path, target: &Path) -> io::Result<()> {
if let Some(existing) = nearest_existing_ancestor(target).await {
let canonical_target = canonicalize_existing_path(&existing).await?;
if !canonical_target.starts_with(canonical_root) {
return Err(UnsafePathError::EscapesRoot.into());
}
}
Ok(())
}
fn ensure_within_root_sync(root: &Path, target: &Path) -> io::Result<()> {
let canonical_root = canonicalize_root_sync(root)?;
ensure_within_canonical_root_sync(&canonical_root, target)
}
fn canonicalize_root_sync(root: &Path) -> io::Result<PathBuf> {
fs::canonicalize(root)
}
fn ensure_within_canonical_root_sync(canonical_root: &Path, target: &Path) -> io::Result<()> {
if let Some(existing) = nearest_existing_ancestor_sync(target) {
let canonical_target = canonicalize_existing_path_sync(&existing)?;
if !canonical_target.starts_with(canonical_root) {
return Err(UnsafePathError::EscapesRoot.into());
}
}
Ok(())
}
#[cfg(not(loom))]
async fn canonicalize_existing_path(path: &Path) -> io::Result<PathBuf> {
match tokio::fs::canonicalize(path).await {
Ok(path) => Ok(path),
Err(err) if err.kind() == io::ErrorKind::NotFound => {
if tokio::fs::symlink_metadata(path).await.is_ok() {
return Err(UnsafePathError::EscapesRoot.into());
}
Err(err)
}
Err(err) => Err(err),
}
}
fn canonicalize_existing_path_sync(path: &Path) -> io::Result<PathBuf> {
match fs::canonicalize(path) {
Ok(path) => Ok(path),
Err(err) if err.kind() == io::ErrorKind::NotFound => {
if fs::symlink_metadata(path).is_ok() {
return Err(UnsafePathError::EscapesRoot.into());
}
Err(err)
}
Err(err) => Err(err),
}
}
#[cfg(not(loom))]
async fn nearest_existing_ancestor(path: &Path) -> Option<PathBuf> {
let mut current = Some(path);
while let Some(candidate) = current {
if tokio::fs::symlink_metadata(candidate).await.is_ok() {
return Some(candidate.to_path_buf());
}
current = candidate.parent();
}
None
}
fn nearest_existing_ancestor_sync(path: &Path) -> Option<PathBuf> {
let mut current = Some(path);
while let Some(candidate) = current {
if fs::symlink_metadata(candidate).is_ok() {
return Some(candidate.to_path_buf());
}
current = candidate.parent();
}
None
}
#[cfg(all(test, not(loom)))]
mod tests {
use super::*;
use tokio::io::AsyncReadExt;
async fn read_to_string(mut file: File) -> io::Result<String> {
let mut buf = String::new();
file.read_to_string(&mut buf).await?;
Ok(buf)
}
fn err_kind<T>(result: io::Result<T>) -> Option<io::ErrorKind> {
result.err().map(|err| err.kind())
}
#[tokio::test]
async fn safe_open_reads_a_regular_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hello.txt");
tokio::fs::write(&path, b"hello world").await.unwrap();
let file = safe_open(&path).await.unwrap();
assert_eq!(read_to_string(file).await.unwrap(), "hello world");
}
#[test]
fn safe_open_sync_reads_a_regular_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hello.txt");
fs::write(&path, b"hello world").unwrap();
let text = {
let mut s = String::new();
use std::io::Read as _;
safe_open_sync(&path)
.unwrap()
.read_to_string(&mut s)
.unwrap();
s
};
assert_eq!(text, "hello world");
}
#[tokio::test]
async fn safe_open_rejects_parent_dir_traversal() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sub/../secret.txt");
assert_eq!(
err_kind(safe_open(&path).await),
Some(io::ErrorKind::InvalidInput),
);
}
#[tokio::test]
async fn safe_open_missing_file_is_not_found() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.txt");
assert_eq!(
err_kind(safe_open(&path).await),
Some(io::ErrorKind::NotFound),
);
}
#[tokio::test]
async fn safe_open_in_serves_files_within_root() {
let root = tempfile::tempdir().unwrap();
tokio::fs::create_dir(root.path().join("assets"))
.await
.unwrap();
tokio::fs::write(root.path().join("assets/app.js"), b"console.log(1)")
.await
.unwrap();
let file = safe_open_in(root.path(), "assets/app.js").await.unwrap();
assert_eq!(read_to_string(file).await.unwrap(), "console.log(1)");
let file = safe_open_in(root.path(), "/assets/app.js").await;
assert_eq!(err_kind(file), Some(io::ErrorKind::InvalidInput));
}
#[tokio::test]
async fn safe_open_in_rejects_traversal_out_of_root() {
let parent = tempfile::tempdir().unwrap();
tokio::fs::write(parent.path().join("secret.txt"), b"top secret")
.await
.unwrap();
let root = parent.path().join("public");
tokio::fs::create_dir(&root).await.unwrap();
tokio::fs::write(root.join("index.html"), b"<h1>hi</h1>")
.await
.unwrap();
safe_open_in(&root, "index.html").await.unwrap();
for payload in ["../secret.txt", "../../etc/passwd", "..\\secret.txt"] {
let result = safe_open_in(&root, payload).await;
assert!(
result.is_err(),
"expected `{payload}` to be rejected, got Ok",
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn safe_open_in_rejects_symlink_escaping_root() {
let parent = tempfile::tempdir().unwrap();
tokio::fs::write(parent.path().join("secret.txt"), b"top secret")
.await
.unwrap();
let root = parent.path().join("public");
tokio::fs::create_dir(&root).await.unwrap();
std::os::unix::fs::symlink(parent.path().join("secret.txt"), root.join("escape")).unwrap();
let result = safe_open_in(&root, "escape").await;
assert_eq!(err_kind(result), Some(io::ErrorKind::InvalidInput));
}
#[cfg(unix)]
#[tokio::test]
async fn safe_open_in_allows_symlink_within_root() {
let root = tempfile::tempdir().unwrap();
tokio::fs::write(root.path().join("real.txt"), b"data")
.await
.unwrap();
std::os::unix::fs::symlink(root.path().join("real.txt"), root.path().join("link")).unwrap();
let file = safe_open_in(root.path(), "link").await.unwrap();
assert_eq!(read_to_string(file).await.unwrap(), "data");
}
#[cfg(unix)]
#[tokio::test]
async fn jail_create_rejects_dangling_symlink_escape() {
let parent = tempfile::tempdir().unwrap();
let root = parent.path().join("public");
let outside = parent.path().join("outside");
tokio::fs::create_dir(&root).await.unwrap();
tokio::fs::create_dir(&outside).await.unwrap();
let outside_target = outside.join("created.txt");
std::os::unix::fs::symlink(&outside_target, root.join("upload.txt")).unwrap();
let result = OpenOptions::new()
.write(true)
.create(true)
.jail(&root)
.open("upload.txt")
.await;
assert_eq!(err_kind(result), Some(io::ErrorKind::InvalidInput));
assert!(!outside_target.exists());
}
#[cfg(unix)]
#[test]
fn jail_create_sync_rejects_dangling_symlink_escape() {
let parent = tempfile::tempdir().unwrap();
let root = parent.path().join("public");
let outside = parent.path().join("outside");
fs::create_dir(&root).unwrap();
fs::create_dir(&outside).unwrap();
let outside_target = outside.join("created.txt");
std::os::unix::fs::symlink(&outside_target, root.join("upload.txt")).unwrap();
let result = OpenOptionsSync::new()
.write(true)
.create(true)
.jail(&root)
.open("upload.txt");
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
assert!(!outside_target.exists());
}
#[cfg(unix)]
#[tokio::test]
async fn jail_allow_symlinks_follows_escaping_link_but_keeps_lexical_guard() {
let parent = tempfile::tempdir().unwrap();
tokio::fs::write(parent.path().join("secret.txt"), b"top secret")
.await
.unwrap();
let root = parent.path().join("public");
tokio::fs::create_dir(&root).await.unwrap();
std::os::unix::fs::symlink(parent.path().join("secret.txt"), root.join("escape")).unwrap();
assert_eq!(
err_kind(safe_open_in(&root, "escape").await),
Some(io::ErrorKind::InvalidInput),
);
let file = OpenOptions::new()
.read(true)
.jail(&root)
.symlinks(SymlinkPolicy::Allow)
.open("escape")
.await
.unwrap();
assert_eq!(read_to_string(file).await.unwrap(), "top secret");
let traversal = OpenOptions::new()
.read(true)
.jail(&root)
.symlinks(SymlinkPolicy::Allow)
.open("../secret.txt")
.await;
assert_eq!(err_kind(traversal), Some(io::ErrorKind::InvalidInput));
}
#[tokio::test]
async fn open_options_can_create_within_jail() {
let root = tempfile::tempdir().unwrap();
OpenOptions::new()
.write(true)
.create(true)
.jail(root.path())
.open("nested/created.txt")
.await
.expect_err("parent dir does not exist yet");
tokio::fs::create_dir(root.path().join("nested"))
.await
.unwrap();
let _file = OpenOptions::new()
.write(true)
.create(true)
.jail(root.path())
.open("nested/created.txt")
.await
.unwrap();
assert!(root.path().join("nested/created.txt").exists());
}
#[cfg(unix)]
#[tokio::test]
async fn open_options_private_created_file_has_no_group_or_other_bits() {
use std::os::unix::fs::PermissionsExt as _;
let root = tempfile::tempdir().unwrap();
let path = root.path().join("secret.txt");
let _file = OpenOptions::new()
.write(true)
.create_new(true)
.created_file_permissions(CreatedFilePermissions::OwnerReadWrite)
.open(&path)
.await
.unwrap();
let mode = tokio::fs::metadata(&path)
.await
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o077, 0);
}
#[cfg(unix)]
#[test]
fn open_options_sync_private_created_file_has_no_group_or_other_bits() {
use std::os::unix::fs::PermissionsExt as _;
let root = tempfile::tempdir().unwrap();
let path = root.path().join("secret.txt");
let _file = OpenOptionsSync::new()
.write(true)
.create_new(true)
.created_file_permissions(CreatedFilePermissions::OwnerReadWrite)
.open(&path)
.unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o077, 0);
}
#[tokio::test]
async fn safe_write_in_creates_parent_dirs() {
let root = tempfile::tempdir().unwrap();
safe_write_in(root.path(), "nested/file.txt", b"hello")
.await
.unwrap();
assert_eq!(
tokio::fs::read_to_string(root.path().join("nested/file.txt"))
.await
.unwrap(),
"hello",
);
}
#[test]
fn safe_write_in_sync_creates_parent_dirs() {
let root = tempfile::tempdir().unwrap();
safe_write_in_sync(root.path(), "nested/file.txt", b"hello").unwrap();
assert_eq!(
fs::read_to_string(root.path().join("nested/file.txt")).unwrap(),
"hello",
);
}
#[test]
fn safe_write_in_sync_rejects_traversal() {
let root = tempfile::tempdir().unwrap();
let err = safe_write_in_sync(root.path(), "../escape.txt", b"nope").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(!root.path().parent().unwrap().join("escape.txt").exists());
}
#[tokio::test]
async fn safe_create_dir_all_in_rejects_absolute_paths() {
let root = tempfile::tempdir().unwrap();
let err = safe_create_dir_all_in(root.path(), "/tmp/escape")
.await
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn safe_create_dir_all_in_sync_rejects_absolute_paths() {
let root = tempfile::tempdir().unwrap();
let err = safe_create_dir_all_in_sync(root.path(), "/tmp/escape").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn safe_path_in_sync_resolves_plain_relative_path() {
let root = tempfile::tempdir().unwrap();
assert_eq!(
safe_path_in_sync(root.path(), "nested/file.txt").unwrap(),
root.path().join("nested/file.txt"),
);
}
#[cfg(unix)]
#[test]
fn safe_write_in_sync_rejects_symlink_escape() {
let parent = tempfile::tempdir().unwrap();
let root = parent.path().join("root");
let outside = parent.path().join("outside");
fs::create_dir(&root).unwrap();
fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(outside.join("created.txt"), root.join("link")).unwrap();
let err = safe_write_in_sync(&root, "link", b"nope").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(!outside.join("created.txt").exists());
}
#[cfg(unix)]
#[test]
fn safe_create_dir_all_in_sync_rejects_symlink_escape() {
let parent = tempfile::tempdir().unwrap();
let root = parent.path().join("root");
let outside = parent.path().join("outside");
fs::create_dir(&root).unwrap();
fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
let err = safe_create_dir_all_in_sync(&root, "link/nested").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(!outside.join("nested").exists());
}
}