diff --git a/datanode/client.go b/datanode/client.go index c4f09ad..e76e65f 100644 --- a/datanode/client.go +++ b/datanode/client.go @@ -233,7 +233,7 @@ func (m *Medium) Delete(p string) error { } // Remove the file by creating a new DataNode without it - if err := m.removeFileLocked(p); err != nil { + if err := m.removeFilesLocked(map[string]struct{}{p: {}}); err != nil { return coreerr.E("datanode.Delete", "failed to delete file: "+p, err) } return nil @@ -253,10 +253,9 @@ func (m *Medium) DeleteAll(p string) error { // Check if p itself is a file info, err := m.dn.Stat(p) + toDelete := make(map[string]struct{}) if err == nil && !info.IsDir() { - if err := m.removeFileLocked(p); err != nil { - return coreerr.E("datanode.DeleteAll", "failed to delete file: "+p, err) - } + toDelete[p] = struct{}{} found = true } @@ -267,13 +266,17 @@ func (m *Medium) DeleteAll(p string) error { } for _, name := range entries { if name == p || strings.HasPrefix(name, prefix) { - if err := m.removeFileLocked(name); err != nil { - return coreerr.E("datanode.DeleteAll", "failed to delete file: "+name, err) - } + toDelete[name] = struct{}{} found = true } } + if found { + if err := m.removeFilesLocked(toDelete); err != nil { + return coreerr.E("datanode.DeleteAll", "failed to delete files", err) + } + } + // Remove explicit dirs under prefix for d := range m.dirs { if d == p || strings.HasPrefix(d, prefix) { @@ -303,15 +306,10 @@ func (m *Medium) Rename(oldPath, newPath string) error { if !info.IsDir() { // Read old, write new, delete old - data, err := m.readFileLocked(oldPath) - if err != nil { + if err := m.rewriteDataNodeLocked(map[string]string{oldPath: newPath}); err != nil { return coreerr.E("datanode.Rename", "failed to read source file: "+oldPath, err) } - m.dn.AddData(newPath, data) m.ensureDirsLocked(path.Dir(newPath)) - if err := m.removeFileLocked(oldPath); err != nil { - return coreerr.E("datanode.Rename", "failed to remove source file: "+oldPath, err) - } return nil } @@ -323,19 +321,15 @@ func (m *Medium) Rename(oldPath, newPath string) error { if err != nil { return coreerr.E("datanode.Rename", "failed to inspect tree: "+oldPath, err) } + renames := make(map[string]string) for _, name := range entries { if strings.HasPrefix(name, oldPrefix) { - newName := newPrefix + strings.TrimPrefix(name, oldPrefix) - data, err := m.readFileLocked(name) - if err != nil { - return coreerr.E("datanode.Rename", "failed to read source file: "+name, err) - } - m.dn.AddData(newName, data) - if err := m.removeFileLocked(name); err != nil { - return coreerr.E("datanode.Rename", "failed to remove source file: "+name, err) - } + renames[name] = newPrefix + strings.TrimPrefix(name, oldPrefix) } } + if err := m.rewriteDataNodeLocked(renames); err != nil { + return coreerr.E("datanode.Rename", "failed to move source files", err) + } // Move explicit dirs dirsToMove := make(map[string]string) @@ -560,20 +554,38 @@ func (m *Medium) readFileLocked(name string) ([]byte, error) { // This is necessary because Borg's DataNode doesn't expose a Remove method. // Caller must hold m.mu write lock. func (m *Medium) removeFileLocked(target string) error { + exclude := map[string]struct{}{target: {}} + return m.removeFilesLocked(exclude) +} + +func (m *Medium) removeFilesLocked(targets map[string]struct{}) error { + renames := make(map[string]string) + for target := range targets { + renames[target] = "" + } + return m.rewriteDataNodeLocked(renames) +} + +func (m *Medium) rewriteDataNodeLocked(renames map[string]string) error { entries, err := m.collectAllLocked() if err != nil { return err } newDN := borgdatanode.New() for _, name := range entries { - if name == target { + targetName, ok := renames[name] + if ok && targetName == "" { continue } + writeName := name + if ok { + writeName = targetName + } data, err := m.readFileLocked(name) if err != nil { return err } - newDN.AddData(name, data) + newDN.AddData(writeName, data) } m.dn = newDN return nil diff --git a/datanode/client_test.go b/datanode/client_test.go index 8beb6cd..fc9a12a 100644 --- a/datanode/client_test.go +++ b/datanode/client_test.go @@ -215,7 +215,34 @@ func TestRenameDir_Bad_ReadFailure(t *testing.T) { err := m.Rename("src", "dst") require.Error(t, err) - assert.Contains(t, err.Error(), "failed to read source file") + assert.Contains(t, err.Error(), "failed to move source files") +} + +func TestRenameDir_Bad_AtomicFailure(t *testing.T) { + m := New() + require.NoError(t, m.Write("src/a.txt", "one")) + require.NoError(t, m.Write("src/b.txt", "two")) + + readCalls := 0 + originalReadAll := dataNodeReadAll + dataNodeReadAll = func(r io.Reader) ([]byte, error) { + readCalls++ + if readCalls == 2 { + return nil, errors.New("read failed") + } + return originalReadAll(r) + } + t.Cleanup(func() { + dataNodeReadAll = originalReadAll + }) + + err := m.Rename("src", "dst") + require.Error(t, err) + + assert.True(t, m.IsFile("src/a.txt")) + assert.True(t, m.IsFile("src/b.txt")) + assert.False(t, m.IsFile("dst/a.txt")) + assert.False(t, m.IsFile("dst/b.txt")) } func TestList_Good(t *testing.T) { diff --git a/docs/api-contract-scan.md b/docs/api-contract-scan.md new file mode 100644 index 0000000..19e36db --- /dev/null +++ b/docs/api-contract-scan.md @@ -0,0 +1,112 @@ +# API Contract Scan + +`CODEX.md` was not present under `/workspace`; conventions were taken from `CLAUDE.md`. + +Coverage is `yes` when package tests either execute the exported function/method (`go test -coverprofile`) or reference the exported type name directly. + +| Name | Signature | Package Path | Description | Test Coverage | +| --- | --- | --- | --- | --- | +| FromTar | `func FromTar(data []byte) (*Medium, error)` | `dappco.re/go/core/io/datanode` | FromTar creates a Medium from a tarball, restoring all files. | yes | +| New | `func New() *Medium` | `dappco.re/go/core/io/datanode` | New creates a new empty DataNode Medium. | yes | +| Medium.Append | `func (m *Medium) Append(p string) (goio.WriteCloser, error)` | `dappco.re/go/core/io/datanode` | Opens a file for appending and returns a writer. | yes | +| Medium.Create | `func (m *Medium) Create(p string) (goio.WriteCloser, error)` | `dappco.re/go/core/io/datanode` | Creates or truncates a file and returns a writer. | yes | +| Medium.DataNode | `func (m *Medium) DataNode() *borgdatanode.DataNode` | `dappco.re/go/core/io/datanode` | DataNode returns the underlying Borg DataNode. | yes | +| Medium.Delete | `func (m *Medium) Delete(p string) error` | `dappco.re/go/core/io/datanode` | Deletes a file or empty directory. | yes | +| Medium.DeleteAll | `func (m *Medium) DeleteAll(p string) error` | `dappco.re/go/core/io/datanode` | Deletes a file or directory tree recursively. | yes | +| Medium.EnsureDir | `func (m *Medium) EnsureDir(p string) error` | `dappco.re/go/core/io/datanode` | Ensures a directory exists. | yes | +| Medium.Exists | `func (m *Medium) Exists(p string) bool` | `dappco.re/go/core/io/datanode` | Reports whether a path exists. | yes | +| Medium.FileGet | `func (m *Medium) FileGet(p string) (string, error)` | `dappco.re/go/core/io/datanode` | Alias for Read. | yes | +| Medium.FileSet | `func (m *Medium) FileSet(p, content string) error` | `dappco.re/go/core/io/datanode` | Alias for Write. | yes | +| Medium.IsDir | `func (m *Medium) IsDir(p string) bool` | `dappco.re/go/core/io/datanode` | Reports whether a path exists and is a directory. | yes | +| Medium.IsFile | `func (m *Medium) IsFile(p string) bool` | `dappco.re/go/core/io/datanode` | Reports whether a path exists and is a regular file. | yes | +| Medium.List | `func (m *Medium) List(p string) ([]fs.DirEntry, error)` | `dappco.re/go/core/io/datanode` | Lists directory entries under the path. | yes | +| Medium.Open | `func (m *Medium) Open(p string) (fs.File, error)` | `dappco.re/go/core/io/datanode` | Opens a file for reading. | yes | +| Medium.Read | `func (m *Medium) Read(p string) (string, error)` | `dappco.re/go/core/io/datanode` | Reads file contents as a string. | yes | +| Medium.ReadStream | `func (m *Medium) ReadStream(p string) (goio.ReadCloser, error)` | `dappco.re/go/core/io/datanode` | Opens a streaming reader for a file. | yes | +| Medium.Rename | `func (m *Medium) Rename(oldPath, newPath string) error` | `dappco.re/go/core/io/datanode` | Renames or moves a file or directory. | yes | +| Medium.Restore | `func (m *Medium) Restore(data []byte) error` | `dappco.re/go/core/io/datanode` | Restore replaces the filesystem contents from a tarball. | yes | +| Medium.Snapshot | `func (m *Medium) Snapshot() ([]byte, error)` | `dappco.re/go/core/io/datanode` | Snapshot serializes the entire filesystem to a tarball. | yes | +| Medium.Stat | `func (m *Medium) Stat(p string) (fs.FileInfo, error)` | `dappco.re/go/core/io/datanode` | Returns file metadata for the path. | yes | +| Medium.Write | `func (m *Medium) Write(p, content string) error` | `dappco.re/go/core/io/datanode` | Writes string content to a file. | yes | +| Medium.WriteMode | `func (m *Medium) WriteMode(p, content string, mode os.FileMode) error` | `dappco.re/go/core/io/datanode` | Writes content to a file with an explicit mode. | no | +| Medium.WriteStream | `func (m *Medium) WriteStream(p string) (goio.WriteCloser, error)` | `dappco.re/go/core/io/datanode` | Opens a streaming writer for a file. | yes | +| Medium | `type Medium struct` | `dappco.re/go/core/io/datanode` | Medium is an in-memory storage backend backed by a Borg DataNode. | yes | +| New | `func New(root string) (*Medium, error)` | `dappco.re/go/core/io/local` | New creates a new local Medium rooted at the given directory. | yes | +| Medium.Append | `func (m *Medium) Append(p string) (goio.WriteCloser, error)` | `dappco.re/go/core/io/local` | Append opens the named file for appending, creating it if it doesn't exist. | no | +| Medium.Create | `func (m *Medium) Create(p string) (goio.WriteCloser, error)` | `dappco.re/go/core/io/local` | Create creates or truncates the named file. | yes | +| Medium.Delete | `func (m *Medium) Delete(p string) error` | `dappco.re/go/core/io/local` | Delete removes a file or empty directory. | yes | +| Medium.DeleteAll | `func (m *Medium) DeleteAll(p string) error` | `dappco.re/go/core/io/local` | DeleteAll removes a file or directory recursively. | yes | +| Medium.EnsureDir | `func (m *Medium) EnsureDir(p string) error` | `dappco.re/go/core/io/local` | EnsureDir creates directory if it doesn't exist. | yes | +| Medium.Exists | `func (m *Medium) Exists(p string) bool` | `dappco.re/go/core/io/local` | Exists returns true if path exists. | yes | +| Medium.FileGet | `func (m *Medium) FileGet(p string) (string, error)` | `dappco.re/go/core/io/local` | FileGet is an alias for Read. | yes | +| Medium.FileSet | `func (m *Medium) FileSet(p, content string) error` | `dappco.re/go/core/io/local` | FileSet is an alias for Write. | yes | +| Medium.IsDir | `func (m *Medium) IsDir(p string) bool` | `dappco.re/go/core/io/local` | IsDir returns true if path is a directory. | yes | +| Medium.IsFile | `func (m *Medium) IsFile(p string) bool` | `dappco.re/go/core/io/local` | IsFile returns true if path is a regular file. | yes | +| Medium.List | `func (m *Medium) List(p string) ([]fs.DirEntry, error)` | `dappco.re/go/core/io/local` | List returns directory entries. | yes | +| Medium.Open | `func (m *Medium) Open(p string) (fs.File, error)` | `dappco.re/go/core/io/local` | Open opens the named file for reading. | yes | +| Medium.Read | `func (m *Medium) Read(p string) (string, error)` | `dappco.re/go/core/io/local` | Read returns file contents as string. | yes | +| Medium.ReadStream | `func (m *Medium) ReadStream(path string) (goio.ReadCloser, error)` | `dappco.re/go/core/io/local` | ReadStream returns a reader for the file content. | yes | +| Medium.Rename | `func (m *Medium) Rename(oldPath, newPath string) error` | `dappco.re/go/core/io/local` | Rename moves a file or directory. | yes | +| Medium.Stat | `func (m *Medium) Stat(p string) (fs.FileInfo, error)` | `dappco.re/go/core/io/local` | Stat returns file info. | yes | +| Medium.Write | `func (m *Medium) Write(p, content string) error` | `dappco.re/go/core/io/local` | Write saves content to file, creating parent directories as needed. | yes | +| Medium.WriteMode | `func (m *Medium) WriteMode(p, content string, mode os.FileMode) error` | `dappco.re/go/core/io/local` | WriteMode saves content to file with explicit permissions. | yes | +| Medium.WriteStream | `func (m *Medium) WriteStream(path string) (goio.WriteCloser, error)` | `dappco.re/go/core/io/local` | WriteStream returns a writer for the file content. | yes | +| Medium | `type Medium struct` | `dappco.re/go/core/io/local` | Medium is a local filesystem storage backend. | yes | +| New | `func New(bucket string, opts ...Option) (*Medium, error)` | `dappco.re/go/core/io/s3` | New creates a new S3 Medium for the given bucket. | yes | +| WithClient | `func WithClient(client *s3.Client) Option` | `dappco.re/go/core/io/s3` | WithClient sets the S3 client for dependency injection. | no | +| WithPrefix | `func WithPrefix(prefix string) Option` | `dappco.re/go/core/io/s3` | WithPrefix sets an optional key prefix for all operations. | yes | +| Medium.Append | `func (m *Medium) Append(p string) (goio.WriteCloser, error)` | `dappco.re/go/core/io/s3` | Append opens the named file for appending. | yes | +| Medium.Create | `func (m *Medium) Create(p string) (goio.WriteCloser, error)` | `dappco.re/go/core/io/s3` | Create creates or truncates the named file. | yes | +| Medium.Delete | `func (m *Medium) Delete(p string) error` | `dappco.re/go/core/io/s3` | Delete removes a single object. | yes | +| Medium.DeleteAll | `func (m *Medium) DeleteAll(p string) error` | `dappco.re/go/core/io/s3` | DeleteAll removes all objects under the given prefix. | yes | +| Medium.EnsureDir | `func (m *Medium) EnsureDir(_ string) error` | `dappco.re/go/core/io/s3` | EnsureDir is a no-op for S3 (S3 has no real directories). | yes | +| Medium.Exists | `func (m *Medium) Exists(p string) bool` | `dappco.re/go/core/io/s3` | Exists checks if a path exists (file or directory prefix). | yes | +| Medium.FileGet | `func (m *Medium) FileGet(p string) (string, error)` | `dappco.re/go/core/io/s3` | FileGet is a convenience function that reads a file from the medium. | yes | +| Medium.FileSet | `func (m *Medium) FileSet(p, content string) error` | `dappco.re/go/core/io/s3` | FileSet is a convenience function that writes a file to the medium. | yes | +| Medium.IsDir | `func (m *Medium) IsDir(p string) bool` | `dappco.re/go/core/io/s3` | IsDir checks if a path exists and is a directory (has objects under it as a prefix). | yes | +| Medium.IsFile | `func (m *Medium) IsFile(p string) bool` | `dappco.re/go/core/io/s3` | IsFile checks if a path exists and is a regular file (not a "directory" prefix). | yes | +| Medium.List | `func (m *Medium) List(p string) ([]fs.DirEntry, error)` | `dappco.re/go/core/io/s3` | List returns directory entries for the given path using ListObjectsV2 with delimiter. | yes | +| Medium.Open | `func (m *Medium) Open(p string) (fs.File, error)` | `dappco.re/go/core/io/s3` | Open opens the named file for reading. | yes | +| Medium.Read | `func (m *Medium) Read(p string) (string, error)` | `dappco.re/go/core/io/s3` | Read retrieves the content of a file as a string. | yes | +| Medium.ReadStream | `func (m *Medium) ReadStream(p string) (goio.ReadCloser, error)` | `dappco.re/go/core/io/s3` | ReadStream returns a reader for the file content. | yes | +| Medium.Rename | `func (m *Medium) Rename(oldPath, newPath string) error` | `dappco.re/go/core/io/s3` | Rename moves an object by copying then deleting the original. | yes | +| Medium.Stat | `func (m *Medium) Stat(p string) (fs.FileInfo, error)` | `dappco.re/go/core/io/s3` | Stat returns file information for the given path using HeadObject. | yes | +| Medium.Write | `func (m *Medium) Write(p, content string) error` | `dappco.re/go/core/io/s3` | Write saves the given content to a file, overwriting it if it exists. | yes | +| Medium.WriteStream | `func (m *Medium) WriteStream(p string) (goio.WriteCloser, error)` | `dappco.re/go/core/io/s3` | WriteStream returns a writer for the file content. | yes | +| Medium | `type Medium struct` | `dappco.re/go/core/io/s3` | Medium is an S3-backed storage backend implementing the io.Medium interface. | yes | +| Option | `type Option func(*Medium)` | `dappco.re/go/core/io/s3` | Option configures a Medium. | no | +| New | `func New(dbPath string, opts ...Option) (*Medium, error)` | `dappco.re/go/core/io/sqlite` | New creates a new SQLite Medium at the given database path. | yes | +| WithTable | `func WithTable(table string) Option` | `dappco.re/go/core/io/sqlite` | WithTable sets the table name (default: "files"). | yes | +| Medium.Append | `func (m *Medium) Append(p string) (goio.WriteCloser, error)` | `dappco.re/go/core/io/sqlite` | Append opens the named file for appending, creating it if it doesn't exist. | yes | +| Medium.Close | `func (m *Medium) Close() error` | `dappco.re/go/core/io/sqlite` | Close closes the underlying database connection. | yes | +| Medium.Create | `func (m *Medium) Create(p string) (goio.WriteCloser, error)` | `dappco.re/go/core/io/sqlite` | Create creates or truncates the named file. | yes | +| Medium.Delete | `func (m *Medium) Delete(p string) error` | `dappco.re/go/core/io/sqlite` | Delete removes a file or empty directory. | yes | +| Medium.DeleteAll | `func (m *Medium) DeleteAll(p string) error` | `dappco.re/go/core/io/sqlite` | DeleteAll removes a file or directory and all its contents recursively. | yes | +| Medium.EnsureDir | `func (m *Medium) EnsureDir(p string) error` | `dappco.re/go/core/io/sqlite` | EnsureDir makes sure a directory exists, creating it if necessary. | yes | +| Medium.Exists | `func (m *Medium) Exists(p string) bool` | `dappco.re/go/core/io/sqlite` | Exists checks if a path exists (file or directory). | yes | +| Medium.FileGet | `func (m *Medium) FileGet(p string) (string, error)` | `dappco.re/go/core/io/sqlite` | FileGet is a convenience function that reads a file from the medium. | yes | +| Medium.FileSet | `func (m *Medium) FileSet(p, content string) error` | `dappco.re/go/core/io/sqlite` | FileSet is a convenience function that writes a file to the medium. | yes | +| Medium.IsDir | `func (m *Medium) IsDir(p string) bool` | `dappco.re/go/core/io/sqlite` | IsDir checks if a path exists and is a directory. | yes | +| Medium.IsFile | `func (m *Medium) IsFile(p string) bool` | `dappco.re/go/core/io/sqlite` | IsFile checks if a path exists and is a regular file. | yes | +| Medium.List | `func (m *Medium) List(p string) ([]fs.DirEntry, error)` | `dappco.re/go/core/io/sqlite` | List returns the directory entries for the given path. | yes | +| Medium.Open | `func (m *Medium) Open(p string) (fs.File, error)` | `dappco.re/go/core/io/sqlite` | Open opens the named file for reading. | yes | +| Medium.Read | `func (m *Medium) Read(p string) (string, error)` | `dappco.re/go/core/io/sqlite` | Read retrieves the content of a file as a string. | yes | +| Medium.ReadStream | `func (m *Medium) ReadStream(p string) (goio.ReadCloser, error)` | `dappco.re/go/core/io/sqlite` | ReadStream returns a reader for the file content. | yes | +| Medium.Rename | `func (m *Medium) Rename(oldPath, newPath string) error` | `dappco.re/go/core/io/sqlite` | Rename moves a file or directory from oldPath to newPath. | yes | +| Medium.Stat | `func (m *Medium) Stat(p string) (fs.FileInfo, error)` | `dappco.re/go/core/io/sqlite` | Stat returns file information for the given path. | yes | +| Medium.Write | `func (m *Medium) Write(p, content string) error` | `dappco.re/go/core/io/sqlite` | Write saves the given content to a file, overwriting it if it exists. | yes | +| Medium.WriteStream | `func (m *Medium) WriteStream(p string) (goio.WriteCloser, error)` | `dappco.re/go/core/io/sqlite` | WriteStream returns a writer for the file content. | yes | +| Medium | `type Medium struct` | `dappco.re/go/core/io/sqlite` | Medium is a SQLite-backed storage backend implementing the io.Medium interface. | yes | +| Option | `type Option func(*Medium)` | `dappco.re/go/core/io/sqlite` | Option configures a Medium. | no | +| New | `func New(c *core.Core, crypt ...cryptProvider) (any, error)` | `dappco.re/go/core/io/workspace` | New creates a new Workspace service instance. | yes | +| Workspace.CreateWorkspace | `func (Workspace) CreateWorkspace(identifier, password string) (string, error)` | `dappco.re/go/core/io/workspace` | Creates a new encrypted workspace. | yes | +| Workspace.SwitchWorkspace | `func (Workspace) SwitchWorkspace(name string) error` | `dappco.re/go/core/io/workspace` | Switches the active workspace. | yes | +| Workspace.WorkspaceFileGet | `func (Workspace) WorkspaceFileGet(filename string) (string, error)` | `dappco.re/go/core/io/workspace` | Reads a file from the active workspace. | yes | +| Workspace.WorkspaceFileSet | `func (Workspace) WorkspaceFileSet(filename, content string) error` | `dappco.re/go/core/io/workspace` | Writes a file into the active workspace. | yes | +| Service.CreateWorkspace | `func (s *Service) CreateWorkspace(identifier, password string) (string, error)` | `dappco.re/go/core/io/workspace` | CreateWorkspace creates a new encrypted workspace. | yes | +| Service.HandleIPCEvents | `func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) core.Result` | `dappco.re/go/core/io/workspace` | HandleIPCEvents handles workspace-related IPC messages. | no | +| Service.SwitchWorkspace | `func (s *Service) SwitchWorkspace(name string) error` | `dappco.re/go/core/io/workspace` | SwitchWorkspace changes the active workspace. | yes | +| Service.WorkspaceFileGet | `func (s *Service) WorkspaceFileGet(filename string) (string, error)` | `dappco.re/go/core/io/workspace` | WorkspaceFileGet retrieves the content of a file from the active workspace. | yes | +| Service.WorkspaceFileSet | `func (s *Service) WorkspaceFileSet(filename, content string) error` | `dappco.re/go/core/io/workspace` | WorkspaceFileSet saves content to a file in the active workspace. | yes | +| Service | `type Service struct` | `dappco.re/go/core/io/workspace` | Service implements the Workspace interface. | yes | +| Workspace | `type Workspace interface` | `dappco.re/go/core/io/workspace` | Workspace provides management for encrypted user workspaces. | no | diff --git a/local/client.go b/local/client.go index d4aaafc..044804d 100644 --- a/local/client.go +++ b/local/client.go @@ -154,11 +154,22 @@ func canonicalPath(p string) string { return absolutePath(p) } +func osUserHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} + func isProtectedPath(full string) bool { full = canonicalPath(full) protected := map[string]struct{}{ canonicalPath(dirSeparator()): {}, } + if home := osUserHomeDir(); home != "" { + protected[canonicalPath(home)] = struct{}{} + } for _, home := range []string{core.Env("HOME"), core.Env("DIR_HOME")} { if home == "" { continue diff --git a/local/client_test.go b/local/client_test.go index 120ee0e..a714554 100644 --- a/local/client_test.go +++ b/local/client_test.go @@ -198,6 +198,21 @@ func TestDeleteAll_ProtectedHomeViaEnv(t *testing.T) { assert.DirExists(t, tempHome) } +func TestDelete_ProtectedHomeBypassesEnvHijack(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + require.NotEmpty(t, home) + + t.Setenv("HOME", t.TempDir()) + + m, err := New("/") + require.NoError(t, err) + + err = m.Delete(home) + require.Error(t, err) + assert.DirExists(t, home) +} + func TestRename(t *testing.T) { root := t.TempDir() m, _ := New(root) diff --git a/s3/s3.go b/s3/s3.go index 3ca4ab9..f6c1301 100644 --- a/s3/s3.go +++ b/s3/s3.go @@ -195,6 +195,28 @@ func (m *Medium) FileSet(p, content string) error { return m.Write(p, content) } +func (m *Medium) deleteObjectBatch(prefix string, keys []string) error { + if len(keys) == 0 { + return nil + } + objects := make([]types.ObjectIdentifier, len(keys)) + for i, key := range keys { + objects[i] = types.ObjectIdentifier{Key: aws.String(key)} + } + + deleteOut, err := m.client.DeleteObjects(context.Background(), &s3.DeleteObjectsInput{ + Bucket: aws.String(m.bucket), + Delete: &types.Delete{Objects: objects, Quiet: aws.Bool(true)}, + }) + if err != nil { + return coreerr.E("s3.DeleteAll", "failed to delete objects", err) + } + if err := deleteObjectsError(prefix, deleteOut.Errors); err != nil { + return err + } + return nil +} + // Delete removes a single object. func (m *Medium) Delete(p string) error { key := m.key(p) @@ -219,16 +241,7 @@ func (m *Medium) DeleteAll(p string) error { return coreerr.E("s3.DeleteAll", "path is required", os.ErrInvalid) } - // First, try deleting the exact key - _, err := m.client.DeleteObject(context.Background(), &s3.DeleteObjectInput{ - Bucket: aws.String(m.bucket), - Key: aws.String(key), - }) - if err != nil { - return coreerr.E("s3.DeleteAll", "failed to delete object: "+key, err) - } - - // Then delete all objects under the prefix + deleteKeys := []string{key} prefix := key if !strings.HasSuffix(prefix, "/") { prefix += "/" @@ -247,26 +260,20 @@ func (m *Medium) DeleteAll(p string) error { return coreerr.E("s3.DeleteAll", "failed to list objects: "+prefix, err) } + for _, obj := range listOut.Contents { + deleteKeys = append(deleteKeys, aws.ToString(obj.Key)) + if len(deleteKeys) == 1000 { + if err := m.deleteObjectBatch(prefix, deleteKeys); err != nil { + return err + } + deleteKeys = nil + } + } + if len(listOut.Contents) == 0 { break } - objects := make([]types.ObjectIdentifier, len(listOut.Contents)) - for i, obj := range listOut.Contents { - objects[i] = types.ObjectIdentifier{Key: obj.Key} - } - - deleteOut, err := m.client.DeleteObjects(context.Background(), &s3.DeleteObjectsInput{ - Bucket: aws.String(m.bucket), - Delete: &types.Delete{Objects: objects, Quiet: aws.Bool(true)}, - }) - if err != nil { - return coreerr.E("s3.DeleteAll", "failed to delete objects", err) - } - if err := deleteObjectsError(prefix, deleteOut.Errors); err != nil { - return err - } - if listOut.IsTruncated != nil && *listOut.IsTruncated { continuationToken = listOut.NextContinuationToken } else { @@ -274,6 +281,12 @@ func (m *Medium) DeleteAll(p string) error { } } + if len(deleteKeys) > 0 { + if err := m.deleteObjectBatch(prefix, deleteKeys); err != nil { + return err + } + } + return nil } diff --git a/s3/s3_test.go b/s3/s3_test.go index a81efff..d614576 100644 --- a/s3/s3_test.go +++ b/s3/s3_test.go @@ -3,7 +3,6 @@ package s3 import ( "bytes" "context" - "errors" "fmt" goio "io" "io/fs" @@ -365,11 +364,18 @@ func TestDeleteAll_Bad_EmptyPath(t *testing.T) { func TestDeleteAll_Bad_DeleteObjectError(t *testing.T) { m, mock := newTestMedium(t) - mock.deleteObjectErrors["dir"] = errors.New("boom") + require.NoError(t, m.Write("dir", "metadata")) + mock.deleteObjectsErrs["dir"] = types.Error{ + Key: aws.String("dir"), + Code: aws.String("AccessDenied"), + Message: aws.String("blocked"), + } err := m.DeleteAll("dir") require.Error(t, err) - assert.Contains(t, err.Error(), "failed to delete object: dir") + assert.Contains(t, err.Error(), "partial delete failed") + assert.Contains(t, err.Error(), "dir: AccessDenied blocked") + assert.True(t, m.IsFile("dir")) } func TestDeleteAll_Bad_PartialDelete(t *testing.T) { diff --git a/workspace/service.go b/workspace/service.go index 9e81764..e7c6ad9 100644 --- a/workspace/service.go +++ b/workspace/service.go @@ -3,7 +3,9 @@ package workspace import ( "crypto/sha256" "encoding/hex" + "errors" "os" + "path/filepath" "strings" "sync" @@ -195,10 +197,37 @@ func workspaceHome() string { return core.Env("DIR_HOME") } +func resolveWorkspacePath(rootPath, workspacePath string) error { + resolvedRoot, err := filepath.EvalSymlinks(rootPath) + if err != nil { + return err + } + + resolvedPath, err := filepath.EvalSymlinks(workspacePath) + if err != nil { + if !os.IsNotExist(err) { + return err + } + // The workspace may not exist yet during creation. Resolve the root and + // re-anchor the final entry under it so containment checks still compare + // canonical paths. + resolvedPath = filepath.Join(resolvedRoot, filepath.Base(workspacePath)) + } + + rel, err := filepath.Rel(resolvedRoot, resolvedPath) + if err != nil { + return err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return os.ErrPermission + } + + return nil +} + func joinWithinRoot(root string, parts ...string) (string, error) { - candidate := core.Path(append([]string{root}, parts...)...) - sep := core.Env("DS") - if candidate == root || strings.HasPrefix(candidate, root+sep) { + candidate := filepath.Clean(core.Path(append([]string{root}, parts...)...)) + if candidate == root || strings.HasPrefix(candidate, root+string(os.PathSeparator)) { return candidate, nil } return "", os.ErrPermission @@ -208,13 +237,34 @@ func (s *Service) workspacePath(op, name string) (string, error) { if name == "" { return "", coreerr.E(op, "workspace name is required", os.ErrInvalid) } - path, err := joinWithinRoot(s.rootPath, name) + path := filepath.Clean(core.Path(s.rootPath, name)) + if err := resolveWorkspacePath(s.rootPath, path); err != nil { + if errors.Is(err, os.ErrPermission) { + return "", coreerr.E(op, "workspace path escapes root", err) + } + return "", coreerr.E(op, "failed to resolve workspace path", err) + } + + rel, err := filepath.Rel(s.rootPath, path) if err != nil { - return "", coreerr.E(op, "workspace path escapes root", err) + return "", coreerr.E(op, "failed to resolve workspace path", err) + } + if rel == "." { + return "", coreerr.E(op, "invalid workspace name: "+name, os.ErrInvalid) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", coreerr.E(op, "workspace path escapes root", os.ErrPermission) + } + if strings.Contains(rel, string(os.PathSeparator)) { + return "", coreerr.E(op, "invalid workspace name: "+name, os.ErrInvalid) } if core.PathDir(path) != s.rootPath { return "", coreerr.E(op, "invalid workspace name: "+name, os.ErrPermission) } + if path == s.rootPath { + return "", coreerr.E(op, "invalid workspace name: "+name, os.ErrInvalid) + } + return path, nil } diff --git a/workspace/service_test.go b/workspace/service_test.go index 1fc7abe..f29ea6c 100644 --- a/workspace/service_test.go +++ b/workspace/service_test.go @@ -67,6 +67,27 @@ func TestSwitchWorkspace_TraversalBlocked(t *testing.T) { assert.Empty(t, s.activeWorkspace) } +func TestSwitchWorkspace_DotNameBlocked(t *testing.T) { + s, _ := newTestService(t) + + err := s.SwitchWorkspace(".") + require.Error(t, err) + assert.Empty(t, s.activeWorkspace) +} + +func TestSwitchWorkspace_SymlinkEscapeBlocked(t *testing.T) { + s, tempHome := newTestService(t) + + outside := t.TempDir() + linkPath := core.Path(tempHome, ".core", "workspaces", "escaped-link") + require.NoError(t, os.Symlink(outside, linkPath)) + + err := s.SwitchWorkspace("escaped-link") + require.Error(t, err) + assert.ErrorIs(t, err, os.ErrPermission) + assert.Empty(t, s.activeWorkspace) +} + func TestWorkspaceFileSet_TraversalBlocked(t *testing.T) { s, tempHome := newTestService(t)