diff --git a/cfg/config.go b/cfg/config.go index 8e4ebdb9baa..b17f1fe6f1e 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -559,6 +559,8 @@ type FileSystemConfig struct { MaxBackground int64 `yaml:"max-background"` + MaxDirEntries int64 `yaml:"max-dir-entries"` + MaxReadAheadKb int64 `yaml:"max-read-ahead-kb"` RenameDirLimit int64 `yaml:"rename-dir-limit"` @@ -1184,6 +1186,8 @@ func BuildFlagSet(flagSet *pflag.FlagSet) error { flagSet.IntP("max-conns-per-host", "", 0, "The max number of TCP connections allowed per server. This is effective when client-protocol is set to 'http1'. A value of 0 indicates no limit on TCP connections (limited by the machine specifications).") + flagSet.IntP("max-dir-entries", "", 0, "If greater than zero, block creating new files in a directory that already contains at least this many entries, returning ENOSPC and logging a warning. 0 (the default) disables the guard.") + flagSet.IntP("max-idle-conns-per-host", "", 100, "The number of maximum idle connections allowed per server.") flagSet.IntP("max-read-ahead-kb", "", 0, "Sets max kernel-read-ahead for the mount in KiB. 0 means system default. Requires sudo permission to set this value, otherwise the value will be ignored and system default will be used.") @@ -1789,6 +1793,10 @@ func BindFlags(v *viper.Viper, flagSet *pflag.FlagSet) error { return err } + if err := v.BindPFlag("file-system.max-dir-entries", flagSet.Lookup("max-dir-entries")); err != nil { + return err + } + if err := v.BindPFlag("gcs-connection.max-idle-conns-per-host", flagSet.Lookup("max-idle-conns-per-host")); err != nil { return err } diff --git a/cfg/params.yaml b/cfg/params.yaml index d1b78295232..23fefce7935 100644 --- a/cfg/params.yaml +++ b/cfg/params.yaml @@ -524,6 +524,15 @@ params: - bucket-type: "pirlo" value: "DefaultMaxBackground()" + - config-path: "file-system.max-dir-entries" + flag-name: "max-dir-entries" + type: "int" + usage: >- + If greater than zero, block creating new files in a directory that + already contains at least this many entries, returning ENOSPC and logging + a warning. 0 (the default) disables the guard. + default: "0" + - config-path: "file-system.max-read-ahead-kb" flag-name: "max-read-ahead-kb" type: "int" diff --git a/cmd/mount.go b/cmd/mount.go index aaa688f266c..e2225cb6fe6 100644 --- a/cmd/mount.go +++ b/cmd/mount.go @@ -129,6 +129,7 @@ be interacting with the file system.`) FilePerms: os.FileMode(newConfig.FileSystem.FileMode), DirPerms: os.FileMode(newConfig.FileSystem.DirMode), RenameDirLimit: newConfig.FileSystem.RenameDirLimit, + MaxDirEntries: newConfig.FileSystem.MaxDirEntries, SequentialReadSizeMb: int32(newConfig.GcsConnection.SequentialReadSizeMb), EnableNonexistentTypeCache: newConfig.MetadataCache.EnableNonexistentTypeCache, NewConfig: newConfig, diff --git a/internal/fs/fs.go b/internal/fs/fs.go index abfd30e26c4..013b973ea1f 100644 --- a/internal/fs/fs.go +++ b/internal/fs/fs.go @@ -133,6 +133,10 @@ type ServerConfig struct { // Allow renaming a directory containing fewer descendants than this limit. RenameDirLimit int64 + // If greater than zero, block creating new files in a directory that + // already contains at least this many entries, returning ENOSPC. + MaxDirEntries int64 + // File chunk size to read from GCS in one call. Specified in MB. SequentialReadSizeMb int32 @@ -202,6 +206,7 @@ func NewFileSystem(ctx context.Context, serverCfg *ServerConfig) (fuseutil.FileS dirTypeCacheTTL: serverCfg.DirTypeCacheTTL, kernelListCacheTTL: cfg.ListCacheTTLSecsToDuration(serverCfg.NewConfig.FileSystem.KernelListCacheTtlSecs), renameDirLimit: serverCfg.RenameDirLimit, + maxDirEntries: serverCfg.MaxDirEntries, sequentialReadSizeMb: serverCfg.SequentialReadSizeMb, uid: serverCfg.Uid, gid: serverCfg.Gid, @@ -509,6 +514,7 @@ type fileSystem struct { kernelListCacheTTL time.Duration renameDirLimit int64 + maxDirEntries int64 sequentialReadSizeMb int32 // The user and group owning everything in the file system. @@ -2037,6 +2043,10 @@ func (fs *fileSystem) MkDir( ctx context.Context, op *fuseops.MkDirOp) (err error) { ctx = fs.getInterruptlessContext(ctx) + + if err = fs.checkDirEntryLimit(ctx, op.Parent, op.Name); err != nil { + return err + } // Find the parent. fs.mu.Lock() parent := fs.dirInodeOrDie(op.Parent) @@ -2097,6 +2107,10 @@ func (fs *fileSystem) MkNode( return syscall.ENOTSUP } + if err = fs.checkDirEntryLimit(ctx, op.Parent, op.Name); err != nil { + return err + } + // Create the child. child, err := fs.createFile(ctx, op.Parent, op.Name) if err != nil { @@ -2228,11 +2242,53 @@ func (fs *fileSystem) createLocalFile(ctx context.Context, parentID fuseops.Inod return child, nil } +// checkDirEntryLimit enforces file-system.max-dir-entries. When the limit is +// greater than zero and the parent directory already contains at least that +// many direct entries, creation of a new entry in it is rejected with ENOSPC +// and a warning is logged. This gives applications a standard "no space left +// on device" signal before a directory grows into the size range that degrades +// listing performance and risks metadata corruption. +// +// The count is non-recursive (direct entries only) and bounded: the listing +// stops once it reaches the limit, and it runs without holding the parent's +// inode lock, so a directory comfortably below the limit only pays to list its +// own (few) entries and concurrent operations on the directory are not +// serialized behind the GCS listing. +// +// LOCKS_EXCLUDED(fs.mu) +func (fs *fileSystem) checkDirEntryLimit(ctx context.Context, parentID fuseops.InodeID, name string) error { + if fs.maxDirEntries <= 0 { + return nil + } + + fs.mu.Lock() + parent := fs.dirInodeOrDie(parentID) + fs.mu.Unlock() + + count, err := parent.CountDirEntriesUpTo(ctx, int(fs.maxDirEntries)) + if err != nil { + return fmt.Errorf("count directory entries for max-dir-entries check: %w", err) + } + + if int64(count) >= fs.maxDirEntries { + logger.Warnf( + "max-dir-entries: rejecting creation of %q with ENOSPC; parent directory already contains at least %d entries", + name, fs.maxDirEntries) + return syscall.ENOSPC + } + + return nil +} + // LOCKS_EXCLUDED(fs.mu) func (fs *fileSystem) CreateFile( ctx context.Context, op *fuseops.CreateFileOp) (err error) { ctx = fs.getInterruptlessContext(ctx) + + if err = fs.checkDirEntryLimit(ctx, op.Parent, op.Name); err != nil { + return err + } // Create the child. var child inode.Inode openMode := util.FileOpenMode(op.OpenFlags) @@ -2290,6 +2346,10 @@ func (fs *fileSystem) CreateSymlink( ctx context.Context, op *fuseops.CreateSymlinkOp) (err error) { ctx = fs.getInterruptlessContext(ctx) + + if err = fs.checkDirEntryLimit(ctx, op.Parent, op.Name); err != nil { + return err + } // Find the parent. fs.mu.Lock() parent := fs.dirInodeOrDie(op.Parent) @@ -2471,6 +2531,15 @@ func (fs *fileSystem) Rename( ctx context.Context, op *fuseops.RenameOp) (err error) { ctx = fs.getInterruptlessContext(ctx) + + // Guard against bypassing max-dir-entries by renaming an entry into an + // already-full directory. Only cross-directory moves add an entry to the + // destination; in-place renames do not change the destination's count. + if op.NewParent != op.OldParent { + if err = fs.checkDirEntryLimit(ctx, op.NewParent, op.NewName); err != nil { + return err + } + } // Find the old and new parents. fs.mu.Lock() oldParent := fs.dirInodeOrDie(op.OldParent) diff --git a/internal/fs/inode/base_dir.go b/internal/fs/inode/base_dir.go index 5a3169b07b4..919da9f647c 100644 --- a/internal/fs/inode/base_dir.go +++ b/internal/fs/inode/base_dir.go @@ -187,6 +187,13 @@ func (d *baseDirInode) ReadDescendants(ctx context.Context, limit int) (map[Name return nil, fuse.ENOSYS } +// CountDirEntriesUpTo always reports 0 for the base directory. It holds only +// the buckets' root directories, not user-created entries, and mutating +// operations on it already fail with ENOSYS via the normal create path. +func (d *baseDirInode) CountDirEntriesUpTo(ctx context.Context, limit int) (int, error) { + return 0, nil +} + // LOCKS_REQUIRED(d) func (d *baseDirInode) ReadEntries( ctx context.Context, diff --git a/internal/fs/inode/dir.go b/internal/fs/inode/dir.go index d16763561b1..9f80236d610 100644 --- a/internal/fs/inode/dir.go +++ b/internal/fs/inode/dir.go @@ -80,6 +80,13 @@ type DirInode interface { // call. ReadDescendants(ctx context.Context, limit int) (map[Name]*Core, error) + // CountDirEntriesUpTo returns the number of direct children of this + // directory, counting at most `limit` entries (it stops once the count + // reaches `limit`). Unlike ReadDescendants this is non-recursive: nested + // descendants are not counted. GCS I/O is performed without holding the + // inode lock, and internal caches are not refreshed. + CountDirEntriesUpTo(ctx context.Context, limit int) (int, error) + // Read some number of entries from the directory, returning a continuation // token that can be used to pick up the read operation where it left off. // Supply the empty token on the first call. @@ -1029,6 +1036,36 @@ func (d *dirInode) readObjectsUnlocked(ctx context.Context, tok string, startOff return } +// CountDirEntriesUpTo returns the number of direct children of this directory, +// counting at most `limit` entries and stopping as soon as the count reaches +// it. It lists non-recursively (Delimiter "/") via listObjectsAndBuildCores, so +// only direct entries are counted, and it performs the GCS listing without +// holding the inode lock (mirroring readObjectsUnlocked) so concurrent +// operations on the directory are not serialized behind the network call. It +// does not refresh internal caches. +// +// LOCK_EXCLUDED(d) +func (d *dirInode) CountDirEntriesUpTo(ctx context.Context, limit int) (int, error) { + if limit <= 0 { + return 0, nil + } + + var tok string + count := 0 + for { + cores, _, newTok, err := d.listObjectsAndBuildCores(ctx, tok, MaxResultsForListObjectsCall, "") + if err != nil { + return 0, fmt.Errorf("list objects: %w", err) + } + + count += len(cores) + if count >= limit || newTok == "" { + return count, nil + } + tok = newTok + } +} + // LOCKS_REQUIRED(d) func (d *dirInode) ReadEntries( ctx context.Context, diff --git a/internal/fs/max_dir_entries_test.go b/internal/fs/max_dir_entries_test.go new file mode 100644 index 00000000000..1b9f409e1d3 --- /dev/null +++ b/internal/fs/max_dir_entries_test.go @@ -0,0 +1,136 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Tests for the file-system.max-dir-entries write guard. + +package fs_test + +import ( + "fmt" + "os" + "path" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +const maxDirEntriesLimit = 5 + +type MaxDirEntriesTest struct { + suite.Suite + fsTest +} + +func TestMaxDirEntriesSuite(t *testing.T) { suite.Run(t, new(MaxDirEntriesTest)) } + +func (t *MaxDirEntriesTest) SetupSuite() { + t.serverCfg.MaxDirEntries = maxDirEntriesLimit + t.serverCfg.ImplicitDirectories = true + t.SetUpTestSuite() +} + +func (t *MaxDirEntriesTest) TearDownSuite() { + t.TearDownTestSuite() +} + +func (t *MaxDirEntriesTest) TearDownTest() { + t.TearDown() +} + +// fillUntilRejected creates files in dir until a create is rejected, and +// asserts the rejection is ENOSPC. It returns the number of files successfully +// created, which must be between 1 and the limit. +func (t *MaxDirEntriesTest) fillUntilRejected(dir string) int { + created := 0 + var lastErr error + for i := 0; i < maxDirEntriesLimit*2; i++ { + f := path.Join(dir, fmt.Sprintf("f%d.txt", i)) + if err := os.WriteFile(f, []byte("x"), 0600); err != nil { + lastErr = err + break + } + created++ + } + require.Error(t.T(), lastErr, "expected a create to be rejected once the directory filled up") + assert.ErrorIs(t.T(), lastErr, syscall.ENOSPC) + assert.GreaterOrEqual(t.T(), created, 1) + assert.LessOrEqual(t.T(), created, maxDirEntriesLimit) + return created +} + +// Once a directory reaches the configured limit, further creates are rejected +// with ENOSPC. The exact boundary index is intentionally not asserted +// (implicit-directory placeholders can shift the count by one); the contract is +// that creation eventually fails with ENOSPC after no more than `limit` +// successful creates. +func (t *MaxDirEntriesTest) TestCreatesBlockedWithENOSPCOnceDirIsFull() { + dir := path.Join(mntDir, "bigdir") + require.NoError(t.T(), os.Mkdir(dir, 0700)) + + t.fillUntilRejected(dir) +} + +// Creating fewer entries than the limit always succeeds. +func (t *MaxDirEntriesTest) TestCreatesAllowedBelowLimit() { + dir := path.Join(mntDir, "smalldir") + require.NoError(t.T(), os.Mkdir(dir, 0700)) + + for i := 0; i < maxDirEntriesLimit-1; i++ { + f := path.Join(dir, fmt.Sprintf("f%d.txt", i)) + require.NoError(t.T(), os.WriteFile(f, []byte("x"), 0600)) + } +} + +// The guard counts direct entries only, not recursive descendants: a parent +// with a small number of direct entries must still accept creates even when its +// subdirectories collectively hold far more than the limit. +func (t *MaxDirEntriesTest) TestDirectEntryCountIsNotRecursive() { + parent := path.Join(mntDir, "parent") + require.NoError(t.T(), os.Mkdir(parent, 0700)) + + // A few subdirectories, each filled just under the limit. The parent's + // direct entry count stays small (only the subdirs), while the recursive + // descendant count is far above the limit. + const subDirs = 3 + for d := 0; d < subDirs; d++ { + sub := path.Join(parent, fmt.Sprintf("sub%d", d)) + require.NoError(t.T(), os.Mkdir(sub, 0700)) + for i := 0; i < maxDirEntriesLimit-1; i++ { + f := path.Join(sub, fmt.Sprintf("f%d.txt", i)) + require.NoError(t.T(), os.WriteFile(f, []byte("x"), 0600)) + } + } + + // The parent has only `subDirs` direct entries (< limit), so a create in the + // parent must succeed even though its recursive descendant count is >> limit. + // This would fail if the guard counted descendants recursively. + require.NoError(t.T(), os.WriteFile(path.Join(parent, "direct.txt"), []byte("x"), 0600)) +} + +// The guard applies to all entry-creating operations, not just plain file +// creates: once a directory is full, MkDir and CreateSymlink into it are also +// rejected with ENOSPC. +func (t *MaxDirEntriesTest) TestMkDirAndSymlinkBlockedWhenDirIsFull() { + dir := path.Join(mntDir, "fulldir") + require.NoError(t.T(), os.Mkdir(dir, 0700)) + t.fillUntilRejected(dir) + + // These must be rejected. They are deliberately not cleaned up: if either + // succeeded (the bug), the leftover entry is the evidence of the failure. + assert.ErrorIs(t.T(), os.Mkdir(path.Join(dir, "newsub"), 0700), syscall.ENOSPC) + assert.ErrorIs(t.T(), os.Symlink("target", path.Join(dir, "newlink")), syscall.ENOSPC) +}