diff --git a/internal/config/instance.go b/internal/config/instance.go index bfc3979bc4..9ee2b2fa42 100644 --- a/internal/config/instance.go +++ b/internal/config/instance.go @@ -29,6 +29,10 @@ type Instance struct { closeThread bool db *sql.DB closed bool + // systemConfig holds machine-wide (all users) config values, loaded read-only at startup. + // It only ever provides values for registered config options, which structurally excludes + // credentials such as the auth token (apiToken is not a registered option). + systemConfig map[string]interface{} } func New() (*Instance, error) { @@ -73,9 +77,38 @@ func NewCustom(localPath string, thread *singlethread.Thread, closeThread bool) } profile.Measure("config.createTable", t) + // Load machine-wide config. A failure here must never prevent the CLI from starting, so we + // log and continue with an empty system config on error. + i.loadSystemConfig() + return i, nil } +// loadSystemConfig reads the optional machine-wide (all users) config file into memory. The file +// is a plain YAML map of registered config keys to values. It is entirely optional: a missing +// file is not an error. Values here act as defaults for users who have not set the key themselves, +// and are only ever surfaced for registered config options, so credentials are never read here. +func (i *Instance) loadSystemConfig() { + path := filepath.Join(storage.SystemAppDataPath(), C.SystemConfigFileName) + + data, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + multilog.Error("config: could not read system config at %s: %s", path, errs.JoinMessage(err)) + } + return + } + + parsed := map[string]interface{}{} + if err := yaml.Unmarshal(data, &parsed); err != nil { + multilog.Error("config: could not parse system config at %s: %s", path, errs.JoinMessage(err)) + return + } + + i.systemConfig = parsed + logging.Debug("Loaded machine-wide config from %s (%d keys)", path, len(parsed)) +} + func (i *Instance) Close() error { mutex := sync.Mutex{} mutex.Lock() @@ -178,11 +211,19 @@ func (i *Instance) rawGet(key string) interface{} { } func (i *Instance) Get(key string) interface{} { + // A value the user explicitly set always wins, so machine-wide config acts as a default only. result := i.rawGet(key) if result != nil { return result } + + // Machine-wide config and built-in defaults only apply to registered options. Because the auth + // token (apiToken) is not a registered option, this branch structurally prevents credentials + // from ever being read from the shared, all-users config file. if opt := mediator.GetOption(key); mediator.KnownOption(opt) { + if v, ok := i.systemConfig[key]; ok { + return v + } return mediator.GetDefault(opt) } return nil diff --git a/internal/config/instance_test.go b/internal/config/instance_test.go index b683eb1477..d5373869aa 100644 --- a/internal/config/instance_test.go +++ b/internal/config/instance_test.go @@ -18,6 +18,7 @@ import ( "github.com/ActiveState/cli/internal/config" "github.com/ActiveState/cli/internal/constants" + mediator "github.com/ActiveState/cli/internal/mediators/config" ) type ConfigTestSuite struct { @@ -124,6 +125,59 @@ func TestRaceReadWrite(t *testing.T) { assert.Equal(t, "bar", cfg2.GetString("Foo")) } +// TestSystemConfig verifies the machine-wide (all users) config layer: it provides defaults for +// registered options, is overridden by a user's own value, and never serves credentials. +func TestSystemConfig(t *testing.T) { + // Register options to exercise the machine-wide layer against. + mediator.RegisterOption("test.system.string", mediator.String, "builtin-default") + mediator.RegisterOption("test.system.bool", mediator.Bool, false) + + // Author a machine-wide config file in a temp dir and point the CLI at it. + sysDir := t.TempDir() + sysContents := "" + + "test.system.string: from-system\n" + + "test.system.bool: true\n" + + // A registered option a user may still override locally. + "test.system.override: from-system\n" + + // An attempt to seed auth via the shared file. It must be ignored because apiToken is + // not a registered option, so credentials are never shared across users. + "apiToken: leaked-shared-token\n" + require.NoError(t, os.WriteFile(filepath.Join(sysDir, constants.SystemConfigFileName), []byte(sysContents), 0644)) + t.Setenv(constants.SystemConfigDirEnvVarName, sysDir) + + mediator.RegisterOption("test.system.override", mediator.String, "builtin-default") + + cfg, err := config.New() + require.NoError(t, err) + defer func() { require.NoError(t, cfg.Close()) }() + + // Machine-wide values apply for registered options the user hasn't set. + assert.Equal(t, "from-system", cfg.GetString("test.system.string")) + assert.Equal(t, true, cfg.GetBool("test.system.bool")) + + // A user's own value wins over the machine-wide value. + require.NoError(t, cfg.Set("test.system.override", "from-user")) + assert.Equal(t, "from-user", cfg.GetString("test.system.override")) + + // Auth is never served from the shared file, even when present in it. IsSet stays false and + // the value is empty rather than the leaked token. + assert.False(t, cfg.IsSet("apiToken"), "apiToken must not be considered set from system config") + assert.Empty(t, cfg.GetString("apiToken"), "auth token must never come from the shared config") +} + +// TestSystemConfigAbsent verifies a missing machine-wide config file is not an error and falls +// back to the built-in default. +func TestSystemConfigAbsent(t *testing.T) { + mediator.RegisterOption("test.system.absent", mediator.String, "builtin-default") + t.Setenv(constants.SystemConfigDirEnvVarName, t.TempDir()) // empty dir, no file + + cfg, err := config.New() + require.NoError(t, err) + defer func() { require.NoError(t, cfg.Close()) }() + + assert.Equal(t, "builtin-default", cfg.GetString("test.system.absent")) +} + func TestConfigTestSuite(t *testing.T) { suite.Run(t, new(ConfigTestSuite)) } diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 8b78e3892c..244c269d64 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -37,6 +37,9 @@ const HomeEnvVarName = "ACTIVESTATE_HOME" // ConfigEnvVarName is the env var used to override the config dir that the State Tool uses const ConfigEnvVarName = "ACTIVESTATE_CLI_CONFIGDIR" +// SystemConfigDirEnvVarName is the env var used to override the machine-wide (all users) config dir +const SystemConfigDirEnvVarName = "ACTIVESTATE_CLI_SYSTEM_CONFIGDIR" + // CacheEnvVarName is the env var used to override the cache dir that the State Tool uses const CacheEnvVarName = "ACTIVESTATE_CLI_CACHEDIR" @@ -70,6 +73,11 @@ const InternalConfigFileNameLegacy = "config.yaml" // InternalConfigFileName is the filename used for our sqlite based settings db const InternalConfigFileName = "config.db" +// SystemConfigFileName is the filename used for the machine-wide (all users) config file. +// It is a plain YAML file so that administrators can author it by hand. It only provides +// values for registered config options and is never used to store credentials. +const SystemConfigFileName = "config.yaml" + // AutoUpdateTimeoutEnvVarName is the name of the environment variable that can be set to override the allowed timeout to check for an available auto-update const AutoUpdateTimeoutEnvVarName = "ACTIVESTATE_CLI_UPDATE_TIMEOUT" diff --git a/internal/installation/storage/storage.go b/internal/installation/storage/storage.go index dde30e6ca5..a77c855329 100644 --- a/internal/installation/storage/storage.go +++ b/internal/installation/storage/storage.go @@ -69,6 +69,17 @@ func appDataPathInTest() (string, error) { return localPath, nil } +// SystemAppDataPath returns the machine-wide (all users) appdata dir. Unlike AppDataPath this +// is not rooted in a user's home directory, so a single file placed here applies to every user +// on the machine. It is used exclusively for read-only config defaults; credentials are never +// stored or read here. The location can be overridden with the SystemConfigDirEnvVarName env var. +func SystemAppDataPath() string { + if localPath, envSet := os.LookupEnv(constants.SystemConfigDirEnvVarName); envSet { + return localPath + } + return filepath.Join(BaseSystemAppDataPath(), relativeAppDataPath()) +} + func AppDataPathWithParent(parentDir string) string { dir := filepath.Join(parentDir, relativeAppDataPath()) if err := os.MkdirAll(dir, os.ModePerm); err != nil { diff --git a/internal/installation/storage/storage_darwin.go b/internal/installation/storage/storage_darwin.go index 2dde6e1630..903a218c35 100644 --- a/internal/installation/storage/storage_darwin.go +++ b/internal/installation/storage/storage_darwin.go @@ -11,3 +11,9 @@ func BaseAppDataPath() string { func BaseCachePath() string { return filepath.Join(homeDir, "Library", "Caches") } + +// BaseSystemAppDataPath returns the machine-wide (all users) config base dir. On macOS this is +// the system-level /Library/Application Support (not the per-user ~/Library variant). +func BaseSystemAppDataPath() string { + return filepath.Join("/Library", "Application Support") +} diff --git a/internal/installation/storage/storage_windows.go b/internal/installation/storage/storage_windows.go index 861b69075d..16782f3edc 100644 --- a/internal/installation/storage/storage_windows.go +++ b/internal/installation/storage/storage_windows.go @@ -20,3 +20,13 @@ func BaseCachePath() string { return filepath.Join(homeDir, "AppData", "Local") } + +// BaseSystemAppDataPath returns the machine-wide (all users) config base dir. On Windows this is +// %PROGRAMDATA% (typically C:\ProgramData), which is shared across all users. +func BaseSystemAppDataPath() string { + if programData := os.Getenv("ProgramData"); programData != "" { + return programData + } + + return filepath.Join("C:\\", "ProgramData") +} diff --git a/internal/installation/storage/storage_xdg.go b/internal/installation/storage/storage_xdg.go index 1efefd0649..0b46eca343 100644 --- a/internal/installation/storage/storage_xdg.go +++ b/internal/installation/storage/storage_xdg.go @@ -23,3 +23,9 @@ func BaseCachePath() string { return filepath.Join(homeDir, ".cache") } + +// BaseSystemAppDataPath returns the machine-wide (all users) config base dir. On Linux this is +// /etc, so machine-wide config lives at /etc/activestate/cli-/. +func BaseSystemAppDataPath() string { + return "/etc" +}