diff --git a/store/fscache/fscache.go b/store/fscache/fscache.go index 2a97bf1..d44fff0 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 // // # Usage Examples // @@ -58,6 +59,11 @@ // 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)) +// // # Encryption Key Management // // Encryption keys can be provided via DSN parameter or environment variable: @@ -91,7 +97,9 @@ import ( "net/url" "os" "path/filepath" + "runtime" "slices" + "strconv" "strings" "time" @@ -141,6 +149,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 +171,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,6 +239,21 @@ 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") if appname == "" { @@ -241,6 +276,15 @@ func fromURL(u *url.URL) (*fsCache, error) { if updateMTime := u.Query().Get("update_mtime"); updateMTime == "on" { opts = append(opts, WithUpdateMTime(true)) } + if vs := u.Query()["umask"]; vs != nil { + for _, v := range vs { + umask, err := parseUmask(v) + if err != nil { + return nil, err + } + opts = append(opts, WithUmask(umask)) + } + } if cap(opts) > len(opts) { opts = slices.Clip(opts) } @@ -291,7 +335,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 +453,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 +461,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) + } + }) +}