feat(cli): read the server URL and a token command from a config file
ERMITE_URL and ERMITE_TOKEN still win. url and token.cmd come from ~/.config/ermite/config (ERMITE_CONFIG selects another). token.cmd runs through the system shell only when a command needs the token, at most once per process, never for version or config, and not when the file is writable by others. Its output is never printed. client.ConfigFromEnv is replaced by client.NewConfig.
- commit
- c0892a5b19b1fbad0f4aafd3d6699f69705100ad
- author
- brice · · 2026-10-12 14:58 UTC
- parent
- fc4d564
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +648 -34
M go.mod +1 -0
| @@ -18,6 +18,7 @@ require ( | |||
| 18 | 18 | connectrpc.com/connect v1.21.0 | |
| 19 | 19 | github.com/a-h/templ v0.3.1020 | |
| 20 | 20 | github.com/alecthomas/chroma/v2 v2.27.0 | |
| 21 | + | github.com/bnema/kvconf v0.1.2 | |
| 21 | 22 | github.com/bnema/zerowrap v1.4.1 | |
| 22 | 23 | github.com/golang-jwt/jwt/v5 v5.3.1 | |
| 23 | 24 | github.com/spf13/cobra v1.10.2 | |
| @@ -18,6 +18,7 @@ require ( | |||||
| 18 | connectrpc.com/connect v1.21.0 | 18 | connectrpc.com/connect v1.21.0 | ||
| 19 | github.com/a-h/templ v0.3.1020 | 19 | github.com/a-h/templ v0.3.1020 | ||
| 20 | github.com/alecthomas/chroma/v2 v2.27.0 | 20 | github.com/alecthomas/chroma/v2 v2.27.0 | ||
| 21 | + | github.com/bnema/kvconf v0.1.2 | |||
| 21 | github.com/bnema/zerowrap v1.4.1 | 22 | github.com/bnema/zerowrap v1.4.1 | ||
| 22 | github.com/golang-jwt/jwt/v5 v5.3.1 | 23 | github.com/golang-jwt/jwt/v5 v5.3.1 | ||
| 23 | github.com/spf13/cobra v1.10.2 | 24 | github.com/spf13/cobra v1.10.2 | ||
M go.sum +2 -0 generated file: collapsed
M internal/adapters/client/config.go +8 -7
| @@ -48,18 +48,19 @@ type Config struct { | |||
| 48 | 48 | UserAgent string | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | - | // ConfigFromEnv reads ERMITE_URL and ERMITE_TOKEN through getenv and validates | |
| 52 | - | // them. Timeout and UserAgent are left for the caller to set. | |
| 53 | - | func ConfigFromEnv(getenv func(string) string) (Config, error) { | |
| 54 | - | raw := strings.TrimSpace(getenv(EnvURL)) | |
| 51 | + | // NewConfig validates the server address and the token, however they were | |
| 52 | + | // found (environment, config file or token command). The token is trimmed and | |
| 53 | + | // may be empty (anonymous). Timeout and UserAgent are left for the caller. | |
| 54 | + | func NewConfig(rawURL, token string) (Config, error) { | |
| 55 | + | raw := strings.TrimSpace(rawURL) | |
| 55 | 56 | if raw == "" { | |
| 56 | - | return Config{}, fmt.Errorf("%s is not set: set it to the address of your ermite server, for example https://git.example.com", EnvURL) | |
| 57 | + | return Config{}, fmt.Errorf("%s is not set: set it to the address of your ermite server, for example https://git.example.com, or run `ermite config set url <url>`", EnvURL) | |
| 57 | 58 | } | |
| 58 | 59 | base, err := ParseURL(raw) | |
| 59 | 60 | if err != nil { | |
| 60 | - | return Config{}, fmt.Errorf("%s: %w", EnvURL, err) | |
| 61 | + | return Config{}, fmt.Errorf("invalid server URL (%s, or `ermite config set url <url>`): %w", EnvURL, err) | |
| 61 | 62 | } | |
| 62 | - | return Config{URL: base.String(), Token: strings.TrimSpace(getenv(EnvToken))}, nil | |
| 63 | + | return Config{URL: base.String(), Token: strings.TrimSpace(token)}, nil | |
| 63 | 64 | } | |
| 64 | 65 | ||
| 65 | 66 | // ParseURL validates a server address: an absolute http(s) URL with a host, | |
| @@ -48,18 +48,19 @@ type Config struct { | |||||
| 48 | UserAgent string | 48 | UserAgent string | ||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | - | // ConfigFromEnv reads ERMITE_URL and ERMITE_TOKEN through getenv and validates | 51 | + | // NewConfig validates the server address and the token, however they were |
| 52 | - | // them. Timeout and UserAgent are left for the caller to set. | 52 | + | // found (environment, config file or token command). The token is trimmed and |
| 53 | - | func ConfigFromEnv(getenv func(string) string) (Config, error) { | 53 | + | // may be empty (anonymous). Timeout and UserAgent are left for the caller. |
| 54 | - | raw := strings.TrimSpace(getenv(EnvURL)) | 54 | + | func NewConfig(rawURL, token string) (Config, error) { |
| 55 | + | raw := strings.TrimSpace(rawURL) | |||
| 55 | if raw == "" { | 56 | if raw == "" { | ||
| 56 | - | return Config{}, fmt.Errorf("%s is not set: set it to the address of your ermite server, for example https://git.example.com", EnvURL) | 57 | + | return Config{}, fmt.Errorf("%s is not set: set it to the address of your ermite server, for example https://git.example.com, or run `ermite config set url <url>`", EnvURL) |
| 57 | } | 58 | } | ||
| 58 | base, err := ParseURL(raw) | 59 | base, err := ParseURL(raw) | ||
| 59 | if err != nil { | 60 | if err != nil { | ||
| 60 | - | return Config{}, fmt.Errorf("%s: %w", EnvURL, err) | 61 | + | return Config{}, fmt.Errorf("invalid server URL (%s, or `ermite config set url <url>`): %w", EnvURL, err) |
| 61 | } | 62 | } | ||
| 62 | - | return Config{URL: base.String(), Token: strings.TrimSpace(getenv(EnvToken))}, nil | 63 | + | return Config{URL: base.String(), Token: strings.TrimSpace(token)}, nil |
| 63 | } | 64 | } | ||
| 64 | 65 | ||||
| 65 | // ParseURL validates a server address: an absolute http(s) URL with a host, | 66 | // ParseURL validates a server address: an absolute http(s) URL with a host, | ||
M internal/adapters/client/config_test.go +6 -10
| @@ -11,20 +11,14 @@ import ( | |||
| 11 | 11 | "bnema.dev/ermite/internal/adapters/client" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | - | func env(kv map[string]string) func(string) string { | |
| 15 | - | return func(k string) string { return kv[k] } | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | func TestConfigFromEnv(t *testing.T) { | |
| 19 | - | cfg, err := client.ConfigFromEnv(env(map[string]string{ | |
| 20 | - | client.EnvURL: " HTTPS://Git.Example.com/base/ ", client.EnvToken: " ermite_abc\n", | |
| 21 | - | })) | |
| 14 | + | func TestNewConfig(t *testing.T) { | |
| 15 | + | cfg, err := client.NewConfig(" HTTPS://Git.Example.com/base/ ", " ermite_abc\n") | |
| 22 | 16 | require.NoError(t, err) | |
| 23 | 17 | assert.Equal(t, "https://Git.Example.com/base", cfg.URL) | |
| 24 | 18 | assert.Equal(t, "ermite_abc", cfg.Token) | |
| 25 | 19 | } | |
| 26 | 20 | ||
| 27 | - | func TestConfigFromEnvRejects(t *testing.T) { | |
| 21 | + | func TestNewConfigRejects(t *testing.T) { | |
| 28 | 22 | tests := []struct { | |
| 29 | 23 | name, url, want string | |
| 30 | 24 | }{ | |
| @@ -39,9 +33,11 @@ func TestConfigFromEnvRejects(t *testing.T) { | |||
| 39 | 33 | } | |
| 40 | 34 | for _, tt := range tests { | |
| 41 | 35 | t.Run(tt.name, func(t *testing.T) { | |
| 42 | - | _, err := client.ConfigFromEnv(env(map[string]string{client.EnvURL: tt.url})) | |
| 36 | + | _, err := client.NewConfig(tt.url, "") | |
| 43 | 37 | require.Error(t, err) | |
| 44 | 38 | assert.Contains(t, err.Error(), tt.want) | |
| 39 | + | assert.Contains(t, err.Error(), "ermite config set url", "the message tells how to fix it") | |
| 40 | + | assert.Contains(t, err.Error(), "ERMITE_URL") | |
| 45 | 41 | assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed") | |
| 46 | 42 | }) | |
| 47 | 43 | } | |
| @@ -11,20 +11,14 @@ import ( | |||||
| 11 | "bnema.dev/ermite/internal/adapters/client" | 11 | "bnema.dev/ermite/internal/adapters/client" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | - | func env(kv map[string]string) func(string) string { | 14 | + | func TestNewConfig(t *testing.T) { |
| 15 | - | return func(k string) string { return kv[k] } | 15 | + | cfg, err := client.NewConfig(" HTTPS://Git.Example.com/base/ ", " ermite_abc\n") |
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | func TestConfigFromEnv(t *testing.T) { | |||
| 19 | - | cfg, err := client.ConfigFromEnv(env(map[string]string{ | |||
| 20 | - | client.EnvURL: " HTTPS://Git.Example.com/base/ ", client.EnvToken: " ermite_abc\n", | |||
| 21 | - | })) | |||
| 22 | require.NoError(t, err) | 16 | require.NoError(t, err) | ||
| 23 | assert.Equal(t, "https://Git.Example.com/base", cfg.URL) | 17 | assert.Equal(t, "https://Git.Example.com/base", cfg.URL) | ||
| 24 | assert.Equal(t, "ermite_abc", cfg.Token) | 18 | assert.Equal(t, "ermite_abc", cfg.Token) | ||
| 25 | } | 19 | } | ||
| 26 | 20 | ||||
| 27 | - | func TestConfigFromEnvRejects(t *testing.T) { | 21 | + | func TestNewConfigRejects(t *testing.T) { |
| 28 | tests := []struct { | 22 | tests := []struct { | ||
| 29 | name, url, want string | 23 | name, url, want string | ||
| 30 | }{ | 24 | }{ | ||
| @@ -39,9 +33,11 @@ func TestConfigFromEnvRejects(t *testing.T) { | |||||
| 39 | } | 33 | } | ||
| 40 | for _, tt := range tests { | 34 | for _, tt := range tests { | ||
| 41 | t.Run(tt.name, func(t *testing.T) { | 35 | t.Run(tt.name, func(t *testing.T) { | ||
| 42 | - | _, err := client.ConfigFromEnv(env(map[string]string{client.EnvURL: tt.url})) | 36 | + | _, err := client.NewConfig(tt.url, "") |
| 43 | require.Error(t, err) | 37 | require.Error(t, err) | ||
| 44 | assert.Contains(t, err.Error(), tt.want) | 38 | assert.Contains(t, err.Error(), tt.want) | ||
| 39 | + | assert.Contains(t, err.Error(), "ermite config set url", "the message tells how to fix it") | |||
| 40 | + | assert.Contains(t, err.Error(), "ERMITE_URL") | |||
| 45 | assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed") | 41 | assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed") | ||
| 46 | }) | 42 | }) | ||
| 47 | } | 43 | } | ||
M internal/app/cli.go +55 -4
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "io" | |
| 10 | 10 | "os" | |
| 11 | 11 | "strings" | |
| 12 | + | "sync" | |
| 12 | 13 | "text/tabwriter" | |
| 13 | 14 | "time" | |
| 14 | 15 | ||
| @@ -30,6 +31,23 @@ type cliEnv struct { | |||
| 30 | 31 | // defaults. | |
| 31 | 32 | backoff client.Backoff | |
| 32 | 33 | sleep func(context.Context, time.Duration) error | |
| 34 | + | ||
| 35 | + | // configPath returns the CLI config file path; nil means the default | |
| 36 | + | // (ERMITE_CONFIG, else ~/.config/ermite/config). | |
| 37 | + | configPath func() (string, error) | |
| 38 | + | // runTokenCmd runs the token.cmd command line and returns its raw standard | |
| 39 | + | // output; nil means running it with the system shell. Tests replace it. | |
| 40 | + | runTokenCmd func(ctx context.Context, cmd string) (string, error) | |
| 41 | + | ||
| 42 | + | // The config file is read at most once and token.cmd run at most once per | |
| 43 | + | // process, and only when a command needs them (see cli_configfile.go). | |
| 44 | + | fileOnce sync.Once | |
| 45 | + | file *cliFile | |
| 46 | + | filePath string | |
| 47 | + | fileErr error | |
| 48 | + | tokenOnce sync.Once | |
| 49 | + | token string | |
| 50 | + | tokenErr error | |
| 33 | 51 | } | |
| 34 | 52 | ||
| 35 | 53 | func defaultCLIEnv() *cliEnv { | |
| @@ -54,9 +72,28 @@ func (e *cliEnv) ctx(cmd *cobra.Command) context.Context { | |||
| 54 | 72 | return ctx | |
| 55 | 73 | } | |
| 56 | 74 | ||
| 57 | - | // config reads and validates ERMITE_URL and ERMITE_TOKEN. | |
| 58 | - | func (e *cliEnv) config() (client.Config, error) { | |
| 59 | - | cfg, err := client.ConfigFromEnv(e.getenv) | |
| 75 | + | // config builds the client configuration: ERMITE_URL and ERMITE_TOKEN win, | |
| 76 | + | // then the config file (url, and the output of token.cmd for the token). | |
| 77 | + | func (e *cliEnv) config(cmd *cobra.Command, warn bool) (client.Config, error) { | |
| 78 | + | return e.buildConfig(cmd, warn, true) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // buildConfig is config; runTokenCmd false leaves token.cmd alone (ERMITE_TOKEN | |
| 82 | + | // is still used), for the calls that need no authentication. | |
| 83 | + | func (e *cliEnv) buildConfig(cmd *cobra.Command, warn, runTokenCmd bool) (client.Config, error) { | |
| 84 | + | rawURL, err := e.resolveURL(cmd, warn) | |
| 85 | + | if err != nil { | |
| 86 | + | return client.Config{}, err | |
| 87 | + | } | |
| 88 | + | // A missing or invalid URL is reported before token.cmd gets to prompt. | |
| 89 | + | if _, err := client.NewConfig(rawURL, ""); err != nil { | |
| 90 | + | return client.Config{}, err | |
| 91 | + | } | |
| 92 | + | token, err := e.resolveToken(cmd, warn, runTokenCmd) | |
| 93 | + | if err != nil { | |
| 94 | + | return client.Config{}, err | |
| 95 | + | } | |
| 96 | + | cfg, err := client.NewConfig(rawURL, token) | |
| 60 | 97 | if err != nil { | |
| 61 | 98 | return cfg, err | |
| 62 | 99 | } | |
| @@ -67,10 +104,24 @@ func (e *cliEnv) config() (client.Config, error) { | |||
| 67 | 104 | // client builds the API client. Commands warn when the token would travel in | |
| 68 | 105 | // the clear; completion stays silent. | |
| 69 | 106 | func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) { | |
| 70 | - | cfg, err := e.config() | |
| 107 | + | cfg, err := e.config(cmd, warn) | |
| 108 | + | if err != nil { | |
| 109 | + | return nil, err | |
| 110 | + | } | |
| 111 | + | return newClient(cmd, cfg, warn) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // clientNoTokenCmd is client without running token.cmd, for GetVersion, which | |
| 115 | + | // is anonymous. | |
| 116 | + | func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) { | |
| 117 | + | cfg, err := e.buildConfig(cmd, warn, false) | |
| 71 | 118 | if err != nil { | |
| 72 | 119 | return nil, err | |
| 73 | 120 | } | |
| 121 | + | return newClient(cmd, cfg, warn) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func newClient(cmd *cobra.Command, cfg client.Config, warn bool) (*client.Client, error) { | |
| 74 | 125 | if w := cfg.InsecureTokenWarning(); w != "" && warn { | |
| 75 | 126 | warnf(cmd, "%s", w) | |
| 76 | 127 | } | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "io" | 9 | "io" | ||
| 10 | "os" | 10 | "os" | ||
| 11 | "strings" | 11 | "strings" | ||
| 12 | + | "sync" | |||
| 12 | "text/tabwriter" | 13 | "text/tabwriter" | ||
| 13 | "time" | 14 | "time" | ||
| 14 | 15 | ||||
| @@ -30,6 +31,23 @@ type cliEnv struct { | |||||
| 30 | // defaults. | 31 | // defaults. | ||
| 31 | backoff client.Backoff | 32 | backoff client.Backoff | ||
| 32 | sleep func(context.Context, time.Duration) error | 33 | sleep func(context.Context, time.Duration) error | ||
| 34 | + | ||||
| 35 | + | // configPath returns the CLI config file path; nil means the default | |||
| 36 | + | // (ERMITE_CONFIG, else ~/.config/ermite/config). | |||
| 37 | + | configPath func() (string, error) | |||
| 38 | + | // runTokenCmd runs the token.cmd command line and returns its raw standard | |||
| 39 | + | // output; nil means running it with the system shell. Tests replace it. | |||
| 40 | + | runTokenCmd func(ctx context.Context, cmd string) (string, error) | |||
| 41 | + | ||||
| 42 | + | // The config file is read at most once and token.cmd run at most once per | |||
| 43 | + | // process, and only when a command needs them (see cli_configfile.go). | |||
| 44 | + | fileOnce sync.Once | |||
| 45 | + | file *cliFile | |||
| 46 | + | filePath string | |||
| 47 | + | fileErr error | |||
| 48 | + | tokenOnce sync.Once | |||
| 49 | + | token string | |||
| 50 | + | tokenErr error | |||
| 33 | } | 51 | } | ||
| 34 | 52 | ||||
| 35 | func defaultCLIEnv() *cliEnv { | 53 | func defaultCLIEnv() *cliEnv { | ||
| @@ -54,9 +72,28 @@ func (e *cliEnv) ctx(cmd *cobra.Command) context.Context { | |||||
| 54 | return ctx | 72 | return ctx | ||
| 55 | } | 73 | } | ||
| 56 | 74 | ||||
| 57 | - | // config reads and validates ERMITE_URL and ERMITE_TOKEN. | 75 | + | // config builds the client configuration: ERMITE_URL and ERMITE_TOKEN win, |
| 58 | - | func (e *cliEnv) config() (client.Config, error) { | 76 | + | // then the config file (url, and the output of token.cmd for the token). |
| 59 | - | cfg, err := client.ConfigFromEnv(e.getenv) | 77 | + | func (e *cliEnv) config(cmd *cobra.Command, warn bool) (client.Config, error) { |
| 78 | + | return e.buildConfig(cmd, warn, true) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // buildConfig is config; runTokenCmd false leaves token.cmd alone (ERMITE_TOKEN | |||
| 82 | + | // is still used), for the calls that need no authentication. | |||
| 83 | + | func (e *cliEnv) buildConfig(cmd *cobra.Command, warn, runTokenCmd bool) (client.Config, error) { | |||
| 84 | + | rawURL, err := e.resolveURL(cmd, warn) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return client.Config{}, err | |||
| 87 | + | } | |||
| 88 | + | // A missing or invalid URL is reported before token.cmd gets to prompt. | |||
| 89 | + | if _, err := client.NewConfig(rawURL, ""); err != nil { | |||
| 90 | + | return client.Config{}, err | |||
| 91 | + | } | |||
| 92 | + | token, err := e.resolveToken(cmd, warn, runTokenCmd) | |||
| 93 | + | if err != nil { | |||
| 94 | + | return client.Config{}, err | |||
| 95 | + | } | |||
| 96 | + | cfg, err := client.NewConfig(rawURL, token) | |||
| 60 | if err != nil { | 97 | if err != nil { | ||
| 61 | return cfg, err | 98 | return cfg, err | ||
| 62 | } | 99 | } | ||
| @@ -67,10 +104,24 @@ func (e *cliEnv) config() (client.Config, error) { | |||||
| 67 | // client builds the API client. Commands warn when the token would travel in | 104 | // client builds the API client. Commands warn when the token would travel in | ||
| 68 | // the clear; completion stays silent. | 105 | // the clear; completion stays silent. | ||
| 69 | func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) { | 106 | func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) { | ||
| 70 | - | cfg, err := e.config() | 107 | + | cfg, err := e.config(cmd, warn) |
| 108 | + | if err != nil { | |||
| 109 | + | return nil, err | |||
| 110 | + | } | |||
| 111 | + | return newClient(cmd, cfg, warn) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // clientNoTokenCmd is client without running token.cmd, for GetVersion, which | |||
| 115 | + | // is anonymous. | |||
| 116 | + | func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) { | |||
| 117 | + | cfg, err := e.buildConfig(cmd, warn, false) | |||
| 71 | if err != nil { | 118 | if err != nil { | ||
| 72 | return nil, err | 119 | return nil, err | ||
| 73 | } | 120 | } | ||
| 121 | + | return newClient(cmd, cfg, warn) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func newClient(cmd *cobra.Command, cfg client.Config, warn bool) (*client.Client, error) { | |||
| 74 | if w := cfg.InsecureTokenWarning(); w != "" && warn { | 125 | if w := cfg.InsecureTokenWarning(); w != "" && warn { | ||
| 75 | warnf(cmd, "%s", w) | 126 | warnf(cmd, "%s", w) | ||
| 76 | } | 127 | } | ||
A internal/app/cli_configfile.go +208 -0
| @@ -0,0 +1,208 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "os" | |
| 12 | + | "os/exec" | |
| 13 | + | "runtime" | |
| 14 | + | "strings" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/kvconf" | |
| 18 | + | "github.com/spf13/cobra" | |
| 19 | + | ||
| 20 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | const ( | |
| 24 | + | // envConfig selects the CLI config file instead of ~/.config/ermite/config. | |
| 25 | + | envConfig = "ERMITE_CONFIG" | |
| 26 | + | // Keys of the CLI config file. | |
| 27 | + | keyURL = "url" | |
| 28 | + | keyTokenCmd = "token.cmd" | |
| 29 | + | ||
| 30 | + | // tokenCmdTimeout bounds one run of token.cmd, prompts included. | |
| 31 | + | tokenCmdTimeout = 60 * time.Second | |
| 32 | + | // tokenCmdMaxOutput bounds what is read from its standard output. | |
| 33 | + | tokenCmdMaxOutput = 4 << 10 | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | // cliFile is the CLI config file. It holds no secret: the token is fetched | |
| 37 | + | // from a secret manager by the token.cmd command line. | |
| 38 | + | type cliFile struct { | |
| 39 | + | URL string `kv:"url"` | |
| 40 | + | TokenCmd string `kv:"token.cmd"` | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // path is the config file: $ERMITE_CONFIG, else <config dir>/ermite/config. | |
| 44 | + | func (e *cliEnv) path() (string, error) { | |
| 45 | + | if e.configPath != nil { | |
| 46 | + | return e.configPath() | |
| 47 | + | } | |
| 48 | + | if p := strings.TrimSpace(e.getenv(envConfig)); p != "" { | |
| 49 | + | return p, nil | |
| 50 | + | } | |
| 51 | + | p, err := kvconf.DefaultPath("ermite", "config") | |
| 52 | + | if err != nil { | |
| 53 | + | return "", fmt.Errorf("no home directory to find the config file: set %s", envConfig) | |
| 54 | + | } | |
| 55 | + | return p, nil | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // readFile reads the config file, once per process. A missing file, or no | |
| 59 | + | // place to look for it, gives an empty one. Problems in the file are warnings | |
| 60 | + | // on stderr, printed once, and only when warn is set. | |
| 61 | + | func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) { | |
| 62 | + | e.fileOnce.Do(func() { | |
| 63 | + | p, err := e.path() | |
| 64 | + | if err != nil { | |
| 65 | + | // Reading is best effort when the location is unknown; the | |
| 66 | + | // `ermite config` commands report this error. | |
| 67 | + | e.file = &cliFile{} | |
| 68 | + | return | |
| 69 | + | } | |
| 70 | + | e.filePath = p | |
| 71 | + | f, warnings, err := kvconf.Load[cliFile](p, kvconf.Options{}) | |
| 72 | + | if err != nil { | |
| 73 | + | e.fileErr = fmt.Errorf("config: %w", err) | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | if warn { | |
| 77 | + | for _, w := range warnings { | |
| 78 | + | warnf(cmd, "config: %s", w) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | e.file = f | |
| 82 | + | }) | |
| 83 | + | return e.file, e.filePath, e.fileErr | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // resolveURL is ERMITE_URL if set, else the url of the config file, else "". | |
| 87 | + | func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) { | |
| 88 | + | if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" { | |
| 89 | + | return v, nil | |
| 90 | + | } | |
| 91 | + | f, _, err := e.readFile(cmd, warn) | |
| 92 | + | if err != nil { | |
| 93 | + | return "", err | |
| 94 | + | } | |
| 95 | + | return strings.TrimSpace(f.URL), nil | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // resolveToken is ERMITE_TOKEN if set, else (when runCmd is set) the output | |
| 99 | + | // of token.cmd, else "" (anonymous). token.cmd runs at most once per process. | |
| 100 | + | func (e *cliEnv) resolveToken(cmd *cobra.Command, warn, runCmd bool) (string, error) { | |
| 101 | + | if v := strings.TrimSpace(e.getenv(client.EnvToken)); v != "" { | |
| 102 | + | return v, nil | |
| 103 | + | } | |
| 104 | + | if !runCmd { | |
| 105 | + | return "", nil | |
| 106 | + | } | |
| 107 | + | return e.tokenFromCmd(cmd, warn) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // tokenFromCmd is the output of token.cmd, or "" when the file sets none. | |
| 111 | + | func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | |
| 112 | + | f, path, err := e.readFile(cmd, warn) | |
| 113 | + | if err != nil { | |
| 114 | + | return "", err | |
| 115 | + | } | |
| 116 | + | line := strings.TrimSpace(f.TokenCmd) | |
| 117 | + | if line == "" { | |
| 118 | + | return "", nil | |
| 119 | + | } | |
| 120 | + | e.tokenOnce.Do(func() { e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line) }) | |
| 121 | + | return e.token, e.tokenErr | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // fetchToken runs token.cmd. Neither its output nor its error text reach the | |
| 125 | + | // caller's messages: the output is the secret. | |
| 126 | + | func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, error) { | |
| 127 | + | if err := requirePrivate(path); err != nil { | |
| 128 | + | return "", err | |
| 129 | + | } | |
| 130 | + | run := e.runTokenCmd | |
| 131 | + | if run == nil { | |
| 132 | + | run = runShell | |
| 133 | + | } | |
| 134 | + | out, err := run(ctx, line) | |
| 135 | + | if err != nil { | |
| 136 | + | return "", fmt.Errorf("token.cmd failed: %w", err) | |
| 137 | + | } | |
| 138 | + | out = strings.TrimSpace(out) | |
| 139 | + | switch { | |
| 140 | + | case out == "": | |
| 141 | + | return "", errors.New("token.cmd printed nothing") | |
| 142 | + | case strings.ContainsAny(out, "\r\n"): | |
| 143 | + | return "", errors.New("token.cmd printed more than one line") | |
| 144 | + | } | |
| 145 | + | return out, nil | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // requirePrivate refuses a config file other users can write: they could | |
| 149 | + | // change token.cmd into any command. Windows has no such mode bits. | |
| 150 | + | func requirePrivate(path string) error { | |
| 151 | + | if runtime.GOOS == "windows" { | |
| 152 | + | return nil | |
| 153 | + | } | |
| 154 | + | info, err := os.Stat(path) | |
| 155 | + | if err != nil { | |
| 156 | + | return fmt.Errorf("config: %w", err) | |
| 157 | + | } | |
| 158 | + | if info.Mode().Perm()&0o022 != 0 { | |
| 159 | + | return fmt.Errorf("config file %s is writable by others: token.cmd not run", path) | |
| 160 | + | } | |
| 161 | + | return nil | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // runShell runs line with the system shell. Standard input and error are the | |
| 165 | + | // terminal's, so a pinentry or a biometric prompt works; standard output is | |
| 166 | + | // captured, up to tokenCmdMaxOutput bytes. The environment is the process's. | |
| 167 | + | func runShell(ctx context.Context, line string) (string, error) { | |
| 168 | + | ctx, cancel := context.WithTimeout(ctx, tokenCmdTimeout) | |
| 169 | + | defer cancel() | |
| 170 | + | c := shellCommand(ctx, line) | |
| 171 | + | out := &cappedBuffer{max: tokenCmdMaxOutput} | |
| 172 | + | c.Stdin, c.Stdout, c.Stderr = os.Stdin, out, os.Stderr | |
| 173 | + | // Do not wait forever for a background process that kept the pipe open. | |
| 174 | + | c.WaitDelay = 2 * time.Second | |
| 175 | + | err := c.Run() | |
| 176 | + | switch { | |
| 177 | + | case errors.Is(ctx.Err(), context.DeadlineExceeded): | |
| 178 | + | return "", fmt.Errorf("timed out after %s", tokenCmdTimeout) | |
| 179 | + | case err != nil: | |
| 180 | + | var exit *exec.ExitError | |
| 181 | + | if errors.As(err, &exit) { | |
| 182 | + | return "", errors.New(exit.ProcessState.String()) | |
| 183 | + | } | |
| 184 | + | return "", err | |
| 185 | + | case out.over: | |
| 186 | + | return "", fmt.Errorf("printed more than %d bytes", tokenCmdMaxOutput) | |
| 187 | + | } | |
| 188 | + | return out.buf.String(), nil | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // cappedBuffer keeps the first max bytes written and notes an overflow. | |
| 192 | + | type cappedBuffer struct { | |
| 193 | + | buf bytes.Buffer | |
| 194 | + | max int | |
| 195 | + | over bool | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func (b *cappedBuffer) Write(p []byte) (int, error) { | |
| 199 | + | n := len(p) // report everything as written: a short write would abort the copy | |
| 200 | + | if room := b.max - b.buf.Len(); len(p) > room { | |
| 201 | + | b.over = true | |
| 202 | + | p = p[:room] | |
| 203 | + | } | |
| 204 | + | b.buf.Write(p) | |
| 205 | + | return n, nil | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | var _ io.Writer = (*cappedBuffer)(nil) | |
| @@ -0,0 +1,208 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "io" | |||
| 11 | + | "os" | |||
| 12 | + | "os/exec" | |||
| 13 | + | "runtime" | |||
| 14 | + | "strings" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/kvconf" | |||
| 18 | + | "github.com/spf13/cobra" | |||
| 19 | + | ||||
| 20 | + | "bnema.dev/ermite/internal/adapters/client" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | const ( | |||
| 24 | + | // envConfig selects the CLI config file instead of ~/.config/ermite/config. | |||
| 25 | + | envConfig = "ERMITE_CONFIG" | |||
| 26 | + | // Keys of the CLI config file. | |||
| 27 | + | keyURL = "url" | |||
| 28 | + | keyTokenCmd = "token.cmd" | |||
| 29 | + | ||||
| 30 | + | // tokenCmdTimeout bounds one run of token.cmd, prompts included. | |||
| 31 | + | tokenCmdTimeout = 60 * time.Second | |||
| 32 | + | // tokenCmdMaxOutput bounds what is read from its standard output. | |||
| 33 | + | tokenCmdMaxOutput = 4 << 10 | |||
| 34 | + | ) | |||
| 35 | + | ||||
| 36 | + | // cliFile is the CLI config file. It holds no secret: the token is fetched | |||
| 37 | + | // from a secret manager by the token.cmd command line. | |||
| 38 | + | type cliFile struct { | |||
| 39 | + | URL string `kv:"url"` | |||
| 40 | + | TokenCmd string `kv:"token.cmd"` | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // path is the config file: $ERMITE_CONFIG, else <config dir>/ermite/config. | |||
| 44 | + | func (e *cliEnv) path() (string, error) { | |||
| 45 | + | if e.configPath != nil { | |||
| 46 | + | return e.configPath() | |||
| 47 | + | } | |||
| 48 | + | if p := strings.TrimSpace(e.getenv(envConfig)); p != "" { | |||
| 49 | + | return p, nil | |||
| 50 | + | } | |||
| 51 | + | p, err := kvconf.DefaultPath("ermite", "config") | |||
| 52 | + | if err != nil { | |||
| 53 | + | return "", fmt.Errorf("no home directory to find the config file: set %s", envConfig) | |||
| 54 | + | } | |||
| 55 | + | return p, nil | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // readFile reads the config file, once per process. A missing file, or no | |||
| 59 | + | // place to look for it, gives an empty one. Problems in the file are warnings | |||
| 60 | + | // on stderr, printed once, and only when warn is set. | |||
| 61 | + | func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) { | |||
| 62 | + | e.fileOnce.Do(func() { | |||
| 63 | + | p, err := e.path() | |||
| 64 | + | if err != nil { | |||
| 65 | + | // Reading is best effort when the location is unknown; the | |||
| 66 | + | // `ermite config` commands report this error. | |||
| 67 | + | e.file = &cliFile{} | |||
| 68 | + | return | |||
| 69 | + | } | |||
| 70 | + | e.filePath = p | |||
| 71 | + | f, warnings, err := kvconf.Load[cliFile](p, kvconf.Options{}) | |||
| 72 | + | if err != nil { | |||
| 73 | + | e.fileErr = fmt.Errorf("config: %w", err) | |||
| 74 | + | return | |||
| 75 | + | } | |||
| 76 | + | if warn { | |||
| 77 | + | for _, w := range warnings { | |||
| 78 | + | warnf(cmd, "config: %s", w) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | e.file = f | |||
| 82 | + | }) | |||
| 83 | + | return e.file, e.filePath, e.fileErr | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // resolveURL is ERMITE_URL if set, else the url of the config file, else "". | |||
| 87 | + | func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) { | |||
| 88 | + | if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" { | |||
| 89 | + | return v, nil | |||
| 90 | + | } | |||
| 91 | + | f, _, err := e.readFile(cmd, warn) | |||
| 92 | + | if err != nil { | |||
| 93 | + | return "", err | |||
| 94 | + | } | |||
| 95 | + | return strings.TrimSpace(f.URL), nil | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // resolveToken is ERMITE_TOKEN if set, else (when runCmd is set) the output | |||
| 99 | + | // of token.cmd, else "" (anonymous). token.cmd runs at most once per process. | |||
| 100 | + | func (e *cliEnv) resolveToken(cmd *cobra.Command, warn, runCmd bool) (string, error) { | |||
| 101 | + | if v := strings.TrimSpace(e.getenv(client.EnvToken)); v != "" { | |||
| 102 | + | return v, nil | |||
| 103 | + | } | |||
| 104 | + | if !runCmd { | |||
| 105 | + | return "", nil | |||
| 106 | + | } | |||
| 107 | + | return e.tokenFromCmd(cmd, warn) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // tokenFromCmd is the output of token.cmd, or "" when the file sets none. | |||
| 111 | + | func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | |||
| 112 | + | f, path, err := e.readFile(cmd, warn) | |||
| 113 | + | if err != nil { | |||
| 114 | + | return "", err | |||
| 115 | + | } | |||
| 116 | + | line := strings.TrimSpace(f.TokenCmd) | |||
| 117 | + | if line == "" { | |||
| 118 | + | return "", nil | |||
| 119 | + | } | |||
| 120 | + | e.tokenOnce.Do(func() { e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line) }) | |||
| 121 | + | return e.token, e.tokenErr | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // fetchToken runs token.cmd. Neither its output nor its error text reach the | |||
| 125 | + | // caller's messages: the output is the secret. | |||
| 126 | + | func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, error) { | |||
| 127 | + | if err := requirePrivate(path); err != nil { | |||
| 128 | + | return "", err | |||
| 129 | + | } | |||
| 130 | + | run := e.runTokenCmd | |||
| 131 | + | if run == nil { | |||
| 132 | + | run = runShell | |||
| 133 | + | } | |||
| 134 | + | out, err := run(ctx, line) | |||
| 135 | + | if err != nil { | |||
| 136 | + | return "", fmt.Errorf("token.cmd failed: %w", err) | |||
| 137 | + | } | |||
| 138 | + | out = strings.TrimSpace(out) | |||
| 139 | + | switch { | |||
| 140 | + | case out == "": | |||
| 141 | + | return "", errors.New("token.cmd printed nothing") | |||
| 142 | + | case strings.ContainsAny(out, "\r\n"): | |||
| 143 | + | return "", errors.New("token.cmd printed more than one line") | |||
| 144 | + | } | |||
| 145 | + | return out, nil | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // requirePrivate refuses a config file other users can write: they could | |||
| 149 | + | // change token.cmd into any command. Windows has no such mode bits. | |||
| 150 | + | func requirePrivate(path string) error { | |||
| 151 | + | if runtime.GOOS == "windows" { | |||
| 152 | + | return nil | |||
| 153 | + | } | |||
| 154 | + | info, err := os.Stat(path) | |||
| 155 | + | if err != nil { | |||
| 156 | + | return fmt.Errorf("config: %w", err) | |||
| 157 | + | } | |||
| 158 | + | if info.Mode().Perm()&0o022 != 0 { | |||
| 159 | + | return fmt.Errorf("config file %s is writable by others: token.cmd not run", path) | |||
| 160 | + | } | |||
| 161 | + | return nil | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // runShell runs line with the system shell. Standard input and error are the | |||
| 165 | + | // terminal's, so a pinentry or a biometric prompt works; standard output is | |||
| 166 | + | // captured, up to tokenCmdMaxOutput bytes. The environment is the process's. | |||
| 167 | + | func runShell(ctx context.Context, line string) (string, error) { | |||
| 168 | + | ctx, cancel := context.WithTimeout(ctx, tokenCmdTimeout) | |||
| 169 | + | defer cancel() | |||
| 170 | + | c := shellCommand(ctx, line) | |||
| 171 | + | out := &cappedBuffer{max: tokenCmdMaxOutput} | |||
| 172 | + | c.Stdin, c.Stdout, c.Stderr = os.Stdin, out, os.Stderr | |||
| 173 | + | // Do not wait forever for a background process that kept the pipe open. | |||
| 174 | + | c.WaitDelay = 2 * time.Second | |||
| 175 | + | err := c.Run() | |||
| 176 | + | switch { | |||
| 177 | + | case errors.Is(ctx.Err(), context.DeadlineExceeded): | |||
| 178 | + | return "", fmt.Errorf("timed out after %s", tokenCmdTimeout) | |||
| 179 | + | case err != nil: | |||
| 180 | + | var exit *exec.ExitError | |||
| 181 | + | if errors.As(err, &exit) { | |||
| 182 | + | return "", errors.New(exit.ProcessState.String()) | |||
| 183 | + | } | |||
| 184 | + | return "", err | |||
| 185 | + | case out.over: | |||
| 186 | + | return "", fmt.Errorf("printed more than %d bytes", tokenCmdMaxOutput) | |||
| 187 | + | } | |||
| 188 | + | return out.buf.String(), nil | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | // cappedBuffer keeps the first max bytes written and notes an overflow. | |||
| 192 | + | type cappedBuffer struct { | |||
| 193 | + | buf bytes.Buffer | |||
| 194 | + | max int | |||
| 195 | + | over bool | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func (b *cappedBuffer) Write(p []byte) (int, error) { | |||
| 199 | + | n := len(p) // report everything as written: a short write would abort the copy | |||
| 200 | + | if room := b.max - b.buf.Len(); len(p) > room { | |||
| 201 | + | b.over = true | |||
| 202 | + | p = p[:room] | |||
| 203 | + | } | |||
| 204 | + | b.buf.Write(p) | |||
| 205 | + | return n, nil | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | var _ io.Writer = (*cappedBuffer)(nil) | |||
A internal/app/cli_configfile_test.go +293 -0
| @@ -0,0 +1,293 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "runtime" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // writeConfigFile writes the config file of env with the given mode. | |
| 21 | + | func writeConfigFile(t *testing.T, env *cliEnv, content string, mode os.FileMode) string { | |
| 22 | + | t.Helper() | |
| 23 | + | path, err := env.configPath() | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |
| 26 | + | require.NoError(t, os.WriteFile(path, []byte(content), mode)) | |
| 27 | + | require.NoError(t, os.Chmod(path, mode)) // umask | |
| 28 | + | return path | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // tokenCmdStub makes runTokenCmd return out and err, and counts the runs. | |
| 32 | + | func tokenCmdStub(env *cliEnv, out string, err error) *[]string { | |
| 33 | + | var ran []string | |
| 34 | + | env.runTokenCmd = func(_ context.Context, line string) (string, error) { | |
| 35 | + | ran = append(ran, line) | |
| 36 | + | return out, err | |
| 37 | + | } | |
| 38 | + | return &ran | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func listFake() *fakeAPI { | |
| 42 | + | return &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 43 | + | return &ermite.ListReposResponse{}, nil | |
| 44 | + | }} | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func lastAuth(f *fakeAPI) string { | |
| 48 | + | f.mu.Lock() | |
| 49 | + | defer f.mu.Unlock() | |
| 50 | + | if len(f.header) == 0 { | |
| 51 | + | return "<no call>" | |
| 52 | + | } | |
| 53 | + | return f.header[len(f.header)-1].Get("Authorization") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestConfigFileSuppliesURLAndToken(t *testing.T) { | |
| 57 | + | f := listFake() | |
| 58 | + | url := f.serve(t) | |
| 59 | + | env := testEnv(t, nil) | |
| 60 | + | writeConfigFile(t, env, "# my server\nurl = "+url+"\ntoken.cmd = pass show ermite/token\n", 0o600) | |
| 61 | + | ran := tokenCmdStub(env, " ermite_fromcmd\n", nil) | |
| 62 | + | ||
| 63 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 64 | + | assert.Equal(t, "Bearer ermite_fromcmd", lastAuth(f)) | |
| 65 | + | assert.Equal(t, []string{"pass show ermite/token"}, *ran) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestConfigFileURLWithoutTokenCmdIsAnonymous(t *testing.T) { | |
| 69 | + | f := listFake() | |
| 70 | + | env := testEnv(t, nil) | |
| 71 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\n", 0o600) | |
| 72 | + | ||
| 73 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 74 | + | assert.Empty(t, lastAuth(f)) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestEnvironmentWinsOverConfigFile(t *testing.T) { | |
| 78 | + | fileSrv, envSrv := listFake(), listFake() | |
| 79 | + | env := testEnv(t, map[string]string{"ERMITE_URL": envSrv.serve(t), "ERMITE_TOKEN": "ermite_env"}) | |
| 80 | + | writeConfigFile(t, env, "url = "+fileSrv.serve(t)+"\ntoken.cmd = echo nope\n", 0o600) | |
| 81 | + | ||
| 82 | + | runCLIWith(t, env, "", "repo", "list").ok(t) // the default runTokenCmd of testEnv fails the test if called | |
| 83 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(envSrv)) | |
| 84 | + | calls, _ := fileSrv.snapshot() | |
| 85 | + | assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestEnvURLAloneStillUsesTokenCmd(t *testing.T) { | |
| 89 | + | f := listFake() | |
| 90 | + | env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)}) | |
| 91 | + | writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600) | |
| 92 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 93 | + | ||
| 94 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 95 | + | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | |
| 96 | + | assert.Len(t, *ran, 1) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) { | |
| 100 | + | f := listFake() | |
| 101 | + | env := testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"}) | |
| 102 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o666) // even an unsafe file is not an issue | |
| 103 | + | ran := tokenCmdStub(env, "other", nil) | |
| 104 | + | ||
| 105 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 106 | + | assert.Empty(t, *ran) | |
| 107 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(f)) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestTokenCmdNotRunForVersionNorCompletion(t *testing.T) { | |
| 111 | + | f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 112 | + | return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil | |
| 113 | + | }} | |
| 114 | + | url := f.serve(t) | |
| 115 | + | ||
| 116 | + | for name, args := range map[string][]string{ | |
| 117 | + | "version": {"version"}, | |
| 118 | + | "version --json": {"version", "--json"}, | |
| 119 | + | "completion": {"completion", "bash"}, | |
| 120 | + | } { | |
| 121 | + | t.Run(name, func(t *testing.T) { | |
| 122 | + | env := testEnv(t, nil) | |
| 123 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 124 | + | ran := tokenCmdStub(env, "", errors.New("must not run")) | |
| 125 | + | runCLIWith(t, env, "", args...).ok(t) | |
| 126 | + | assert.Empty(t, *ran) | |
| 127 | + | }) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | t.Run("version without any url", func(t *testing.T) { | |
| 131 | + | env := testEnv(t, nil) | |
| 132 | + | writeConfigFile(t, env, "token.cmd = get-token\n", 0o600) | |
| 133 | + | ran := tokenCmdStub(env, "", errors.New("must not run")) | |
| 134 | + | res := runCLIWith(t, env, "", "version").ok(t) | |
| 135 | + | assert.Equal(t, "ermite v0.0.1\n", res.stdout) | |
| 136 | + | assert.Empty(t, *ran) | |
| 137 | + | }) | |
| 138 | + | ||
| 139 | + | t.Run("version uses the url of the file and sends no token", func(t *testing.T) { | |
| 140 | + | env := testEnv(t, nil) | |
| 141 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 142 | + | tokenCmdStub(env, "ermite_cmd", nil) | |
| 143 | + | res := runCLIWith(t, env, "", "version").ok(t) | |
| 144 | + | assert.Contains(t, res.stdout, "v0.4.0 at "+url) | |
| 145 | + | assert.Empty(t, lastAuth(f)) | |
| 146 | + | }) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestTokenCmdRunsOncePerProcess(t *testing.T) { | |
| 150 | + | f := listFake() | |
| 151 | + | env := testEnv(t, nil) | |
| 152 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |
| 153 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 154 | + | root := newRootCmdWith(env) | |
| 155 | + | root.SetArgs([]string{"repo", "list"}) | |
| 156 | + | for range 2 { | |
| 157 | + | c, _, err := root.Find([]string{"repo", "list"}) | |
| 158 | + | require.NoError(t, err) | |
| 159 | + | _, err = env.client(c, true) | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | } | |
| 162 | + | assert.Len(t, *ran, 1) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestTokenCmdRefusedWhenConfigFileIsWritableByOthers(t *testing.T) { | |
| 166 | + | if runtime.GOOS == "windows" { | |
| 167 | + | t.Skip("no Unix permission bits") | |
| 168 | + | } | |
| 169 | + | for _, mode := range []os.FileMode{0o666, 0o662, 0o620} { | |
| 170 | + | t.Run(mode.String(), func(t *testing.T) { | |
| 171 | + | env := testEnv(t, nil) | |
| 172 | + | path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", mode) | |
| 173 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 174 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 175 | + | require.Error(t, res.err) | |
| 176 | + | assert.Equal(t, "config file "+path+" is writable by others: token.cmd not run", res.err.Error()) | |
| 177 | + | assert.Empty(t, *ran) | |
| 178 | + | }) | |
| 179 | + | } | |
| 180 | + | t.Run("group readable is fine", func(t *testing.T) { | |
| 181 | + | f := listFake() | |
| 182 | + | env := testEnv(t, nil) | |
| 183 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o644) | |
| 184 | + | tokenCmdStub(env, "ermite_cmd", nil) | |
| 185 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 186 | + | }) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func TestTokenCmdFailuresNeverShowOutput(t *testing.T) { | |
| 190 | + | const secret = "s3cret-token-value" | |
| 191 | + | tests := []struct { | |
| 192 | + | name string | |
| 193 | + | out string | |
| 194 | + | err error | |
| 195 | + | want string | |
| 196 | + | }{ | |
| 197 | + | {"failure", secret, errors.New("exit status 3"), "token.cmd failed: exit status 3"}, | |
| 198 | + | {"empty", " \n", nil, "token.cmd printed nothing"}, | |
| 199 | + | {"multiline", secret + "\n" + secret + "\n", nil, "token.cmd printed more than one line"}, | |
| 200 | + | {"crlf multiline", secret + "\r\n" + secret, nil, "token.cmd printed more than one line"}, | |
| 201 | + | } | |
| 202 | + | for _, tt := range tests { | |
| 203 | + | t.Run(tt.name, func(t *testing.T) { | |
| 204 | + | env := testEnv(t, nil) | |
| 205 | + | writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600) | |
| 206 | + | tokenCmdStub(env, tt.out, tt.err) | |
| 207 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 208 | + | require.Error(t, res.err) | |
| 209 | + | assert.Equal(t, tt.want, res.err.Error()) | |
| 210 | + | assert.NotContains(t, res.err.Error()+res.stdout+res.stderr, secret) | |
| 211 | + | }) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestMissingURLIsReportedBeforeTokenCmdRuns(t *testing.T) { | |
| 216 | + | env := testEnv(t, nil) | |
| 217 | + | writeConfigFile(t, env, "token.cmd = get-token\n", 0o600) | |
| 218 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 219 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 220 | + | require.Error(t, res.err) | |
| 221 | + | assert.Contains(t, res.err.Error(), "ERMITE_URL is not set") | |
| 222 | + | assert.Contains(t, res.err.Error(), "ermite config set url") | |
| 223 | + | assert.Empty(t, *ran) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestInvalidFileURLIsReported(t *testing.T) { | |
| 227 | + | env := testEnv(t, nil) | |
| 228 | + | writeConfigFile(t, env, "url = git.example.com\n", 0o600) | |
| 229 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 230 | + | require.Error(t, res.err) | |
| 231 | + | assert.Contains(t, res.err.Error(), "invalid server URL") | |
| 232 | + | assert.Contains(t, res.err.Error(), "http") | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestConfigFileWarnings(t *testing.T) { | |
| 236 | + | f := listFake() | |
| 237 | + | env := testEnv(t, nil) | |
| 238 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ncolour = blue\n", 0o600) | |
| 239 | + | res := runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 240 | + | assert.Equal(t, 1, strings.Count(res.stderr, "ermite: warning: config: "), res.stderr) | |
| 241 | + | assert.Contains(t, res.stderr, "colour") | |
| 242 | + | assert.NotContains(t, res.stderr, "blue", "values are never echoed") | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func TestCompletionStaysSilentAboutTheConfigFile(t *testing.T) { | |
| 246 | + | env := testEnv(t, nil) | |
| 247 | + | writeConfigFile(t, env, "colour = blue\nurl = nope\n", 0o600) | |
| 248 | + | res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t) | |
| 249 | + | assert.NotContains(t, res.stderr, "warning") | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func TestERMITE_CONFIGSelectsTheFile(t *testing.T) { | |
| 253 | + | f := listFake() | |
| 254 | + | path := filepath.Join(t.TempDir(), "other", "conf") | |
| 255 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |
| 256 | + | require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\n"), 0o600)) | |
| 257 | + | ||
| 258 | + | env := testEnv(t, map[string]string{"ERMITE_CONFIG": path}) | |
| 259 | + | env.configPath = nil // the default resolution, which reads ERMITE_CONFIG | |
| 260 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func TestTokenCmdRunsThroughTheShell(t *testing.T) { | |
| 264 | + | if runtime.GOOS == "windows" { | |
| 265 | + | t.Skip("uses sh") | |
| 266 | + | } | |
| 267 | + | f := listFake() | |
| 268 | + | env := testEnv(t, nil) | |
| 269 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = printf secret\n", 0o600) | |
| 270 | + | env.runTokenCmd = nil // the real thing | |
| 271 | + | ||
| 272 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 273 | + | assert.Equal(t, "Bearer secret", lastAuth(f)) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func TestRunShellReportsFailuresWithoutOutput(t *testing.T) { | |
| 277 | + | if runtime.GOOS == "windows" { | |
| 278 | + | t.Skip("uses sh") | |
| 279 | + | } | |
| 280 | + | _, err := runShell(t.Context(), "echo s3cret; exit 3") | |
| 281 | + | require.Error(t, err) | |
| 282 | + | assert.Contains(t, err.Error(), "exit status 3") | |
| 283 | + | assert.NotContains(t, err.Error(), "s3cret") | |
| 284 | + | ||
| 285 | + | _, err = runShell(t.Context(), "head -c 5000 /dev/zero | tr '\\0' a") | |
| 286 | + | require.Error(t, err) | |
| 287 | + | assert.Contains(t, err.Error(), "more than 4096 bytes") | |
| 288 | + | assert.NotContains(t, err.Error(), "aaa") | |
| 289 | + | ||
| 290 | + | out, err := runShell(t.Context(), "echo ermite_ok") | |
| 291 | + | require.NoError(t, err) | |
| 292 | + | assert.Equal(t, "ermite_ok\n", out) | |
| 293 | + | } | |
| @@ -0,0 +1,293 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "runtime" | |||
| 11 | + | "strings" | |||
| 12 | + | "testing" | |||
| 13 | + | ||||
| 14 | + | "github.com/stretchr/testify/assert" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | "bnema.dev/ermite-api/gen/ermite" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // writeConfigFile writes the config file of env with the given mode. | |||
| 21 | + | func writeConfigFile(t *testing.T, env *cliEnv, content string, mode os.FileMode) string { | |||
| 22 | + | t.Helper() | |||
| 23 | + | path, err := env.configPath() | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |||
| 26 | + | require.NoError(t, os.WriteFile(path, []byte(content), mode)) | |||
| 27 | + | require.NoError(t, os.Chmod(path, mode)) // umask | |||
| 28 | + | return path | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // tokenCmdStub makes runTokenCmd return out and err, and counts the runs. | |||
| 32 | + | func tokenCmdStub(env *cliEnv, out string, err error) *[]string { | |||
| 33 | + | var ran []string | |||
| 34 | + | env.runTokenCmd = func(_ context.Context, line string) (string, error) { | |||
| 35 | + | ran = append(ran, line) | |||
| 36 | + | return out, err | |||
| 37 | + | } | |||
| 38 | + | return &ran | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func listFake() *fakeAPI { | |||
| 42 | + | return &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |||
| 43 | + | return &ermite.ListReposResponse{}, nil | |||
| 44 | + | }} | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func lastAuth(f *fakeAPI) string { | |||
| 48 | + | f.mu.Lock() | |||
| 49 | + | defer f.mu.Unlock() | |||
| 50 | + | if len(f.header) == 0 { | |||
| 51 | + | return "<no call>" | |||
| 52 | + | } | |||
| 53 | + | return f.header[len(f.header)-1].Get("Authorization") | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestConfigFileSuppliesURLAndToken(t *testing.T) { | |||
| 57 | + | f := listFake() | |||
| 58 | + | url := f.serve(t) | |||
| 59 | + | env := testEnv(t, nil) | |||
| 60 | + | writeConfigFile(t, env, "# my server\nurl = "+url+"\ntoken.cmd = pass show ermite/token\n", 0o600) | |||
| 61 | + | ran := tokenCmdStub(env, " ermite_fromcmd\n", nil) | |||
| 62 | + | ||||
| 63 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 64 | + | assert.Equal(t, "Bearer ermite_fromcmd", lastAuth(f)) | |||
| 65 | + | assert.Equal(t, []string{"pass show ermite/token"}, *ran) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestConfigFileURLWithoutTokenCmdIsAnonymous(t *testing.T) { | |||
| 69 | + | f := listFake() | |||
| 70 | + | env := testEnv(t, nil) | |||
| 71 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\n", 0o600) | |||
| 72 | + | ||||
| 73 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 74 | + | assert.Empty(t, lastAuth(f)) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestEnvironmentWinsOverConfigFile(t *testing.T) { | |||
| 78 | + | fileSrv, envSrv := listFake(), listFake() | |||
| 79 | + | env := testEnv(t, map[string]string{"ERMITE_URL": envSrv.serve(t), "ERMITE_TOKEN": "ermite_env"}) | |||
| 80 | + | writeConfigFile(t, env, "url = "+fileSrv.serve(t)+"\ntoken.cmd = echo nope\n", 0o600) | |||
| 81 | + | ||||
| 82 | + | runCLIWith(t, env, "", "repo", "list").ok(t) // the default runTokenCmd of testEnv fails the test if called | |||
| 83 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(envSrv)) | |||
| 84 | + | calls, _ := fileSrv.snapshot() | |||
| 85 | + | assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set") | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestEnvURLAloneStillUsesTokenCmd(t *testing.T) { | |||
| 89 | + | f := listFake() | |||
| 90 | + | env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)}) | |||
| 91 | + | writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600) | |||
| 92 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 93 | + | ||||
| 94 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 95 | + | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | |||
| 96 | + | assert.Len(t, *ran, 1) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) { | |||
| 100 | + | f := listFake() | |||
| 101 | + | env := testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"}) | |||
| 102 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o666) // even an unsafe file is not an issue | |||
| 103 | + | ran := tokenCmdStub(env, "other", nil) | |||
| 104 | + | ||||
| 105 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 106 | + | assert.Empty(t, *ran) | |||
| 107 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(f)) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestTokenCmdNotRunForVersionNorCompletion(t *testing.T) { | |||
| 111 | + | f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |||
| 112 | + | return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil | |||
| 113 | + | }} | |||
| 114 | + | url := f.serve(t) | |||
| 115 | + | ||||
| 116 | + | for name, args := range map[string][]string{ | |||
| 117 | + | "version": {"version"}, | |||
| 118 | + | "version --json": {"version", "--json"}, | |||
| 119 | + | "completion": {"completion", "bash"}, | |||
| 120 | + | } { | |||
| 121 | + | t.Run(name, func(t *testing.T) { | |||
| 122 | + | env := testEnv(t, nil) | |||
| 123 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |||
| 124 | + | ran := tokenCmdStub(env, "", errors.New("must not run")) | |||
| 125 | + | runCLIWith(t, env, "", args...).ok(t) | |||
| 126 | + | assert.Empty(t, *ran) | |||
| 127 | + | }) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | t.Run("version without any url", func(t *testing.T) { | |||
| 131 | + | env := testEnv(t, nil) | |||
| 132 | + | writeConfigFile(t, env, "token.cmd = get-token\n", 0o600) | |||
| 133 | + | ran := tokenCmdStub(env, "", errors.New("must not run")) | |||
| 134 | + | res := runCLIWith(t, env, "", "version").ok(t) | |||
| 135 | + | assert.Equal(t, "ermite v0.0.1\n", res.stdout) | |||
| 136 | + | assert.Empty(t, *ran) | |||
| 137 | + | }) | |||
| 138 | + | ||||
| 139 | + | t.Run("version uses the url of the file and sends no token", func(t *testing.T) { | |||
| 140 | + | env := testEnv(t, nil) | |||
| 141 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |||
| 142 | + | tokenCmdStub(env, "ermite_cmd", nil) | |||
| 143 | + | res := runCLIWith(t, env, "", "version").ok(t) | |||
| 144 | + | assert.Contains(t, res.stdout, "v0.4.0 at "+url) | |||
| 145 | + | assert.Empty(t, lastAuth(f)) | |||
| 146 | + | }) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestTokenCmdRunsOncePerProcess(t *testing.T) { | |||
| 150 | + | f := listFake() | |||
| 151 | + | env := testEnv(t, nil) | |||
| 152 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |||
| 153 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 154 | + | root := newRootCmdWith(env) | |||
| 155 | + | root.SetArgs([]string{"repo", "list"}) | |||
| 156 | + | for range 2 { | |||
| 157 | + | c, _, err := root.Find([]string{"repo", "list"}) | |||
| 158 | + | require.NoError(t, err) | |||
| 159 | + | _, err = env.client(c, true) | |||
| 160 | + | require.NoError(t, err) | |||
| 161 | + | } | |||
| 162 | + | assert.Len(t, *ran, 1) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestTokenCmdRefusedWhenConfigFileIsWritableByOthers(t *testing.T) { | |||
| 166 | + | if runtime.GOOS == "windows" { | |||
| 167 | + | t.Skip("no Unix permission bits") | |||
| 168 | + | } | |||
| 169 | + | for _, mode := range []os.FileMode{0o666, 0o662, 0o620} { | |||
| 170 | + | t.Run(mode.String(), func(t *testing.T) { | |||
| 171 | + | env := testEnv(t, nil) | |||
| 172 | + | path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", mode) | |||
| 173 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 174 | + | res := runCLIWith(t, env, "", "repo", "list") | |||
| 175 | + | require.Error(t, res.err) | |||
| 176 | + | assert.Equal(t, "config file "+path+" is writable by others: token.cmd not run", res.err.Error()) | |||
| 177 | + | assert.Empty(t, *ran) | |||
| 178 | + | }) | |||
| 179 | + | } | |||
| 180 | + | t.Run("group readable is fine", func(t *testing.T) { | |||
| 181 | + | f := listFake() | |||
| 182 | + | env := testEnv(t, nil) | |||
| 183 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o644) | |||
| 184 | + | tokenCmdStub(env, "ermite_cmd", nil) | |||
| 185 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 186 | + | }) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func TestTokenCmdFailuresNeverShowOutput(t *testing.T) { | |||
| 190 | + | const secret = "s3cret-token-value" | |||
| 191 | + | tests := []struct { | |||
| 192 | + | name string | |||
| 193 | + | out string | |||
| 194 | + | err error | |||
| 195 | + | want string | |||
| 196 | + | }{ | |||
| 197 | + | {"failure", secret, errors.New("exit status 3"), "token.cmd failed: exit status 3"}, | |||
| 198 | + | {"empty", " \n", nil, "token.cmd printed nothing"}, | |||
| 199 | + | {"multiline", secret + "\n" + secret + "\n", nil, "token.cmd printed more than one line"}, | |||
| 200 | + | {"crlf multiline", secret + "\r\n" + secret, nil, "token.cmd printed more than one line"}, | |||
| 201 | + | } | |||
| 202 | + | for _, tt := range tests { | |||
| 203 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 204 | + | env := testEnv(t, nil) | |||
| 205 | + | writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600) | |||
| 206 | + | tokenCmdStub(env, tt.out, tt.err) | |||
| 207 | + | res := runCLIWith(t, env, "", "repo", "list") | |||
| 208 | + | require.Error(t, res.err) | |||
| 209 | + | assert.Equal(t, tt.want, res.err.Error()) | |||
| 210 | + | assert.NotContains(t, res.err.Error()+res.stdout+res.stderr, secret) | |||
| 211 | + | }) | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func TestMissingURLIsReportedBeforeTokenCmdRuns(t *testing.T) { | |||
| 216 | + | env := testEnv(t, nil) | |||
| 217 | + | writeConfigFile(t, env, "token.cmd = get-token\n", 0o600) | |||
| 218 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 219 | + | res := runCLIWith(t, env, "", "repo", "list") | |||
| 220 | + | require.Error(t, res.err) | |||
| 221 | + | assert.Contains(t, res.err.Error(), "ERMITE_URL is not set") | |||
| 222 | + | assert.Contains(t, res.err.Error(), "ermite config set url") | |||
| 223 | + | assert.Empty(t, *ran) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func TestInvalidFileURLIsReported(t *testing.T) { | |||
| 227 | + | env := testEnv(t, nil) | |||
| 228 | + | writeConfigFile(t, env, "url = git.example.com\n", 0o600) | |||
| 229 | + | res := runCLIWith(t, env, "", "repo", "list") | |||
| 230 | + | require.Error(t, res.err) | |||
| 231 | + | assert.Contains(t, res.err.Error(), "invalid server URL") | |||
| 232 | + | assert.Contains(t, res.err.Error(), "http") | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestConfigFileWarnings(t *testing.T) { | |||
| 236 | + | f := listFake() | |||
| 237 | + | env := testEnv(t, nil) | |||
| 238 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ncolour = blue\n", 0o600) | |||
| 239 | + | res := runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 240 | + | assert.Equal(t, 1, strings.Count(res.stderr, "ermite: warning: config: "), res.stderr) | |||
| 241 | + | assert.Contains(t, res.stderr, "colour") | |||
| 242 | + | assert.NotContains(t, res.stderr, "blue", "values are never echoed") | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func TestCompletionStaysSilentAboutTheConfigFile(t *testing.T) { | |||
| 246 | + | env := testEnv(t, nil) | |||
| 247 | + | writeConfigFile(t, env, "colour = blue\nurl = nope\n", 0o600) | |||
| 248 | + | res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t) | |||
| 249 | + | assert.NotContains(t, res.stderr, "warning") | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func TestERMITE_CONFIGSelectsTheFile(t *testing.T) { | |||
| 253 | + | f := listFake() | |||
| 254 | + | path := filepath.Join(t.TempDir(), "other", "conf") | |||
| 255 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |||
| 256 | + | require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\n"), 0o600)) | |||
| 257 | + | ||||
| 258 | + | env := testEnv(t, map[string]string{"ERMITE_CONFIG": path}) | |||
| 259 | + | env.configPath = nil // the default resolution, which reads ERMITE_CONFIG | |||
| 260 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func TestTokenCmdRunsThroughTheShell(t *testing.T) { | |||
| 264 | + | if runtime.GOOS == "windows" { | |||
| 265 | + | t.Skip("uses sh") | |||
| 266 | + | } | |||
| 267 | + | f := listFake() | |||
| 268 | + | env := testEnv(t, nil) | |||
| 269 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = printf secret\n", 0o600) | |||
| 270 | + | env.runTokenCmd = nil // the real thing | |||
| 271 | + | ||||
| 272 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 273 | + | assert.Equal(t, "Bearer secret", lastAuth(f)) | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func TestRunShellReportsFailuresWithoutOutput(t *testing.T) { | |||
| 277 | + | if runtime.GOOS == "windows" { | |||
| 278 | + | t.Skip("uses sh") | |||
| 279 | + | } | |||
| 280 | + | _, err := runShell(t.Context(), "echo s3cret; exit 3") | |||
| 281 | + | require.Error(t, err) | |||
| 282 | + | assert.Contains(t, err.Error(), "exit status 3") | |||
| 283 | + | assert.NotContains(t, err.Error(), "s3cret") | |||
| 284 | + | ||||
| 285 | + | _, err = runShell(t.Context(), "head -c 5000 /dev/zero | tr '\\0' a") | |||
| 286 | + | require.Error(t, err) | |||
| 287 | + | assert.Contains(t, err.Error(), "more than 4096 bytes") | |||
| 288 | + | assert.NotContains(t, err.Error(), "aaa") | |||
| 289 | + | ||||
| 290 | + | out, err := runShell(t.Context(), "echo ermite_ok") | |||
| 291 | + | require.NoError(t, err) | |||
| 292 | + | assert.Equal(t, "ermite_ok\n", out) | |||
| 293 | + | } | |||
M internal/app/cli_fake_test.go +28 -6
| @@ -5,8 +5,10 @@ package app | |||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | 7 | "context" | |
| 8 | + | "errors" | |
| 8 | 9 | "net/http" | |
| 9 | 10 | "net/http/httptest" | |
| 11 | + | "path/filepath" | |
| 10 | 12 | "strings" | |
| 11 | 13 | "sync" | |
| 12 | 14 | "testing" | |
| @@ -173,15 +175,35 @@ type result struct { | |||
| 173 | 175 | err error | |
| 174 | 176 | } | |
| 175 | 177 | ||
| 178 | + | // testEnv is a cliEnv with the given variables, the fixed clock, and a config | |
| 179 | + | // file path inside a fresh temp dir (no file yet) so that a test never reads | |
| 180 | + | // the developer's own ~/.config/ermite/config. token.cmd must not run unless | |
| 181 | + | // the test sets runTokenCmd. | |
| 182 | + | func testEnv(t *testing.T, vars map[string]string) *cliEnv { | |
| 183 | + | t.Helper() | |
| 184 | + | path := filepath.Join(t.TempDir(), "ermite", "config") | |
| 185 | + | return &cliEnv{ | |
| 186 | + | getenv: func(k string) string { return vars[k] }, | |
| 187 | + | now: func() time.Time { return testNow }, | |
| 188 | + | version: "v0.0.1", | |
| 189 | + | sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() }, | |
| 190 | + | configPath: func() (string, error) { return path, nil }, | |
| 191 | + | runTokenCmd: func(context.Context, string) (string, error) { | |
| 192 | + | t.Error("token.cmd must not run") | |
| 193 | + | return "", errors.New("token.cmd must not run") | |
| 194 | + | }, | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 176 | 198 | // runCLI runs the command tree in process with the given environment. | |
| 177 | 199 | func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result { | |
| 178 | 200 | t.Helper() | |
| 179 | - | env := &cliEnv{ | |
| 180 | - | getenv: func(k string) string { return vars[k] }, | |
| 181 | - | now: func() time.Time { return testNow }, | |
| 182 | - | version: "v0.0.1", | |
| 183 | - | sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() }, | |
| 184 | - | } | |
| 201 | + | return runCLIWith(t, testEnv(t, vars), stdin, args...) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // runCLIWith runs the command tree in process with env. | |
| 205 | + | func runCLIWith(t *testing.T, env *cliEnv, stdin string, args ...string) result { | |
| 206 | + | t.Helper() | |
| 185 | 207 | root := newRootCmdWith(env) | |
| 186 | 208 | var out, errb bytes.Buffer | |
| 187 | 209 | root.SetOut(&out) | |
| @@ -5,8 +5,10 @@ package app | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | "context" | 7 | "context" | ||
| 8 | + | "errors" | |||
| 8 | "net/http" | 9 | "net/http" | ||
| 9 | "net/http/httptest" | 10 | "net/http/httptest" | ||
| 11 | + | "path/filepath" | |||
| 10 | "strings" | 12 | "strings" | ||
| 11 | "sync" | 13 | "sync" | ||
| 12 | "testing" | 14 | "testing" | ||
| @@ -173,15 +175,35 @@ type result struct { | |||||
| 173 | err error | 175 | err error | ||
| 174 | } | 176 | } | ||
| 175 | 177 | ||||
| 178 | + | // testEnv is a cliEnv with the given variables, the fixed clock, and a config | |||
| 179 | + | // file path inside a fresh temp dir (no file yet) so that a test never reads | |||
| 180 | + | // the developer's own ~/.config/ermite/config. token.cmd must not run unless | |||
| 181 | + | // the test sets runTokenCmd. | |||
| 182 | + | func testEnv(t *testing.T, vars map[string]string) *cliEnv { | |||
| 183 | + | t.Helper() | |||
| 184 | + | path := filepath.Join(t.TempDir(), "ermite", "config") | |||
| 185 | + | return &cliEnv{ | |||
| 186 | + | getenv: func(k string) string { return vars[k] }, | |||
| 187 | + | now: func() time.Time { return testNow }, | |||
| 188 | + | version: "v0.0.1", | |||
| 189 | + | sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() }, | |||
| 190 | + | configPath: func() (string, error) { return path, nil }, | |||
| 191 | + | runTokenCmd: func(context.Context, string) (string, error) { | |||
| 192 | + | t.Error("token.cmd must not run") | |||
| 193 | + | return "", errors.New("token.cmd must not run") | |||
| 194 | + | }, | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 176 | // runCLI runs the command tree in process with the given environment. | 198 | // runCLI runs the command tree in process with the given environment. | ||
| 177 | func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result { | 199 | func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result { | ||
| 178 | t.Helper() | 200 | t.Helper() | ||
| 179 | - | env := &cliEnv{ | 201 | + | return runCLIWith(t, testEnv(t, vars), stdin, args...) |
| 180 | - | getenv: func(k string) string { return vars[k] }, | 202 | + | } |
| 181 | - | now: func() time.Time { return testNow }, | 203 | + | |
| 182 | - | version: "v0.0.1", | 204 | + | // runCLIWith runs the command tree in process with env. |
| 183 | - | sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() }, | 205 | + | func runCLIWith(t *testing.T, env *cliEnv, stdin string, args ...string) result { |
| 184 | - | } | 206 | + | t.Helper() |
| 185 | root := newRootCmdWith(env) | 207 | root := newRootCmdWith(env) | ||
| 186 | var out, errb bytes.Buffer | 208 | var out, errb bytes.Buffer | ||
| 187 | root.SetOut(&out) | 209 | root.SetOut(&out) | ||
A internal/app/cli_shell_unix.go +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | //go:build !windows | |
| 4 | + | ||
| 5 | + | package app | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "os/exec" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // shellCommand runs line with `sh -c`. | |
| 13 | + | func shellCommand(ctx context.Context, line string) *exec.Cmd { | |
| 14 | + | return exec.CommandContext(ctx, "sh", "-c", line) | |
| 15 | + | } | |
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | //go:build !windows | |||
| 4 | + | ||||
| 5 | + | package app | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "os/exec" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // shellCommand runs line with `sh -c`. | |||
| 13 | + | func shellCommand(ctx context.Context, line string) *exec.Cmd { | |||
| 14 | + | return exec.CommandContext(ctx, "sh", "-c", line) | |||
| 15 | + | } | |||
A internal/app/cli_shell_windows.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | //go:build windows | |
| 4 | + | ||
| 5 | + | package app | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "os/exec" | |
| 10 | + | "syscall" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // shellCommand runs line with `cmd /C`. The command line is passed as is: | |
| 14 | + | // Go's argument quoting would mangle quotes cmd.exe means to read itself. | |
| 15 | + | func shellCommand(ctx context.Context, line string) *exec.Cmd { | |
| 16 | + | c := exec.CommandContext(ctx, "cmd") | |
| 17 | + | c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line} | |
| 18 | + | return c | |
| 19 | + | } | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | //go:build windows | |||
| 4 | + | ||||
| 5 | + | package app | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "os/exec" | |||
| 10 | + | "syscall" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // shellCommand runs line with `cmd /C`. The command line is passed as is: | |||
| 14 | + | // Go's argument quoting would mangle quotes cmd.exe means to read itself. | |||
| 15 | + | func shellCommand(ctx context.Context, line string) *exec.Cmd { | |||
| 16 | + | c := exec.CommandContext(ctx, "cmd") | |||
| 17 | + | c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line} | |||
| 18 | + | return c | |||
| 19 | + | } | |||
M internal/app/cli_version.go +13 -7
| @@ -33,11 +33,13 @@ type jsonServerVersion struct { | |||
| 33 | 33 | func newVersionCmd(env *cliEnv) *cobra.Command { | |
| 34 | 34 | cmd := &cobra.Command{ | |
| 35 | 35 | Use: "version", | |
| 36 | - | Short: "Print the client version and, when ERMITE_URL is set, the server's", | |
| 37 | - | Long: "Print the version of this client and, when ERMITE_URL is set, the version of the server and\n" + | |
| 38 | - | "the optional features of the API it implements. A warning on standard error says which of the\n" + | |
| 39 | - | "features this client uses the server lacks. Without ERMITE_URL only the client is shown.\n\n" + | |
| 40 | - | "JSON fields (--json): client.version; server, null without ERMITE_URL, with url, version,\n" + | |
| 36 | + | Short: "Print the client version and, when a server URL is configured, the server's", | |
| 37 | + | Long: "Print the version of this client and, when a server URL is configured (ERMITE_URL or the url\n" + | |
| 38 | + | "of the config file, see `ermite config`), the version of the server and the optional features\n" + | |
| 39 | + | "of the API it implements. A warning on standard error says which of the features this client\n" + | |
| 40 | + | "uses the server lacks. Without a URL only the client is shown. The token command of the config\n" + | |
| 41 | + | "file is not run.\n\n" + | |
| 42 | + | "JSON fields (--json): client.version; server, null without a URL, with url, version,\n" + | |
| 41 | 43 | "features (array of identifiers) and missing_features (the ones this client uses that the\n" + | |
| 42 | 44 | "server lacks).", | |
| 43 | 45 | Args: cobra.NoArgs, | |
| @@ -46,7 +48,10 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||
| 46 | 48 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 47 | 49 | out := cmd.OutOrStdout() | |
| 48 | 50 | clientLine := "ermite " + env.version | |
| 49 | - | url := strings.TrimSpace(env.getenv(client.EnvURL)) | |
| 51 | + | url, err := env.resolveURL(cmd, true) | |
| 52 | + | if err != nil { | |
| 53 | + | return err | |
| 54 | + | } | |
| 50 | 55 | if url == "" { | |
| 51 | 56 | if *asJSON { | |
| 52 | 57 | return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}}) | |
| @@ -55,7 +60,8 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||
| 55 | 60 | return err | |
| 56 | 61 | } | |
| 57 | 62 | ||
| 58 | - | c, err := env.client(cmd, true) | |
| 63 | + | // GetVersion is anonymous: token.cmd is not run for it. | |
| 64 | + | c, err := env.clientNoTokenCmd(cmd, true) | |
| 59 | 65 | if err != nil { | |
| 60 | 66 | return err | |
| 61 | 67 | } | |
| @@ -33,11 +33,13 @@ type jsonServerVersion struct { | |||||
| 33 | func newVersionCmd(env *cliEnv) *cobra.Command { | 33 | func newVersionCmd(env *cliEnv) *cobra.Command { | ||
| 34 | cmd := &cobra.Command{ | 34 | cmd := &cobra.Command{ | ||
| 35 | Use: "version", | 35 | Use: "version", | ||
| 36 | - | Short: "Print the client version and, when ERMITE_URL is set, the server's", | 36 | + | Short: "Print the client version and, when a server URL is configured, the server's", |
| 37 | - | Long: "Print the version of this client and, when ERMITE_URL is set, the version of the server and\n" + | 37 | + | Long: "Print the version of this client and, when a server URL is configured (ERMITE_URL or the url\n" + |
| 38 | - | "the optional features of the API it implements. A warning on standard error says which of the\n" + | 38 | + | "of the config file, see `ermite config`), the version of the server and the optional features\n" + |
| 39 | - | "features this client uses the server lacks. Without ERMITE_URL only the client is shown.\n\n" + | 39 | + | "of the API it implements. A warning on standard error says which of the features this client\n" + |
| 40 | - | "JSON fields (--json): client.version; server, null without ERMITE_URL, with url, version,\n" + | 40 | + | "uses the server lacks. Without a URL only the client is shown. The token command of the config\n" + |
| 41 | + | "file is not run.\n\n" + | |||
| 42 | + | "JSON fields (--json): client.version; server, null without a URL, with url, version,\n" + | |||
| 41 | "features (array of identifiers) and missing_features (the ones this client uses that the\n" + | 43 | "features (array of identifiers) and missing_features (the ones this client uses that the\n" + | ||
| 42 | "server lacks).", | 44 | "server lacks).", | ||
| 43 | Args: cobra.NoArgs, | 45 | Args: cobra.NoArgs, | ||
| @@ -46,7 +48,10 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||||
| 46 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | 48 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | ||
| 47 | out := cmd.OutOrStdout() | 49 | out := cmd.OutOrStdout() | ||
| 48 | clientLine := "ermite " + env.version | 50 | clientLine := "ermite " + env.version | ||
| 49 | - | url := strings.TrimSpace(env.getenv(client.EnvURL)) | 51 | + | url, err := env.resolveURL(cmd, true) |
| 52 | + | if err != nil { | |||
| 53 | + | return err | |||
| 54 | + | } | |||
| 50 | if url == "" { | 55 | if url == "" { | ||
| 51 | if *asJSON { | 56 | if *asJSON { | ||
| 52 | return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}}) | 57 | return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}}) | ||
| @@ -55,7 +60,8 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||||
| 55 | return err | 60 | return err | ||
| 56 | } | 61 | } | ||
| 57 | 62 | ||||
| 58 | - | c, err := env.client(cmd, true) | 63 | + | // GetVersion is anonymous: token.cmd is not run for it. |
| 64 | + | c, err := env.clientNoTokenCmd(cmd, true) | |||
| 59 | if err != nil { | 65 | if err != nil { | ||
| 60 | return err | 66 | return err | ||
| 61 | } | 67 | } | ||