Skip to content
Open
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
14 changes: 14 additions & 0 deletions src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,20 @@ impl OvlNode {
whiteouts.insert(name);
}
}

/// Clear all children and whiteouts. Used by cache invalidation to
/// force a full re-scan of the directory.
pub fn clear_children(&mut self) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we just use mark_unloaded?

@wangjia184 wangjia184 May 11, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, I tried mark_unloaded but that does not work for .wh.* changes. And I dont want to change the load part.

if let DirState::Dir {
children,
whiteouts,
..
} = &mut self.dir_state
{
children.clear();
whiteouts.clear();
}
}
}

impl Drop for OvlNode {
Expand Down
36 changes: 36 additions & 0 deletions src/overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ pub struct OverlayFs {
notifier: Arc<OnceLock<fuser::Notifier>>,
}

/// Custom ioctl command: clear and re-scan a directory's cached children.
/// Encoded as _IO('f', 0x66) — direction=none, size=0, type='f'(0x66), nr=0x66.
/// Immediately rebuilds the directory listing, picking up externally created
/// whiteout files and other changes.
const FUSE_OVFS_IOC_REFRESH_DIR: libc::Ioctl = (b'f' as libc::Ioctl) << 8 | 0x66;

struct OverlayInner {
layers: Vec<OvlLayer>,
inodes: InodeTable,
Expand Down Expand Up @@ -3653,6 +3659,36 @@ impl Filesystem for OverlayFs {
let is_set = cmd_ioctl == libc::FS_IOC_SETVERSION || cmd_ioctl == libc::FS_IOC_SETFLAGS;
let is_get = cmd_ioctl == libc::FS_IOC_GETVERSION || cmd_ioctl == libc::FS_IOC_GETFLAGS;

// Custom command: clear and re-scan a directory's children.
if cmd_ioctl == FUSE_OVFS_IOC_REFRESH_DIR {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to make sure the caller has appropriate permissions, can a process without privileges call the ioctl?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no special permission required by FUSE_OVFS_IOC_REFRESH_DIR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it can be abused by an unprivileged user (e.g. uid != 0 inside the container), to cause a lot of load on the file system as it invalidates the entire directory.

let node = match inner.node(&node_id) {
Ok(n) => n,
Err(e) => {
reply.error(Errno::from_i32(e.0));
return;
}
};
if !node.is_dir() {
reply.error(Errno::ENOTDIR);
return;
}
let path = inner.node_path(node_id);
let last_layer = match inner.nodes.get(&node_id) {
Some(n) => n.last_layer_idx,
None => {
reply.error(Errno::ENOENT);
return;
}
};
if let Some(node) = inner.nodes.get_mut(&node_id) {
node.clear_children();
}
inner.load_dir_impl(node_id, &path, Some(last_layer), &self.config);
debug!("ioctl invalidate: directory ino={}", ino);
reply.ioctl(0, &[]);
return;
}

if !is_set && !is_get {
reply.error(Errno::ENOSYS);
return;
Expand Down
35 changes: 35 additions & 0 deletions tests/ioctl-invalidate.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>

/* _IO('f', 0x66) — same value as FUSE_OVFS_IOC_REFRESH_DIR in overlay.rs */
#define FUSE_OVFS_IOC_REFRESH_DIR _IO('f', 0x66)

int main(int argc, char *argv[])
{
if (argc != 2) {
fprintf(stderr, "Usage: %s <directory>\n", argv[0]);
return 1;
}

int fd = open(argv[1], O_RDONLY | O_DIRECTORY);
if (fd < 0) {
perror("open");
return 1;
}

int ret = ioctl(fd, FUSE_OVFS_IOC_REFRESH_DIR);
if (ret < 0) {
fprintf(stderr, "ioctl failed: %s\n", strerror(errno));
close(fd);
return 1;
}

close(fd);
return 0;
}
248 changes: 248 additions & 0 deletions tests/test-cache-invalidate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
#!/bin/bash
# Test: ioctl cache invalidation for directory children.

set -xeuo pipefail

# Build ioctl helper (resolve paths BEFORE cd $TESTDIR)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
IOCTL_C="$SCRIPT_DIR/ioctl-invalidate.c"
IOCTL_BIN="$SCRIPT_DIR/ioctl-invalidate"
if [ ! -f "$IOCTL_BIN" ]; then
gcc -o "$IOCTL_BIN" "$IOCTL_C"
fi

cleanup() {
cd /
umount "$MERGED" 2>/dev/null || true
rm -rf "$TESTDIR"
}

TESTDIR=$(mktemp -d /tmp/test-cache-invalidate.XXXXXX)
trap cleanup EXIT
cd "$TESTDIR"

MERGED="$TESTDIR/merged"

# ========================================
# Test 1: .wh.* whiteout prepared BEFORE mount
# ========================================
echo "=== Test 1: .wh.* whiteout before mount ==="
mkdir -p lower upper workdir merged

echo "lower file" > lower/myfile.txt
touch upper/.wh.myfile.txt

fuse-overlayfs -o lowerdir=lower,upperdir=upper,workdir=workdir merged

# Whiteout should work at mount time
test ! -e merged/myfile.txt

umount merged
rm -rf lower upper workdir merged

# ========================================
# Test 2: Remove .wh.* after mount + ioctl → file reappears
# ========================================
echo "=== Test 2: Remove .wh.* then ioctl ==="
mkdir -p lower upper workdir merged

echo "lower file" > lower/myfile.txt
touch upper/.wh.myfile.txt

fuse-overlayfs -o lowerdir=lower,upperdir=upper,workdir=workdir merged

# Whiteout effective at mount time
test ! -e merged/myfile.txt

# Load cache by listing the directory
ls merged > /dev/null

# Remove whiteout externally
rm -f upper/.wh.myfile.txt

# Cache loaded — file should still NOT be visible
test ! -e merged/myfile.txt

# Invalidate
$IOCTL_BIN merged

# ks_cache: use ls to verify (avoids stale kernel dentry cache from earlier stat)
ls merged/ > /tmp/cache-test-out.txt
grep myfile.txt /tmp/cache-test-out.txt
grep "lower file" merged/myfile.txt

umount merged
rm -rf lower upper workdir merged

# ========================================
# Test 3: Add .wh.* after mount + ioctl → file disappears
# ========================================
echo "=== Test 3: Add .wh.* then ioctl ==="
mkdir -p lower upper workdir merged

echo "lower file" > lower/myfile.txt

fuse-overlayfs -o lowerdir=lower,upperdir=upper,workdir=workdir merged

# File visible after mount
test -e merged/myfile.txt

# Load cache by listing the directory (avoids kernel dentry cache on myfile.txt)
ls merged > /dev/null

# Verify file still visible (cache loaded)
ls merged/ > /tmp/cache-test-out.txt
grep myfile.txt /tmp/cache-test-out.txt

# Create whiteout externally
touch upper/.wh.myfile.txt

# Cache loaded — file should still be visible
ls merged/ > /tmp/cache-test-out.txt
grep myfile.txt /tmp/cache-test-out.txt

# Invalidate
$IOCTL_BIN merged

# File should now be hidden from directory listing
ls merged/ > /tmp/cache-test-out.txt
if grep -q myfile.txt /tmp/cache-test-out.txt; then
echo "ERROR: myfile.txt should be hidden after ioctl"
exit 1
fi

umount merged
rm -rf lower upper workdir merged

# ========================================
# Test 4: Add file to lower after mount + ioctl → file appears
# ========================================
echo "=== Test 4: Add file to lower then ioctl ==="
mkdir -p lower upper workdir merged

echo "initial" > lower/initial.txt

fuse-overlayfs -o lowerdir=lower,upperdir=upper,workdir=workdir merged

# Load cache
ls merged > /dev/null

# Add file to lower externally
echo "new lower file" > lower/newfile.txt

# Cache loaded — new file NOT visible
test ! -e merged/newfile.txt

# Invalidate
$IOCTL_BIN merged

# File should now be visible
test -e merged/newfile.txt
grep "new lower file" merged/newfile.txt

umount merged
rm -rf lower upper workdir merged

# ========================================
# Test 5: ioctl on non-directory → ENOTDIR
# ========================================
echo "=== Test 5: ioctl on non-directory ==="
mkdir -p lower upper workdir merged

touch upper/file.txt

fuse-overlayfs -o lowerdir=lower,upperdir=upper,workdir=workdir merged

set +e
$IOCTL_BIN merged/file.txt
rc=$?
set -e
test "$rc" -ne 0

umount merged
rm -rf lower upper workdir merged

# ========================================
# Test 6: Remove file from lower externally + ioctl → file disappears
# ========================================
echo "=== Test 6: Remove file from lower then ioctl ==="
mkdir -p lower upper workdir merged

echo "remove me" > lower/rmfile.txt

fuse-overlayfs -o lowerdir=lower,upperdir=upper,workdir=workdir merged

# File visible
test -e merged/rmfile.txt

# Load cache by listing (avoids kernel dentry on rmfile.txt itself)
ls merged > /dev/null

# Verify file in listing
ls merged/ > /tmp/cache-test-out.txt
grep rmfile.txt /tmp/cache-test-out.txt

# Remove file from lower externally
rm -f lower/rmfile.txt

# Cache loaded — file should still be in listing
ls merged/ > /tmp/cache-test-out.txt
grep rmfile.txt /tmp/cache-test-out.txt

# Invalidate
$IOCTL_BIN merged

# File should now be gone from listing
ls merged/ > /tmp/cache-test-out.txt
if grep -q rmfile.txt /tmp/cache-test-out.txt; then
echo "ERROR: rmfile.txt should be gone after ioctl"
exit 1
fi

umount merged
rm -rf lower upper workdir merged

# ========================================
# Test 7: Multiple lower layers + whiteout in upper
# ========================================
echo "=== Test 7: Multiple lower layers ==="
mkdir -p lower1 lower2 upper workdir merged

echo "from lower1" > lower1/file_a.txt
echo "from lower2" > lower2/file_b.txt

fuse-overlayfs -o lowerdir=lower1:lower2,upperdir=upper,workdir=workdir merged

# Load cache
ls merged > /dev/null

# Verify both files in listing
ls merged/ > /tmp/cache-test-out.txt
grep file_a.txt /tmp/cache-test-out.txt
grep file_b.txt /tmp/cache-test-out.txt

# Create whiteout in upper for lower1's file
touch upper/.wh.file_a.txt

# Cache loaded — both files still in listing
ls merged/ > /tmp/cache-test-out.txt
grep file_a.txt /tmp/cache-test-out.txt
grep file_b.txt /tmp/cache-test-out.txt

# Invalidate
$IOCTL_BIN merged

# file_a should be gone
ls merged/ > /tmp/cache-test-out.txt
if grep -q file_a.txt /tmp/cache-test-out.txt; then
echo "ERROR: file_a.txt should be hidden after ioctl"
exit 1
fi
# file_b should still be there
grep file_b.txt /tmp/cache-test-out.txt
grep "from lower2" merged/file_b.txt

umount merged
rm -rf lower1 lower2 upper workdir merged

echo "All cache invalidation tests passed!"
Loading