use crate::resolve::{resolve_beneath, Follow};
use crate::traits::refuse_absolute_target;
use crate::traits::{DirEntry, DirEntryKind, EffectiveAccess, Filesystem, PathAccess, ReadRange};
use async_trait::async_trait;
use std::io;
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Clone)]
pub struct LocalFs {
root: PathBuf,
read_only: bool,
}
impl LocalFs {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
read_only: false,
}
}
pub fn read_only(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
read_only: true,
}
}
pub fn set_read_only(&mut self, read_only: bool) {
self.read_only = read_only;
}
pub fn root(&self) -> &Path {
&self.root
}
fn resolve(&self, path: &Path, follow: Follow) -> io::Result<PathBuf> {
resolve_beneath(&self.root, path, follow)
}
fn resolve_for_change(&self, path: &Path) -> io::Result<PathBuf> {
let resolved = self.resolve(path, Follow::LinkItself)?;
if resolved == self.root.canonicalize()? {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"cannot remove or rename the mount root",
));
}
Ok(resolved)
}
fn check_writable(&self) -> io::Result<()> {
if self.read_only {
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"filesystem is read-only",
))
} else {
Ok(())
}
}
#[cfg(unix)]
fn extract_permissions(meta: &std::fs::Metadata) -> Option<u32> {
use std::os::unix::fs::PermissionsExt;
Some(meta.permissions().mode())
}
#[cfg(not(unix))]
fn extract_permissions(meta: &std::fs::Metadata) -> Option<u32> {
Some(Self::synthesized_mode(meta.is_dir(), meta.permissions().readonly()))
}
#[cfg(unix)]
fn effective_access(full: &Path) -> EffectiveAccess {
use rustix::fs::{Access, AtFlags, accessat};
use rustix::fs::CWD;
let ask = |mode: Access| {
accessat(CWD, full, mode, AtFlags::EACCESS).is_ok()
};
EffectiveAccess {
read: ask(Access::READ_OK),
write: ask(Access::WRITE_OK),
execute: ask(Access::EXEC_OK),
}
}
#[cfg_attr(unix, allow(dead_code))]
pub(crate) fn synthesized_mode(is_dir: bool, readonly: bool) -> u32 {
match (is_dir, readonly) {
(true, false) => 0o777,
(true, true) => 0o555,
(false, false) => 0o666,
(false, true) => 0o444,
}
}
async fn dir_entry_no_follow(path: &Path) -> io::Result<Option<DirEntry>> {
let metadata = match fs::symlink_metadata(path).await {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let file_type = metadata.file_type();
let (kind, symlink_target) = if file_type.is_symlink() {
(DirEntryKind::Symlink, fs::read_link(path).await.ok())
} else if file_type.is_dir() {
(DirEntryKind::Directory, None)
} else {
(DirEntryKind::File, None)
};
Ok(Some(DirEntry {
name: path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
kind,
size: metadata.len(),
modified: metadata.modified().ok(),
permissions: Self::extract_permissions(&metadata),
symlink_target,
}))
}
}
#[async_trait]
impl Filesystem for LocalFs {
async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
let full_path = self.resolve(path, Follow::Final)?;
fs::read(&full_path).await
}
async fn read_range(&self, path: &Path, range: Option<ReadRange>) -> io::Result<Vec<u8>> {
let Some(r) = range else {
return self.read(path).await;
};
if r.offset.is_none() && r.limit.is_none() {
let content = self.read(path).await?;
return Ok(r.apply(&content));
}
let full_path = self.resolve(path, Follow::Final)?;
let offset = r.offset.unwrap_or(0);
let limit = r.limit;
tokio::task::spawn_blocking(move || -> io::Result<Vec<u8>> {
use std::io::{Read, Seek, SeekFrom};
let mut file = std::fs::File::open(&full_path)?;
if offset > 0 {
file.seek(SeekFrom::Start(offset))?;
}
let mut buf = Vec::new();
match limit {
Some(limit) => {
file.take(limit).read_to_end(&mut buf)?;
}
None => {
file.read_to_end(&mut buf)?;
}
}
Ok(buf)
})
.await
.map_err(io::Error::other)?
}
async fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
self.check_writable()?;
let full_path = self.resolve(path, Follow::Final)?;
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(&full_path, data).await
}
async fn append(&self, path: &Path, data: &[u8]) -> io::Result<()> {
self.check_writable()?;
let full_path = self.resolve(path, Follow::Final)?;
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).await?;
}
use tokio::io::AsyncWriteExt;
let mut file = fs::OpenOptions::new()
.append(true)
.create(true)
.open(&full_path)
.await?;
file.write_all(data).await?;
file.flush().await
}
async fn set_mtime(&self, path: &Path, mtime: std::time::SystemTime) -> io::Result<()> {
self.check_writable()?;
let full_path = self.resolve(path, Follow::Final)?;
tokio::task::spawn_blocking(move || {
let file = match std::fs::OpenOptions::new().read(true).open(&full_path) {
Ok(f) => f,
Err(_) => std::fs::OpenOptions::new().write(true).open(&full_path)?,
};
file.set_modified(mtime)
})
.await
.map_err(io::Error::other)?
}
async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
let full_path = self.resolve(path, Follow::Final)?;
let mut entries = Vec::new();
let mut dir = fs::read_dir(&full_path).await?;
while let Some(entry) = dir.next_entry().await? {
if let Some(de) = Self::dir_entry_no_follow(&entry.path()).await? {
entries.push(de);
}
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
async fn stat(&self, path: &Path) -> io::Result<DirEntry> {
let full_path = self.resolve(path, Follow::Final)?;
let meta = fs::metadata(&full_path).await?;
let kind = if meta.is_dir() {
DirEntryKind::Directory
} else {
DirEntryKind::File
};
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "/".to_string());
Ok(DirEntry {
name,
kind,
size: meta.len(),
modified: meta.modified().ok(),
permissions: Self::extract_permissions(&meta),
symlink_target: None, })
}
async fn lstat(&self, path: &Path) -> io::Result<DirEntry> {
let full_path = self.resolve(path, Follow::LinkItself)?;
let meta = fs::symlink_metadata(&full_path).await?;
let file_type = meta.file_type();
let kind = if file_type.is_symlink() {
DirEntryKind::Symlink
} else if meta.is_dir() {
DirEntryKind::Directory
} else {
DirEntryKind::File
};
let symlink_target = if file_type.is_symlink() {
fs::read_link(&full_path).await.ok()
} else {
None
};
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "/".to_string());
Ok(DirEntry {
name,
kind,
size: meta.len(),
modified: meta.modified().ok(),
permissions: Self::extract_permissions(&meta),
symlink_target,
})
}
async fn read_link(&self, path: &Path) -> io::Result<PathBuf> {
let full_path = self.resolve(path, Follow::LinkItself)?;
fs::read_link(&full_path).await
}
async fn symlink(&self, target: &Path, link: &Path) -> io::Result<()> {
self.check_writable()?;
refuse_absolute_target(target)?;
let link_path = self.resolve(link, Follow::LinkItself)?;
if let Some(parent) = link_path.parent() {
fs::create_dir_all(parent).await?;
}
#[cfg(unix)]
{
tokio::fs::symlink(target, &link_path).await
}
#[cfg(windows)]
{
tokio::fs::symlink_file(target, &link_path).await
}
}
async fn mkdir(&self, path: &Path) -> io::Result<()> {
self.check_writable()?;
let full_path = self.resolve(path, Follow::Final)?;
fs::create_dir_all(&full_path).await
}
async fn remove(&self, path: &Path) -> io::Result<()> {
self.check_writable()?;
let full_path = self.resolve_for_change(path)?;
let meta = fs::symlink_metadata(&full_path).await?;
if meta.is_dir() {
fs::remove_dir(&full_path).await
} else {
fs::remove_file(&full_path).await
}
}
async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
self.check_writable()?;
let from_path = self.resolve_for_change(from)?;
let to_path = self.resolve_for_change(to)?;
if let Some(parent) = to_path.parent() {
fs::create_dir_all(parent).await?;
}
fs::rename(&from_path, &to_path).await
}
#[cfg(unix)]
async fn path_access(&self, path: &Path) -> io::Result<PathAccess> {
let full = self.resolve(path, Follow::Final)?;
let _ = fs::metadata(&full).await?;
let access =
tokio::task::spawn_blocking(move || Self::effective_access(&full))
.await
.map_err(io::Error::other)?;
Ok(PathAccess::from_effective_access(access, self.read_only))
}
fn read_only(&self) -> bool {
self.read_only
}
fn real_path(&self, path: &Path) -> Option<PathBuf> {
self.resolve(path, Follow::Final).ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir() -> PathBuf {
let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
env::temp_dir().join(format!("kaish-test-{}-{}", std::process::id(), id))
}
async fn setup() -> (LocalFs, PathBuf) {
let dir = temp_dir();
let _ = fs::remove_dir_all(&dir).await;
fs::create_dir_all(&dir).await.unwrap();
(LocalFs::new(&dir), dir)
}
async fn cleanup(dir: &Path) {
let _ = fs::remove_dir_all(dir).await;
}
#[tokio::test]
async fn test_write_and_read() {
let (fs, dir) = setup().await;
fs.write(Path::new("test.txt"), b"hello").await.unwrap();
let data = fs.read(Path::new("test.txt")).await.unwrap();
assert_eq!(data, b"hello");
cleanup(&dir).await;
}
#[tokio::test]
async fn test_append_creates_a_missing_file() {
let (fs, dir) = setup().await;
fs.append(Path::new("new.txt"), b"hello").await.unwrap();
let data = fs.read(Path::new("new.txt")).await.unwrap();
assert_eq!(data, b"hello");
cleanup(&dir).await;
}
#[tokio::test]
async fn test_append_extends_an_existing_file() {
let (fs, dir) = setup().await;
fs.write(Path::new("existing.txt"), b"original\n").await.unwrap();
fs.append(Path::new("existing.txt"), b"appended\n").await.unwrap();
let data = fs.read(Path::new("existing.txt")).await.unwrap();
assert_eq!(data, b"original\nappended\n");
cleanup(&dir).await;
}
#[cfg(unix)]
#[tokio::test]
async fn test_append_succeeds_on_a_write_only_file() {
use std::os::unix::fs::PermissionsExt;
let (fs, dir) = setup().await;
let path = dir.join("write_only.txt");
std::fs::write(&path, b"original\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o200)).unwrap();
let read_still_works = std::fs::read(&path).is_ok();
let result = fs.append(Path::new("write_only.txt"), b"appended\n").await;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
if read_still_works {
eprintln!("skipping: running as root, 0200 did not deny read");
cleanup(&dir).await;
return;
}
result.unwrap();
let data = std::fs::read(&path).unwrap();
assert_eq!(data, b"original\nappended\n");
cleanup(&dir).await;
}
#[tokio::test]
async fn test_read_range_bytes_positional() {
let (fs, dir) = setup().await;
fs.write(Path::new("data.bin"), b"0123456789abcdef")
.await
.unwrap();
let mid = fs
.read_range(Path::new("data.bin"), Some(ReadRange::bytes(4, 5)))
.await
.unwrap();
assert_eq!(mid, b"45678");
let tail = fs
.read_range(Path::new("data.bin"), Some(ReadRange::bytes(10, 999)))
.await
.unwrap();
assert_eq!(tail, b"abcdef");
let past = fs
.read_range(Path::new("data.bin"), Some(ReadRange::bytes(100, 8)))
.await
.unwrap();
assert!(past.is_empty());
let whole = fs
.read_range(Path::new("data.bin"), None)
.await
.unwrap();
assert_eq!(whole, b"0123456789abcdef");
cleanup(&dir).await;
}
#[tokio::test]
async fn test_read_range_reconstructs_file_in_chunks() {
let (fs, dir) = setup().await;
let payload: Vec<u8> = (0..1000u32).map(|i| (i % 251) as u8).collect();
fs.write(Path::new("big.bin"), &payload).await.unwrap();
let mut rebuilt = Vec::new();
let mut offset = 0u64;
loop {
let chunk = fs
.read_range(Path::new("big.bin"), Some(ReadRange::bytes(offset, 256)))
.await
.unwrap();
if chunk.is_empty() {
break;
}
offset += chunk.len() as u64;
rebuilt.extend_from_slice(&chunk);
}
assert_eq!(rebuilt, payload);
cleanup(&dir).await;
}
#[tokio::test]
async fn test_nested_write() {
let (fs, dir) = setup().await;
fs.write(Path::new("a/b/c.txt"), b"nested").await.unwrap();
let data = fs.read(Path::new("a/b/c.txt")).await.unwrap();
assert_eq!(data, b"nested");
cleanup(&dir).await;
}
#[test]
fn synthesized_mode_keeps_writability_and_never_claims_exec() {
assert_eq!(LocalFs::synthesized_mode(false, false) & 0o222, 0o222);
assert_eq!(LocalFs::synthesized_mode(false, true) & 0o222, 0);
assert_eq!(LocalFs::synthesized_mode(true, false) & 0o222, 0o222);
assert_eq!(LocalFs::synthesized_mode(true, true) & 0o222, 0);
for (is_dir, readonly) in [(false, false), (false, true), (true, false), (true, true)] {
assert_ne!(LocalFs::synthesized_mode(is_dir, readonly) & 0o444, 0);
}
assert_eq!(LocalFs::synthesized_mode(false, false) & 0o111, 0);
assert_eq!(LocalFs::synthesized_mode(false, true) & 0o111, 0);
assert_ne!(LocalFs::synthesized_mode(true, false) & 0o111, 0);
assert_ne!(LocalFs::synthesized_mode(true, true) & 0o111, 0);
}
#[tokio::test]
async fn test_read_only() {
let (_, dir) = setup().await;
let fs = LocalFs::read_only(&dir);
let result = fs.write(Path::new("test.txt"), b"data").await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
cleanup(&dir).await;
}
#[tokio::test]
async fn test_list() {
let (fs, dir) = setup().await;
fs.write(Path::new("a.txt"), b"a").await.unwrap();
fs.write(Path::new("b.txt"), b"b").await.unwrap();
fs.mkdir(Path::new("subdir")).await.unwrap();
let entries = fs.list(Path::new("")).await.unwrap();
assert_eq!(entries.len(), 3);
let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
assert!(names.contains(&&"a.txt".to_string()));
assert!(names.contains(&&"b.txt".to_string()));
assert!(names.contains(&&"subdir".to_string()));
cleanup(&dir).await;
}
#[tokio::test]
async fn dir_entry_no_follow_reports_a_vanished_entry_as_gone() {
let (_fs, dir) = setup().await;
let gone = dir.join("already-unlinked");
let got = LocalFs::dir_entry_no_follow(&gone).await.unwrap();
assert!(
got.is_none(),
"a missing entry must be reported as gone (Ok(None)), not an error — got {got:?}"
);
cleanup(&dir).await;
}
#[tokio::test]
async fn dir_entry_no_follow_builds_a_real_files_entry() {
let (fs, dir) = setup().await;
fs.write(Path::new("hi.txt"), b"hello").await.unwrap();
let entry = LocalFs::dir_entry_no_follow(&dir.join("hi.txt"))
.await
.unwrap()
.expect("a present file must produce an entry");
assert_eq!(entry.name, "hi.txt");
assert_eq!(entry.kind, DirEntryKind::File);
assert_eq!(entry.size, 5);
cleanup(&dir).await;
}
#[cfg(unix)]
#[tokio::test]
async fn list_keeps_a_dangling_symlink() {
let (fs, dir) = setup().await;
fs.write(Path::new("real.txt"), b"r").await.unwrap();
std::os::unix::fs::symlink("nowhere", dir.join("dangling")).unwrap();
let entries = fs.list(Path::new("")).await.unwrap();
let dangling = entries
.iter()
.find(|e| e.name == "dangling")
.expect("a dangling symlink must still be listed, not skipped as gone");
assert_eq!(dangling.kind, DirEntryKind::Symlink);
assert!(entries.iter().any(|e| e.name == "real.txt"));
cleanup(&dir).await;
}
#[tokio::test]
async fn test_stat() {
let (fs, dir) = setup().await;
fs.write(Path::new("file.txt"), b"content").await.unwrap();
fs.mkdir(Path::new("dir")).await.unwrap();
let file_entry = fs.stat(Path::new("file.txt")).await.unwrap();
assert!(file_entry.is_file());
assert_eq!(file_entry.size, 7);
let dir_entry = fs.stat(Path::new("dir")).await.unwrap();
assert!(dir_entry.is_dir());
cleanup(&dir).await;
}
#[tokio::test]
async fn test_remove() {
let (fs, dir) = setup().await;
fs.write(Path::new("file.txt"), b"data").await.unwrap();
assert!(fs.exists(Path::new("file.txt")).await);
fs.remove(Path::new("file.txt")).await.unwrap();
assert!(!fs.exists(Path::new("file.txt")).await);
cleanup(&dir).await;
}
#[cfg(unix)]
#[tokio::test]
async fn read_link_and_lstat_through_an_escaping_intermediate_are_refused() {
let (fs, dir) = setup().await;
let outside = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink("/secret", outside.path().join("host-link")).unwrap();
std::os::unix::fs::symlink(outside.path(), dir.join("out")).unwrap();
let read_link = fs.read_link(Path::new("out/host-link")).await;
let lstat = fs.lstat(Path::new("out/host-link")).await;
cleanup(&dir).await;
assert_eq!(read_link.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
assert_eq!(lstat.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
}
#[cfg(unix)]
#[tokio::test]
async fn write_under_a_dangling_intermediate_link_creates_nothing() {
let (fs, dir) = setup().await;
let outside = dir.parent().unwrap().join(format!("kaish-escape-dir-{}", std::process::id()));
std::os::unix::fs::symlink(
format!("../{}", outside.file_name().unwrap().to_string_lossy()),
dir.join("dangle"),
)
.unwrap();
let result = fs.write(Path::new("dangle/x"), b"out").await;
let escaped = outside.exists();
cleanup(&dir).await;
let _ = std::fs::remove_dir_all(&outside);
assert!(!escaped, "write created a directory outside the root through a dangling link");
assert!(result.is_err(), "write through a dangling intermediate link must fail");
}
#[cfg(unix)]
#[tokio::test]
async fn write_through_a_dangling_link_pointing_outside_is_refused() {
let (fs, dir) = setup().await;
let outside = dir.parent().unwrap().join(format!("kaish-escape-{}", std::process::id()));
std::os::unix::fs::symlink(
format!("../{}", outside.file_name().unwrap().to_string_lossy()),
dir.join("link"),
)
.unwrap();
let result = fs.write(Path::new("link"), b"out").await;
let escaped = outside.exists();
cleanup(&dir).await;
let _ = std::fs::remove_file(&outside);
assert!(!escaped, "write created a file outside the root through a dangling link");
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
}
#[tokio::test]
async fn write_under_a_missing_dotdot_chain_is_refused() {
let (fs, dir) = setup().await;
let sibling = format!("../kaish-escape-{}", std::process::id());
let result = fs.write(Path::new(&format!("{sibling}/x")), b"out").await;
let escaped = dir.parent().unwrap().join(&sibling[3..]).exists();
cleanup(&dir).await;
let _ = std::fs::remove_dir_all(dir.parent().unwrap().join(&sibling[3..]));
assert!(!escaped, "write created a directory beside the root");
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
}
#[tokio::test]
async fn test_path_escape_blocked() {
let (fs, dir) = setup().await;
let result = fs.read(Path::new("../../../etc/passwd")).await;
assert!(result.is_err());
cleanup(&dir).await;
}
#[tokio::test]
async fn test_lstat_path_escape_blocked() {
let (fs, dir) = setup().await;
let result = fs.lstat(Path::new("../../etc/passwd")).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
cleanup(&dir).await;
}
#[tokio::test]
async fn test_read_link_path_escape_blocked() {
let (fs, dir) = setup().await;
let result = fs.read_link(Path::new("../../etc/passwd")).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
cleanup(&dir).await;
}
#[cfg(unix)]
#[tokio::test]
async fn test_lstat_on_valid_symlink() {
let (fs, dir) = setup().await;
fs.write(Path::new("target.txt"), b"content").await.unwrap();
fs.symlink(Path::new("target.txt"), Path::new("link.txt"))
.await
.unwrap();
let entry = fs.lstat(Path::new("link.txt")).await.unwrap();
assert!(entry.is_symlink(), "lstat should report symlink kind");
cleanup(&dir).await;
}
#[cfg(unix)]
#[tokio::test]
async fn test_symlink_absolute_target_refused() {
let (fs, dir) = setup().await;
let result = fs
.symlink(Path::new("/etc/passwd"), Path::new("escape_link"))
.await;
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
assert!(!dir.join("escape_link").exists());
cleanup(&dir).await;
}
#[cfg(unix)]
#[tokio::test]
async fn test_remove_symlink_to_dir_unlinks_link_not_target() {
let (fs, dir) = setup().await;
fs.mkdir(Path::new("realdir")).await.unwrap();
fs.write(Path::new("realdir/keep.txt"), b"precious")
.await
.unwrap();
fs.symlink(Path::new("realdir"), Path::new("link"))
.await
.unwrap();
fs.remove(Path::new("link")).await.unwrap();
assert!(
fs.lstat(Path::new("link")).await.is_err(),
"symlink should be unlinked"
);
assert!(
fs.exists(Path::new("realdir")).await,
"target dir must survive"
);
assert_eq!(
fs.read(Path::new("realdir/keep.txt")).await.unwrap(),
b"precious"
);
cleanup(&dir).await;
}
#[cfg(unix)]
#[tokio::test]
async fn test_remove_symlink_to_file_unlinks_link_not_target() {
let (fs, dir) = setup().await;
fs.write(Path::new("target.txt"), b"content").await.unwrap();
fs.symlink(Path::new("target.txt"), Path::new("link.txt"))
.await
.unwrap();
fs.remove(Path::new("link.txt")).await.unwrap();
assert!(
fs.lstat(Path::new("link.txt")).await.is_err(),
"symlink should be unlinked"
);
assert_eq!(
fs.read(Path::new("target.txt")).await.unwrap(),
b"content",
"target file must survive"
);
cleanup(&dir).await;
}
#[cfg(unix)]
#[tokio::test]
async fn test_symlink_relative_target_allowed() {
let (fs, dir) = setup().await;
fs.write(Path::new("target.txt"), b"content").await.unwrap();
let result = fs
.symlink(Path::new("target.txt"), Path::new("rel_link"))
.await;
assert!(result.is_ok());
cleanup(&dir).await;
}
}