From b8de27655fa86a0f388c141d3693c6732a753eae Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 21 Apr 2026 21:41:17 +0530 Subject: [PATCH] Utility function to delete content of dir pointed to by open file --- tools/utils/rmtree.go | 80 ++++++++++++++++++++++++++++++++++++++ tools/utils/rmtree_test.go | 52 +++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 tools/utils/rmtree.go create mode 100644 tools/utils/rmtree_test.go diff --git a/tools/utils/rmtree.go b/tools/utils/rmtree.go new file mode 100644 index 000000000..7e6402bff --- /dev/null +++ b/tools/utils/rmtree.go @@ -0,0 +1,80 @@ +package utils + +import ( + "errors" + "fmt" + "io" + "os" + + "golang.org/x/sys/unix" +) + +var _ = fmt.Print + +// RemoveChildren recursively removes all files and subdirectories +// within the directory pointed to by dirFile. Removes all it can but returns +// the first error, if any. +func RemoveChildren(dirFile *os.File) error { + fd := int(dirFile.Fd()) + var firstErr error + + // Rewind directory pointer to ensure we start from the beginning + if _, err := dirFile.Seek(0, 0); err != nil { + return &os.PathError{Op: "seek", Path: dirFile.Name(), Err: err} + } + + for { + // Read names in small chunks to handle very large directories + names, err := dirFile.Readdirnames(64) + if err != nil { + if errors.Is(err, io.EOF) { + break + } + if firstErr == nil { + firstErr = &os.PathError{Op: "readdirnames", Path: dirFile.Name(), Err: err} + } + break + } + + for _, name := range names { + var stat unix.Stat_t + // Get file info relative to the parent FD + err := unix.Fstatat(fd, name, &stat, unix.AT_SYMLINK_NOFOLLOW) + if err != nil { + if firstErr == nil { + firstErr = &os.PathError{Op: "fstatat", Path: name, Err: err} + } + continue + } + + if (stat.Mode & unix.S_IFMT) == unix.S_IFDIR { + // Open subdirectory relative to parent FD + childFd, err := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + if firstErr == nil { + firstErr = &os.PathError{Op: "openat", Path: name, Err: err} + } + continue + } + + childFile := os.NewFile(uintptr(childFd), name) + if err := RemoveChildren(childFile); err != nil && firstErr == nil { + firstErr = err + } + childFile.Close() + + // Remove the empty subdirectory + if err := unix.Unlinkat(fd, name, unix.AT_REMOVEDIR); err != nil && firstErr == nil { + firstErr = &os.PathError{Op: "unlinkat", Path: name, Err: err} + } + } else { + // Remove file/symlink + if err := unix.Unlinkat(fd, name, 0); err != nil && firstErr == nil { + firstErr = &os.PathError{Op: "unlinkat", Path: name, Err: err} + } + } + } + } + _, _ = dirFile.Seek(0, 0) + return firstErr +} diff --git a/tools/utils/rmtree_test.go b/tools/utils/rmtree_test.go new file mode 100644 index 000000000..d76d3a8d6 --- /dev/null +++ b/tools/utils/rmtree_test.go @@ -0,0 +1,52 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRemoveChildren(t *testing.T) { + tmpDir := t.TempDir() + + // Create nested structure + subDir := filepath.Join(tmpDir, "subdir") + os.Mkdir(subDir, 0755) + os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("data"), 0644) + os.WriteFile(filepath.Join(subDir, "file2.txt"), []byte("data"), 0644) + + d, err := os.Open(tmpDir) + if err != nil { + t.Fatal(err) + } + defer d.Close() + + if err := RemoveChildren(d); err != nil { + t.Errorf("expected no error, got %v", err) + } + + // Verify directory is empty + entries, _ := os.ReadDir(tmpDir) + if len(entries) != 0 { + t.Errorf("expected 0 entries, got %d", len(entries)) + } +} + +func TestRemoveChildren_FirstError(t *testing.T) { + tmpDir := t.TempDir() + + // Create a read-only file to trigger an error on some systems + // (Note: Behavior varies by OS; this is a conceptual test for firstErr) + lockedFile := filepath.Join(tmpDir, "locked") + os.WriteFile(lockedFile, nil, 0000) + + d, _ := os.Open(tmpDir) + defer d.Close() + + err := RemoveChildren(d) + if err != nil { + if _, ok := err.(*os.PathError); !ok { + t.Errorf("expected *os.PathError, got %T", err) + } + } +}