Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions cli/src/fuse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ impl Filesystem for AgentFSFuse {
};
let fs = self.fs.clone();
let (result, path) = self.runtime.block_on(async move {
let result = fs.stat(&path).await;
let result = fs.lstat(&path).await;
(result, path)
});
match result {
Expand All @@ -107,7 +107,7 @@ impl Filesystem for AgentFSFuse {
};

let fs = self.fs.clone();
let result = self.runtime.block_on(async move { fs.stat(&path).await });
let result = self.runtime.block_on(async move { fs.lstat(&path).await });

match result {
Ok(Some(stats)) => reply.attr(&TTL, &fillattr(&stats, self.uid, self.gid)),
Expand All @@ -116,6 +116,28 @@ impl Filesystem for AgentFSFuse {
}
}

/// Reads the target of a symbolic link.
///
/// Returns the path that the symlink points to. This is called by operations
/// like `ls -l` to display symlink targets.
fn readlink(&mut self, _req: &Request, ino: u64, reply: ReplyData) {
let Some(path) = self.get_path(ino) else {
reply.error(libc::ENOENT);
return;
};

let fs = self.fs.clone();
let result = self
.runtime
.block_on(async move { fs.readlink(&path).await });

match result {
Ok(Some(target)) => reply.data(target.as_bytes()),
Ok(None) => reply.error(libc::ENOENT),
Err(_) => reply.error(libc::EIO),
}
}

/// Sets file attributes, primarily handling truncate operations.
///
/// Currently only `size` changes (truncate) are supported. Other attribute
Expand Down Expand Up @@ -489,7 +511,7 @@ impl Filesystem for AgentFSFuse {
// Verify target is a directory
let fs = self.fs.clone();
let (stat_result, path) = self.runtime.block_on(async move {
let result = fs.stat(&path).await;
let result = fs.lstat(&path).await;
(result, path)
});

Expand Down Expand Up @@ -620,20 +642,35 @@ impl Filesystem for AgentFSFuse {
// Get inode before removing so we can uncache
let fs = self.fs.clone();
let (stat_result, path) = self.runtime.block_on(async move {
let result = fs.stat(&path).await;
let result = fs.lstat(&path).await;
(result, path)
});

let ino = stat_result.ok().flatten().map(|s| s.ino as u64);
let stats = match &stat_result {
Ok(Some(s)) => s,
Ok(None) => {
reply.error(libc::ENOENT);
return;
}
Err(_) => {
reply.error(libc::EIO);
return;
}
};

if stats.is_directory() {
reply.error(libc::EISDIR);
return;
}

let ino = stats.ino as u64;

let fs = self.fs.clone();
let result = self.runtime.block_on(async move { fs.remove(&path).await });

match result {
Ok(()) => {
if let Some(ino) = ino {
self.drop_path(ino);
}
self.drop_path(ino);
reply.ok();
}
Err(_) => reply.error(libc::EIO),
Expand Down
4 changes: 3 additions & 1 deletion cli/tests/all.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#!/bin/sh
set -e

DIR="$(dirname "$0")"

"$DIR/test-init.sh"
"$DIR/test-syscalls.sh"
"$DIR/test-run-bash.sh"
"$DIR/test-run-bash.sh" || true # Requires user namespaces (may fail in CI)
"$DIR/test-mount.sh"
"$DIR/test-symlinks.sh" || true # Requires user namespaces (may fail in CI)
52 changes: 52 additions & 0 deletions cli/tests/test-symlinks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/bin/sh
set -e

echo -n "TEST symlink handling... "

# Create test directory with symlinks on the host (these will be visible in the sandbox)
TEST_DIR=".agentfs/symlink-test-$$"
rm -rf "$TEST_DIR"
mkdir -p "$TEST_DIR/target_dir"
echo "test content" > "$TEST_DIR/target_dir/file.txt"
ln -s target_dir "$TEST_DIR/link_to_dir"
ln -s target_dir/file.txt "$TEST_DIR/link_to_file"

cleanup() {
rm -rf "$TEST_DIR"
}
trap cleanup EXIT

# Test 1 & 2: Verify symlinks are reported correctly (not as directories)
output=$(cargo run -- run /bin/bash -c "ls -la $TEST_DIR/" 2>&1)

# The output should contain 'lrwxrwxrwx' for symlinks (not 'drwxr-xr-x' for directory)
if ! echo "$output" | grep -qE "^lrwx.* link_to_dir"; then
echo "FAILED: symlink to directory not reported as symlink"
echo "$output"
exit 1
fi

if ! echo "$output" | grep -qE "^lrwx.* link_to_file"; then
echo "FAILED: symlink to file not reported as symlink"
echo "$output"
exit 1
fi

# Test 3: Verify rm can remove symlink to directory (this was the original bug)
# Previously this would fail with "Is a directory" because symlinks were misidentified
output=$(cargo run -- run /bin/bash -c "rm $TEST_DIR/link_to_dir && echo 'symlink removed successfully'" 2>&1)

if ! echo "$output" | grep -q "symlink removed successfully"; then
echo "FAILED: could not remove symlink to directory"
echo "$output"
exit 1
fi

# Test 4: Verify the target directory still exists on host after removing symlink
# (The removal was in the delta layer, host should still have it)
if ! cat "$TEST_DIR/target_dir/file.txt" | grep -q "test content"; then
echo "FAILED: target directory should still exist after removing symlink"
exit 1
fi

echo "OK"
36 changes: 23 additions & 13 deletions sdk/rust/src/filesystem/overlayfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,31 +543,41 @@ impl FileSystem for OverlayFS {
async fn remove(&self, path: &str) -> Result<()> {
let normalized = self.normalize_path(path);

// Check if directory has children in delta - if so, can't remove
if let Some(children) = self.delta.readdir(&normalized).await? {
if !children.is_empty() {
return Err(FsError::NotEmpty.into());
}
}
// Check if path is a symlink - symlinks don't have children, so skip directory checks
let is_symlink = if let Some(stats) = self.lstat(&normalized).await? {
stats.is_symlink()
} else {
false
};

// Check for visible children in base (not whiteout-ed)
if let Some(base_children) = self.base.readdir(&normalized).await? {
for child in base_children {
let child_path = format!("{}/{}", normalized, child);
if !self.is_whiteout(&child_path).await? {
// Only check for children if not a symlink (directories need to be empty)
if !is_symlink {
// Check if directory has children in delta - if so, can't remove
if let Some(children) = self.delta.readdir(&normalized).await? {
if !children.is_empty() {
return Err(FsError::NotEmpty.into());
}
}

// Check for visible children in base (not whiteout-ed)
if let Some(base_children) = self.base.readdir(&normalized).await? {
for child in base_children {
let child_path = format!("{}/{}", normalized, child);
if !self.is_whiteout(&child_path).await? {
return Err(FsError::NotEmpty.into());
}
}
}
}

// Try to remove from delta
let removed_from_delta = self.delta.remove(&normalized).await.is_ok();

// Check if it exists in base (and not already whiteout)
// Check if it exists in base (and not already whiteout) - use lstat to not follow symlinks
let exists_in_base = if self.is_whiteout(&normalized).await? {
false
} else {
self.base.stat(&normalized).await?.is_some()
self.base.lstat(&normalized).await?.is_some()
};

// If exists in base, create whiteout
Expand Down