Skip to content
Merged
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
70 changes: 62 additions & 8 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 (default 0)
//
// # Usage Examples
//
Expand All @@ -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:
Expand Down Expand Up @@ -91,7 +99,9 @@ import (
"net/url"
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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

Expand All @@ -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 {
Expand Down Expand Up @@ -219,28 +241,51 @@ 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
}
opts := make([]Option, 0, 5)
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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -409,14 +454,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, 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
Expand Down
89 changes: 89 additions & 0 deletions store/fscache/fscache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import (
"net/url"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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=")
Expand Down Expand Up @@ -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)
}
})
}
Loading