feat: support explicit instance data deletion
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsafeInstanceDataDirectory = errors.New("unsafe instance data directory")
|
||||
ErrInstanceDataNotDirectory = errors.New("instance data path is not a directory")
|
||||
)
|
||||
|
||||
// InstanceDataRemover deletes one explicitly authorized browser User Data Dir.
|
||||
// Callers must check browser occupancy before invoking it.
|
||||
type InstanceDataRemover interface {
|
||||
RemoveInstanceData(context.Context, string) error
|
||||
}
|
||||
|
||||
type instanceDataRemover struct {
|
||||
lstat func(string) (os.FileInfo, error)
|
||||
removeAll func(string) error
|
||||
}
|
||||
|
||||
func NewInstanceDataRemover() InstanceDataRemover {
|
||||
return newInstanceDataRemover(os.Lstat, os.RemoveAll)
|
||||
}
|
||||
|
||||
func newInstanceDataRemover(lstat func(string) (os.FileInfo, error), removeAll func(string) error) instanceDataRemover {
|
||||
return instanceDataRemover{lstat: lstat, removeAll: removeAll}
|
||||
}
|
||||
|
||||
func (r instanceDataRemover) RemoveInstanceData(ctx context.Context, userDataDir string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := normalizeRemovableInstanceDataDir(userDataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := r.lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect instance data directory: %w", err)
|
||||
}
|
||||
if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 || isReparsePoint(info) {
|
||||
return fmt.Errorf("%w: links and reparse points are not removable", ErrUnsafeInstanceDataDirectory)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return ErrInstanceDataNotDirectory
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.removeAll(path); err != nil {
|
||||
return fmt.Errorf("remove instance data directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeRemovableInstanceDataDir(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || !filepath.IsAbs(value) {
|
||||
return "", fmt.Errorf("%w: path must be absolute", ErrUnsafeInstanceDataDirectory)
|
||||
}
|
||||
path := filepath.Clean(value)
|
||||
volumeRoot := filepath.Clean(filepath.VolumeName(path) + string(filepath.Separator))
|
||||
if strings.EqualFold(path, volumeRoot) {
|
||||
return "", fmt.Errorf("%w: volume root is not removable", ErrUnsafeInstanceDataDirectory)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInstanceDataRemoverDeletesExistingDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "profile")
|
||||
if err := os.MkdirAll(filepath.Join(target, "Default"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(target, "Local State"), []byte("state"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remover := NewInstanceDataRemover()
|
||||
if err := remover.RemoveInstanceData(context.Background(), target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("target still exists or could not be inspected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceDataRemoverRejectsUnsafePathsBeforeDeletion(t *testing.T) {
|
||||
called := false
|
||||
remover := newInstanceDataRemover(func(string) (os.FileInfo, error) {
|
||||
return nil, os.ErrNotExist
|
||||
}, func(string) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
for _, path := range []string{"", "relative-profile", filepath.VolumeName(t.TempDir()) + string(filepath.Separator)} {
|
||||
if err := remover.RemoveInstanceData(context.Background(), path); !errors.Is(err, ErrUnsafeInstanceDataDirectory) {
|
||||
t.Fatalf("path %q error = %v", path, err)
|
||||
}
|
||||
}
|
||||
if called {
|
||||
t.Fatal("unsafe path reached removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceDataRemoverRejectsFilesAndLinks(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "not-a-directory")
|
||||
if err := os.WriteFile(file, []byte("data"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewInstanceDataRemover().RemoveInstanceData(context.Background(), file); !errors.Is(err, ErrInstanceDataNotDirectory) {
|
||||
t.Fatalf("file error = %v", err)
|
||||
}
|
||||
|
||||
linkInfo := fakeFileInfo{mode: os.ModeSymlink | 0o777}
|
||||
remover := newInstanceDataRemover(func(string) (os.FileInfo, error) {
|
||||
return linkInfo, nil
|
||||
}, func(string) error {
|
||||
t.Fatal("link reached removal")
|
||||
return nil
|
||||
})
|
||||
if err := remover.RemoveInstanceData(context.Background(), t.TempDir()); !errors.Is(err, ErrUnsafeInstanceDataDirectory) {
|
||||
t.Fatalf("link error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceDataRemoverHonorsCanceledContext(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "profile")
|
||||
if err := os.Mkdir(target, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := NewInstanceDataRemover().RemoveInstanceData(ctx, target); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(target); err != nil {
|
||||
t.Fatalf("canceled deletion changed target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeFileInfo struct {
|
||||
mode os.FileMode
|
||||
sys any
|
||||
}
|
||||
|
||||
func (f fakeFileInfo) Name() string { return "fake" }
|
||||
func (f fakeFileInfo) Size() int64 { return 0 }
|
||||
func (f fakeFileInfo) Mode() os.FileMode { return f.mode }
|
||||
func (f fakeFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (f fakeFileInfo) IsDir() bool { return f.mode.IsDir() }
|
||||
func (f fakeFileInfo) Sys() any { return f.sys }
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package files
|
||||
|
||||
import "io/fs"
|
||||
|
||||
func isReparsePoint(fs.FileInfo) bool {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func isReparsePoint(info fs.FileInfo) bool {
|
||||
attributes, ok := info.Sys().(*syscall.Win32FileAttributeData)
|
||||
return ok && attributes.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build windows
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstanceDataRemoverRejectsWindowsReparsePoint(t *testing.T) {
|
||||
called := false
|
||||
remover := newInstanceDataRemover(func(string) (os.FileInfo, error) {
|
||||
return fakeFileInfo{
|
||||
mode: os.ModeDir,
|
||||
sys: &syscall.Win32FileAttributeData{FileAttributes: syscall.FILE_ATTRIBUTE_REPARSE_POINT},
|
||||
}, nil
|
||||
}, func(string) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
|
||||
err := remover.RemoveInstanceData(context.Background(), `C:\profiles\junction`)
|
||||
if !errors.Is(err, ErrUnsafeInstanceDataDirectory) {
|
||||
t.Fatalf("RemoveInstanceData() error = %v, want unsafe directory", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("reparse point reached removal")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user