feat: add portable directory safeguards
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user