From 84961e982f6230c6d178c1e54bccedbbfb13ee47 Mon Sep 17 00:00:00 2001 From: Tom Payne Date: Sat, 1 Aug 2026 12:31:16 +0000 Subject: [PATCH] feat(fscache): add umask for file and dir permission control --- store/fscache/fscache.go | 70 +++++++++++++++++++++++---- store/fscache/fscache_test.go | 89 +++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 8 deletions(-) diff --git a/store/fscache/fscache.go b/store/fscache/fscache.go index 2a97bf1..df48dd0 100644 --- a/store/fscache/fscache.go +++ b/store/fscache/fscache.go @@ -33,6 +33,7 @@ // - encrypt (optional): Enable AES-GCM encryption ("on" or "aesgcm") // - encrypt_key (optional): Base64-encoded AES key (URL-safe, RFC 4648 ยง5) // - update_mtime (optional): Update file mtime on cache hits ("on" to enable) +// - umask (optional): Permission mask to apply to created files and directories (default 0) // // # Usage Examples // @@ -58,6 +59,13 @@ // fscache://?appname=myapp&update_mtime=on // fscache.Open("myapp", fscache.WithUpdateMTime(true)) // +// Private cache files and directories: +// +// fscache://?appname=myapp&umask=077 +// fscache.Open("myapp", fscache.WithUmask(0o077)) +// +// On Windows, only umask=0 is supported. +// // # Encryption Key Management // // Encryption keys can be provided via DSN parameter or environment variable: @@ -91,7 +99,9 @@ import ( "net/url" "os" "path/filepath" + "runtime" "slices" + "strconv" "strings" "time" @@ -141,6 +151,7 @@ type fsCache struct { timeout time.Duration // optional timeout for operations enc encryptor // optional encryptor for data updateMTime bool // whether to update file mtime on cache hits + umask fs.FileMode // umask for created files and directories // internal dependencies @@ -162,6 +173,17 @@ func parseTimeout(v string) time.Duration { return max(timeout, 0) } +func parseUmask(v string) (fs.FileMode, error) { + if v == "" { + return fs.FileMode(0), errors.New("empty umask") + } + umask, err := strconv.ParseUint(v, 8, 32) + if err != nil { + return fs.FileMode(0), fmt.Errorf("invalid umask: %s: %w", v, err) + } + return fs.FileMode(umask), nil +} + var errEncryptionEnabledWithoutKey = errors.New("fscache: encryption enabled but no key provided") type Option interface { @@ -219,8 +241,24 @@ func WithUpdateMTime(enabled bool) Option { }) } +// WithUmask sets the permission mask for created files and directories. On +// Windows, only 0 (no permission change) is supported, see [os.Chmod]. +func WithUmask(umask fs.FileMode) Option { + return optionFunc(func(c *fsCache) error { + if umask > 0o777 { + return fmt.Errorf("%o: invalid umask", umask) + } + if runtime.GOOS == "windows" && umask != 0 { + return fmt.Errorf("%o: unsupported umask on Windows", umask) + } + c.umask = umask + return nil + }) +} + func fromURL(u *url.URL) (*fsCache, error) { - appname := u.Query().Get("appname") + query := u.Query() + appname := query.Get("appname") if appname == "" { return nil, ErrMissingAppName } @@ -228,19 +266,26 @@ func fromURL(u *url.URL) (*fsCache, error) { if u.Path != "" && u.Path != "/" { opts = append(opts, WithBaseDir(u.Path)) } - if v := u.Query().Get("connect_timeout"); v != "" { + if v := query.Get("connect_timeout"); v != "" { opts = append(opts, WithConnectTimeout(parseTimeout(v))) } - if v := u.Query().Get("timeout"); v != "" { + if v := query.Get("timeout"); v != "" { opts = append(opts, WithTimeout(parseTimeout(v))) } - if encrypt := u.Query().Get("encrypt"); encrypt == "on" || encrypt == "aesgcm" { - key := cmp.Or(u.Query().Get("encrypt_key"), os.Getenv("FSCACHE_ENCRYPT_KEY")) + if encrypt := query.Get("encrypt"); encrypt == "on" || encrypt == "aesgcm" { + key := cmp.Or(query.Get("encrypt_key"), os.Getenv("FSCACHE_ENCRYPT_KEY")) opts = append(opts, WithEncryption(key)) } - if updateMTime := u.Query().Get("update_mtime"); updateMTime == "on" { + if updateMTime := query.Get("update_mtime"); updateMTime == "on" { opts = append(opts, WithUpdateMTime(true)) } + if query.Has("umask") { + umask, err := parseUmask(query.Get("umask")) + if err != nil { + return nil, err + } + opts = append(opts, WithUmask(umask)) + } if cap(opts) > len(opts) { opts = slices.Clip(opts) } @@ -291,7 +336,7 @@ func (c *fsCache) initialize(appname string) error { return ErrMissingAppName } c.base = filepath.Join(c.base, appname) - if err := os.MkdirAll(c.base, 0o755); err != nil { + if err := os.MkdirAll(c.base, 0o755&^c.umask); err != nil { return errors.Join(ErrCreateCacheDir, err) } var err error @@ -409,7 +454,7 @@ func (c *fsCache) set(key string, entry []byte) error { } } name := c.fn.FileName(key) - if err := c.root.MkdirAll(filepath.Dir(name), 0o755); err != nil { + if err := c.root.MkdirAll(filepath.Dir(name), 0o755&^c.umask); err != nil { return err } f, err := c.root.Create(name) @@ -417,6 +462,15 @@ func (c *fsCache) set(key string, entry []byte) error { return err } defer f.Close() + if c.umask != 0 { + info, err2 := f.Stat() + if err2 != nil { + return err2 + } + if err3 := f.Chmod(info.Mode().Perm() &^ c.umask); err3 != nil { + return err3 + } + } _, err = f.Write(entry) if err != nil { return err diff --git a/store/fscache/fscache_test.go b/store/fscache/fscache_test.go index 6473cbc..d4ec1dd 100644 --- a/store/fscache/fscache_test.go +++ b/store/fscache/fscache_test.go @@ -23,6 +23,8 @@ import ( "net/url" "path/filepath" "runtime" + "strconv" + "strings" "testing" "time" @@ -253,6 +255,30 @@ func Test_parseTimeout(t *testing.T) { } } +func Test_parseUmask(t *testing.T) { + tests := []struct { + name string + v string + want fs.FileMode + wantErr bool + }{ + {"empty", "", 0, true}, + {"valid", "022", fs.FileMode(0o022), false}, + {"invalid", "invalid", 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseUmask(tt.v) + if tt.wantErr { + testutil.RequireError(t, err) + } else { + testutil.RequireNoError(t, err) + testutil.AssertEqual(t, tt.want, got, "parseUmask(%q)", tt.v) + } + }) + } +} + func TestFSCache_SetGet_WithEncryption(t *testing.T) { u, err := url.Parse("fscache://" + filepath.ToSlash(t.TempDir()) + "?appname=testapp&encrypt=aesgcm&encrypt_key=6S-Ks2YYOW0xMvTzKSv6QD30gZeOi1c6Ydr-As5csWk=") @@ -319,3 +345,66 @@ func Test_fsCache_SetGet_UpdateMTime(t *testing.T) { testutil.AssertTrue(t, mtime2.After(mtime1)) } + +func Test_fsCache_SetGet_Umask(t *testing.T) { + umask := fs.FileMode(0o077) + if runtime.GOOS == "windows" { + umask = fs.FileMode(0) + } + u, err := url.Parse("fscache://" + filepath.ToSlash(t.TempDir()) + + "?appname=testapp&umask=" + strconv.FormatUint(uint64(umask), 8)) + testutil.RequireNoError(t, err) + cache, err := fromURL(u) + testutil.RequireNoError(t, err) + t.Cleanup(func() { cache.Close() }) + + keyName := "mykey" + value := []byte("some value") + + err = cache.Set(keyName, value) + testutil.RequireNoError(t, err) + + // Check file permissions + fname := cache.fn.FileName(keyName) + info1, err := fs.Stat(cache.root.FS(), fname) + testutil.RequireNoError(t, err) + testutil.AssertTrue(t, info1.Mode().Perm()&umask == 0) + + // Check parent directory permissions + info2, err := fs.Stat(cache.root.FS(), filepath.Dir(fname)) + testutil.RequireNoError(t, err) + testutil.AssertTrue(t, info2.Mode().Perm()&umask == 0) +} + +func Test_fsCache_SetUmaskErrors(t *testing.T) { + t.Run("empty", func(t *testing.T) { + u, err := url.Parse("fscache://" + filepath.ToSlash(t.TempDir()) + + "?appname=testapp&umask=") + testutil.RequireNoError(t, err) + _, err = fromURL(u) + testutil.RequireError(t, err) + testutil.AssertTrue(t, strings.Contains(err.Error(), "empty umask")) + }) + + t.Run("invalid", func(t *testing.T) { + u, err := url.Parse("fscache://" + filepath.ToSlash(t.TempDir()) + + "?appname=testapp&umask=1000") + testutil.RequireNoError(t, err) + _, err = fromURL(u) + testutil.RequireError(t, err) + testutil.AssertTrue(t, strings.Contains(err.Error(), "invalid umask")) + }) + + t.Run("unsupported on windows", func(t *testing.T) { + u, err := url.Parse("fscache://" + filepath.ToSlash(t.TempDir()) + + "?appname=testapp&umask=200") + testutil.RequireNoError(t, err) + _, err = fromURL(u) + if runtime.GOOS == "windows" { + testutil.RequireError(t, err) + testutil.AssertTrue(t, strings.Contains(err.Error(), "unsupported umask")) + } else { + testutil.RequireNoError(t, err) + } + }) +}