diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e752a0..8630333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `internal/config` profile-aware credentials loader: TOML config under + the OS-specific user-config path (XDG on Linux), multi-profile, with + resolution precedence flag > env > profile > default profile. Env + fallback via `KAS_LOGIN`, `KAS_AUTHDATA`, `KAS_AUTHTYPE`. Auth-data + is redacted by `Credentials.String` so secrets do not surface in + logs or `--help`. Validates `auth_type` (`plain` or `session`) and + reports missing required fields. (Closes #2.) - `internal/soap` codec for the KAS-API envelope: `Value` discriminated union mirroring the Apache xml-soap `ns2:Map` shape (xsi:type: string/int/float/boolean, ns2:Map, SOAP-ENC:Array), `Decode` for diff --git a/go.mod b/go.mod index 5bd9711..9c9d05e 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/chmmou/kasapi-cli go 1.23 + +require github.com/BurntSushi/toml v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f74b269 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..af3b68c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,82 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +// Auth type values accepted by the KAS API. +const ( + AuthPlain = "plain" + AuthSession = "session" +) + +// Profile holds the credentials for a single KAS account. +type Profile struct { + Login string `toml:"login"` + AuthData string `toml:"auth_data"` + AuthType string `toml:"auth_type"` +} + +// Config is the parsed TOML file. +type Config struct { + DefaultProfile string `toml:"default_profile"` + Profiles map[string]Profile `toml:"profiles"` +} + +// ErrNoConfig is returned by Load when the config file does not exist. +// Callers may continue with a nil Config and rely on flags or env vars. +var ErrNoConfig = errors.New("config: file not found") + +// DefaultPath returns the OS-specific default location of the config +// file: $XDG_CONFIG_HOME/kasapi-cli/config.toml on Linux, equivalent +// paths on macOS and Windows via os.UserConfigDir. +func DefaultPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("config: locate user config dir: %w", err) + } + return filepath.Join(dir, "kasapi-cli", "config.toml"), nil +} + +// Load reads and parses the config file at path. If path is empty the +// OS default location (DefaultPath) is used. ErrNoConfig is returned +// when the file does not exist; other errors signal malformed input. +func Load(path string) (*Config, error) { + if path == "" { + var err error + path, err = DefaultPath() + if err != nil { + return nil, err + } + } + var cfg Config + if _, err := toml.DecodeFile(path, &cfg); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNoConfig + } + return nil, fmt.Errorf("config: parse %s: %w", path, err) + } + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("config: %s: %w", path, err) + } + return &cfg, nil +} + +func (c *Config) validate() error { + for name, p := range c.Profiles { + if p.AuthType != "" && p.AuthType != AuthPlain && p.AuthType != AuthSession { + return fmt.Errorf("profile %q: auth_type %q must be %q or %q", name, p.AuthType, AuthPlain, AuthSession) + } + } + if c.DefaultProfile != "" { + if _, ok := c.Profiles[c.DefaultProfile]; !ok { + return fmt.Errorf("default_profile %q not defined under [profiles]", c.DefaultProfile) + } + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..5da33e1 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,241 @@ +package config_test + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chmmou/kasapi-cli/internal/config" +) + +func writeConfig(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +func TestLoadValid(t *testing.T) { + path := writeConfig(t, ` +default_profile = "main" + +[profiles.main] +login = "w0000000" +auth_data = "secret" +auth_type = "session" + +[profiles.staging] +login = "w0000001" +auth_data = "other" +auth_type = "plain" +`) + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.DefaultProfile != "main" { + t.Errorf("DefaultProfile = %q", cfg.DefaultProfile) + } + if len(cfg.Profiles) != 2 { + t.Errorf("len(Profiles) = %d, want 2", len(cfg.Profiles)) + } + if cfg.Profiles["staging"].AuthType != "plain" { + t.Errorf("staging.AuthType = %q", cfg.Profiles["staging"].AuthType) + } +} + +func TestLoadMissingFile(t *testing.T) { + _, err := config.Load(filepath.Join(t.TempDir(), "absent.toml")) + if !errors.Is(err, config.ErrNoConfig) { + t.Fatalf("err = %v, want ErrNoConfig", err) + } +} + +func TestLoadMalformed(t *testing.T) { + path := writeConfig(t, "default_profile = \nthis is = not toml [[") + _, err := config.Load(path) + if err == nil || errors.Is(err, config.ErrNoConfig) { + t.Fatalf("expected parse error, got %v", err) + } +} + +func TestLoadRejectsUnknownAuthType(t *testing.T) { + path := writeConfig(t, ` +[profiles.main] +login = "x" +auth_data = "y" +auth_type = "magic" +`) + _, err := config.Load(path) + if err == nil || !strings.Contains(err.Error(), "auth_type") { + t.Fatalf("expected auth_type validation error, got %v", err) + } +} + +func TestLoadRejectsUnknownDefaultProfile(t *testing.T) { + path := writeConfig(t, ` +default_profile = "nope" + +[profiles.main] +login = "x" +auth_data = "y" +auth_type = "session" +`) + _, err := config.Load(path) + if err == nil || !strings.Contains(err.Error(), "default_profile") { + t.Fatalf("expected default_profile validation error, got %v", err) + } +} + +func sampleConfig() *config.Config { + return &config.Config{ + DefaultProfile: "main", + Profiles: map[string]config.Profile{ + "main": { + Login: "w0000000", + AuthData: "main-secret", + AuthType: config.AuthSession, + }, + "staging": { + Login: "w0000001", + AuthData: "stg-secret", + AuthType: config.AuthPlain, + }, + }, + } +} + +func TestResolvePrecedence(t *testing.T) { + cfg := sampleConfig() + cases := []struct { + name string + env config.Env + ov config.Override + want config.Credentials + }{ + { + name: "default profile", + want: config.Credentials{Login: "w0000000", AuthData: "main-secret", AuthType: "session"}, + }, + { + name: "named profile via flag", + ov: config.Override{Profile: "staging"}, + want: config.Credentials{Login: "w0000001", AuthData: "stg-secret", AuthType: "plain"}, + }, + { + name: "env overrides profile", + env: config.Env{Login: "envuser", AuthData: "env-secret"}, + want: config.Credentials{Login: "envuser", AuthData: "env-secret", AuthType: "session"}, + }, + { + name: "flag overrides env and profile", + env: config.Env{Login: "envuser"}, + ov: config.Override{Login: "flaguser", AuthData: "flag-secret", AuthType: "plain"}, + want: config.Credentials{Login: "flaguser", AuthData: "flag-secret", AuthType: "plain"}, + }, + { + name: "partial flag falls back per-field", + ov: config.Override{Login: "flaguser"}, + want: config.Credentials{Login: "flaguser", AuthData: "main-secret", AuthType: "session"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := cfg.Resolve(tc.env, tc.ov) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != tc.want { + t.Errorf("got %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestResolveUnknownProfile(t *testing.T) { + cfg := sampleConfig() + _, err := cfg.Resolve(config.Env{}, config.Override{Profile: "missing"}) + if err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("expected unknown-profile error, got %v", err) + } +} + +func TestResolveMissingCredentials(t *testing.T) { + cfg := &config.Config{ + Profiles: map[string]config.Profile{ + "only-login": {Login: "w0000000"}, + }, + DefaultProfile: "only-login", + } + _, err := cfg.Resolve(config.Env{}, config.Override{}) + if err == nil { + t.Fatal("expected error for missing credentials") + } + for _, want := range []string{"auth_data", "auth_type"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } + } +} + +func TestResolveEnvOnlyNoConfig(t *testing.T) { + var cfg *config.Config + got, err := cfg.Resolve(config.Env{ + Login: "w0000000", + AuthData: "secret", + AuthType: "session", + }, config.Override{}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + want := config.Credentials{Login: "w0000000", AuthData: "secret", AuthType: "session"} + if got != want { + t.Errorf("got %+v, want %+v", got, want) + } +} + +func TestResolveProfileWithoutConfigRejected(t *testing.T) { + var cfg *config.Config + _, err := cfg.Resolve(config.Env{}, config.Override{Profile: "main"}) + if err == nil || !strings.Contains(err.Error(), "no config file") { + t.Fatalf("expected error about missing config, got %v", err) + } +} + +func TestCredentialsStringRedacts(t *testing.T) { + c := config.Credentials{Login: "w0000000", AuthData: "supersecret", AuthType: "session"} + s := c.String() + if strings.Contains(s, "supersecret") { + t.Errorf("AuthData leaked: %q", s) + } + if !strings.Contains(s, "redacted") { + t.Errorf("missing redaction marker: %q", s) + } + if !strings.Contains(s, "w0000000") { + t.Errorf("Login should be visible: %q", s) + } +} + +func TestCredentialsStringEmptyAuth(t *testing.T) { + c := config.Credentials{Login: "w0000000"} + s := c.String() + if !strings.Contains(s, "") { + t.Errorf("empty AuthData should render as , got %q", s) + } +} + +func TestEnvFromOS(t *testing.T) { + t.Setenv("KAS_LOGIN", "envuser") + t.Setenv("KAS_AUTHDATA", "envsecret") + t.Setenv("KAS_AUTHTYPE", "plain") + got := config.EnvFromOS() + want := config.Env{Login: "envuser", AuthData: "envsecret", AuthType: "plain"} + if got != want { + t.Errorf("got %+v, want %+v", got, want) + } +} diff --git a/internal/config/credentials.go b/internal/config/credentials.go new file mode 100644 index 0000000..7f55f2e --- /dev/null +++ b/internal/config/credentials.go @@ -0,0 +1,107 @@ +package config + +import ( + "fmt" + "os" + "strings" +) + +// Credentials are the resolved values for a single KAS API call. +type Credentials struct { + Login string + AuthData string + AuthType string +} + +// String returns a representation safe for logs: AuthData is redacted. +func (c Credentials) String() string { + if c.AuthData == "" { + return fmt.Sprintf("config.Credentials{Login:%q AuthType:%q AuthData:}", c.Login, c.AuthType) + } + return fmt.Sprintf("config.Credentials{Login:%q AuthType:%q AuthData:}", c.Login, c.AuthType, len(c.AuthData)) +} + +// Override captures values supplied on the command line. Empty fields +// mean the flag was not given. +type Override struct { + Profile string + Login string + AuthData string + AuthType string +} + +// Env captures the environment variables consulted during Resolve. +type Env struct { + Login string + AuthData string + AuthType string +} + +// EnvFromOS reads the relevant environment variables from os.Getenv: +// KAS_LOGIN, KAS_AUTHDATA, KAS_AUTHTYPE. +func EnvFromOS() Env { + return Env{ + Login: os.Getenv("KAS_LOGIN"), + AuthData: os.Getenv("KAS_AUTHDATA"), + AuthType: os.Getenv("KAS_AUTHTYPE"), + } +} + +// Resolve applies the precedence flag > env > config(profile) > +// config(default_profile) and returns the credentials for a single +// call. The receiver may be nil — in that case only flags and env are +// consulted, and supplying ov.Profile is rejected. +func (c *Config) Resolve(env Env, ov Override) (Credentials, error) { + var prof Profile + if c != nil { + name := ov.Profile + if name == "" { + name = c.DefaultProfile + } + if name != "" { + p, ok := c.Profiles[name] + if !ok { + return Credentials{}, fmt.Errorf("config: profile %q not defined", name) + } + prof = p + } + } else if ov.Profile != "" { + return Credentials{}, fmt.Errorf("config: --profile %q given but no config file loaded", ov.Profile) + } + + cred := Credentials{ + Login: pick(ov.Login, env.Login, prof.Login), + AuthData: pick(ov.AuthData, env.AuthData, prof.AuthData), + AuthType: pick(ov.AuthType, env.AuthType, prof.AuthType), + } + return cred, cred.validate() +} + +func pick(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +func (c Credentials) validate() error { + var missing []string + if c.Login == "" { + missing = append(missing, "login (--login or KAS_LOGIN)") + } + if c.AuthData == "" { + missing = append(missing, "auth_data (--auth-data or KAS_AUTHDATA)") + } + if c.AuthType == "" { + missing = append(missing, "auth_type (--auth-type or KAS_AUTHTYPE)") + } + if len(missing) > 0 { + return fmt.Errorf("config: missing credentials: %s", strings.Join(missing, ", ")) + } + if c.AuthType != AuthPlain && c.AuthType != AuthSession { + return fmt.Errorf("config: auth_type %q must be %q or %q", c.AuthType, AuthPlain, AuthSession) + } + return nil +} diff --git a/internal/config/doc.go b/internal/config/doc.go index 5d69ef4..76ac5de 100644 --- a/internal/config/doc.go +++ b/internal/config/doc.go @@ -1,3 +1,23 @@ -// Package config loads KAS credentials and CLI defaults from a TOML file -// (XDG path), env vars, and command-line flags. See issue #2. +// Package config loads KAS credentials and CLI defaults from a TOML +// file (XDG path), environment variables, and command-line flags, and +// resolves the effective values for a single API call. +// +// Selection precedence (highest first): command-line flag, environment +// variable, named profile from the config file, default profile from +// the config file. Missing required fields are reported by Resolve as +// an error rather than silently filled in. +// +// Credentials redact the auth_data field in their String method so +// secrets do not appear in the default log output or in --help. +// +// File format: +// +// default_profile = "main" +// +// [profiles.main] +// login = "w0000000" +// auth_data = "..." +// auth_type = "session" # or "plain" +// +// See issue #2. package config