Repository: theupdateframework/go-tuf
Pull request #347 (2022-07-29 12:17:26)

feat: Adds a new raw file metadata storage for clients
This came up during a discussion related to new features for the sigstore TUF client, as there is a desire to make implementations in different languages sharing the target and metadata cache.

Release Notes: Possibility to store metadata files as raw JSON files to allow for interoperability with other TUF implementations.

**Types of changes**:
- [  ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [  ] Breaking change (fix or feature that would cause existing functionality to not work as expected)

**Description of the changes being introduced by the pull request**:
This pull request introduces a new implementation of the client's `LocalStore` interface. This storage stores the metadata files are regular files in a provided directory. This would improve compatibility to share metadata cache between different language implementations.

**Please verify and check that the pull request fulfills the following requirements**:

- [x] Tests have been added for the bug fix or new feature
- [ ] Docs have been added for the bug fix or new feature


kommendorkapten: Yeah, let me add version that's safe for concurrent use! 
kommendorkapten: I added a new wrapper, 'ConcurrentLocalStore' which can be used to protect any `LocalStore` (e.g. the in memory one and the raw JSON one) for concurrent access. 
This would let the client decide what use depending on their access pattern.
kommendorkapten: Windows tests are failing, I will look into this tomorrow.
kommendorkapten: The failing tests are due to filesystem permission, which on Windows is very different from UNIX-like operating system.
I found this post on the topic: https://medium.com/@MichalPristas/go-and-file-perms-on-windows-3c944d55dd44
The summary is that in windows you can only create a file with either:
* 0666 (Read/writeable by all users)
* 0444 (Readable by all users)

And this seems to be correct according the source code: https://github.com/golang/go/blob/master/src/syscall/syscall_windows.go#L307
The provided permission is only used to determine if a file is intended to be create as read-only. 
The same is valid for `Mkdir`, the permission mode bits are ignored on Windows: https://github.com/golang/go/blob/master/src/syscall/syscall_windows.go#L518
And for reference the `Chmod` call: https://github.com/golang/go/blob/master/src/syscall/syscall_windows.go#L646 (only the read-only flag is manipulated).

It seems that there are some ACL concept in Windows that we can rely on. But I'm not qualified to speak if the normal permission modes set (all-read or all-write) actually are system-wide or if the effective permission applies to the user only. I found this ACL implementation in go: https://github.com/hectane/go-acl
It haven't had any updates in three years, and I would not be comfortable relying on this, as I lack understanding on Windows filesystem access semantics.

As this feature of verifying the filesystem permission bits, is new for this implementation (LevelDB implementation does not care), nor is there any guidance for implementors of `LocalStore` to consider this my suggestion is _to skip file permissions for Windows_.

Please advise on how to proceed. 

kommendorkapten: Any updates on how to proceed with this (file permission on Windows host)? My comments and proposal are in the comment above: https://github.com/theupdateframework/go-tuf/pull/347#issuecomment-1211615181
cc @trishankatdatadog @ethan-lowman-dd 
trishankatdatadog: > Any updates on how to proceed with this (file permission on Windows host)? My comments and proposal are in the comment above: [#347 (comment)](https://github.com/theupdateframework/go-tuf/pull/347#issuecomment-1211615181) cc @trishankatdatadog @ethan-lowman-dd

I'm going to make a point of looking at this tonight.
trishankatdatadog: > Please advise on how to proceed.

Sorry for the late reply.

Let's take a look at the python-tuf [reference implementation](https://github.com/theupdateframework/python-tuf/blob/7210487281ee24d14ebfcdc7c1543d2751113c5f/tuf/ngclient/updater.py): there, the permissions of the metadata/targets cache directories is not checked, and metadata/targets files appear to be RW-able by the user but not others (using both `tempfile.NamedTemporaryFile()` and `open()`).

I like your idea for checking the permissions on both the directories and files, but technically, this is not a requirement in the TUF specification. At least the directory permissions can probably be left to the application calling go-tuf, but the files inside them are trickier. If the golang stdlib doesn't give control beyond either RW or RO by all, then there is not much we can do for now.

Is not supporting Windows for now an option? That seems the safest to me.
trishankatdatadog: @kommendorkapten do you need another review? If so, I recommend pinging those who have reviewed before. Thanks!
kommendorkapten: @ethan-lowman-dd would you mind taking another look? Addressed your concerns and added filesystem permission check for the metadata files/directories on UNIX-like systems (see discussion above on why Windows is exempted). 
trishankatdatadog: @kommendorkapten if there isn't already a GitHub issue to track the Windows file permissions issue, would you mind making one for it? We should track it. Thanks!
kommendorkapten: @trishankatdatadog Of course! Created this> https://github.com/theupdateframework/go-tuf/issues/360
kommendorkapten: Good feedback @ethan-lowman-dd, all your comments should be addressed now 👍 
ethan-lowman-dd: @asraa or @joshuagl Could you please provide a followup review when you get the chance?
trishankatdatadog: > Good feedback @ethan-lowman-dd, all your comments should be addressed now 👍

Nice work, thanks Ethan
trishankatdatadog: @kommendorkapten would you please rebase and resolve conflicts? thx!
kommendorkapten: Was off on PTO, so didn't see you message @trishankatdatadog until now. PR is updated now.
trishankatdatadog: > Was off on PTO, so didn't see you message @trishankatdatadog until now. PR is updated now.

Thanks! Would you pls fix the linting errors?
kommendorkapten: > Thanks! Would you pls fix the linting errors?

Sorry, was not aware of them, fixed now.

Patch:
diff --git a/.gitignore b/.gitignore
index 64b8f8a..39e7149 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
 cmd/tuf/tuf
 cmd/tuf-client/tuf-client
 .vscode
+*~
diff --git a/client/filejsonstore/filejsonstore.go b/client/filejsonstore/filejsonstore.go
new file mode 100644
index 0000000..41277c8
--- /dev/null
+++ b/client/filejsonstore/filejsonstore.go
@@ -0,0 +1,148 @@
+package client
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"sync"
+
+	"github.com/theupdateframework/go-tuf/client"
+	"github.com/theupdateframework/go-tuf/internal/fsutil"
+	"github.com/theupdateframework/go-tuf/util"
+)
+
+const (
+	// user:  rwx
+	// group: r-x
+	// other: ---
+	dirCreateMode = os.FileMode(0750)
+	// user:  rw-
+	// group: r--
+	// other: ---
+	fileCreateMode = os.FileMode(0640)
+)
+
+// FileJSONStore represents a local metadata cache relying on raw JSON files
+// as retrieved from the remote repository.
+type FileJSONStore struct {
+	mtx     sync.RWMutex
+	baseDir string
+}
+
+var _ client.LocalStore = (*FileJSONStore)(nil)
+
+// NewFileJSONStore returns a new metadata cache, implemented using raw JSON
+// files, stored in a directory provided by the client.
+// If the provided directory does not exist on disk, it will be created.
+// The provided metadata cache is safe for concurrent access.
+func NewFileJSONStore(baseDir string) (*FileJSONStore, error) {
+	f := &FileJSONStore{
+		baseDir: baseDir,
+	}
+
+	// Does the directory exist?
+	fi, err := os.Stat(baseDir)
+	if err != nil {
+		if errors.Is(err, fs.ErrNotExist) {
+			// Create the directory
+			if err = os.MkdirAll(baseDir, dirCreateMode); err != nil {
+				return nil, fmt.Errorf("error creating directory for metadata cache: %w", err)
+			}
+		} else {
+			return nil, fmt.Errorf("error getting FileInfo for %s: %w", baseDir, err)
+		}
+	} else {
+		// Verify that it is a directory
+		if !fi.IsDir() {
+			return nil, fmt.Errorf("can not open %s, not a directory", baseDir)
+		}
+		// Verify file mode is not too permissive.
+		if err = fsutil.EnsureMaxPermissions(fi, dirCreateMode); err != nil {
+			return nil, err
+		}
+	}
+
+	return f, nil
+}
+
+// GetMeta returns the currently cached set of metadata files.
+func (f *FileJSONStore) GetMeta() (map[string]json.RawMessage, error) {
+	f.mtx.RLock()
+	defer f.mtx.RUnlock()
+
+	names, err := os.ReadDir(f.baseDir)
+	if err != nil {
+		return nil, fmt.Errorf("error reading directory %s: %w", f.baseDir, err)
+	}
+
+	meta := map[string]json.RawMessage{}
+	for _, name := range names {
+		ok, err := fsutil.IsMetaFile(name)
+		if err != nil {
+			return nil, err
+		}
+		if !ok {
+			continue
+		}
+
+		// Verify permissions
+		info, err := name.Info()
+		if err != nil {
+			return nil, fmt.Errorf("error retrieving FileInfo for %s: %w", name.Name(), err)
+		}
+		if err = fsutil.EnsureMaxPermissions(info, fileCreateMode); err != nil {
+			return nil, err
+		}
+
+		p := filepath.Join(f.baseDir, name.Name())
+		b, err := os.ReadFile(p)
+		if err != nil {
+			return nil, fmt.Errorf("error reading file %s: %w", name.Name(), err)
+		}
+		meta[name.Name()] = b
+	}
+
+	return meta, nil
+}
+
+// SetMeta stores a metadata file in the cache. If the metadata file exist,
+// it will be overwritten.
+func (f *FileJSONStore) SetMeta(name string, meta json.RawMessage) error {
+	f.mtx.Lock()
+	defer f.mtx.Unlock()
+
+	if filepath.Ext(name) != ".json" {
+		return fmt.Errorf("file %s is not a JSON file", name)
+	}
+
+	p := filepath.Join(f.baseDir, name)
+	err := util.AtomicallyWriteFile(p, meta, fileCreateMode)
+	return err
+}
+
+// DeleteMeta deletes a metadata file from the cache.
+// If the file does not exist, an *os.PathError is returned.
+func (f *FileJSONStore) DeleteMeta(name string) error {
+	f.mtx.Lock()
+	defer f.mtx.Unlock()
+
+	if filepath.Ext(name) != ".json" {
+		return fmt.Errorf("file %s is not a JSON file", name)
+	}
+
+	p := filepath.Join(f.baseDir, name)
+	err := os.Remove(p)
+	if err == nil {
+		return nil
+	}
+
+	return fmt.Errorf("error deleting file %s: %w", name, err)
+}
+
+// Close closes the metadata cache. This is a no-op.
+func (f *FileJSONStore) Close() error {
+	return nil
+}
diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go
new file mode 100644
index 0000000..138f210
--- /dev/null
+++ b/internal/fsutil/fsutil.go
@@ -0,0 +1,23 @@
+// Package fsutil defiens a set of internal utility functions used to
+// interact with the file system.
+package fsutil
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+// IsMetaFile tests wheter a DirEntry appears to be a metadata file or not.
+func IsMetaFile(e os.DirEntry) (bool, error) {
+	if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
+		return false, nil
+	}
+
+	info, err := e.Info()
+	if err != nil {
+		return false, fmt.Errorf("error retrieving FileInfo for %s: %w", e.Name(), err)
+	}
+
+	return info.Mode().IsRegular(), nil
+}
diff --git a/internal/fsutil/perm.go b/internal/fsutil/perm.go
new file mode 100644
index 0000000..f94add6
--- /dev/null
+++ b/internal/fsutil/perm.go
@@ -0,0 +1,30 @@
+//go:build !windows
+// +build !windows
+
+package fsutil
+
+import (
+	"fmt"
+	"os"
+)
+
+// EnsureMaxPermissions tests the provided file info, returning an error if the
+// file's permission bits contain excess permissions not set in maxPerms.
+//
+// For example, a file with permissions -rw------- will successfully validate
+// with maxPerms -rw-r--r-- or -rw-rw-r--, but will not validate with maxPerms
+// -r-------- (due to excess --w------- permission) or --w------- (due to
+// excess -r-------- permission).
+//
+// Only permission bits of the file modes are considered.
+func EnsureMaxPermissions(fi os.FileInfo, maxPerms os.FileMode) error {
+	gotPerm := fi.Mode().Perm()
+	forbiddenPerms := (^maxPerms).Perm()
+	excessPerms := gotPerm & forbiddenPerms
+
+	if excessPerms != 0 {
+		return fmt.Errorf("permission bits for file %v failed validation: want at most %v, got %v with excess perms %v", fi.Name(), maxPerms.Perm(), gotPerm, excessPerms)
+	}
+
+	return nil
+}
diff --git a/internal/fsutil/perm_windows.go b/internal/fsutil/perm_windows.go
new file mode 100644
index 0000000..cecfee6
--- /dev/null
+++ b/internal/fsutil/perm_windows.go
@@ -0,0 +1,17 @@
+package fsutil
+
+import (
+	"os"
+)
+
+// EnsureMaxPermissions tests the provided file info to make sure the
+// permission bits matches the provided.
+// On Windows system the permission bits are not really compatible with
+// UNIX-like permission bits. By setting the UNIX-like permission bits
+// on a Windows system only read/write by all users can be achieved.
+// See this issue for tracking and more details:
+// https://github.com/theupdateframework/go-tuf/issues/360
+// Currently this method will always return nil.
+func EnsureMaxPermissions(fi os.FileInfo, perm os.FileMode) error {
+	return nil
+}
diff --git a/local_store.go b/local_store.go
index 34038f3..1b4a7f6 100644
--- a/local_store.go
+++ b/local_store.go
@@ -13,6 +13,7 @@ import (
 
 	"github.com/theupdateframework/go-tuf/data"
 	"github.com/theupdateframework/go-tuf/encrypted"
+	"github.com/theupdateframework/go-tuf/internal/fsutil"
 	"github.com/theupdateframework/go-tuf/internal/sets"
 	"github.com/theupdateframework/go-tuf/pkg/keys"
 	"github.com/theupdateframework/go-tuf/util"
@@ -221,19 +222,6 @@ func (f *fileSystemStore) stagedDir() string {
 	return filepath.Join(f.dir, "staged")
 }
 
-func isMetaFile(e os.DirEntry) (bool, error) {
-	if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
-		return false, nil
-	}
-
-	info, err := e.Info()
-	if err != nil {
-		return false, err
-	}
-
-	return info.Mode().IsRegular(), nil
-}
-
 func (f *fileSystemStore) GetMeta() (map[string]json.RawMessage, error) {
 	// Build a map of metadata names (e.g. root.json) to their full paths
 	// (whether in the committed repo dir, or in the staged repo dir).
@@ -246,7 +234,7 @@ func (f *fileSystemStore) GetMeta() (map[string]json.RawMessage, error) {
 	}
 
 	for _, e := range committed {
-		imf, err := isMetaFile(e)
+		imf, err := fsutil.IsMetaFile(e)
 		if err != nil {
 			return nil, err
 		}
@@ -263,7 +251,7 @@ func (f *fileSystemStore) GetMeta() (map[string]json.RawMessage, error) {
 	}
 
 	for _, e := range staged {
-		imf, err := isMetaFile(e)
+		imf, err := fsutil.IsMetaFile(e)
 		if err != nil {
 			return nil, err
 		}


Test patch:
diff --git a/client/filejsonstore/filejsonstore_test.go b/client/filejsonstore/filejsonstore_test.go
new file mode 100644
index 0000000..6d00e04
--- /dev/null
+++ b/client/filejsonstore/filejsonstore_test.go
@@ -0,0 +1,194 @@
+package client
+
+import (
+	"encoding/json"
+	"errors"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"gopkg.in/check.v1"
+)
+
+type FileJSONStoreSuite struct{}
+
+var _ = check.Suite(&FileJSONStoreSuite{})
+
+// Hook up gocheck into the "go test" runner
+func Test(t *testing.T) { check.TestingT(t) }
+
+func (FileJSONStoreSuite) TestNewOk(c *check.C) {
+	tmp := c.MkDir()
+	p := filepath.Join(tmp, "tuf_raw.db")
+
+	// Assert path does not exist
+	fi, err := os.Stat(p)
+	c.Assert(fi, check.IsNil)
+	c.Assert(errors.Is(err, os.ErrNotExist), check.Equals, true)
+
+	// Create implementation
+	s, err := NewFileJSONStore(p)
+	c.Assert(err, check.IsNil)
+	c.Assert(s, check.NotNil)
+
+	// Assert path does exist and is a directory
+	fi, err = os.Stat(p)
+	c.Assert(fi, check.NotNil)
+	c.Assert(err, check.IsNil)
+	c.Assert(fi.IsDir(), check.Equals, true)
+}
+
+func (FileJSONStoreSuite) TestNewFileExists(c *check.C) {
+	tmp := c.MkDir()
+	p := filepath.Join(tmp, "tuf_raw.db")
+
+	// Create an empty file
+	f, err := os.Create(p)
+	c.Assert(err, check.IsNil)
+	f.Close()
+
+	// Create implementation
+	s, err := NewFileJSONStore(p)
+	c.Assert(s, check.IsNil)
+	c.Assert(err, check.NotNil)
+	found := strings.Contains(err.Error(), ", not a directory")
+	c.Assert(found, check.Equals, true)
+}
+
+func (FileJSONStoreSuite) TestNewDirectoryExists(c *check.C) {
+	tmp := c.MkDir()
+	p := filepath.Join(tmp, "tuf_raw.db")
+
+	err := os.Mkdir(p, 0750)
+	c.Assert(err, check.IsNil)
+
+	// Create implementation
+	s, err := NewFileJSONStore(p)
+	c.Assert(s, check.NotNil)
+	c.Assert(err, check.IsNil)
+}
+
+func (FileJSONStoreSuite) TestGetMetaEmpty(c *check.C) {
+	tmp := c.MkDir()
+	p := filepath.Join(tmp, "tuf_raw.db")
+	s, err := NewFileJSONStore(p)
+	c.Assert(err, check.IsNil)
+
+	md, err := s.GetMeta()
+	c.Assert(err, check.IsNil)
+	c.Assert(md, check.HasLen, 0)
+}
+
+func (FileJSONStoreSuite) TestGetNoDirectory(c *check.C) {
+	tmp := c.MkDir()
+	p := filepath.Join(tmp, "tuf_raw.db")
+	s, err := NewFileJSONStore(p)
+	c.Assert(err, check.IsNil)
+
+	err = os.Remove(p)
+	c.Assert(err, check.IsNil)
+
+	md, err := s.GetMeta()
+	c.Assert(md, check.IsNil)
+	c.Assert(err, check.NotNil)
+}
+
+func (FileJSONStoreSuite) TestMetadataOperations(c *check.C) {
+	tmp := c.MkDir()
+	p := filepath.Join(tmp, "tuf_raw.db")
+	s, err := NewFileJSONStore(p)
+	c.Assert(err, check.IsNil)
+
+	expected := map[string]json.RawMessage{
+		"file1.json": []byte{0xf1, 0xe1, 0xd1},
+		"file2.json": []byte{0xf2, 0xe2, 0xd2},
+		"file3.json": []byte{0xf3, 0xe3, 0xd3},
+	}
+
+	for k, v := range expected {
+		err := s.SetMeta(k, v)
+		c.Assert(err, check.IsNil)
+	}
+
+	md, err := s.GetMeta()
+	c.Assert(err, check.IsNil)
+	c.Assert(md, check.HasLen, 3)
+	c.Assert(md, check.DeepEquals, expected)
+
+	// Delete all items
+	count := 3
+	for k := range expected {
+		err = s.DeleteMeta(k)
+		c.Assert(err, check.IsNil)
+
+		md, err := s.GetMeta()
+		c.Assert(err, check.IsNil)
+
+		count--
+		c.Assert(md, check.HasLen, count)
+	}
+
+	md, err = s.GetMeta()
+	c.Assert(err, check.IsNil)
+	c.Assert(md, check.HasLen, 0)
+}
+
+func (FileJSONStoreSuite) TestGetNoJSON(c *check.C) {
+	tmp := c.MkDir()
+	p := filepath.Join(tmp, "tuf_raw.db")
+	s, err := NewFileJSONStore(p)
+	c.Assert(s, check.NotNil)
+	c.Assert(err, check.IsNil)
+
+	// Create a file which does not end with '.json'
+	fp := filepath.Join(p, "meta.xml")
+	err = os.WriteFile(fp, []byte{}, 0644)
+	c.Assert(err, check.IsNil)
+
+	md, err := s.GetMeta()
+	c.Assert(err, check.IsNil)
+	c.Assert(md, check.HasLen, 0)
+}
+
+func (FileJSONStoreSuite) TestNoJSON(c *check.C) {
+	tmp := c.MkDir()
+	p := filepath.Join(tmp, "tuf_raw.db")
+	s, err := NewFileJSONStore(p)
+	c.Assert(s, check.NotNil)
+	c.Assert(err, check.IsNil)
+
+	files := []string{
+		"file.xml",
+		"file",
+		"",
+	}
+	for _, f := range files {
+		err := s.SetMeta(f, []byte{})
+		c.Assert(err, check.ErrorMatches, "file.*is not a JSON file")
+	}
+}
+
+func (FileJSONStoreSuite) TestClose(c *check.C) {
+	tmp := c.MkDir()
+	p := filepath.Join(tmp, "tuf_raw.db")
+	s, err := NewFileJSONStore(p)
+	c.Assert(s, check.NotNil)
+	c.Assert(err, check.IsNil)
+
+	err = s.Close()
+	c.Assert(err, check.IsNil)
+}
+
+func (FileJSONStoreSuite) TestDelete(c *check.C) {
+	tmp := c.MkDir()
+	p := filepath.Join(tmp, "tuf_raw.db")
+	s, err := NewFileJSONStore(p)
+	c.Assert(s, check.NotNil)
+	c.Assert(err, check.IsNil)
+
+	err = s.DeleteMeta("not_json.yml")
+	c.Assert(err, check.ErrorMatches, "file not_json\\.yml is not a JSON file")
+	err = s.DeleteMeta("non_existing.json")
+	c.Assert(errors.Is(err, os.ErrNotExist), check.Equals, true)
+}
diff --git a/client/filejsonstore/perm_test.go b/client/filejsonstore/perm_test.go
new file mode 100644
index 0000000..cda58c4
--- /dev/null
+++ b/client/filejsonstore/perm_test.go
@@ -0,0 +1,54 @@
+//go:build !windows
+// +build !windows
+
+package client
+
+import (
+	"os"
+	"path/filepath"
+
+	"gopkg.in/check.v1"
+)
+
+func (FileJSONStoreSuite) TestNewDirectoryExistsWrongPerm(c *check.C) {
+	tmp := c.MkDir()
+	p := filepath.Join(tmp, "tuf_raw.db")
+
+	err := os.Mkdir(p, 0750)
+	c.Assert(err, check.IsNil)
+
+	// Modify the directory permission and try again
+	err = os.Chmod(p, 0751)
+	c.Assert(err, check.IsNil)
+	s, err := NewFileJSONStore(p)
+	c.Assert(s, check.IsNil)
+	c.Assert(err, check.ErrorMatches, "permission bits for file tuf_raw.db failed.*")
+}
+
+func (FileJSONStoreSuite) TestNewNoCreate(c *check.C) {
+	tmp := c.MkDir()
+	p := filepath.Join(tmp, "tuf_raw.db")
+
+	// Clear the write bit for the user
+	err := os.Chmod(tmp, 0551)
+	c.Assert(err, check.IsNil)
+	s, err := NewFileJSONStore(p)
+	c.Assert(s, check.IsNil)
+	c.Assert(err, check.NotNil)
+}
+
+func (FileJSONStoreSuite) TestGetTooPermissive(c *check.C) {
+	tmp := c.MkDir()
+	p := filepath.Join(tmp, "tuf_raw.db")
+	s, err := NewFileJSONStore(p)
+	c.Assert(s, check.NotNil)
+	c.Assert(err, check.IsNil)
+
+	fp := filepath.Join(p, "meta.json")
+	err = os.WriteFile(fp, []byte{}, 0644)
+	c.Assert(err, check.IsNil)
+
+	md, err := s.GetMeta()
+	c.Assert(md, check.IsNil)
+	c.Assert(err, check.ErrorMatches, "permission bits for file meta.json failed.*")
+}
diff --git a/internal/fsutil/perm_test.go b/internal/fsutil/perm_test.go
new file mode 100644
index 0000000..6061d29
--- /dev/null
+++ b/internal/fsutil/perm_test.go
@@ -0,0 +1,71 @@
+//go:build !windows
+// +build !windows
+
+package fsutil
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestEnsureMaxPermissions(t *testing.T) {
+	tmp := t.TempDir()
+	p := filepath.Join(tmp, "file.txt")
+
+	// Start with 0644 and change using os.Chmod so umask doesn't interfere.
+	err := os.WriteFile(p, []byte(`AAA`), 0644)
+	assert.NoError(t, err)
+
+	// Check matching (1)
+	err = os.Chmod(p, 0464)
+	assert.NoError(t, err)
+	fi, err := os.Stat(p)
+	assert.NoError(t, err)
+	err = EnsureMaxPermissions(fi, os.FileMode(0464))
+	assert.NoError(t, err)
+
+	// Check matching (2)
+	err = os.Chmod(p, 0642)
+	assert.NoError(t, err)
+	fi, err = os.Stat(p)
+	assert.NoError(t, err)
+	err = EnsureMaxPermissions(fi, os.FileMode(0642))
+	assert.NoError(t, err)
+
+	// Check matching with file mode bits
+	err = os.Chmod(p, 0444)
+	assert.NoError(t, err)
+	fi, err = os.Stat(p)
+	assert.NoError(t, err)
+	err = EnsureMaxPermissions(fi, os.ModeSymlink|os.ModeAppend|os.FileMode(0444))
+	assert.NoError(t, err)
+
+	// Check not matching (1)
+	err = os.Chmod(p, 0444)
+	assert.NoError(t, err)
+	fi, err = os.Stat(p)
+	assert.NoError(t, err)
+	err = EnsureMaxPermissions(fi, os.FileMode(0400))
+	assert.Error(t, err)
+
+	// Check not matching (2)
+	err = os.Chmod(p, 0444)
+	assert.NoError(t, err)
+	fi, err = os.Stat(p)
+	assert.NoError(t, err)
+	err = EnsureMaxPermissions(fi, os.FileMode(0222))
+	assert.Error(t, err)
+	fmt.Println(err)
+
+	// Check matching due to more restrictive perms on file
+	err = os.Chmod(p, 0444)
+	assert.NoError(t, err)
+	fi, err = os.Stat(p)
+	assert.NoError(t, err)
+	err = EnsureMaxPermissions(fi, os.FileMode(0666))
+	assert.NoError(t, err)
+}