Skip to content

Commit 8327cfb

Browse files
authored
Add symlink support to FUSE filesystem (#136)
Two issues were preventing symlink creation in agentfs run: 1. The FUSE implementation was missing the symlink() callback, causing EPERM when tools like npm tried to create symlinks in node_modules/.bin/ 2. The OverlayFS ensure_parent_dirs() didn't create parent directories in the delta layer when they only existed in the base layer. This caused the delta layer's symlink/write operations to fail with "parent directory does not exist" since they only look up parents in the delta database. Also adds symlink creation tests to test-mount.sh and test-symlinks.sh.
2 parents 5a28250 + aba3ba5 commit 8327cfb

4 files changed

Lines changed: 128 additions & 1 deletion

File tree

cli/src/fuse.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,58 @@ impl Filesystem for AgentFSFuse {
655655
reply.created(&TTL, &attr, 0, fh, 0);
656656
}
657657

658+
/// Creates a symbolic link.
659+
///
660+
/// Creates a symlink at `name` under `parent` pointing to `link`.
661+
fn symlink(
662+
&mut self,
663+
_req: &Request,
664+
parent: u64,
665+
link_name: &OsStr,
666+
target: &Path,
667+
reply: ReplyEntry,
668+
) {
669+
let Some(path) = self.lookup_path(parent, link_name) else {
670+
reply.error(libc::ENOENT);
671+
return;
672+
};
673+
674+
let Some(target_str) = target.to_str() else {
675+
reply.error(libc::EINVAL);
676+
return;
677+
};
678+
679+
let fs = self.fs.clone();
680+
let target_owned = target_str.to_string();
681+
let (result, path) = self.runtime.block_on(async move {
682+
let result = fs.symlink(&target_owned, &path).await;
683+
(result, path)
684+
});
685+
686+
if result.is_err() {
687+
reply.error(libc::EIO);
688+
return;
689+
}
690+
691+
// Get the new symlink's stats
692+
let fs = self.fs.clone();
693+
let (stat_result, path) = self.runtime.block_on(async move {
694+
let result = fs.lstat(&path).await;
695+
(result, path)
696+
});
697+
698+
match stat_result {
699+
Ok(Some(stats)) => {
700+
let attr = fillattr(&stats, self.uid, self.gid);
701+
self.add_path(attr.ino, path);
702+
reply.entry(&TTL, &attr, 0);
703+
}
704+
_ => {
705+
reply.error(libc::EIO);
706+
}
707+
}
708+
}
709+
658710
/// Removes a file (unlinks it from the directory).
659711
///
660712
/// Gets the file's inode before removal to clean up the path cache.

cli/tests/test-mount.sh

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,50 @@ if [ "$NESTED_CONTENT" != "nested file" ]; then
8080
exit 1
8181
fi
8282

83+
# Test symlink creation
84+
ln -s nested.txt "$MOUNTPOINT/testdir/link_to_nested"
85+
if [ ! -L "$MOUNTPOINT/testdir/link_to_nested" ]; then
86+
echo "FAILED: symlink was not created"
87+
kill $MOUNT_PID 2>/dev/null || true
88+
exit 1
89+
fi
90+
91+
# Test reading symlink target
92+
LINK_TARGET=$(readlink "$MOUNTPOINT/testdir/link_to_nested")
93+
if [ "$LINK_TARGET" != "nested.txt" ]; then
94+
echo "FAILED: symlink target mismatch"
95+
echo "Expected: nested.txt"
96+
echo "Got: $LINK_TARGET"
97+
kill $MOUNT_PID 2>/dev/null || true
98+
exit 1
99+
fi
100+
101+
# Test following symlink to read file
102+
LINKED_CONTENT=$(cat "$MOUNTPOINT/testdir/link_to_nested")
103+
if [ "$LINKED_CONTENT" != "nested file" ]; then
104+
echo "FAILED: reading through symlink failed"
105+
echo "Expected: nested file"
106+
echo "Got: $LINKED_CONTENT"
107+
kill $MOUNT_PID 2>/dev/null || true
108+
exit 1
109+
fi
110+
111+
# Test symlink to directory
112+
ln -s testdir "$MOUNTPOINT/link_to_testdir"
113+
if [ ! -L "$MOUNTPOINT/link_to_testdir" ]; then
114+
echo "FAILED: symlink to directory was not created"
115+
kill $MOUNT_PID 2>/dev/null || true
116+
exit 1
117+
fi
118+
119+
# Test accessing file through directory symlink
120+
DIR_LINKED_CONTENT=$(cat "$MOUNTPOINT/link_to_testdir/nested.txt")
121+
if [ "$DIR_LINKED_CONTENT" != "nested file" ]; then
122+
echo "FAILED: reading through directory symlink failed"
123+
kill $MOUNT_PID 2>/dev/null || true
124+
exit 1
125+
fi
126+
83127
# Unmount
84128
fusermount -u "$MOUNTPOINT"
85129

cli/tests/test-symlinks.sh

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,31 @@ if ! cat "$TEST_DIR/target_dir/file.txt" | grep -q "test content"; then
4949
exit 1
5050
fi
5151

52+
# Test 5: Create a symlink inside the sandbox (tests FUSE symlink creation)
53+
output=$(cargo run -- run /bin/bash -c "ln -s target_dir/file.txt $TEST_DIR/new_symlink && readlink $TEST_DIR/new_symlink" 2>&1)
54+
55+
if ! echo "$output" | grep -q "target_dir/file.txt"; then
56+
echo "FAILED: could not create symlink in sandbox"
57+
echo "$output"
58+
exit 1
59+
fi
60+
61+
# Test 6: Create and follow symlink to read file content
62+
output=$(cargo run -- run /bin/bash -c "ln -s target_dir $TEST_DIR/new_dir_link && cat $TEST_DIR/new_dir_link/file.txt" 2>&1)
63+
64+
if ! echo "$output" | grep -q "test content"; then
65+
echo "FAILED: could not read through newly created symlink"
66+
echo "$output"
67+
exit 1
68+
fi
69+
70+
# Test 7: Verify symlinks created in sandbox are visible via ls -l
71+
output=$(cargo run -- run /bin/bash -c "ln -s foo $TEST_DIR/test_link && ls -la $TEST_DIR/test_link" 2>&1)
72+
73+
if ! echo "$output" | grep -qE "^lrwx.*test_link -> foo"; then
74+
echo "FAILED: newly created symlink not shown correctly in ls"
75+
echo "$output"
76+
exit 1
77+
fi
78+
5279
echo "OK"

sdk/rust/src/filesystem/overlayfs.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,11 @@ impl OverlayFS {
364364

365365
match stats {
366366
Some(s) if s.is_directory() => {
367-
// Already a directory, continue
367+
// Directory exists in base or delta
368+
// Make sure it exists in delta too (required for delta operations)
369+
if self.delta.stat(&current).await?.is_none() {
370+
self.delta.mkdir(&current).await?;
371+
}
368372
}
369373
Some(_) => {
370374
// Exists but not a directory - this is an error

0 commit comments

Comments
 (0)