From 9c2d3bf64e90c688c08ea300864fd299cef4bbaa Mon Sep 17 00:00:00 2001 From: Nadav0077 <18245584+Nadav0077@users.noreply.github.com> Date: Sun, 19 Apr 2026 00:32:19 +0300 Subject: [PATCH] Clamp negative inode size to zero in inodeAttrs size_bytes is stored as a signed INTEGER in the SQLite base_nodes and overlay_entries tables and surfaces as int64 throughout resolver and overlay code. inodeAttrs was taking uint64 and relying on every caller to do a uint64(size) cast, so a negative row value would wrap into a gigantic st_size (for example -1 becomes ~18 exabytes) and get published to the kernel as the file size. Change the parameter to int64 and clamp negatives to zero inside. This removes the silent wrap, stops repeating the cast at every call site, and keeps the existing "dir with size 0 reports 4096" behavior intact. Add unit tests for the clamp, the dir size default, the symlink mode bit, and the zero-mode defaults. --- internal/fusefs/fuse_unix.go | 18 +++-- internal/fusefs/inode_attrs_unix_test.go | 85 ++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 internal/fusefs/inode_attrs_unix_test.go diff --git a/internal/fusefs/fuse_unix.go b/internal/fusefs/fuse_unix.go index 3a1108e..f652583 100644 --- a/internal/fusefs/fuse_unix.go +++ b/internal/fusefs/fuse_unix.go @@ -195,7 +195,7 @@ func (fs *ArtifactFuse) LookUpInode(_ context.Context, op *fuseops.LookUpInodeOp fs.mu.Unlock() op.Entry.Child = ref.ID - op.Entry.Attributes = inodeAttrs(mode, uint64(size), typ, mtime) + op.Entry.Attributes = inodeAttrs(mode, size, typ, mtime) setChildEntryExpiry(&op.Entry, time.Second) return nil } @@ -216,7 +216,7 @@ func (fs *ArtifactFuse) GetInodeAttributes(_ context.Context, op *fuseops.GetIno if err != nil { return syscall.ENOENT } - op.Attributes = inodeAttrs(mode, uint64(size), typ, mtime) + op.Attributes = inodeAttrs(mode, size, typ, mtime) op.AttributesExpiration = attrExpiry(time.Second) return nil } @@ -243,7 +243,7 @@ func (fs *ArtifactFuse) SetInodeAttributes(ctx context.Context, op *fuseops.SetI if op.Mtime != nil { mtime = *op.Mtime } - op.Attributes = inodeAttrs(mode, uint64(size), typ, mtime) + op.Attributes = inodeAttrs(mode, size, typ, mtime) op.AttributesExpiration = attrExpiry(time.Second) return nil } @@ -567,7 +567,15 @@ func TryUnmount(mountPoint string) error { return err } -func inodeAttrs(mode uint32, size uint64, typ string, mtime time.Time) fuseops.InodeAttributes { +// inodeAttrs builds the kernel-visible attributes for an inode. The size is +// taken as a signed int64 because that's how snapshot and overlay state store +// it; a negative value there means the on-disk row is corrupt. Publishing it +// verbatim would wrap into a huge uint64 (e.g. -1 becomes ~18 exabytes), so +// clamp to zero instead. +func inodeAttrs(mode uint32, size int64, typ string, mtime time.Time) fuseops.InodeAttributes { + if size < 0 { + size = 0 + } m := os.FileMode(mode & 0o777) if m == 0 { if typ == "dir" { @@ -586,7 +594,7 @@ func inodeAttrs(mode uint32, size uint64, typ string, mtime time.Time) fuseops.I m |= os.ModeSymlink } return fuseops.InodeAttributes{ - Size: size, + Size: uint64(size), Nlink: 1, Mode: m, Uid: uint32(os.Getuid()), diff --git a/internal/fusefs/inode_attrs_unix_test.go b/internal/fusefs/inode_attrs_unix_test.go new file mode 100644 index 0000000..1f976a4 --- /dev/null +++ b/internal/fusefs/inode_attrs_unix_test.go @@ -0,0 +1,85 @@ +//go:build !windows + +package fusefs + +import ( + "math" + "os" + "testing" + "time" +) + +func TestInodeAttrs_ClampsNegativeSizeToZero(t *testing.T) { + cases := []struct { + name string + size int64 + }{ + {"minus one", -1}, + {"min int64", math.MinInt64}, + {"arbitrary negative", -4096}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := inodeAttrs(0o644, tc.size, "file", time.Unix(0, 0)) + if got.Size != 0 { + t.Fatalf("file size = %d, want 0 for input %d", got.Size, tc.size) + } + }) + } +} + +func TestInodeAttrs_PreservesPositiveFileSize(t *testing.T) { + got := inodeAttrs(0o644, 42, "file", time.Unix(0, 0)) + if got.Size != 42 { + t.Fatalf("file size = %d, want 42", got.Size) + } +} + +func TestInodeAttrs_PreservesMaxInt64(t *testing.T) { + got := inodeAttrs(0o644, math.MaxInt64, "file", time.Unix(0, 0)) + if got.Size != math.MaxInt64 { + t.Fatalf("file size = %d, want MaxInt64", got.Size) + } +} + +func TestInodeAttrs_DirZeroSizeBecomes4096(t *testing.T) { + got := inodeAttrs(0o755, 0, "dir", time.Unix(0, 0)) + if got.Size != 4096 { + t.Fatalf("dir size = %d, want 4096", got.Size) + } +} + +func TestInodeAttrs_DirNegativeSizeAlsoBecomes4096(t *testing.T) { + // Negative clamps to 0, which the dir branch then upgrades to 4096. + got := inodeAttrs(0o755, -1, "dir", time.Unix(0, 0)) + if got.Size != 4096 { + t.Fatalf("dir size = %d, want 4096", got.Size) + } +} + +func TestInodeAttrs_SymlinkModeBitSet(t *testing.T) { + got := inodeAttrs(0o777, 16, "symlink", time.Unix(0, 0)) + if got.Mode&os.ModeSymlink == 0 { + t.Fatalf("symlink mode bit not set in %v", got.Mode) + } + if got.Size != 16 { + t.Fatalf("symlink size = %d, want 16", got.Size) + } +} + +func TestInodeAttrs_DefaultFileModeWhenZero(t *testing.T) { + got := inodeAttrs(0, 1, "file", time.Unix(0, 0)) + if got.Mode.Perm() != 0o644 { + t.Fatalf("file default perm = %v, want 0644", got.Mode.Perm()) + } +} + +func TestInodeAttrs_DefaultDirModeWhenZero(t *testing.T) { + got := inodeAttrs(0, 1, "dir", time.Unix(0, 0)) + if got.Mode.Perm() != 0o755 { + t.Fatalf("dir default perm = %v, want 0755", got.Mode.Perm()) + } + if got.Mode&os.ModeDir == 0 { + t.Fatalf("dir mode bit not set in %v", got.Mode) + } +}