diff --git a/.gitignore b/.gitignore index 64b8f8a8..39e7149c 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 00000000..41277c88 --- /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/client/filejsonstore/filejsonstore_test.go b/client/filejsonstore/filejsonstore_test.go new file mode 100644 index 00000000..6d00e046 --- /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 00000000..cda58c43 --- /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/fsutil.go b/internal/fsutil/fsutil.go new file mode 100644 index 00000000..138f2103 --- /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 00000000..f94add60 --- /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_test.go b/internal/fsutil/perm_test.go new file mode 100644 index 00000000..6061d291 --- /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) +} diff --git a/internal/fsutil/perm_windows.go b/internal/fsutil/perm_windows.go new file mode 100644 index 00000000..cecfee65 --- /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 34038f35..1b4a7f60 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 }