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
8 changes: 8 additions & 0 deletions cfg/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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
}
Expand Down
9 changes: 9 additions & 0 deletions cfg/params.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions cmd/mount.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
69 changes: 69 additions & 0 deletions internal/fs/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Comment thread
chaitanyapantheor marked this conversation as resolved.
// Create the child.
var child inode.Inode
openMode := util.FileOpenMode(op.OpenFlags)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions internal/fs/inode/base_dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions internal/fs/inode/dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
136 changes: 136 additions & 0 deletions internal/fs/max_dir_entries_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading