Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions internal/config/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Comment on lines +87 to +90
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))
}
Comment on lines +102 to +110

func (i *Instance) Close() error {
mutex := sync.Mutex{}
mutex.Lock()
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions internal/config/instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
}
8 changes: 8 additions & 0 deletions internal/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"

Expand Down
11 changes: 11 additions & 0 deletions internal/installation/storage/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment on lines +72 to +76
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 {
Expand Down
6 changes: 6 additions & 0 deletions internal/installation/storage/storage_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
10 changes: 10 additions & 0 deletions internal/installation/storage/storage_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
6 changes: 6 additions & 0 deletions internal/installation/storage/storage_xdg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<channel>/.
func BaseSystemAppDataPath() string {
return "/etc"
}
Loading