feat: add portable directory safeguards

This commit is contained in:
QiuSW
2026-07-28 00:12:29 +08:00
parent 92091a6690
commit 733954ddc8
15 changed files with 914 additions and 95 deletions
+194 -18
View File
@@ -17,13 +17,34 @@ const currentVersion = 1
var ErrDefaultConfigUnavailable = errors.New("default config is unavailable")
type DirectorySource string
const (
DirectorySourcePortable DirectorySource = "portable"
DirectorySourceCustom DirectorySource = "custom"
)
const (
legacyDefaultUserDataDir = `C:\Users\Public\chub\profiles`
legacyLogDir = `C:\Users\Public\chub\logs`
)
type Settings struct {
ChromePath string `json:"chromePath"`
EdgePath string `json:"edgePath"`
DefaultDir string `json:"defaultUserDataDir"`
LogDir string `json:"logDir"`
RemoteDebugStartPort int `json:"remoteDebugStartPort"`
CloseOnExit bool `json:"closeOnExit"`
ChromePath string `json:"chromePath"`
EdgePath string `json:"edgePath"`
DefaultDir string `json:"defaultUserDataDir"`
DefaultDirSource DirectorySource `json:"defaultUserDataDirSource,omitempty"`
LogDir string `json:"logDir"`
LogDirSource DirectorySource `json:"logDirSource,omitempty"`
RemoteDebugStartPort int `json:"remoteDebugStartPort"`
CloseOnExit bool `json:"closeOnExit"`
}
// PortableDirectories are resolved from the actual executable location for
// the default store, never from the process working directory.
type PortableDirectories struct {
DefaultDir string
LogDir string
}
type Instance struct {
@@ -68,14 +89,25 @@ func DefaultPath() (string, error) {
return defaultPathForExecutable(executable)
}
// DefaultPortableDirectories resolves the portable defaults beside the actual
// executable. It is useful before config.json can be opened so the UI can
// still offer a recoverable manual-path flow.
func DefaultPortableDirectories() (PortableDirectories, error) {
executable, err := os.Executable()
if err != nil {
return PortableDirectories{}, fmt.Errorf("%w: resolve executable", ErrDefaultConfigUnavailable)
}
return portableDirectoriesForExecutable(executable)
}
// DefaultInstanceUserDataRoot returns the portable root for new browser
// profiles. It resolves the actual executable location and never uses cwd.
func DefaultInstanceUserDataRoot() (string, error) {
executable, err := os.Executable()
directories, err := DefaultPortableDirectories()
if err != nil {
return "", fmt.Errorf("%w: resolve executable", ErrDefaultConfigUnavailable)
return "", err
}
return defaultInstanceUserDataRootForExecutable(executable)
return directories.DefaultDir, nil
}
// DefaultInstanceUserDataDir returns a unique-instance profile path below the
@@ -120,6 +152,12 @@ func OpenDefault() (*Store, error) {
func (s *Store) Path() string { return s.path }
// PortableDirectories resolves defaults relative to this store's config.json
// location. OpenDefault always places that file beside chub.exe.
func (s *Store) PortableDirectories() (PortableDirectories, error) {
return portableDirectoriesForConfigPath(s.path)
}
func defaultPathForExecutable(executable string) (string, error) {
path := strings.TrimSpace(executable)
if path == "" || !filepath.IsAbs(path) {
@@ -129,11 +167,39 @@ func defaultPathForExecutable(executable string) (string, error) {
}
func defaultInstanceUserDataRootForExecutable(executable string) (string, error) {
directories, err := portableDirectoriesForExecutable(executable)
if err != nil {
return "", err
}
return directories.DefaultDir, nil
}
func portableDirectoriesForExecutable(executable string) (PortableDirectories, error) {
path := strings.TrimSpace(executable)
if path == "" || !filepath.IsAbs(path) {
return "", fmt.Errorf("%w: executable path must be absolute", ErrDefaultConfigUnavailable)
return PortableDirectories{}, fmt.Errorf("%w: executable path must be absolute", ErrDefaultConfigUnavailable)
}
return filepath.Join(filepath.Dir(filepath.Clean(path)), "user_data_dirs"), nil
return portableDirectoriesForBase(filepath.Dir(filepath.Clean(path)))
}
func portableDirectoriesForConfigPath(configPath string) (PortableDirectories, error) {
path := strings.TrimSpace(configPath)
if path == "" || !filepath.IsAbs(path) || !strings.EqualFold(filepath.Base(path), "config.json") {
return PortableDirectories{}, fmt.Errorf("%w: config path must be an absolute config.json path", ErrDefaultConfigUnavailable)
}
return portableDirectoriesForBase(filepath.Dir(filepath.Clean(path)))
}
func portableDirectoriesForBase(base string) (PortableDirectories, error) {
base = strings.TrimSpace(base)
if base == "" || !filepath.IsAbs(base) {
return PortableDirectories{}, fmt.Errorf("%w: portable base path must be absolute", ErrDefaultConfigUnavailable)
}
base = filepath.Clean(base)
return PortableDirectories{
DefaultDir: filepath.Join(base, "user_data_dirs"),
LogDir: filepath.Join(base, "logs"),
}, nil
}
func defaultInstanceUserDataDir(root, instanceID string) (string, error) {
@@ -202,7 +268,11 @@ func migrateLegacyConfig(target *Store, legacyPath string) (*Store, error) {
func (s *Store) Load() (File, error) {
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return File{Version: currentVersion, Settings: DefaultSettings()}, nil
settings, settingsErr := s.defaultSettings()
if settingsErr != nil {
return File{}, settingsErr
}
return File{Version: currentVersion, Settings: settings}, nil
}
if err != nil {
return File{}, fmt.Errorf("read config: %w", err)
@@ -217,12 +287,15 @@ func (s *Store) Load() (File, error) {
if result.Version != currentVersion {
return File{}, fmt.Errorf("unsupported config version %d", result.Version)
}
if result.Settings.RemoteDebugStartPort == 0 {
result.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
if err := s.normalizeSettings(&result.Settings); err != nil {
return File{}, err
}
if err := normalizeInstanceRemoteDebugPorts(&result); err != nil {
return File{}, err
}
if err := normalizeInstanceNames(&result); err != nil {
return File{}, err
}
if err := normalizeProxyConfig(&result); err != nil {
return File{}, err
}
@@ -230,11 +303,29 @@ func (s *Store) Load() (File, error) {
}
func DefaultSettings() Settings {
directories, err := DefaultPortableDirectories()
if err != nil {
return Settings{RemoteDebugStartPort: domain.DefaultRemoteDebugPort, CloseOnExit: true}
}
return defaultSettingsForDirectories(directories)
}
func (s *Store) defaultSettings() (Settings, error) {
directories, err := s.PortableDirectories()
if err != nil {
return Settings{}, err
}
return defaultSettingsForDirectories(directories), nil
}
func defaultSettingsForDirectories(directories PortableDirectories) Settings {
return Settings{
ChromePath: `C:\Program Files\Google\Chrome\Application\chrome.exe`,
EdgePath: `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
DefaultDir: `C:\Users\Public\chub\profiles`,
LogDir: `C:\Users\Public\chub\logs`,
DefaultDir: directories.DefaultDir,
DefaultDirSource: DirectorySourcePortable,
LogDir: directories.LogDir,
LogDirSource: DirectorySourcePortable,
RemoteDebugStartPort: domain.DefaultRemoteDebugPort,
CloseOnExit: true,
}
@@ -242,8 +333,8 @@ func DefaultSettings() Settings {
func (s *Store) Save(value File) error {
value.Version = currentVersion
if value.Settings.RemoteDebugStartPort == 0 {
value.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
if err := s.normalizeSettings(&value.Settings); err != nil {
return err
}
if value.Instances == nil {
value.Instances = []Instance{}
@@ -254,6 +345,9 @@ func (s *Store) Save(value File) error {
if err := normalizeInstanceRemoteDebugPorts(&value); err != nil {
return err
}
if err := normalizeInstanceNames(&value); err != nil {
return err
}
if err := normalizeProxyConfig(&value); err != nil {
return err
}
@@ -291,6 +385,88 @@ func (s *Store) Save(value File) error {
return nil
}
func (s *Store) normalizeSettings(settings *Settings) error {
if settings == nil {
return errors.New("settings are required")
}
directories, err := s.PortableDirectories()
if err != nil {
return err
}
defaultDir, defaultSource, err := normalizeDirectorySetting(settings.DefaultDir, settings.DefaultDirSource, directories.DefaultDir, legacyDefaultUserDataDir)
if err != nil {
return fmt.Errorf("invalid default user data directory: %w", err)
}
logDir, logSource, err := normalizeDirectorySetting(settings.LogDir, settings.LogDirSource, directories.LogDir, legacyLogDir)
if err != nil {
return fmt.Errorf("invalid log directory: %w", err)
}
settings.DefaultDir = defaultDir
settings.DefaultDirSource = defaultSource
settings.LogDir = logDir
settings.LogDirSource = logSource
if settings.RemoteDebugStartPort == 0 {
settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
}
return nil
}
func normalizeDirectorySetting(value string, source DirectorySource, portableDefault, legacyDefault string) (string, DirectorySource, error) {
value = strings.TrimSpace(value)
source = DirectorySource(strings.ToLower(strings.TrimSpace(string(source))))
if source == "" {
if value == "" || sameDirectory(value, portableDefault) || sameDirectory(value, legacyDefault) {
source = DirectorySourcePortable
} else {
source = DirectorySourceCustom
}
}
switch source {
case DirectorySourcePortable:
return filepath.Clean(portableDefault), DirectorySourcePortable, nil
case DirectorySourceCustom:
if value == "" || !filepath.IsAbs(value) {
return "", "", errors.New("custom directory must be absolute")
}
return filepath.Clean(value), DirectorySourceCustom, nil
default:
return "", "", errors.New("directory source must be portable or custom")
}
}
func sameDirectory(left, right string) bool {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
if left == "" || right == "" {
return false
}
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
}
func normalizeInstanceNames(value *File) error {
if value == nil {
return errors.New("config is required")
}
seen := make(map[string]struct{}, len(value.Instances))
for index := range value.Instances {
instance := &value.Instances[index]
name, err := domain.NormalizeInstanceName(instance.Name)
if err != nil {
return errors.New("invalid instance name configuration")
}
if _, err := domain.InstanceProfileDirectoryName(name, instance.ID); err != nil {
return errors.New("invalid instance name configuration")
}
key := strings.ToUpper(name)
if _, exists := seen[key]; exists {
return errors.New("duplicate instance name configuration")
}
seen[key] = struct{}{}
instance.Name = name
}
return nil
}
func normalizeInstanceRemoteDebugPorts(value *File) error {
if value == nil {
return errors.New("config is required")
+52 -7
View File
@@ -23,7 +23,7 @@ func TestStoreRoundTripAndCreatesPrivateFile(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got.Version != currentVersion || got.Settings != want.Settings || len(got.Instances) != 1 || got.Instances[0].ID != "a" {
if got.Version != currentVersion || got.Settings.ChromePath != want.Settings.ChromePath || got.Settings.RemoteDebugStartPort != want.Settings.RemoteDebugStartPort || got.Settings.DefaultDirSource != DirectorySourcePortable || got.Settings.LogDirSource != DirectorySourcePortable || len(got.Instances) != 1 || got.Instances[0].ID != "a" {
t.Fatalf("round trip mismatch: %#v", got)
}
}
@@ -45,7 +45,7 @@ func TestStoreAddsDefaultRemoteDebugPortForExistingConfig(t *testing.T) {
func TestStoreAssignsPreferredPortsForLegacyInstancesInStableOrder(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
contents := `{"version":1,"settings":{"remoteDebugStartPort":9777},"instances":[{"id":"first","launch":{"Kind":"chrome","UserDataDir":"C:\\profiles\\first"}},{"id":"second","launch":{"Kind":"edge","UserDataDir":"C:\\profiles\\second"}}]}`
contents := `{"version":1,"settings":{"remoteDebugStartPort":9777},"instances":[{"id":"first","name":"第一实例","launch":{"Kind":"chrome","UserDataDir":"C:\\profiles\\first"}},{"id":"second","name":"第二实例","launch":{"Kind":"edge","UserDataDir":"C:\\profiles\\second"}}]}`
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
@@ -75,13 +75,13 @@ func TestStoreRejectsInvalidOrDuplicatePreferredPorts(t *testing.T) {
t.Fatal(err)
}
duplicate := File{Instances: []Instance{
{ID: "one", PreferredRemoteDebugPort: 9666},
{ID: "two", PreferredRemoteDebugPort: 9666},
{ID: "one", Name: "一号", PreferredRemoteDebugPort: 9666},
{ID: "two", Name: "二号", PreferredRemoteDebugPort: 9666},
}}
if err := store.Save(duplicate); err == nil {
t.Fatal("duplicate preferred port was accepted")
}
invalid := File{Instances: []Instance{{ID: "one", PreferredRemoteDebugPort: domain.MinRemoteDebugPort - 1}}}
invalid := File{Instances: []Instance{{ID: "one", Name: "一号", PreferredRemoteDebugPort: domain.MinRemoteDebugPort - 1}}}
if err := store.Save(invalid); err == nil {
t.Fatal("invalid preferred port was accepted")
}
@@ -134,6 +134,51 @@ func TestDefaultInstanceUserDataDirectoryUsesExecutableDirectoryAndStableID(t *t
}
}
func TestStoreMigratesHistoricalDirectoriesToPortableDefaults(t *testing.T) {
base := filepath.Join(t.TempDir(), "published")
store, err := New(filepath.Join(base, "config.json"))
if err != nil {
t.Fatal(err)
}
if err := store.Save(File{Settings: Settings{DefaultDir: legacyDefaultUserDataDir, LogDir: legacyLogDir}}); err != nil {
t.Fatal(err)
}
got, err := store.Load()
if err != nil {
t.Fatal(err)
}
if got.Settings.DefaultDir != filepath.Join(base, "user_data_dirs") || got.Settings.LogDir != filepath.Join(base, "logs") || got.Settings.DefaultDirSource != DirectorySourcePortable || got.Settings.LogDirSource != DirectorySourcePortable {
t.Fatalf("portable migration = %#v", got.Settings)
}
custom := filepath.Join(t.TempDir(), "custom-profiles")
got.Settings.DefaultDir = custom
got.Settings.DefaultDirSource = DirectorySourceCustom
if err := store.Save(got); err != nil {
t.Fatal(err)
}
reloaded, err := store.Load()
if err != nil || reloaded.Settings.DefaultDir != custom || reloaded.Settings.DefaultDirSource != DirectorySourceCustom {
t.Fatalf("custom directory = %#v, error = %v", reloaded.Settings, err)
}
}
func TestStoreRejectsInvalidAndDuplicateInstanceNames(t *testing.T) {
store, err := New(filepath.Join(t.TempDir(), "config.json"))
if err != nil {
t.Fatal(err)
}
for _, instances := range [][]Instance{
{{ID: "one", Name: "审核"}, {ID: "two", Name: "审核"}},
{{ID: "one", Name: "Audit"}, {ID: "two", Name: "aUDIT"}},
{{ID: "one", Name: "CON"}},
} {
if err := store.Save(File{Instances: instances}); err == nil {
t.Fatalf("unsafe instance configuration persisted: %#v", instances)
}
}
}
func TestOpenDefaultAtMigratesValidLegacyWithoutRemovingIt(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "portable", "config.json")
@@ -176,7 +221,7 @@ func TestOpenDefaultAtPrefersExistingTargetOverLegacy(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := targetStore.Save(File{Instances: []Instance{{ID: "target", PreferredRemoteDebugPort: 9666}}}); err != nil {
if err := targetStore.Save(File{Instances: []Instance{{ID: "target", Name: "目标实例", PreferredRemoteDebugPort: 9666}}}); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil {
@@ -241,7 +286,7 @@ func TestOpenDefaultAtRejectsUncreatableTargetWithoutTouchingLegacy(t *testing.T
if err != nil {
t.Fatal(err)
}
if err := legacyStore.Save(File{Instances: []Instance{{ID: "legacy", PreferredRemoteDebugPort: 9666}}}); err != nil {
if err := legacyStore.Save(File{Instances: []Instance{{ID: "legacy", Name: "旧实例", PreferredRemoteDebugPort: 9666}}}); err != nil {
t.Fatal(err)
}
if _, err := openDefaultAt(target, legacy); !errors.Is(err, ErrDefaultConfigUnavailable) {
@@ -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
+16
View File
@@ -3,6 +3,8 @@ package logging
import (
"io"
"log/slog"
"os"
"path/filepath"
)
// New creates the process logger. Callers provide the sink so tests and the
@@ -13,3 +15,17 @@ func New(w io.Writer) *slog.Logger {
}
return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo}))
}
// OpenFile creates an append-only local diagnostic log in the configured
// directory. It never falls back to cwd, so a bad configured directory stays
// visible to the caller instead of scattering logs beside a shortcut.
func OpenFile(directory string) (*slog.Logger, io.Closer, error) {
if err := os.MkdirAll(directory, 0o700); err != nil {
return nil, nil, err
}
file, err := os.OpenFile(filepath.Join(directory, "chub.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return nil, nil, err
}
return New(file), file, nil
}
+18
View File
@@ -2,6 +2,8 @@ package logging
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -20,3 +22,19 @@ func TestNewWritesStructuredLog(t *testing.T) {
func TestNewNilWriterDoesNotPanic(t *testing.T) {
New(nil).Info("discarded")
}
func TestOpenFileWritesInsideConfiguredDirectory(t *testing.T) {
directory := filepath.Join(t.TempDir(), "logs")
logger, closer, err := OpenFile(directory)
if err != nil {
t.Fatal(err)
}
logger.Info("file event")
if err := closer.Close(); err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(filepath.Join(directory, "chub.log"))
if err != nil || !strings.Contains(string(contents), `"msg":"file event"`) {
t.Fatalf("log contents = %q, error = %v", contents, err)
}
}