Skip to content

Commit 027940d

Browse files
twpaynebartventer
authored andcommitted
feat(fscache): add umask for file and dir permission control
1 parent cf5ae53 commit 027940d

2 files changed

Lines changed: 151 additions & 8 deletions

File tree

store/fscache/fscache.go

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
// - encrypt (optional): Enable AES-GCM encryption ("on" or "aesgcm")
3434
// - encrypt_key (optional): Base64-encoded AES key (URL-safe, RFC 4648 §5)
3535
// - update_mtime (optional): Update file mtime on cache hits ("on" to enable)
36+
// - umask (optional): Permission mask to apply to created files and directories (default 0)
3637
//
3738
// # Usage Examples
3839
//
@@ -58,6 +59,13 @@
5859
// fscache://?appname=myapp&update_mtime=on
5960
// fscache.Open("myapp", fscache.WithUpdateMTime(true))
6061
//
62+
// Private cache files and directories:
63+
//
64+
// fscache://?appname=myapp&umask=077
65+
// fscache.Open("myapp", fscache.WithUmask(0o077))
66+
//
67+
// On Windows, only umask=0 is supported.
68+
//
6169
// # Encryption Key Management
6270
//
6371
// Encryption keys can be provided via DSN parameter or environment variable:
@@ -91,7 +99,9 @@ import (
9199
"net/url"
92100
"os"
93101
"path/filepath"
102+
"runtime"
94103
"slices"
104+
"strconv"
95105
"strings"
96106
"time"
97107

@@ -141,6 +151,7 @@ type fsCache struct {
141151
timeout time.Duration // optional timeout for operations
142152
enc encryptor // optional encryptor for data
143153
updateMTime bool // whether to update file mtime on cache hits
154+
umask fs.FileMode // umask for created files and directories
144155

145156
// internal dependencies
146157

@@ -162,6 +173,17 @@ func parseTimeout(v string) time.Duration {
162173
return max(timeout, 0)
163174
}
164175

176+
func parseUmask(v string) (fs.FileMode, error) {
177+
if v == "" {
178+
return fs.FileMode(0), errors.New("empty umask")
179+
}
180+
umask, err := strconv.ParseUint(v, 8, 32)
181+
if err != nil {
182+
return fs.FileMode(0), fmt.Errorf("invalid umask: %s: %w", v, err)
183+
}
184+
return fs.FileMode(umask), nil
185+
}
186+
165187
var errEncryptionEnabledWithoutKey = errors.New("fscache: encryption enabled but no key provided")
166188

167189
type Option interface {
@@ -219,28 +241,51 @@ func WithUpdateMTime(enabled bool) Option {
219241
})
220242
}
221243

244+
// WithUmask sets the permission mask for created files and directories. On
245+
// Windows, only 0 (no permission change) is supported, see [os.Chmod].
246+
func WithUmask(umask fs.FileMode) Option {
247+
return optionFunc(func(c *fsCache) error {
248+
if umask > 0o777 {
249+
return fmt.Errorf("%o: invalid umask", umask)
250+
}
251+
if runtime.GOOS == "windows" && umask != 0 {
252+
return fmt.Errorf("%o: unsupported umask on Windows", umask)
253+
}
254+
c.umask = umask
255+
return nil
256+
})
257+
}
258+
222259
func fromURL(u *url.URL) (*fsCache, error) {
223-
appname := u.Query().Get("appname")
260+
query := u.Query()
261+
appname := query.Get("appname")
224262
if appname == "" {
225263
return nil, ErrMissingAppName
226264
}
227265
opts := make([]Option, 0, 5)
228266
if u.Path != "" && u.Path != "/" {
229267
opts = append(opts, WithBaseDir(u.Path))
230268
}
231-
if v := u.Query().Get("connect_timeout"); v != "" {
269+
if v := query.Get("connect_timeout"); v != "" {
232270
opts = append(opts, WithConnectTimeout(parseTimeout(v)))
233271
}
234-
if v := u.Query().Get("timeout"); v != "" {
272+
if v := query.Get("timeout"); v != "" {
235273
opts = append(opts, WithTimeout(parseTimeout(v)))
236274
}
237-
if encrypt := u.Query().Get("encrypt"); encrypt == "on" || encrypt == "aesgcm" {
238-
key := cmp.Or(u.Query().Get("encrypt_key"), os.Getenv("FSCACHE_ENCRYPT_KEY"))
275+
if encrypt := query.Get("encrypt"); encrypt == "on" || encrypt == "aesgcm" {
276+
key := cmp.Or(query.Get("encrypt_key"), os.Getenv("FSCACHE_ENCRYPT_KEY"))
239277
opts = append(opts, WithEncryption(key))
240278
}
241-
if updateMTime := u.Query().Get("update_mtime"); updateMTime == "on" {
279+
if updateMTime := query.Get("update_mtime"); updateMTime == "on" {
242280
opts = append(opts, WithUpdateMTime(true))
243281
}
282+
if query.Has("umask") {
283+
umask, err := parseUmask(query.Get("umask"))
284+
if err != nil {
285+
return nil, err
286+
}
287+
opts = append(opts, WithUmask(umask))
288+
}
244289
if cap(opts) > len(opts) {
245290
opts = slices.Clip(opts)
246291
}
@@ -291,7 +336,7 @@ func (c *fsCache) initialize(appname string) error {
291336
return ErrMissingAppName
292337
}
293338
c.base = filepath.Join(c.base, appname)
294-
if err := os.MkdirAll(c.base, 0o755); err != nil {
339+
if err := os.MkdirAll(c.base, 0o755&^c.umask); err != nil {
295340
return errors.Join(ErrCreateCacheDir, err)
296341
}
297342
var err error
@@ -409,14 +454,23 @@ func (c *fsCache) set(key string, entry []byte) error {
409454
}
410455
}
411456
name := c.fn.FileName(key)
412-
if err := c.root.MkdirAll(filepath.Dir(name), 0o755); err != nil {
457+
if err := c.root.MkdirAll(filepath.Dir(name), 0o755&^c.umask); err != nil {
413458
return err
414459
}
415460
f, err := c.root.Create(name)
416461
if err != nil {
417462
return err
418463
}
419464
defer f.Close()
465+
if c.umask != 0 {
466+
info, err2 := f.Stat()
467+
if err2 != nil {
468+
return err2
469+
}
470+
if err3 := f.Chmod(info.Mode().Perm() &^ c.umask); err3 != nil {
471+
return err3
472+
}
473+
}
420474
_, err = f.Write(entry)
421475
if err != nil {
422476
return err

store/fscache/fscache_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import (
2323
"net/url"
2424
"path/filepath"
2525
"runtime"
26+
"strconv"
27+
"strings"
2628
"testing"
2729
"time"
2830

@@ -253,6 +255,30 @@ func Test_parseTimeout(t *testing.T) {
253255
}
254256
}
255257

258+
func Test_parseUmask(t *testing.T) {
259+
tests := []struct {
260+
name string
261+
v string
262+
want fs.FileMode
263+
wantErr bool
264+
}{
265+
{"empty", "", 0, true},
266+
{"valid", "022", fs.FileMode(0o022), false},
267+
{"invalid", "invalid", 0, true},
268+
}
269+
for _, tt := range tests {
270+
t.Run(tt.name, func(t *testing.T) {
271+
got, err := parseUmask(tt.v)
272+
if tt.wantErr {
273+
testutil.RequireError(t, err)
274+
} else {
275+
testutil.RequireNoError(t, err)
276+
testutil.AssertEqual(t, tt.want, got, "parseUmask(%q)", tt.v)
277+
}
278+
})
279+
}
280+
}
281+
256282
func TestFSCache_SetGet_WithEncryption(t *testing.T) {
257283
u, err := url.Parse("fscache://" + filepath.ToSlash(t.TempDir()) +
258284
"?appname=testapp&encrypt=aesgcm&encrypt_key=6S-Ks2YYOW0xMvTzKSv6QD30gZeOi1c6Ydr-As5csWk=")
@@ -319,3 +345,66 @@ func Test_fsCache_SetGet_UpdateMTime(t *testing.T) {
319345

320346
testutil.AssertTrue(t, mtime2.After(mtime1))
321347
}
348+
349+
func Test_fsCache_SetGet_Umask(t *testing.T) {
350+
umask := fs.FileMode(0o077)
351+
if runtime.GOOS == "windows" {
352+
umask = fs.FileMode(0)
353+
}
354+
u, err := url.Parse("fscache://" + filepath.ToSlash(t.TempDir()) +
355+
"?appname=testapp&umask=" + strconv.FormatUint(uint64(umask), 8))
356+
testutil.RequireNoError(t, err)
357+
cache, err := fromURL(u)
358+
testutil.RequireNoError(t, err)
359+
t.Cleanup(func() { cache.Close() })
360+
361+
keyName := "mykey"
362+
value := []byte("some value")
363+
364+
err = cache.Set(keyName, value)
365+
testutil.RequireNoError(t, err)
366+
367+
// Check file permissions
368+
fname := cache.fn.FileName(keyName)
369+
info1, err := fs.Stat(cache.root.FS(), fname)
370+
testutil.RequireNoError(t, err)
371+
testutil.AssertTrue(t, info1.Mode().Perm()&umask == 0)
372+
373+
// Check parent directory permissions
374+
info2, err := fs.Stat(cache.root.FS(), filepath.Dir(fname))
375+
testutil.RequireNoError(t, err)
376+
testutil.AssertTrue(t, info2.Mode().Perm()&umask == 0)
377+
}
378+
379+
func Test_fsCache_SetUmaskErrors(t *testing.T) {
380+
t.Run("empty", func(t *testing.T) {
381+
u, err := url.Parse("fscache://" + filepath.ToSlash(t.TempDir()) +
382+
"?appname=testapp&umask=")
383+
testutil.RequireNoError(t, err)
384+
_, err = fromURL(u)
385+
testutil.RequireError(t, err)
386+
testutil.AssertTrue(t, strings.Contains(err.Error(), "empty umask"))
387+
})
388+
389+
t.Run("invalid", func(t *testing.T) {
390+
u, err := url.Parse("fscache://" + filepath.ToSlash(t.TempDir()) +
391+
"?appname=testapp&umask=1000")
392+
testutil.RequireNoError(t, err)
393+
_, err = fromURL(u)
394+
testutil.RequireError(t, err)
395+
testutil.AssertTrue(t, strings.Contains(err.Error(), "invalid umask"))
396+
})
397+
398+
t.Run("unsupported on windows", func(t *testing.T) {
399+
u, err := url.Parse("fscache://" + filepath.ToSlash(t.TempDir()) +
400+
"?appname=testapp&umask=200")
401+
testutil.RequireNoError(t, err)
402+
_, err = fromURL(u)
403+
if runtime.GOOS == "windows" {
404+
testutil.RequireError(t, err)
405+
testutil.AssertTrue(t, strings.Contains(err.Error(), "unsupported umask"))
406+
} else {
407+
testutil.RequireNoError(t, err)
408+
}
409+
})
410+
}

0 commit comments

Comments
 (0)