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
43 changes: 41 additions & 2 deletions store/fscache/fscache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand All @@ -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:
Expand Down Expand Up @@ -92,6 +98,7 @@ import (
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -141,6 +148,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

Expand All @@ -162,6 +170,17 @@ func parseTimeout(v string) time.Duration {
return max(timeout, 0)
}

func parseUmask(v string) fs.FileMode {
if v == "" {
return 0
}
umask, err := strconv.ParseUint(v, 8, 32)
if err == nil {
return fs.FileMode(umask)
}
return 0
}

var errEncryptionEnabledWithoutKey = errors.New("fscache: encryption enabled but no key provided")

type Option interface {
Expand Down Expand Up @@ -219,6 +238,14 @@ func WithUpdateMTime(enabled bool) Option {
})
}

// WithUmask sets the permission mask for created files and directories.
func WithUmask(umask fs.FileMode) Option {
return optionFunc(func(c *fsCache) error {
c.umask = umask
return nil
})
}

func fromURL(u *url.URL) (*fsCache, error) {
appname := u.Query().Get("appname")
if appname == "" {
Expand All @@ -241,6 +268,9 @@ func fromURL(u *url.URL) (*fsCache, error) {
if updateMTime := u.Query().Get("update_mtime"); updateMTime == "on" {
opts = append(opts, WithUpdateMTime(true))
}
if v := u.Query().Get("umask"); v != "" {
opts = append(opts, WithUmask(parseUmask(v)))
}
if cap(opts) > len(opts) {
opts = slices.Clip(opts)
}
Expand Down Expand Up @@ -291,7 +321,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
Expand Down Expand Up @@ -409,14 +439,23 @@ 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)
if err != nil {
return err
}
defer f.Close()
if c.umask != 0 {
info, err := f.Stat()
if err != nil {
return err
}
if err := f.Chmod(info.Mode().Perm() &^ c.umask); err != nil {
return err
}
}
_, err = f.Write(entry)
if err != nil {
return err
Expand Down
44 changes: 44 additions & 0 deletions store/fscache/fscache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,24 @@ func Test_parseTimeout(t *testing.T) {
}
}

func Test_parseUmask(t *testing.T) {
tests := []struct {
name string
v string
want fs.FileMode
}{
{"empty", "", 0},
{"valid", "022", fs.FileMode(0o022)},
{"invalid", "invalid", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseUmask(tt.v)
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=")
Expand Down Expand Up @@ -319,3 +337,29 @@ func Test_fsCache_SetGet_UpdateMTime(t *testing.T) {

testutil.AssertTrue(t, mtime2.After(mtime1))
}

func Test_fsCache_SetGet_Umask(t *testing.T) {
u, err := url.Parse("fscache://" + filepath.ToSlash(t.TempDir()) +
"?appname=testapp&umask=077")
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()&0o077 == 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()&0o077 == 0)
}