feat: add portable directory safeguards
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var ErrInstanceDataDirectoryUnavailable = errors.New("instance data directory is unavailable")
|
||||
|
||||
// DirectoryPreparer verifies one explicitly supplied absolute directory.
|
||||
// It is used for program-owned locations such as the configured log folder.
|
||||
type DirectoryPreparer interface {
|
||||
PrepareDirectory(context.Context, string) error
|
||||
}
|
||||
|
||||
// InstanceDataPreparer creates and verifies only the User Data Dir belonging
|
||||
// to the explicitly requested instance. It never discovers or changes other
|
||||
// browser data directories.
|
||||
type InstanceDataPreparer interface {
|
||||
PrepareInstanceDataDir(context.Context, string) error
|
||||
}
|
||||
|
||||
type instanceDataPreparer struct {
|
||||
mkdirAll func(string, os.FileMode) error
|
||||
lstat func(string) (os.FileInfo, error)
|
||||
createTemp func(string, string) (*os.File, error)
|
||||
remove func(string) error
|
||||
}
|
||||
|
||||
func NewInstanceDataPreparer() InstanceDataPreparer {
|
||||
return newInstanceDataPreparer(os.MkdirAll, os.Lstat, os.CreateTemp, os.Remove)
|
||||
}
|
||||
|
||||
func NewDirectoryPreparer() DirectoryPreparer {
|
||||
return newInstanceDataPreparer(os.MkdirAll, os.Lstat, os.CreateTemp, os.Remove)
|
||||
}
|
||||
|
||||
func newInstanceDataPreparer(
|
||||
mkdirAll func(string, os.FileMode) error,
|
||||
lstat func(string) (os.FileInfo, error),
|
||||
createTemp func(string, string) (*os.File, error),
|
||||
remove func(string) error,
|
||||
) instanceDataPreparer {
|
||||
return instanceDataPreparer{mkdirAll: mkdirAll, lstat: lstat, createTemp: createTemp, remove: remove}
|
||||
}
|
||||
|
||||
func (p instanceDataPreparer) PrepareInstanceDataDir(ctx context.Context, userDataDir string) error {
|
||||
return p.PrepareDirectory(ctx, userDataDir)
|
||||
}
|
||||
|
||||
func (p instanceDataPreparer) PrepareDirectory(ctx context.Context, directory string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := normalizePreparatoryInstanceDataDir(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.mkdirAll(path, 0o700); err != nil {
|
||||
return fmt.Errorf("%w: create", ErrInstanceDataDirectoryUnavailable)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := p.lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: inspect", ErrInstanceDataDirectoryUnavailable)
|
||||
}
|
||||
if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 || isReparsePoint(info) {
|
||||
return fmt.Errorf("%w: links and reparse points are not supported", ErrUnsafeInstanceDataDirectory)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return ErrInstanceDataNotDirectory
|
||||
}
|
||||
probe, err := p.createTemp(path, ".chub-write-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: write", ErrInstanceDataDirectoryUnavailable)
|
||||
}
|
||||
probePath := probe.Name()
|
||||
if err := probe.Close(); err != nil {
|
||||
_ = p.remove(probePath)
|
||||
return fmt.Errorf("%w: close", ErrInstanceDataDirectoryUnavailable)
|
||||
}
|
||||
if err := p.remove(probePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("%w: cleanup", ErrInstanceDataDirectoryUnavailable)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizePreparatoryInstanceDataDir is kept separate from deletion's
|
||||
// helper so callers can use a stable, intention-revealing validation entry.
|
||||
func normalizePreparatoryInstanceDataDir(value string) (string, error) {
|
||||
path, err := normalizeRemovableInstanceDataDir(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(path), nil
|
||||
}
|
||||
@@ -81,6 +81,36 @@ func TestInstanceDataRemoverHonorsCanceledContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceDataPreparerCreatesAndVerifiesWritableDirectory(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "new-profile")
|
||||
if err := NewInstanceDataPreparer().PrepareInstanceDataDir(context.Background(), target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Fatalf("prepared directory = %#v, error = %v", info, err)
|
||||
}
|
||||
probes, err := filepath.Glob(filepath.Join(target, ".chub-write-*"))
|
||||
if err != nil || len(probes) != 0 {
|
||||
t.Fatalf("write probes = %#v, error = %v", probes, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceDataPreparerRejectsUnavailableDirectoryBeforeBrowserLaunch(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "profile")
|
||||
preparer := newInstanceDataPreparer(os.MkdirAll, os.Lstat, func(string, string) (*os.File, error) {
|
||||
return nil, errors.New("denied")
|
||||
}, os.Remove)
|
||||
if err := preparer.PrepareInstanceDataDir(context.Background(), target); !errors.Is(err, ErrInstanceDataDirectoryUnavailable) {
|
||||
t.Fatalf("unwritable directory error = %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := NewInstanceDataPreparer().PrepareInstanceDataDir(ctx, filepath.Join(t.TempDir(), "canceled")); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("canceled preparation error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeFileInfo struct {
|
||||
mode os.FileMode
|
||||
sys any
|
||||
|
||||
Reference in New Issue
Block a user