Merge pull request 'feat(cli): config file with the server URL and a token command' (#12) from feat/cli-config into main
27 files · +1765 -75
M README.md +25 -1
| @@ -30,12 +30,35 @@ ermite serve | |||
| 30 | 30 | | `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names | | |
| 31 | 31 | | `ERMITE_CI_LOG_RETENTION` | `90d` | how long CI runs, jobs, checks and logs are kept: `Nd` (days) or a Go duration such as `720h`, at least one hour | | |
| 32 | 32 | ||
| 33 | - | `ermite version` prints the version (and the server's when `ERMITE_URL` is set). | |
| 33 | + | `ermite version` prints the version (and the server's when a server URL is configured, see Remote CLI). | |
| 34 | 34 | ||
| 35 | 35 | ## Remote CLI | |
| 36 | 36 | ||
| 37 | 37 | The same binary drives a running server over its API. It reads `ERMITE_URL` (the server address, `http://` or `https://`) and `ERMITE_TOKEN` (a bearer token; optional for public reads). A token sent over plain `http://` to a host that is not loopback triggers a warning on every call. | |
| 38 | 38 | ||
| 39 | + | Each setting comes from the environment or from a config file, the environment first: | |
| 40 | + | ||
| 41 | + | | Setting | Environment | Config file key | | |
| 42 | + | |---|---|---| | |
| 43 | + | | server address | `ERMITE_URL` | `url` | | |
| 44 | + | | bearer token (optional for public reads) | `ERMITE_TOKEN` | `token.cmd`, a command that prints it | | |
| 45 | + | ||
| 46 | + | The file is `~/.config/ermite/config` (`$XDG_CONFIG_HOME/ermite/config` when set; `~/Library/Application Support/ermite/config` on macOS and `%AppData%\ermite\config` on Windows; `ERMITE_CONFIG` names another file). `ermite config show` prints the path in use. It holds no secret: `token.cmd` is a command line that fetches the token from your secret manager (pass, gopass, op, bw, secret-tool, …). | |
| 47 | + | ||
| 48 | + | ```ini | |
| 49 | + | # ~/.config/ermite/config | |
| 50 | + | url = https://git.example.com | |
| 51 | + | token.cmd = pass show ermite/token | |
| 52 | + | ``` | |
| 53 | + | ||
| 54 | + | ```sh | |
| 55 | + | ermite config set url https://git.example.com | |
| 56 | + | ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermite/token, op read op://vault/ermite/token, secret-tool lookup service ermite | |
| 57 | + | ermite config show [--json] # path and stored values; unset removes a key | |
| 58 | + | ``` | |
| 59 | + | ||
| 60 | + | `token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and, when standard input is a terminal, that terminal (so a pinentry or biometric prompt works; otherwise it reads nothing). On a timeout or when ermite is interrupted or terminated, the command and everything it started are killed (when standard input is a terminal, only the shell is, so that prompts keep working). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file, or the directory holding it, can be written by other users or the file belongs to another user (`ermite config show` and `set` warn about it). On Windows this check does not exist: keep the file in your own profile, the default place. It belongs to the server of the file: when `ERMITE_URL` names a different server than the file's `url`, it is not run and the call is anonymous (with a notice; set `ERMITE_TOKEN` to authenticate there). A failure is reported in one line without its output. Shell completion never runs it: it is anonymous unless `ERMITE_TOKEN` is set. | |
| 61 | + | ||
| 39 | 62 | ```sh | |
| 40 | 63 | export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_... | |
| 41 | 64 | ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes] | |
| @@ -50,6 +73,7 @@ ermite runner list | remove <id> | |||
| 50 | 73 | ermite web login # prints a one-time sign-in link (60 s) | |
| 51 | 74 | ermite web logout --all # signs every browser out | |
| 52 | 75 | ermite signing-key show # public key and allowed_signers line of squash commits | |
| 76 | + | ermite config show [--json] | set <url|token.cmd> <value> | unset <key> # the CLI config file | |
| 53 | 77 | ermite version # client and server, warns about missing server features | |
| 54 | 78 | ermite watch [repo] [--json] # follows events, reconnects and resumes | |
| 55 | 79 | ``` | |
| @@ -30,12 +30,35 @@ ermite serve | |||||
| 30 | | `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names | | 30 | | `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names | | ||
| 31 | | `ERMITE_CI_LOG_RETENTION` | `90d` | how long CI runs, jobs, checks and logs are kept: `Nd` (days) or a Go duration such as `720h`, at least one hour | | 31 | | `ERMITE_CI_LOG_RETENTION` | `90d` | how long CI runs, jobs, checks and logs are kept: `Nd` (days) or a Go duration such as `720h`, at least one hour | | ||
| 32 | 32 | ||||
| 33 | - | `ermite version` prints the version (and the server's when `ERMITE_URL` is set). | 33 | + | `ermite version` prints the version (and the server's when a server URL is configured, see Remote CLI). |
| 34 | 34 | ||||
| 35 | ## Remote CLI | 35 | ## Remote CLI | ||
| 36 | 36 | ||||
| 37 | The same binary drives a running server over its API. It reads `ERMITE_URL` (the server address, `http://` or `https://`) and `ERMITE_TOKEN` (a bearer token; optional for public reads). A token sent over plain `http://` to a host that is not loopback triggers a warning on every call. | 37 | The same binary drives a running server over its API. It reads `ERMITE_URL` (the server address, `http://` or `https://`) and `ERMITE_TOKEN` (a bearer token; optional for public reads). A token sent over plain `http://` to a host that is not loopback triggers a warning on every call. | ||
| 38 | 38 | ||||
| 39 | + | Each setting comes from the environment or from a config file, the environment first: | |||
| 40 | + | ||||
| 41 | + | | Setting | Environment | Config file key | | |||
| 42 | + | |---|---|---| | |||
| 43 | + | | server address | `ERMITE_URL` | `url` | | |||
| 44 | + | | bearer token (optional for public reads) | `ERMITE_TOKEN` | `token.cmd`, a command that prints it | | |||
| 45 | + | ||||
| 46 | + | The file is `~/.config/ermite/config` (`$XDG_CONFIG_HOME/ermite/config` when set; `~/Library/Application Support/ermite/config` on macOS and `%AppData%\ermite\config` on Windows; `ERMITE_CONFIG` names another file). `ermite config show` prints the path in use. It holds no secret: `token.cmd` is a command line that fetches the token from your secret manager (pass, gopass, op, bw, secret-tool, …). | |||
| 47 | + | ||||
| 48 | + | ```ini | |||
| 49 | + | # ~/.config/ermite/config | |||
| 50 | + | url = https://git.example.com | |||
| 51 | + | token.cmd = pass show ermite/token | |||
| 52 | + | ``` | |||
| 53 | + | ||||
| 54 | + | ```sh | |||
| 55 | + | ermite config set url https://git.example.com | |||
| 56 | + | ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermite/token, op read op://vault/ermite/token, secret-tool lookup service ermite | |||
| 57 | + | ermite config show [--json] # path and stored values; unset removes a key | |||
| 58 | + | ``` | |||
| 59 | + | ||||
| 60 | + | `token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and, when standard input is a terminal, that terminal (so a pinentry or biometric prompt works; otherwise it reads nothing). On a timeout or when ermite is interrupted or terminated, the command and everything it started are killed (when standard input is a terminal, only the shell is, so that prompts keep working). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file, or the directory holding it, can be written by other users or the file belongs to another user (`ermite config show` and `set` warn about it). On Windows this check does not exist: keep the file in your own profile, the default place. It belongs to the server of the file: when `ERMITE_URL` names a different server than the file's `url`, it is not run and the call is anonymous (with a notice; set `ERMITE_TOKEN` to authenticate there). A failure is reported in one line without its output. Shell completion never runs it: it is anonymous unless `ERMITE_TOKEN` is set. | |||
| 61 | + | ||||
| 39 | ```sh | 62 | ```sh | ||
| 40 | export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_... | 63 | export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_... | ||
| 41 | ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes] | 64 | ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes] | ||
| @@ -50,6 +73,7 @@ ermite runner list | remove <id> | |||||
| 50 | ermite web login # prints a one-time sign-in link (60 s) | 73 | ermite web login # prints a one-time sign-in link (60 s) | ||
| 51 | ermite web logout --all # signs every browser out | 74 | ermite web logout --all # signs every browser out | ||
| 52 | ermite signing-key show # public key and allowed_signers line of squash commits | 75 | ermite signing-key show # public key and allowed_signers line of squash commits | ||
| 76 | + | ermite config show [--json] | set <url|token.cmd> <value> | unset <key> # the CLI config file | |||
| 53 | ermite version # client and server, warns about missing server features | 77 | ermite version # client and server, warns about missing server features | ||
| 54 | ermite watch [repo] [--json] # follows events, reconnects and resumes | 78 | ermite watch [repo] [--json] # follows events, reconnects and resumes | ||
| 55 | ``` | 79 | ``` | ||
M go.mod +2 -1
| @@ -19,6 +19,7 @@ require ( | |||
| 19 | 19 | connectrpc.com/connect v1.21.0 | |
| 20 | 20 | github.com/a-h/templ v0.3.1020 | |
| 21 | 21 | github.com/alecthomas/chroma/v2 v2.27.0 | |
| 22 | + | github.com/bnema/kvconf v0.1.2 | |
| 22 | 23 | github.com/bnema/zerowrap v1.4.1 | |
| 23 | 24 | github.com/golang-jwt/jwt/v5 v5.3.1 | |
| 24 | 25 | github.com/spf13/cobra v1.10.2 | |
| @@ -26,6 +27,7 @@ require ( | |||
| 26 | 27 | github.com/yuin/goldmark v1.8.6 | |
| 27 | 28 | golang.org/x/crypto v0.57.0 | |
| 28 | 29 | golang.org/x/sys v0.49.0 | |
| 30 | + | golang.org/x/term v0.46.0 | |
| 29 | 31 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 | |
| 30 | 32 | google.golang.org/protobuf v1.36.12 | |
| 31 | 33 | ) | |
| @@ -152,7 +154,6 @@ require ( | |||
| 152 | 154 | golang.org/x/mod v0.41.0 // indirect | |
| 153 | 155 | golang.org/x/net v0.59.0 // indirect | |
| 154 | 156 | golang.org/x/sync v0.23.0 // indirect | |
| 155 | - | golang.org/x/term v0.46.0 // indirect | |
| 156 | 157 | golang.org/x/text v0.42.0 // indirect | |
| 157 | 158 | golang.org/x/tools v0.51.0 // indirect | |
| 158 | 159 | google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect | |
| @@ -19,6 +19,7 @@ require ( | |||||
| 19 | connectrpc.com/connect v1.21.0 | 19 | connectrpc.com/connect v1.21.0 | ||
| 20 | github.com/a-h/templ v0.3.1020 | 20 | github.com/a-h/templ v0.3.1020 | ||
| 21 | github.com/alecthomas/chroma/v2 v2.27.0 | 21 | github.com/alecthomas/chroma/v2 v2.27.0 | ||
| 22 | + | github.com/bnema/kvconf v0.1.2 | |||
| 22 | github.com/bnema/zerowrap v1.4.1 | 23 | github.com/bnema/zerowrap v1.4.1 | ||
| 23 | github.com/golang-jwt/jwt/v5 v5.3.1 | 24 | github.com/golang-jwt/jwt/v5 v5.3.1 | ||
| 24 | github.com/spf13/cobra v1.10.2 | 25 | github.com/spf13/cobra v1.10.2 | ||
| @@ -26,6 +27,7 @@ require ( | |||||
| 26 | github.com/yuin/goldmark v1.8.6 | 27 | github.com/yuin/goldmark v1.8.6 | ||
| 27 | golang.org/x/crypto v0.57.0 | 28 | golang.org/x/crypto v0.57.0 | ||
| 28 | golang.org/x/sys v0.49.0 | 29 | golang.org/x/sys v0.49.0 | ||
| 30 | + | golang.org/x/term v0.46.0 | |||
| 29 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 | 31 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 | ||
| 30 | google.golang.org/protobuf v1.36.12 | 32 | google.golang.org/protobuf v1.36.12 | ||
| 31 | ) | 33 | ) | ||
| @@ -152,7 +154,6 @@ require ( | |||||
| 152 | golang.org/x/mod v0.41.0 // indirect | 154 | golang.org/x/mod v0.41.0 // indirect | ||
| 153 | golang.org/x/net v0.59.0 // indirect | 155 | golang.org/x/net v0.59.0 // indirect | ||
| 154 | golang.org/x/sync v0.23.0 // indirect | 156 | golang.org/x/sync v0.23.0 // indirect | ||
| 155 | - | golang.org/x/term v0.46.0 // indirect | |||
| 156 | golang.org/x/text v0.42.0 // indirect | 157 | golang.org/x/text v0.42.0 // indirect | ||
| 157 | golang.org/x/tools v0.51.0 // indirect | 158 | golang.org/x/tools v0.51.0 // indirect | ||
| 158 | google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect | 159 | google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect | ||
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: %w", 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: %w", 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 +11 -10
| @@ -11,20 +11,21 @@ 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 TestNewConfigUnsetTellsHowToFixIt(t *testing.T) { | |
| 22 | + | _, err := client.NewConfig(" ", "") | |
| 23 | + | require.Error(t, err) | |
| 24 | + | assert.Contains(t, err.Error(), "ERMITE_URL") | |
| 25 | + | assert.Contains(t, err.Error(), "ermite config set url") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestNewConfigRejects(t *testing.T) { | |
| 28 | 29 | tests := []struct { | |
| 29 | 30 | name, url, want string | |
| 30 | 31 | }{ | |
| @@ -39,7 +40,7 @@ func TestConfigFromEnvRejects(t *testing.T) { | |||
| 39 | 40 | } | |
| 40 | 41 | for _, tt := range tests { | |
| 41 | 42 | t.Run(tt.name, func(t *testing.T) { | |
| 42 | - | _, err := client.ConfigFromEnv(env(map[string]string{client.EnvURL: tt.url})) | |
| 43 | + | _, err := client.NewConfig(tt.url, "") | |
| 43 | 44 | require.Error(t, err) | |
| 44 | 45 | assert.Contains(t, err.Error(), tt.want) | |
| 45 | 46 | assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed") | |
| @@ -11,20 +11,21 @@ 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 TestNewConfigUnsetTellsHowToFixIt(t *testing.T) { |
| 22 | + | _, err := client.NewConfig(" ", "") | |||
| 23 | + | require.Error(t, err) | |||
| 24 | + | assert.Contains(t, err.Error(), "ERMITE_URL") | |||
| 25 | + | assert.Contains(t, err.Error(), "ermite config set url") | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestNewConfigRejects(t *testing.T) { | |||
| 28 | tests := []struct { | 29 | tests := []struct { | ||
| 29 | name, url, want string | 30 | name, url, want string | ||
| 30 | }{ | 31 | }{ | ||
| @@ -39,7 +40,7 @@ func TestConfigFromEnvRejects(t *testing.T) { | |||||
| 39 | } | 40 | } | ||
| 40 | for _, tt := range tests { | 41 | for _, tt := range tests { | ||
| 41 | t.Run(tt.name, func(t *testing.T) { | 42 | t.Run(tt.name, func(t *testing.T) { | ||
| 42 | - | _, err := client.ConfigFromEnv(env(map[string]string{client.EnvURL: tt.url})) | 43 | + | _, err := client.NewConfig(tt.url, "") |
| 43 | require.Error(t, err) | 44 | require.Error(t, err) | ||
| 44 | assert.Contains(t, err.Error(), tt.want) | 45 | assert.Contains(t, err.Error(), tt.want) | ||
| 45 | assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed") | 46 | assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed") | ||
M internal/app/cli.go +69 -10
| @@ -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,10 +31,28 @@ 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. 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 | + | fileTrust error // why token.cmd must not run from this file, if so | |
| 48 | + | fileErr error | |
| 49 | + | tokenOnce sync.Once | |
| 50 | + | token string | |
| 51 | + | tokenErr error | |
| 33 | 52 | } | |
| 34 | 53 | ||
| 35 | 54 | func defaultCLIEnv() *cliEnv { | |
| 36 | - | return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion()} | |
| 55 | + | return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion(), runTokenCmd: runTokenShell} | |
| 37 | 56 | } | |
| 38 | 57 | ||
| 39 | 58 | // userAgent identifies this CLI to the server. | |
| @@ -54,23 +73,56 @@ func (e *cliEnv) ctx(cmd *cobra.Command) context.Context { | |||
| 54 | 73 | return ctx | |
| 55 | 74 | } | |
| 56 | 75 | ||
| 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) | |
| 76 | + | // buildConfig builds the client configuration: ERMITE_URL and ERMITE_TOKEN | |
| 77 | + | // win, then the config file (url, and the output of token.cmd for the token). | |
| 78 | + | // runTokenCmd false leaves token.cmd alone (ERMITE_TOKEN is still used), for | |
| 79 | + | // the calls that need no authentication. | |
| 80 | + | func (e *cliEnv) buildConfig(cmd *cobra.Command, warn, runTokenCmd bool) (client.Config, error) { | |
| 81 | + | rawURL, err := e.resolveURL(cmd, warn) | |
| 60 | 82 | if err != nil { | |
| 61 | - | return cfg, err | |
| 83 | + | return client.Config{}, err | |
| 84 | + | } | |
| 85 | + | if strings.TrimSpace(rawURL) != "" { | |
| 86 | + | if _, err := client.ParseURL(strings.TrimSpace(rawURL)); err != nil { | |
| 87 | + | return client.Config{}, fmt.Errorf("%s: %w", e.urlSource(), err) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | // A missing or invalid URL is reported before token.cmd gets to prompt. | |
| 91 | + | cfg, err := client.NewConfig(rawURL, "") | |
| 92 | + | if err != nil { | |
| 93 | + | return client.Config{}, err | |
| 94 | + | } | |
| 95 | + | cfg.Token = strings.TrimSpace(e.getenv(client.EnvToken)) | |
| 96 | + | if cfg.Token == "" && runTokenCmd { | |
| 97 | + | if cfg.Token, err = e.tokenFromCmd(cmd, warn); err != nil { | |
| 98 | + | return client.Config{}, err | |
| 99 | + | } | |
| 62 | 100 | } | |
| 63 | 101 | cfg.UserAgent = e.userAgent() | |
| 64 | 102 | return cfg, nil | |
| 65 | 103 | } | |
| 66 | 104 | ||
| 67 | - | // client builds the API client. Commands warn when the token would travel in | |
| 68 | - | // the clear; completion stays silent. | |
| 69 | - | func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) { | |
| 70 | - | cfg, err := e.config() | |
| 105 | + | // client builds the API client. It warns when the token would travel in the | |
| 106 | + | // clear. | |
| 107 | + | func (e *cliEnv) client(cmd *cobra.Command) (*client.Client, error) { | |
| 108 | + | cfg, err := e.buildConfig(cmd, true, true) | |
| 71 | 109 | if err != nil { | |
| 72 | 110 | return nil, err | |
| 73 | 111 | } | |
| 112 | + | return newClient(cmd, cfg, true) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // clientNoTokenCmd is client without running token.cmd (ERMITE_TOKEN is still | |
| 116 | + | // used), for GetVersion, which is anonymous, and for completions. | |
| 117 | + | func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) { | |
| 118 | + | cfg, err := e.buildConfig(cmd, warn, false) | |
| 119 | + | if err != nil { | |
| 120 | + | return nil, err | |
| 121 | + | } | |
| 122 | + | return newClient(cmd, cfg, warn) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func newClient(cmd *cobra.Command, cfg client.Config, warn bool) (*client.Client, error) { | |
| 74 | 126 | if w := cfg.InsecureTokenWarning(); w != "" && warn { | |
| 75 | 127 | warnf(cmd, "%s", w) | |
| 76 | 128 | } | |
| @@ -128,13 +180,20 @@ func collect[T any](ctx context.Context, fetch func(token string) ([]T, string, | |||
| 128 | 180 | } | |
| 129 | 181 | } | |
| 130 | 182 | ||
| 183 | + | // completionClient is the client of the shell completions: silent, and | |
| 184 | + | // anonymous unless ERMITE_TOKEN is set, because completing must never run | |
| 185 | + | // token.cmd (it may prompt, and a completion script cannot answer). | |
| 186 | + | func (e *cliEnv) completionClient(cmd *cobra.Command) (*client.Client, error) { | |
| 187 | + | return e.clientNoTokenCmd(cmd, false) | |
| 188 | + | } | |
| 189 | + | ||
| 131 | 190 | // complete builds a shell completion: it opens a client (silently giving up | |
| 132 | 191 | // on any failure), lists with a 3 s timeout and keeps the entries whose | |
| 133 | 192 | // first element starts with the prefix. | |
| 134 | 193 | func complete[T any](env *cliEnv, cmd *cobra.Command, prefix string, | |
| 135 | 194 | list func(context.Context, *client.Client) ([]T, error), entry func(T) (key, text string), | |
| 136 | 195 | ) []string { | |
| 137 | - | c, err := env.client(cmd, false) | |
| 196 | + | c, err := env.completionClient(cmd) | |
| 138 | 197 | if err != nil { | |
| 139 | 198 | return nil | |
| 140 | 199 | } | |
| @@ -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,10 +31,28 @@ 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. 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 | + | fileTrust error // why token.cmd must not run from this file, if so | |||
| 48 | + | fileErr error | |||
| 49 | + | tokenOnce sync.Once | |||
| 50 | + | token string | |||
| 51 | + | tokenErr error | |||
| 33 | } | 52 | } | ||
| 34 | 53 | ||||
| 35 | func defaultCLIEnv() *cliEnv { | 54 | func defaultCLIEnv() *cliEnv { | ||
| 36 | - | return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion()} | 55 | + | return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion(), runTokenCmd: runTokenShell} |
| 37 | } | 56 | } | ||
| 38 | 57 | ||||
| 39 | // userAgent identifies this CLI to the server. | 58 | // userAgent identifies this CLI to the server. | ||
| @@ -54,23 +73,56 @@ func (e *cliEnv) ctx(cmd *cobra.Command) context.Context { | |||||
| 54 | return ctx | 73 | return ctx | ||
| 55 | } | 74 | } | ||
| 56 | 75 | ||||
| 57 | - | // config reads and validates ERMITE_URL and ERMITE_TOKEN. | 76 | + | // buildConfig builds the client configuration: ERMITE_URL and ERMITE_TOKEN |
| 58 | - | func (e *cliEnv) config() (client.Config, error) { | 77 | + | // win, then the config file (url, and the output of token.cmd for the token). |
| 59 | - | cfg, err := client.ConfigFromEnv(e.getenv) | 78 | + | // runTokenCmd false leaves token.cmd alone (ERMITE_TOKEN is still used), for |
| 79 | + | // the calls that need no authentication. | |||
| 80 | + | func (e *cliEnv) buildConfig(cmd *cobra.Command, warn, runTokenCmd bool) (client.Config, error) { | |||
| 81 | + | rawURL, err := e.resolveURL(cmd, warn) | |||
| 60 | if err != nil { | 82 | if err != nil { | ||
| 61 | - | return cfg, err | 83 | + | return client.Config{}, err |
| 84 | + | } | |||
| 85 | + | if strings.TrimSpace(rawURL) != "" { | |||
| 86 | + | if _, err := client.ParseURL(strings.TrimSpace(rawURL)); err != nil { | |||
| 87 | + | return client.Config{}, fmt.Errorf("%s: %w", e.urlSource(), err) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | // A missing or invalid URL is reported before token.cmd gets to prompt. | |||
| 91 | + | cfg, err := client.NewConfig(rawURL, "") | |||
| 92 | + | if err != nil { | |||
| 93 | + | return client.Config{}, err | |||
| 94 | + | } | |||
| 95 | + | cfg.Token = strings.TrimSpace(e.getenv(client.EnvToken)) | |||
| 96 | + | if cfg.Token == "" && runTokenCmd { | |||
| 97 | + | if cfg.Token, err = e.tokenFromCmd(cmd, warn); err != nil { | |||
| 98 | + | return client.Config{}, err | |||
| 99 | + | } | |||
| 62 | } | 100 | } | ||
| 63 | cfg.UserAgent = e.userAgent() | 101 | cfg.UserAgent = e.userAgent() | ||
| 64 | return cfg, nil | 102 | return cfg, nil | ||
| 65 | } | 103 | } | ||
| 66 | 104 | ||||
| 67 | - | // client builds the API client. Commands warn when the token would travel in | 105 | + | // client builds the API client. It warns when the token would travel in the |
| 68 | - | // the clear; completion stays silent. | 106 | + | // clear. |
| 69 | - | func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) { | 107 | + | func (e *cliEnv) client(cmd *cobra.Command) (*client.Client, error) { |
| 70 | - | cfg, err := e.config() | 108 | + | cfg, err := e.buildConfig(cmd, true, true) |
| 71 | if err != nil { | 109 | if err != nil { | ||
| 72 | return nil, err | 110 | return nil, err | ||
| 73 | } | 111 | } | ||
| 112 | + | return newClient(cmd, cfg, true) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // clientNoTokenCmd is client without running token.cmd (ERMITE_TOKEN is still | |||
| 116 | + | // used), for GetVersion, which is anonymous, and for completions. | |||
| 117 | + | func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) { | |||
| 118 | + | cfg, err := e.buildConfig(cmd, warn, false) | |||
| 119 | + | if err != nil { | |||
| 120 | + | return nil, err | |||
| 121 | + | } | |||
| 122 | + | return newClient(cmd, cfg, warn) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func newClient(cmd *cobra.Command, cfg client.Config, warn bool) (*client.Client, error) { | |||
| 74 | if w := cfg.InsecureTokenWarning(); w != "" && warn { | 126 | if w := cfg.InsecureTokenWarning(); w != "" && warn { | ||
| 75 | warnf(cmd, "%s", w) | 127 | warnf(cmd, "%s", w) | ||
| 76 | } | 128 | } | ||
| @@ -128,13 +180,20 @@ func collect[T any](ctx context.Context, fetch func(token string) ([]T, string, | |||||
| 128 | } | 180 | } | ||
| 129 | } | 181 | } | ||
| 130 | 182 | ||||
| 183 | + | // completionClient is the client of the shell completions: silent, and | |||
| 184 | + | // anonymous unless ERMITE_TOKEN is set, because completing must never run | |||
| 185 | + | // token.cmd (it may prompt, and a completion script cannot answer). | |||
| 186 | + | func (e *cliEnv) completionClient(cmd *cobra.Command) (*client.Client, error) { | |||
| 187 | + | return e.clientNoTokenCmd(cmd, false) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 131 | // complete builds a shell completion: it opens a client (silently giving up | 190 | // complete builds a shell completion: it opens a client (silently giving up | ||
| 132 | // on any failure), lists with a 3 s timeout and keeps the entries whose | 191 | // on any failure), lists with a 3 s timeout and keeps the entries whose | ||
| 133 | // first element starts with the prefix. | 192 | // first element starts with the prefix. | ||
| 134 | func complete[T any](env *cliEnv, cmd *cobra.Command, prefix string, | 193 | func complete[T any](env *cliEnv, cmd *cobra.Command, prefix string, | ||
| 135 | list func(context.Context, *client.Client) ([]T, error), entry func(T) (key, text string), | 194 | list func(context.Context, *client.Client) ([]T, error), entry func(T) (key, text string), | ||
| 136 | ) []string { | 195 | ) []string { | ||
| 137 | - | c, err := env.client(cmd, false) | 196 | + | c, err := env.completionClient(cmd) |
| 138 | if err != nil { | 197 | if err != nil { | ||
| 139 | return nil | 198 | return nil | ||
| 140 | } | 199 | } | ||
A internal/app/cli_config.go +214 -0
| @@ -0,0 +1,214 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io/fs" | |
| 9 | + | "slices" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/kvconf" | |
| 13 | + | "github.com/spf13/cobra" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // notSet is what `config show` prints for a key without a value. | |
| 19 | + | const notSet = "(not set)" | |
| 20 | + | ||
| 21 | + | // configKeys are the keys of the CLI config file. | |
| 22 | + | var configKeys = []string{keyURL, keyTokenCmd} | |
| 23 | + | ||
| 24 | + | func newConfigCmd(env *cliEnv) *cobra.Command { | |
| 25 | + | cmd := &cobra.Command{ | |
| 26 | + | Use: "config", | |
| 27 | + | Short: "Show and edit the config file of the remote commands", | |
| 28 | + | Long: "The remote commands read the server address and a way to get a token from a config file,\n" + | |
| 29 | + | "~/.config/ermite/config (the config directory of the platform on macOS and Windows; or the file\n" + | |
| 30 | + | "named by ERMITE_CONFIG; `ermite config show` prints the path in use):\n\n" + | |
| 31 | + | " url = https://git.example.com\n" + | |
| 32 | + | " token.cmd = pass show ermite/token\n\n" + | |
| 33 | + | "token.cmd is a command line run with the system shell when a command needs the token (at most\n" + | |
| 34 | + | "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" + | |
| 35 | + | "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" + | |
| 36 | + | "take precedence over the file. token.cmd belongs to the server of the file: it is not run when\n" + | |
| 37 | + | "ERMITE_URL names another server than the url of the file (the call is then anonymous, with a\n" + | |
| 38 | + | "notice: set ERMITE_TOKEN). `ermite version`, `ermite config` and shell completion never run it\n" + | |
| 39 | + | "(completion stays anonymous unless ERMITE_TOKEN is set).\n\n" + | |
| 40 | + | "token.cmd is also not run when the file belongs to another user, or when the file or its directory\n" + | |
| 41 | + | "can be written by other users (Unix); `ermite config show` and `set` warn about it. On Windows\n" + | |
| 42 | + | "this check does not exist: keep the file in your own profile (the default place).", | |
| 43 | + | } | |
| 44 | + | cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env)) | |
| 45 | + | return cmd | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // jsonConfig is the stable JSON shape of `ermite config show --json`. | |
| 49 | + | type jsonConfig struct { | |
| 50 | + | File string `json:"file"` | |
| 51 | + | URL *string `json:"url"` | |
| 52 | + | TokenCmd *string `json:"token_cmd"` | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func newConfigShowCmd(env *cliEnv) *cobra.Command { | |
| 56 | + | cmd := &cobra.Command{ | |
| 57 | + | Use: "show", | |
| 58 | + | Short: "Print the config file path and its settings", | |
| 59 | + | Long: "Print the path of the config file, then the url and token.cmd stored in it ((not set) when\n" + | |
| 60 | + | "absent). It never runs token.cmd, and warns when token.cmd would be refused (file permissions).\n\n" + | |
| 61 | + | "JSON fields (--json): file; url and token_cmd, null when not set.", | |
| 62 | + | Args: cobra.NoArgs, | |
| 63 | + | } | |
| 64 | + | asJSON := addJSONFlag(cmd) | |
| 65 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 66 | + | f, path, err := env.readFile(cmd, true) | |
| 67 | + | if path == "" { | |
| 68 | + | p, perr := env.path() | |
| 69 | + | if perr != nil { | |
| 70 | + | return perr | |
| 71 | + | } | |
| 72 | + | path = p | |
| 73 | + | } | |
| 74 | + | if err != nil { | |
| 75 | + | // Say which file is unreadable. | |
| 76 | + | _ = fields(cmd.OutOrStdout(), [][2]string{{"file", path}}) | |
| 77 | + | return err | |
| 78 | + | } | |
| 79 | + | if strings.TrimSpace(env.getenv(client.EnvURL)) != "" && f.URL != "" { | |
| 80 | + | noticef(cmd, "%s is set and takes precedence over url", client.EnvURL) | |
| 81 | + | } | |
| 82 | + | if strings.TrimSpace(env.getenv(client.EnvToken)) != "" && f.TokenCmd != "" { | |
| 83 | + | noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken) | |
| 84 | + | } | |
| 85 | + | if f.TokenCmd != "" && env.fileTrust != nil { | |
| 86 | + | warnf(cmd, "%s", env.fileTrust) | |
| 87 | + | } | |
| 88 | + | if *asJSON { | |
| 89 | + | return writeJSON(cmd.OutOrStdout(), jsonConfig{File: path, URL: nilIfBlank(f.URL), TokenCmd: nilIfBlank(f.TokenCmd)}) | |
| 90 | + | } | |
| 91 | + | return fields(cmd.OutOrStdout(), [][2]string{ | |
| 92 | + | {"file", path}, | |
| 93 | + | {keyURL, orNotSet(f.URL)}, | |
| 94 | + | {keyTokenCmd, orNotSet(f.TokenCmd)}, | |
| 95 | + | }) | |
| 96 | + | } | |
| 97 | + | return cmd | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func nilIfBlank(s string) *string { | |
| 101 | + | if strings.TrimSpace(s) == "" { | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | return &s | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func orNotSet(s string) string { | |
| 108 | + | if strings.TrimSpace(s) == "" { | |
| 109 | + | return notSet | |
| 110 | + | } | |
| 111 | + | return s | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func newConfigSetCmd(env *cliEnv) *cobra.Command { | |
| 115 | + | return &cobra.Command{ | |
| 116 | + | Use: "set <url|token.cmd> <value>", | |
| 117 | + | Short: "Set the server address or the token command in the config file", | |
| 118 | + | Long: "Write a setting to the config file, keeping its comments, and create the file (mode 0600) if needed.\n" + | |
| 119 | + | "url is checked: an http(s) address without credentials. token.cmd is a command line that prints the\n" + | |
| 120 | + | "token, for example `pass show ermite/token`; it is stored as is and not run.", | |
| 121 | + | Args: cobra.ExactArgs(2), | |
| 122 | + | ValidArgsFunction: completeConfigKeys, | |
| 123 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 124 | + | key, value := args[0], strings.TrimSpace(args[1]) | |
| 125 | + | if err := checkConfigKey(key); err != nil { | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | switch key { | |
| 129 | + | case keyURL: | |
| 130 | + | u, err := client.ParseURL(value) | |
| 131 | + | if err != nil { | |
| 132 | + | return fmt.Errorf("invalid url: %w", err) | |
| 133 | + | } | |
| 134 | + | value = u.String() | |
| 135 | + | case keyTokenCmd: | |
| 136 | + | if value == "" { | |
| 137 | + | return errors.New("token.cmd is empty: use `ermite config unset token.cmd` to remove it") | |
| 138 | + | } | |
| 139 | + | if strings.ContainsAny(value, "\r\n") { | |
| 140 | + | return errors.New("token.cmd must be a single line") | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | path, err := env.path() | |
| 144 | + | if err != nil { | |
| 145 | + | return err | |
| 146 | + | } | |
| 147 | + | if err := kvconf.SetFile(path, map[string]*string{key: &value}, 0o600); err != nil { | |
| 148 | + | return fmt.Errorf("writing %s: %w", path, err) | |
| 149 | + | } | |
| 150 | + | noticef(cmd, "%s set in %s", key, path) | |
| 151 | + | // Say now, not at the first command, that token.cmd would be refused. | |
| 152 | + | if f, _, trust, err := loadConfigFile(path); err == nil && trust != nil && f.TokenCmd != "" { | |
| 153 | + | warnf(cmd, "%s", trust) | |
| 154 | + | } | |
| 155 | + | return nil | |
| 156 | + | }, | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func newConfigUnsetCmd(env *cliEnv) *cobra.Command { | |
| 161 | + | return &cobra.Command{ | |
| 162 | + | Use: "unset <url|token.cmd>", | |
| 163 | + | Short: "Remove a setting from the config file", | |
| 164 | + | Args: cobra.ExactArgs(1), | |
| 165 | + | ValidArgsFunction: completeConfigKeys, | |
| 166 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 167 | + | key := args[0] | |
| 168 | + | if err := checkConfigKey(key); err != nil { | |
| 169 | + | return err | |
| 170 | + | } | |
| 171 | + | path, err := env.path() | |
| 172 | + | if err != nil { | |
| 173 | + | return err | |
| 174 | + | } | |
| 175 | + | data, err := kvconf.ReadFile(path, kvconf.Options{}) | |
| 176 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 177 | + | noticef(cmd, "%s is not set: %s does not exist", key, path) | |
| 178 | + | return nil | |
| 179 | + | } | |
| 180 | + | if err != nil { | |
| 181 | + | return fmt.Errorf("reading %s: %w", path, err) | |
| 182 | + | } | |
| 183 | + | entries, _, err := kvconf.ParseBytes(data, kvconf.Options{}) | |
| 184 | + | if err != nil { | |
| 185 | + | return fmt.Errorf("reading %s: %w", path, err) | |
| 186 | + | } | |
| 187 | + | if !slices.ContainsFunc(entries, func(e kvconf.Entry) bool { return e.Key == key }) { | |
| 188 | + | noticef(cmd, "%s is not set", key) | |
| 189 | + | return nil | |
| 190 | + | } | |
| 191 | + | if err := kvconf.SetFile(path, map[string]*string{key: nil}, 0o600); err != nil { | |
| 192 | + | return fmt.Errorf("writing %s: %w", path, err) | |
| 193 | + | } | |
| 194 | + | noticef(cmd, "%s removed from %s", key, path) | |
| 195 | + | return nil | |
| 196 | + | }, | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func checkConfigKey(key string) error { | |
| 201 | + | for _, k := range configKeys { | |
| 202 | + | if key == k { | |
| 203 | + | return nil | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | return fmt.Errorf("unknown key %q: the keys are %s", key, strings.Join(configKeys, " and ")) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func completeConfigKeys(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { | |
| 210 | + | if len(args) > 0 { | |
| 211 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 212 | + | } | |
| 213 | + | return configKeys, cobra.ShellCompDirectiveNoFileComp | |
| 214 | + | } | |
| @@ -0,0 +1,214 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io/fs" | |||
| 9 | + | "slices" | |||
| 10 | + | "strings" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/kvconf" | |||
| 13 | + | "github.com/spf13/cobra" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/adapters/client" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // notSet is what `config show` prints for a key without a value. | |||
| 19 | + | const notSet = "(not set)" | |||
| 20 | + | ||||
| 21 | + | // configKeys are the keys of the CLI config file. | |||
| 22 | + | var configKeys = []string{keyURL, keyTokenCmd} | |||
| 23 | + | ||||
| 24 | + | func newConfigCmd(env *cliEnv) *cobra.Command { | |||
| 25 | + | cmd := &cobra.Command{ | |||
| 26 | + | Use: "config", | |||
| 27 | + | Short: "Show and edit the config file of the remote commands", | |||
| 28 | + | Long: "The remote commands read the server address and a way to get a token from a config file,\n" + | |||
| 29 | + | "~/.config/ermite/config (the config directory of the platform on macOS and Windows; or the file\n" + | |||
| 30 | + | "named by ERMITE_CONFIG; `ermite config show` prints the path in use):\n\n" + | |||
| 31 | + | " url = https://git.example.com\n" + | |||
| 32 | + | " token.cmd = pass show ermite/token\n\n" + | |||
| 33 | + | "token.cmd is a command line run with the system shell when a command needs the token (at most\n" + | |||
| 34 | + | "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" + | |||
| 35 | + | "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" + | |||
| 36 | + | "take precedence over the file. token.cmd belongs to the server of the file: it is not run when\n" + | |||
| 37 | + | "ERMITE_URL names another server than the url of the file (the call is then anonymous, with a\n" + | |||
| 38 | + | "notice: set ERMITE_TOKEN). `ermite version`, `ermite config` and shell completion never run it\n" + | |||
| 39 | + | "(completion stays anonymous unless ERMITE_TOKEN is set).\n\n" + | |||
| 40 | + | "token.cmd is also not run when the file belongs to another user, or when the file or its directory\n" + | |||
| 41 | + | "can be written by other users (Unix); `ermite config show` and `set` warn about it. On Windows\n" + | |||
| 42 | + | "this check does not exist: keep the file in your own profile (the default place).", | |||
| 43 | + | } | |||
| 44 | + | cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env)) | |||
| 45 | + | return cmd | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // jsonConfig is the stable JSON shape of `ermite config show --json`. | |||
| 49 | + | type jsonConfig struct { | |||
| 50 | + | File string `json:"file"` | |||
| 51 | + | URL *string `json:"url"` | |||
| 52 | + | TokenCmd *string `json:"token_cmd"` | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func newConfigShowCmd(env *cliEnv) *cobra.Command { | |||
| 56 | + | cmd := &cobra.Command{ | |||
| 57 | + | Use: "show", | |||
| 58 | + | Short: "Print the config file path and its settings", | |||
| 59 | + | Long: "Print the path of the config file, then the url and token.cmd stored in it ((not set) when\n" + | |||
| 60 | + | "absent). It never runs token.cmd, and warns when token.cmd would be refused (file permissions).\n\n" + | |||
| 61 | + | "JSON fields (--json): file; url and token_cmd, null when not set.", | |||
| 62 | + | Args: cobra.NoArgs, | |||
| 63 | + | } | |||
| 64 | + | asJSON := addJSONFlag(cmd) | |||
| 65 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |||
| 66 | + | f, path, err := env.readFile(cmd, true) | |||
| 67 | + | if path == "" { | |||
| 68 | + | p, perr := env.path() | |||
| 69 | + | if perr != nil { | |||
| 70 | + | return perr | |||
| 71 | + | } | |||
| 72 | + | path = p | |||
| 73 | + | } | |||
| 74 | + | if err != nil { | |||
| 75 | + | // Say which file is unreadable. | |||
| 76 | + | _ = fields(cmd.OutOrStdout(), [][2]string{{"file", path}}) | |||
| 77 | + | return err | |||
| 78 | + | } | |||
| 79 | + | if strings.TrimSpace(env.getenv(client.EnvURL)) != "" && f.URL != "" { | |||
| 80 | + | noticef(cmd, "%s is set and takes precedence over url", client.EnvURL) | |||
| 81 | + | } | |||
| 82 | + | if strings.TrimSpace(env.getenv(client.EnvToken)) != "" && f.TokenCmd != "" { | |||
| 83 | + | noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken) | |||
| 84 | + | } | |||
| 85 | + | if f.TokenCmd != "" && env.fileTrust != nil { | |||
| 86 | + | warnf(cmd, "%s", env.fileTrust) | |||
| 87 | + | } | |||
| 88 | + | if *asJSON { | |||
| 89 | + | return writeJSON(cmd.OutOrStdout(), jsonConfig{File: path, URL: nilIfBlank(f.URL), TokenCmd: nilIfBlank(f.TokenCmd)}) | |||
| 90 | + | } | |||
| 91 | + | return fields(cmd.OutOrStdout(), [][2]string{ | |||
| 92 | + | {"file", path}, | |||
| 93 | + | {keyURL, orNotSet(f.URL)}, | |||
| 94 | + | {keyTokenCmd, orNotSet(f.TokenCmd)}, | |||
| 95 | + | }) | |||
| 96 | + | } | |||
| 97 | + | return cmd | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func nilIfBlank(s string) *string { | |||
| 101 | + | if strings.TrimSpace(s) == "" { | |||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 104 | + | return &s | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func orNotSet(s string) string { | |||
| 108 | + | if strings.TrimSpace(s) == "" { | |||
| 109 | + | return notSet | |||
| 110 | + | } | |||
| 111 | + | return s | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func newConfigSetCmd(env *cliEnv) *cobra.Command { | |||
| 115 | + | return &cobra.Command{ | |||
| 116 | + | Use: "set <url|token.cmd> <value>", | |||
| 117 | + | Short: "Set the server address or the token command in the config file", | |||
| 118 | + | Long: "Write a setting to the config file, keeping its comments, and create the file (mode 0600) if needed.\n" + | |||
| 119 | + | "url is checked: an http(s) address without credentials. token.cmd is a command line that prints the\n" + | |||
| 120 | + | "token, for example `pass show ermite/token`; it is stored as is and not run.", | |||
| 121 | + | Args: cobra.ExactArgs(2), | |||
| 122 | + | ValidArgsFunction: completeConfigKeys, | |||
| 123 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 124 | + | key, value := args[0], strings.TrimSpace(args[1]) | |||
| 125 | + | if err := checkConfigKey(key); err != nil { | |||
| 126 | + | return err | |||
| 127 | + | } | |||
| 128 | + | switch key { | |||
| 129 | + | case keyURL: | |||
| 130 | + | u, err := client.ParseURL(value) | |||
| 131 | + | if err != nil { | |||
| 132 | + | return fmt.Errorf("invalid url: %w", err) | |||
| 133 | + | } | |||
| 134 | + | value = u.String() | |||
| 135 | + | case keyTokenCmd: | |||
| 136 | + | if value == "" { | |||
| 137 | + | return errors.New("token.cmd is empty: use `ermite config unset token.cmd` to remove it") | |||
| 138 | + | } | |||
| 139 | + | if strings.ContainsAny(value, "\r\n") { | |||
| 140 | + | return errors.New("token.cmd must be a single line") | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | path, err := env.path() | |||
| 144 | + | if err != nil { | |||
| 145 | + | return err | |||
| 146 | + | } | |||
| 147 | + | if err := kvconf.SetFile(path, map[string]*string{key: &value}, 0o600); err != nil { | |||
| 148 | + | return fmt.Errorf("writing %s: %w", path, err) | |||
| 149 | + | } | |||
| 150 | + | noticef(cmd, "%s set in %s", key, path) | |||
| 151 | + | // Say now, not at the first command, that token.cmd would be refused. | |||
| 152 | + | if f, _, trust, err := loadConfigFile(path); err == nil && trust != nil && f.TokenCmd != "" { | |||
| 153 | + | warnf(cmd, "%s", trust) | |||
| 154 | + | } | |||
| 155 | + | return nil | |||
| 156 | + | }, | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func newConfigUnsetCmd(env *cliEnv) *cobra.Command { | |||
| 161 | + | return &cobra.Command{ | |||
| 162 | + | Use: "unset <url|token.cmd>", | |||
| 163 | + | Short: "Remove a setting from the config file", | |||
| 164 | + | Args: cobra.ExactArgs(1), | |||
| 165 | + | ValidArgsFunction: completeConfigKeys, | |||
| 166 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 167 | + | key := args[0] | |||
| 168 | + | if err := checkConfigKey(key); err != nil { | |||
| 169 | + | return err | |||
| 170 | + | } | |||
| 171 | + | path, err := env.path() | |||
| 172 | + | if err != nil { | |||
| 173 | + | return err | |||
| 174 | + | } | |||
| 175 | + | data, err := kvconf.ReadFile(path, kvconf.Options{}) | |||
| 176 | + | if errors.Is(err, fs.ErrNotExist) { | |||
| 177 | + | noticef(cmd, "%s is not set: %s does not exist", key, path) | |||
| 178 | + | return nil | |||
| 179 | + | } | |||
| 180 | + | if err != nil { | |||
| 181 | + | return fmt.Errorf("reading %s: %w", path, err) | |||
| 182 | + | } | |||
| 183 | + | entries, _, err := kvconf.ParseBytes(data, kvconf.Options{}) | |||
| 184 | + | if err != nil { | |||
| 185 | + | return fmt.Errorf("reading %s: %w", path, err) | |||
| 186 | + | } | |||
| 187 | + | if !slices.ContainsFunc(entries, func(e kvconf.Entry) bool { return e.Key == key }) { | |||
| 188 | + | noticef(cmd, "%s is not set", key) | |||
| 189 | + | return nil | |||
| 190 | + | } | |||
| 191 | + | if err := kvconf.SetFile(path, map[string]*string{key: nil}, 0o600); err != nil { | |||
| 192 | + | return fmt.Errorf("writing %s: %w", path, err) | |||
| 193 | + | } | |||
| 194 | + | noticef(cmd, "%s removed from %s", key, path) | |||
| 195 | + | return nil | |||
| 196 | + | }, | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func checkConfigKey(key string) error { | |||
| 201 | + | for _, k := range configKeys { | |||
| 202 | + | if key == k { | |||
| 203 | + | return nil | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | return fmt.Errorf("unknown key %q: the keys are %s", key, strings.Join(configKeys, " and ")) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func completeConfigKeys(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { | |||
| 210 | + | if len(args) > 0 { | |||
| 211 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |||
| 212 | + | } | |||
| 213 | + | return configKeys, cobra.ShellCompDirectiveNoFileComp | |||
| 214 | + | } | |||
A internal/app/cli_config_ci_test.go +114 -0
| @@ -0,0 +1,114 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "connectrpc.com/connect" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // The RunService calls of the `run` and `runner` commands answer with empty | |
| 16 | + | // results: these tests look at which address and token reach the server, not | |
| 17 | + | // at the output. | |
| 18 | + | func (f *fakeAPI) ListRuns(_ context.Context, r *connect.Request[ermite.ListRunsRequest]) (*connect.Response[ermite.ListRunsResponse], error) { | |
| 19 | + | return respond(f, "ListRuns", r, func(*ermite.ListRunsRequest) (*ermite.ListRunsResponse, error) { | |
| 20 | + | return &ermite.ListRunsResponse{}, nil | |
| 21 | + | }) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (f *fakeAPI) CancelRun(_ context.Context, r *connect.Request[ermite.CancelRunRequest]) (*connect.Response[ermite.CancelRunResponse], error) { | |
| 25 | + | return respond(f, "CancelRun", r, func(*ermite.CancelRunRequest) (*ermite.CancelRunResponse, error) { | |
| 26 | + | return &ermite.CancelRunResponse{}, nil | |
| 27 | + | }) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (f *fakeAPI) RerunRun(_ context.Context, r *connect.Request[ermite.RerunRunRequest]) (*connect.Response[ermite.RerunRunResponse], error) { | |
| 31 | + | return respond(f, "RerunRun", r, func(*ermite.RerunRunRequest) (*ermite.RerunRunResponse, error) { | |
| 32 | + | return &ermite.RerunRunResponse{}, nil | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (f *fakeAPI) ListRunners(_ context.Context, r *connect.Request[ermite.ListRunnersRequest]) (*connect.Response[ermite.ListRunnersResponse], error) { | |
| 37 | + | return respond(f, "ListRunners", r, func(*ermite.ListRunnersRequest) (*ermite.ListRunnersResponse, error) { | |
| 38 | + | return &ermite.ListRunnersResponse{Runners: []*ermite.Runner{{Id: "r1", Name: "alpha"}}}, nil | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (f *fakeAPI) RemoveRunner(_ context.Context, r *connect.Request[ermite.RemoveRunnerRequest]) (*connect.Response[ermite.RemoveRunnerResponse], error) { | |
| 43 | + | return respond(f, "RemoveRunner", r, func(*ermite.RemoveRunnerRequest) (*ermite.RemoveRunnerResponse, error) { | |
| 44 | + | return &ermite.RemoveRunnerResponse{}, nil | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // The `run` and `runner` commands read the server address and the token like | |
| 49 | + | // every other remote command: the config file (url, token.cmd), with | |
| 50 | + | // ERMITE_URL / ERMITE_TOKEN winning, and token.cmd run once and only for the | |
| 51 | + | // file's own url. | |
| 52 | + | func TestCIcommandsUseTheConfigFile(t *testing.T) { | |
| 53 | + | commands := map[string][]string{ | |
| 54 | + | "run list": {"run", "list", "proj"}, | |
| 55 | + | "run cancel": {"run", "cancel", "proj", "1"}, | |
| 56 | + | "run rerun": {"run", "rerun", "proj", "1"}, | |
| 57 | + | "runner list": {"runner", "list"}, | |
| 58 | + | "runner remove": {"runner", "remove", "r1"}, | |
| 59 | + | } | |
| 60 | + | for name, args := range commands { | |
| 61 | + | t.Run(name, func(t *testing.T) { | |
| 62 | + | f := &fakeAPI{} | |
| 63 | + | url := f.serve(t) | |
| 64 | + | ||
| 65 | + | // url and token.cmd from the file | |
| 66 | + | env := testEnv(t, nil) | |
| 67 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 68 | + | ran := tokenCmdStub(env, "ermite_cmd\n", nil) | |
| 69 | + | runCLIWith(t, env, "", args...).ok(t) | |
| 70 | + | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | |
| 71 | + | assert.Len(t, *ran, 1) | |
| 72 | + | ||
| 73 | + | // ERMITE_TOKEN wins and token.cmd stays idle | |
| 74 | + | env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"}) | |
| 75 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 76 | + | ran = tokenCmdStub(env, "ermite_cmd", nil) | |
| 77 | + | runCLIWith(t, env, "", args...).ok(t) | |
| 78 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(f)) | |
| 79 | + | assert.Empty(t, *ran) | |
| 80 | + | ||
| 81 | + | // another ERMITE_URL never receives the file's token | |
| 82 | + | other := &fakeAPI{} | |
| 83 | + | env = testEnv(t, map[string]string{"ERMITE_URL": other.serve(t)}) | |
| 84 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 85 | + | ran = tokenCmdStub(env, "ermite_cmd", nil) | |
| 86 | + | runCLIWith(t, env, "", args...).ok(t) | |
| 87 | + | assert.Empty(t, lastAuth(other)) | |
| 88 | + | assert.Empty(t, *ran) | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Completing a repository name for `run` or a runner id for `runner remove` | |
| 94 | + | // must not run token.cmd: it may prompt, and a completion script cannot answer. | |
| 95 | + | func TestCICompletionNeverRunsTokenCmd(t *testing.T) { | |
| 96 | + | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 97 | + | return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil | |
| 98 | + | }} | |
| 99 | + | url := f.serve(t) | |
| 100 | + | for name, args := range map[string][]string{ | |
| 101 | + | "run list": {"__complete", "run", "list", ""}, | |
| 102 | + | "run cancel": {"__complete", "run", "cancel", ""}, | |
| 103 | + | "runner remove": {"__complete", "runner", "remove", ""}, | |
| 104 | + | } { | |
| 105 | + | t.Run(name, func(t *testing.T) { | |
| 106 | + | env := testEnv(t, nil) | |
| 107 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 108 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 109 | + | runCLIWith(t, env, "", args...).ok(t) | |
| 110 | + | assert.Empty(t, *ran) | |
| 111 | + | assert.Empty(t, lastAuth(f)) | |
| 112 | + | }) | |
| 113 | + | } | |
| 114 | + | } | |
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "connectrpc.com/connect" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite-api/gen/ermite" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // The RunService calls of the `run` and `runner` commands answer with empty | |||
| 16 | + | // results: these tests look at which address and token reach the server, not | |||
| 17 | + | // at the output. | |||
| 18 | + | func (f *fakeAPI) ListRuns(_ context.Context, r *connect.Request[ermite.ListRunsRequest]) (*connect.Response[ermite.ListRunsResponse], error) { | |||
| 19 | + | return respond(f, "ListRuns", r, func(*ermite.ListRunsRequest) (*ermite.ListRunsResponse, error) { | |||
| 20 | + | return &ermite.ListRunsResponse{}, nil | |||
| 21 | + | }) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (f *fakeAPI) CancelRun(_ context.Context, r *connect.Request[ermite.CancelRunRequest]) (*connect.Response[ermite.CancelRunResponse], error) { | |||
| 25 | + | return respond(f, "CancelRun", r, func(*ermite.CancelRunRequest) (*ermite.CancelRunResponse, error) { | |||
| 26 | + | return &ermite.CancelRunResponse{}, nil | |||
| 27 | + | }) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (f *fakeAPI) RerunRun(_ context.Context, r *connect.Request[ermite.RerunRunRequest]) (*connect.Response[ermite.RerunRunResponse], error) { | |||
| 31 | + | return respond(f, "RerunRun", r, func(*ermite.RerunRunRequest) (*ermite.RerunRunResponse, error) { | |||
| 32 | + | return &ermite.RerunRunResponse{}, nil | |||
| 33 | + | }) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (f *fakeAPI) ListRunners(_ context.Context, r *connect.Request[ermite.ListRunnersRequest]) (*connect.Response[ermite.ListRunnersResponse], error) { | |||
| 37 | + | return respond(f, "ListRunners", r, func(*ermite.ListRunnersRequest) (*ermite.ListRunnersResponse, error) { | |||
| 38 | + | return &ermite.ListRunnersResponse{Runners: []*ermite.Runner{{Id: "r1", Name: "alpha"}}}, nil | |||
| 39 | + | }) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (f *fakeAPI) RemoveRunner(_ context.Context, r *connect.Request[ermite.RemoveRunnerRequest]) (*connect.Response[ermite.RemoveRunnerResponse], error) { | |||
| 43 | + | return respond(f, "RemoveRunner", r, func(*ermite.RemoveRunnerRequest) (*ermite.RemoveRunnerResponse, error) { | |||
| 44 | + | return &ermite.RemoveRunnerResponse{}, nil | |||
| 45 | + | }) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // The `run` and `runner` commands read the server address and the token like | |||
| 49 | + | // every other remote command: the config file (url, token.cmd), with | |||
| 50 | + | // ERMITE_URL / ERMITE_TOKEN winning, and token.cmd run once and only for the | |||
| 51 | + | // file's own url. | |||
| 52 | + | func TestCIcommandsUseTheConfigFile(t *testing.T) { | |||
| 53 | + | commands := map[string][]string{ | |||
| 54 | + | "run list": {"run", "list", "proj"}, | |||
| 55 | + | "run cancel": {"run", "cancel", "proj", "1"}, | |||
| 56 | + | "run rerun": {"run", "rerun", "proj", "1"}, | |||
| 57 | + | "runner list": {"runner", "list"}, | |||
| 58 | + | "runner remove": {"runner", "remove", "r1"}, | |||
| 59 | + | } | |||
| 60 | + | for name, args := range commands { | |||
| 61 | + | t.Run(name, func(t *testing.T) { | |||
| 62 | + | f := &fakeAPI{} | |||
| 63 | + | url := f.serve(t) | |||
| 64 | + | ||||
| 65 | + | // url and token.cmd from the file | |||
| 66 | + | env := testEnv(t, nil) | |||
| 67 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |||
| 68 | + | ran := tokenCmdStub(env, "ermite_cmd\n", nil) | |||
| 69 | + | runCLIWith(t, env, "", args...).ok(t) | |||
| 70 | + | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | |||
| 71 | + | assert.Len(t, *ran, 1) | |||
| 72 | + | ||||
| 73 | + | // ERMITE_TOKEN wins and token.cmd stays idle | |||
| 74 | + | env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"}) | |||
| 75 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |||
| 76 | + | ran = tokenCmdStub(env, "ermite_cmd", nil) | |||
| 77 | + | runCLIWith(t, env, "", args...).ok(t) | |||
| 78 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(f)) | |||
| 79 | + | assert.Empty(t, *ran) | |||
| 80 | + | ||||
| 81 | + | // another ERMITE_URL never receives the file's token | |||
| 82 | + | other := &fakeAPI{} | |||
| 83 | + | env = testEnv(t, map[string]string{"ERMITE_URL": other.serve(t)}) | |||
| 84 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |||
| 85 | + | ran = tokenCmdStub(env, "ermite_cmd", nil) | |||
| 86 | + | runCLIWith(t, env, "", args...).ok(t) | |||
| 87 | + | assert.Empty(t, lastAuth(other)) | |||
| 88 | + | assert.Empty(t, *ran) | |||
| 89 | + | }) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // Completing a repository name for `run` or a runner id for `runner remove` | |||
| 94 | + | // must not run token.cmd: it may prompt, and a completion script cannot answer. | |||
| 95 | + | func TestCICompletionNeverRunsTokenCmd(t *testing.T) { | |||
| 96 | + | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |||
| 97 | + | return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil | |||
| 98 | + | }} | |||
| 99 | + | url := f.serve(t) | |||
| 100 | + | for name, args := range map[string][]string{ | |||
| 101 | + | "run list": {"__complete", "run", "list", ""}, | |||
| 102 | + | "run cancel": {"__complete", "run", "cancel", ""}, | |||
| 103 | + | "runner remove": {"__complete", "runner", "remove", ""}, | |||
| 104 | + | } { | |||
| 105 | + | t.Run(name, func(t *testing.T) { | |||
| 106 | + | env := testEnv(t, nil) | |||
| 107 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |||
| 108 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 109 | + | runCLIWith(t, env, "", args...).ok(t) | |||
| 110 | + | assert.Empty(t, *ran) | |||
| 111 | + | assert.Empty(t, lastAuth(f)) | |||
| 112 | + | }) | |||
| 113 | + | } | |||
| 114 | + | } | |||
A internal/app/cli_config_test.go +235 -0
| @@ -0,0 +1,235 @@ | |||
| 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 | + | "strconv" | |
| 12 | + | "strings" | |
| 13 | + | "testing" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/assert" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestConfigShowWithoutFile(t *testing.T) { | |
| 20 | + | env := testEnv(t, nil) | |
| 21 | + | path, _ := env.configPath() | |
| 22 | + | res := runCLIWith(t, env, "", "config", "show").ok(t) | |
| 23 | + | assert.Equal(t, "file "+path+"\nurl (not set)\ntoken.cmd (not set)\n", res.stdout) | |
| 24 | + | _, err := os.Stat(path) | |
| 25 | + | assert.ErrorIs(t, err, os.ErrNotExist, "show does not create the file") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestConfigSetUnsetShowRoundTrip(t *testing.T) { | |
| 29 | + | env := testEnv(t, nil) | |
| 30 | + | path, _ := env.configPath() | |
| 31 | + | writeConfigFile(t, env, "# my forge\nurl = https://old.example.com\n\n# secrets stay in pass\n", 0o600) | |
| 32 | + | ||
| 33 | + | runCLIWith(t, env, "", "config", "set", "url", "https://git.example.com/").ok(t) | |
| 34 | + | runCLIWith(t, env, "", "config", "set", "token.cmd", "pass show ermite/token").ok(t) | |
| 35 | + | // a fresh process sees the new content | |
| 36 | + | res := runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t) | |
| 37 | + | assert.Contains(t, res.stdout, "url https://git.example.com\n") | |
| 38 | + | assert.Contains(t, res.stdout, "token.cmd pass show ermite/token\n") | |
| 39 | + | ||
| 40 | + | data, err := os.ReadFile(path) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | assert.Contains(t, string(data), "# my forge\n") | |
| 43 | + | assert.Contains(t, string(data), "# secrets stay in pass\n") | |
| 44 | + | ||
| 45 | + | runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "token.cmd").ok(t) | |
| 46 | + | runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "url").ok(t) | |
| 47 | + | data, err = os.ReadFile(path) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | assert.NotContains(t, string(data), "token.cmd") | |
| 50 | + | assert.NotContains(t, string(data), "url") | |
| 51 | + | assert.Contains(t, string(data), "# my forge\n", "comments survive") | |
| 52 | + | ||
| 53 | + | res = runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t) | |
| 54 | + | assert.Contains(t, res.stdout, "url (not set)\n") | |
| 55 | + | assert.Contains(t, res.stdout, "token.cmd (not set)\n") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // testEnvAt is testEnv with the config file at path: a new process. | |
| 59 | + | func testEnvAt(t *testing.T, path string) *cliEnv { | |
| 60 | + | t.Helper() | |
| 61 | + | env := testEnv(t, nil) | |
| 62 | + | env.configPath = func() (string, error) { return path, nil } | |
| 63 | + | return env | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestConfigSetCreatesPrivateFile(t *testing.T) { | |
| 67 | + | env := testEnv(t, nil) | |
| 68 | + | path, _ := env.configPath() | |
| 69 | + | res := runCLIWith(t, env, "", "config", "set", "token.cmd", "gopass show -o ermite").ok(t) | |
| 70 | + | assert.Contains(t, res.stderr, path) | |
| 71 | + | info, err := os.Stat(path) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | if runtime.GOOS != "windows" { | |
| 74 | + | assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) | |
| 75 | + | } | |
| 76 | + | data, _ := os.ReadFile(path) | |
| 77 | + | assert.Equal(t, "token.cmd = gopass show -o ermite\n", string(data)) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestConfigSetRefusesBadInput(t *testing.T) { | |
| 81 | + | tests := []struct { | |
| 82 | + | name string | |
| 83 | + | args []string | |
| 84 | + | want string | |
| 85 | + | }{ | |
| 86 | + | {"unknown key", []string{"set", "token", "x"}, `unknown key "token": the keys are url and token.cmd`}, | |
| 87 | + | {"unknown key unset", []string{"unset", "colour"}, `unknown key "colour": the keys are url and token.cmd`}, | |
| 88 | + | {"no scheme", []string{"set", "url", "git.example.com"}, "invalid url"}, | |
| 89 | + | {"credentials", []string{"set", "url", "https://u:s3cret@git.example.com"}, "must not contain credentials"}, | |
| 90 | + | {"ftp", []string{"set", "url", "ftp://git.example.com"}, "invalid url"}, | |
| 91 | + | {"empty token.cmd", []string{"set", "token.cmd", " "}, "token.cmd is empty"}, | |
| 92 | + | {"multiline token.cmd", []string{"set", "token.cmd", "a\nb"}, "single line"}, | |
| 93 | + | {"missing value", []string{"set", "url"}, "accepts 2 arg"}, | |
| 94 | + | } | |
| 95 | + | for _, tt := range tests { | |
| 96 | + | t.Run(tt.name, func(t *testing.T) { | |
| 97 | + | env := testEnv(t, nil) | |
| 98 | + | path, _ := env.configPath() | |
| 99 | + | res := runCLIWith(t, env, "", append([]string{"config"}, tt.args...)...) | |
| 100 | + | require.Error(t, res.err) | |
| 101 | + | assert.Contains(t, res.err.Error(), tt.want) | |
| 102 | + | assert.NotContains(t, res.err.Error(), "s3cret") | |
| 103 | + | _, err := os.Stat(path) | |
| 104 | + | assert.ErrorIs(t, err, os.ErrNotExist, "nothing is written") | |
| 105 | + | }) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestConfigUnsetWithoutFile(t *testing.T) { | |
| 110 | + | env := testEnv(t, nil) | |
| 111 | + | path, _ := env.configPath() | |
| 112 | + | res := runCLIWith(t, env, "", "config", "unset", "url").ok(t) | |
| 113 | + | assert.Contains(t, res.stderr, "does not exist") | |
| 114 | + | _, err := os.Stat(path) | |
| 115 | + | assert.ErrorIs(t, err, os.ErrNotExist) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) { | |
| 119 | + | env := testEnv(t, map[string]string{"ERMITE_URL": "https://env.example.com", "ERMITE_TOKEN": "ermite_x"}) | |
| 120 | + | writeConfigFile(t, env, "url = https://file.example.com\ntoken.cmd = get-token\n", 0o666) | |
| 121 | + | res := runCLIWith(t, env, "", "config", "show").ok(t) // testEnv fails the test if token.cmd runs | |
| 122 | + | assert.Contains(t, res.stdout, "url https://file.example.com\n", "show prints what is stored") | |
| 123 | + | assert.Contains(t, res.stderr, "ERMITE_URL is set and takes precedence") | |
| 124 | + | assert.Contains(t, res.stderr, "ERMITE_TOKEN is set: token.cmd is not used") | |
| 125 | + | assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed") | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestHelpDocumentsTheConfigFileContract(t *testing.T) { | |
| 129 | + | root := runCLI(t, nil, "", "--help").ok(t).stdout | |
| 130 | + | cfg := runCLI(t, nil, "", "config", "--help").ok(t).stdout | |
| 131 | + | for name, help := range map[string]string{"root": root, "config": cfg} { | |
| 132 | + | assert.Contains(t, help, "ermite/config", "%s help names the file", name) | |
| 133 | + | assert.Contains(t, help, "ERMITE_CONFIG", "%s help names the override", name) | |
| 134 | + | assert.Contains(t, help, "ERMITE_URL", "%s help states the precedence", name) | |
| 135 | + | assert.Contains(t, help, "ERMITE_TOKEN", "%s help states the precedence", name) | |
| 136 | + | } | |
| 137 | + | assert.Contains(t, cfg, "token.cmd = ", "the config help has an example") | |
| 138 | + | assert.Contains(t, cfg, "Windows", "the config help states the Windows limit") | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestConfigShowNamesTheFileItCannotRead(t *testing.T) { | |
| 142 | + | env := testEnv(t, nil) | |
| 143 | + | path := writeConfigFile(t, env, "url = https://git.example.com\n", 0o600) | |
| 144 | + | require.NoError(t, os.Remove(path)) | |
| 145 | + | require.NoError(t, os.Mkdir(path, 0o700)) // a directory is not a config file | |
| 146 | + | res := runCLIWith(t, env, "", "config", "show") | |
| 147 | + | require.Error(t, res.err) | |
| 148 | + | assert.Contains(t, res.stdout, path) | |
| 149 | + | assert.Contains(t, res.err.Error(), "config:") | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestConfigUnsetOfAnAbsentKey(t *testing.T) { | |
| 153 | + | env := testEnv(t, nil) | |
| 154 | + | path := writeConfigFile(t, env, "# only a comment\nurl = https://git.example.com\n", 0o600) | |
| 155 | + | res := runCLIWith(t, env, "", "config", "unset", "token.cmd").ok(t) | |
| 156 | + | assert.Equal(t, "ermite: token.cmd is not set\n", res.stderr) | |
| 157 | + | data, err := os.ReadFile(path) | |
| 158 | + | require.NoError(t, err) | |
| 159 | + | assert.Equal(t, "# only a comment\nurl = https://git.example.com\n", string(data), "the file is untouched") | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestConfigShowJSON(t *testing.T) { | |
| 163 | + | env := testEnv(t, nil) | |
| 164 | + | path, _ := env.configPath() | |
| 165 | + | res := runCLIWith(t, env, "", "config", "show", "--json").ok(t) | |
| 166 | + | assert.JSONEq(t, `{"file":`+strconv.Quote(path)+`,"url":null,"token_cmd":null}`, res.stdout) | |
| 167 | + | ||
| 168 | + | env = testEnv(t, nil) | |
| 169 | + | path = writeConfigFile(t, env, "url = https://git.example.com\ntoken.cmd = pass show ermite/token\n", 0o600) | |
| 170 | + | res = runCLIWith(t, env, "", "config", "show", "--json").ok(t) // testEnv fails the test if token.cmd runs | |
| 171 | + | assert.JSONEq(t, `{"file":`+strconv.Quote(path)+`,"url":"https://git.example.com","token_cmd":"pass show ermite/token"}`, res.stdout) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestConfigShowAndSetWarnWhenTokenCmdWouldBeRefused(t *testing.T) { | |
| 175 | + | if runtime.GOOS == "windows" { | |
| 176 | + | t.Skip("no Unix permission bits") | |
| 177 | + | } | |
| 178 | + | env := testEnv(t, nil) | |
| 179 | + | path := writeConfigFile(t, env, "url = https://git.example.com\ntoken.cmd = get-token\n", 0o666) | |
| 180 | + | ||
| 181 | + | res := runCLIWith(t, env, "", "config", "show").ok(t) | |
| 182 | + | assert.Contains(t, res.stderr, "ermite: warning: config file "+path+" is writable by others: token.cmd not run") | |
| 183 | + | ||
| 184 | + | // a fresh process; set keeps the mode of an existing file | |
| 185 | + | res = runCLIWith(t, testEnvAt(t, path), "", "config", "set", "url", "https://git2.example.com").ok(t) | |
| 186 | + | assert.Contains(t, res.stderr, "writable by others") | |
| 187 | + | ||
| 188 | + | // no warning without a token.cmd, or with a private file | |
| 189 | + | env = testEnv(t, nil) | |
| 190 | + | writeConfigFile(t, env, "url = https://git.example.com\n", 0o666) | |
| 191 | + | assert.NotContains(t, runCLIWith(t, env, "", "config", "show").ok(t).stderr, "warning") | |
| 192 | + | res = runCLIWith(t, testEnv(t, nil), "", "config", "set", "token.cmd", "get-token").ok(t) | |
| 193 | + | assert.NotContains(t, res.stderr, "warning") | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestDefaultConfigPathUsesXDGConfigHome(t *testing.T) { | |
| 197 | + | xdg := t.TempDir() | |
| 198 | + | t.Setenv("XDG_CONFIG_HOME", xdg) | |
| 199 | + | t.Setenv("ERMITE_CONFIG", "") | |
| 200 | + | f := listFake() | |
| 201 | + | ||
| 202 | + | env := defaultCLIEnv() // the real path resolution, the real environment | |
| 203 | + | res := runCLIWith(t, env, "", "config", "set", "url", f.serve(t)).ok(t) | |
| 204 | + | want := filepath.Join(xdg, "ermite", "config") | |
| 205 | + | assert.Contains(t, res.stderr, want) | |
| 206 | + | info, err := os.Stat(want) | |
| 207 | + | require.NoError(t, err) | |
| 208 | + | if runtime.GOOS != "windows" { | |
| 209 | + | assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | env = defaultCLIEnv() | |
| 213 | + | env.runTokenCmd = func(context.Context, string) (string, error) { return "", errors.New("must not run") } | |
| 214 | + | t.Setenv("ERMITE_URL", "") | |
| 215 | + | t.Setenv("ERMITE_TOKEN", "") | |
| 216 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 217 | + | assert.Equal(t, 1, callCount(f, "ListRepos")) | |
| 218 | + | ||
| 219 | + | // ERMITE_CONFIG wins over the default | |
| 220 | + | other := filepath.Join(t.TempDir(), "other") | |
| 221 | + | t.Setenv("ERMITE_CONFIG", other) | |
| 222 | + | res = runCLIWith(t, defaultCLIEnv(), "", "config", "show").ok(t) | |
| 223 | + | assert.Contains(t, res.stdout, other) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func callCount(f *fakeAPI, name string) int { | |
| 227 | + | calls, _ := f.snapshot() | |
| 228 | + | n := 0 | |
| 229 | + | for _, c := range calls { | |
| 230 | + | if c == name { | |
| 231 | + | n++ | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | return n | |
| 235 | + | } | |
| @@ -0,0 +1,235 @@ | |||||
| 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 | + | "strconv" | |||
| 12 | + | "strings" | |||
| 13 | + | "testing" | |||
| 14 | + | ||||
| 15 | + | "github.com/stretchr/testify/assert" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestConfigShowWithoutFile(t *testing.T) { | |||
| 20 | + | env := testEnv(t, nil) | |||
| 21 | + | path, _ := env.configPath() | |||
| 22 | + | res := runCLIWith(t, env, "", "config", "show").ok(t) | |||
| 23 | + | assert.Equal(t, "file "+path+"\nurl (not set)\ntoken.cmd (not set)\n", res.stdout) | |||
| 24 | + | _, err := os.Stat(path) | |||
| 25 | + | assert.ErrorIs(t, err, os.ErrNotExist, "show does not create the file") | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestConfigSetUnsetShowRoundTrip(t *testing.T) { | |||
| 29 | + | env := testEnv(t, nil) | |||
| 30 | + | path, _ := env.configPath() | |||
| 31 | + | writeConfigFile(t, env, "# my forge\nurl = https://old.example.com\n\n# secrets stay in pass\n", 0o600) | |||
| 32 | + | ||||
| 33 | + | runCLIWith(t, env, "", "config", "set", "url", "https://git.example.com/").ok(t) | |||
| 34 | + | runCLIWith(t, env, "", "config", "set", "token.cmd", "pass show ermite/token").ok(t) | |||
| 35 | + | // a fresh process sees the new content | |||
| 36 | + | res := runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t) | |||
| 37 | + | assert.Contains(t, res.stdout, "url https://git.example.com\n") | |||
| 38 | + | assert.Contains(t, res.stdout, "token.cmd pass show ermite/token\n") | |||
| 39 | + | ||||
| 40 | + | data, err := os.ReadFile(path) | |||
| 41 | + | require.NoError(t, err) | |||
| 42 | + | assert.Contains(t, string(data), "# my forge\n") | |||
| 43 | + | assert.Contains(t, string(data), "# secrets stay in pass\n") | |||
| 44 | + | ||||
| 45 | + | runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "token.cmd").ok(t) | |||
| 46 | + | runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "url").ok(t) | |||
| 47 | + | data, err = os.ReadFile(path) | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | assert.NotContains(t, string(data), "token.cmd") | |||
| 50 | + | assert.NotContains(t, string(data), "url") | |||
| 51 | + | assert.Contains(t, string(data), "# my forge\n", "comments survive") | |||
| 52 | + | ||||
| 53 | + | res = runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t) | |||
| 54 | + | assert.Contains(t, res.stdout, "url (not set)\n") | |||
| 55 | + | assert.Contains(t, res.stdout, "token.cmd (not set)\n") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // testEnvAt is testEnv with the config file at path: a new process. | |||
| 59 | + | func testEnvAt(t *testing.T, path string) *cliEnv { | |||
| 60 | + | t.Helper() | |||
| 61 | + | env := testEnv(t, nil) | |||
| 62 | + | env.configPath = func() (string, error) { return path, nil } | |||
| 63 | + | return env | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestConfigSetCreatesPrivateFile(t *testing.T) { | |||
| 67 | + | env := testEnv(t, nil) | |||
| 68 | + | path, _ := env.configPath() | |||
| 69 | + | res := runCLIWith(t, env, "", "config", "set", "token.cmd", "gopass show -o ermite").ok(t) | |||
| 70 | + | assert.Contains(t, res.stderr, path) | |||
| 71 | + | info, err := os.Stat(path) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | if runtime.GOOS != "windows" { | |||
| 74 | + | assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) | |||
| 75 | + | } | |||
| 76 | + | data, _ := os.ReadFile(path) | |||
| 77 | + | assert.Equal(t, "token.cmd = gopass show -o ermite\n", string(data)) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestConfigSetRefusesBadInput(t *testing.T) { | |||
| 81 | + | tests := []struct { | |||
| 82 | + | name string | |||
| 83 | + | args []string | |||
| 84 | + | want string | |||
| 85 | + | }{ | |||
| 86 | + | {"unknown key", []string{"set", "token", "x"}, `unknown key "token": the keys are url and token.cmd`}, | |||
| 87 | + | {"unknown key unset", []string{"unset", "colour"}, `unknown key "colour": the keys are url and token.cmd`}, | |||
| 88 | + | {"no scheme", []string{"set", "url", "git.example.com"}, "invalid url"}, | |||
| 89 | + | {"credentials", []string{"set", "url", "https://u:s3cret@git.example.com"}, "must not contain credentials"}, | |||
| 90 | + | {"ftp", []string{"set", "url", "ftp://git.example.com"}, "invalid url"}, | |||
| 91 | + | {"empty token.cmd", []string{"set", "token.cmd", " "}, "token.cmd is empty"}, | |||
| 92 | + | {"multiline token.cmd", []string{"set", "token.cmd", "a\nb"}, "single line"}, | |||
| 93 | + | {"missing value", []string{"set", "url"}, "accepts 2 arg"}, | |||
| 94 | + | } | |||
| 95 | + | for _, tt := range tests { | |||
| 96 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 97 | + | env := testEnv(t, nil) | |||
| 98 | + | path, _ := env.configPath() | |||
| 99 | + | res := runCLIWith(t, env, "", append([]string{"config"}, tt.args...)...) | |||
| 100 | + | require.Error(t, res.err) | |||
| 101 | + | assert.Contains(t, res.err.Error(), tt.want) | |||
| 102 | + | assert.NotContains(t, res.err.Error(), "s3cret") | |||
| 103 | + | _, err := os.Stat(path) | |||
| 104 | + | assert.ErrorIs(t, err, os.ErrNotExist, "nothing is written") | |||
| 105 | + | }) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestConfigUnsetWithoutFile(t *testing.T) { | |||
| 110 | + | env := testEnv(t, nil) | |||
| 111 | + | path, _ := env.configPath() | |||
| 112 | + | res := runCLIWith(t, env, "", "config", "unset", "url").ok(t) | |||
| 113 | + | assert.Contains(t, res.stderr, "does not exist") | |||
| 114 | + | _, err := os.Stat(path) | |||
| 115 | + | assert.ErrorIs(t, err, os.ErrNotExist) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) { | |||
| 119 | + | env := testEnv(t, map[string]string{"ERMITE_URL": "https://env.example.com", "ERMITE_TOKEN": "ermite_x"}) | |||
| 120 | + | writeConfigFile(t, env, "url = https://file.example.com\ntoken.cmd = get-token\n", 0o666) | |||
| 121 | + | res := runCLIWith(t, env, "", "config", "show").ok(t) // testEnv fails the test if token.cmd runs | |||
| 122 | + | assert.Contains(t, res.stdout, "url https://file.example.com\n", "show prints what is stored") | |||
| 123 | + | assert.Contains(t, res.stderr, "ERMITE_URL is set and takes precedence") | |||
| 124 | + | assert.Contains(t, res.stderr, "ERMITE_TOKEN is set: token.cmd is not used") | |||
| 125 | + | assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed") | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestHelpDocumentsTheConfigFileContract(t *testing.T) { | |||
| 129 | + | root := runCLI(t, nil, "", "--help").ok(t).stdout | |||
| 130 | + | cfg := runCLI(t, nil, "", "config", "--help").ok(t).stdout | |||
| 131 | + | for name, help := range map[string]string{"root": root, "config": cfg} { | |||
| 132 | + | assert.Contains(t, help, "ermite/config", "%s help names the file", name) | |||
| 133 | + | assert.Contains(t, help, "ERMITE_CONFIG", "%s help names the override", name) | |||
| 134 | + | assert.Contains(t, help, "ERMITE_URL", "%s help states the precedence", name) | |||
| 135 | + | assert.Contains(t, help, "ERMITE_TOKEN", "%s help states the precedence", name) | |||
| 136 | + | } | |||
| 137 | + | assert.Contains(t, cfg, "token.cmd = ", "the config help has an example") | |||
| 138 | + | assert.Contains(t, cfg, "Windows", "the config help states the Windows limit") | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestConfigShowNamesTheFileItCannotRead(t *testing.T) { | |||
| 142 | + | env := testEnv(t, nil) | |||
| 143 | + | path := writeConfigFile(t, env, "url = https://git.example.com\n", 0o600) | |||
| 144 | + | require.NoError(t, os.Remove(path)) | |||
| 145 | + | require.NoError(t, os.Mkdir(path, 0o700)) // a directory is not a config file | |||
| 146 | + | res := runCLIWith(t, env, "", "config", "show") | |||
| 147 | + | require.Error(t, res.err) | |||
| 148 | + | assert.Contains(t, res.stdout, path) | |||
| 149 | + | assert.Contains(t, res.err.Error(), "config:") | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestConfigUnsetOfAnAbsentKey(t *testing.T) { | |||
| 153 | + | env := testEnv(t, nil) | |||
| 154 | + | path := writeConfigFile(t, env, "# only a comment\nurl = https://git.example.com\n", 0o600) | |||
| 155 | + | res := runCLIWith(t, env, "", "config", "unset", "token.cmd").ok(t) | |||
| 156 | + | assert.Equal(t, "ermite: token.cmd is not set\n", res.stderr) | |||
| 157 | + | data, err := os.ReadFile(path) | |||
| 158 | + | require.NoError(t, err) | |||
| 159 | + | assert.Equal(t, "# only a comment\nurl = https://git.example.com\n", string(data), "the file is untouched") | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestConfigShowJSON(t *testing.T) { | |||
| 163 | + | env := testEnv(t, nil) | |||
| 164 | + | path, _ := env.configPath() | |||
| 165 | + | res := runCLIWith(t, env, "", "config", "show", "--json").ok(t) | |||
| 166 | + | assert.JSONEq(t, `{"file":`+strconv.Quote(path)+`,"url":null,"token_cmd":null}`, res.stdout) | |||
| 167 | + | ||||
| 168 | + | env = testEnv(t, nil) | |||
| 169 | + | path = writeConfigFile(t, env, "url = https://git.example.com\ntoken.cmd = pass show ermite/token\n", 0o600) | |||
| 170 | + | res = runCLIWith(t, env, "", "config", "show", "--json").ok(t) // testEnv fails the test if token.cmd runs | |||
| 171 | + | assert.JSONEq(t, `{"file":`+strconv.Quote(path)+`,"url":"https://git.example.com","token_cmd":"pass show ermite/token"}`, res.stdout) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestConfigShowAndSetWarnWhenTokenCmdWouldBeRefused(t *testing.T) { | |||
| 175 | + | if runtime.GOOS == "windows" { | |||
| 176 | + | t.Skip("no Unix permission bits") | |||
| 177 | + | } | |||
| 178 | + | env := testEnv(t, nil) | |||
| 179 | + | path := writeConfigFile(t, env, "url = https://git.example.com\ntoken.cmd = get-token\n", 0o666) | |||
| 180 | + | ||||
| 181 | + | res := runCLIWith(t, env, "", "config", "show").ok(t) | |||
| 182 | + | assert.Contains(t, res.stderr, "ermite: warning: config file "+path+" is writable by others: token.cmd not run") | |||
| 183 | + | ||||
| 184 | + | // a fresh process; set keeps the mode of an existing file | |||
| 185 | + | res = runCLIWith(t, testEnvAt(t, path), "", "config", "set", "url", "https://git2.example.com").ok(t) | |||
| 186 | + | assert.Contains(t, res.stderr, "writable by others") | |||
| 187 | + | ||||
| 188 | + | // no warning without a token.cmd, or with a private file | |||
| 189 | + | env = testEnv(t, nil) | |||
| 190 | + | writeConfigFile(t, env, "url = https://git.example.com\n", 0o666) | |||
| 191 | + | assert.NotContains(t, runCLIWith(t, env, "", "config", "show").ok(t).stderr, "warning") | |||
| 192 | + | res = runCLIWith(t, testEnv(t, nil), "", "config", "set", "token.cmd", "get-token").ok(t) | |||
| 193 | + | assert.NotContains(t, res.stderr, "warning") | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func TestDefaultConfigPathUsesXDGConfigHome(t *testing.T) { | |||
| 197 | + | xdg := t.TempDir() | |||
| 198 | + | t.Setenv("XDG_CONFIG_HOME", xdg) | |||
| 199 | + | t.Setenv("ERMITE_CONFIG", "") | |||
| 200 | + | f := listFake() | |||
| 201 | + | ||||
| 202 | + | env := defaultCLIEnv() // the real path resolution, the real environment | |||
| 203 | + | res := runCLIWith(t, env, "", "config", "set", "url", f.serve(t)).ok(t) | |||
| 204 | + | want := filepath.Join(xdg, "ermite", "config") | |||
| 205 | + | assert.Contains(t, res.stderr, want) | |||
| 206 | + | info, err := os.Stat(want) | |||
| 207 | + | require.NoError(t, err) | |||
| 208 | + | if runtime.GOOS != "windows" { | |||
| 209 | + | assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | env = defaultCLIEnv() | |||
| 213 | + | env.runTokenCmd = func(context.Context, string) (string, error) { return "", errors.New("must not run") } | |||
| 214 | + | t.Setenv("ERMITE_URL", "") | |||
| 215 | + | t.Setenv("ERMITE_TOKEN", "") | |||
| 216 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 217 | + | assert.Equal(t, 1, callCount(f, "ListRepos")) | |||
| 218 | + | ||||
| 219 | + | // ERMITE_CONFIG wins over the default | |||
| 220 | + | other := filepath.Join(t.TempDir(), "other") | |||
| 221 | + | t.Setenv("ERMITE_CONFIG", other) | |||
| 222 | + | res = runCLIWith(t, defaultCLIEnv(), "", "config", "show").ok(t) | |||
| 223 | + | assert.Contains(t, res.stdout, other) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func callCount(f *fakeAPI, name string) int { | |||
| 227 | + | calls, _ := f.snapshot() | |||
| 228 | + | n := 0 | |||
| 229 | + | for _, c := range calls { | |||
| 230 | + | if c == name { | |||
| 231 | + | n++ | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | return n | |||
| 235 | + | } | |||
A internal/app/cli_configfile.go +272 -0
| @@ -0,0 +1,272 @@ | |||
| 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 | + | "io/fs" | |
| 12 | + | "os" | |
| 13 | + | "os/exec" | |
| 14 | + | "os/signal" | |
| 15 | + | "strings" | |
| 16 | + | "syscall" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/bnema/kvconf" | |
| 20 | + | "github.com/spf13/cobra" | |
| 21 | + | "golang.org/x/term" | |
| 22 | + | ||
| 23 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | const ( | |
| 27 | + | // envConfig selects the CLI config file instead of ~/.config/ermite/config. | |
| 28 | + | envConfig = "ERMITE_CONFIG" | |
| 29 | + | // Keys of the CLI config file. | |
| 30 | + | keyURL = "url" | |
| 31 | + | keyTokenCmd = "token.cmd" | |
| 32 | + | ||
| 33 | + | // tokenCmdTimeout bounds one run of token.cmd, prompts included. | |
| 34 | + | tokenCmdTimeout = 60 * time.Second | |
| 35 | + | // tokenCmdMaxOutput bounds what is read from its standard output. | |
| 36 | + | tokenCmdMaxOutput = 4 << 10 | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | // cliFile is the CLI config file. It holds no secret: the token is fetched | |
| 40 | + | // from a secret manager by the token.cmd command line. | |
| 41 | + | type cliFile struct { | |
| 42 | + | URL string `kv:"url"` | |
| 43 | + | TokenCmd string `kv:"token.cmd"` | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // path is the config file: $ERMITE_CONFIG, else <config dir>/ermite/config. | |
| 47 | + | func (e *cliEnv) path() (string, error) { | |
| 48 | + | if e.configPath != nil { | |
| 49 | + | return e.configPath() | |
| 50 | + | } | |
| 51 | + | if p := strings.TrimSpace(e.getenv(envConfig)); p != "" { | |
| 52 | + | return p, nil | |
| 53 | + | } | |
| 54 | + | p, err := kvconf.DefaultPath("ermite", "config") | |
| 55 | + | if err != nil { | |
| 56 | + | return "", fmt.Errorf("no home directory to find the config file: set %s", envConfig) | |
| 57 | + | } | |
| 58 | + | return p, nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // readFile reads the config file, once per process. A missing file, or no | |
| 62 | + | // place to look for it, gives an empty one. Problems in the file are warnings | |
| 63 | + | // on stderr, printed once, and only when warn is set. The path is returned | |
| 64 | + | // even when reading fails. | |
| 65 | + | // | |
| 66 | + | // The file is opened once: what is parsed and what checkPrivate vets (see | |
| 67 | + | // fetchToken) are the same file, so token.cmd cannot come from a file that was | |
| 68 | + | // swapped after the check. | |
| 69 | + | func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) { | |
| 70 | + | e.fileOnce.Do(func() { | |
| 71 | + | p, err := e.path() | |
| 72 | + | if err != nil { | |
| 73 | + | // Reading is best effort when the location is unknown; the | |
| 74 | + | // `ermite config` commands report this error. | |
| 75 | + | e.file = &cliFile{} | |
| 76 | + | return | |
| 77 | + | } | |
| 78 | + | e.filePath = p | |
| 79 | + | f, warnings, trust, err := loadConfigFile(p) | |
| 80 | + | if err != nil { | |
| 81 | + | e.fileErr = fmt.Errorf("config: %w", err) | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | e.fileTrust = trust | |
| 85 | + | if warn { | |
| 86 | + | for _, w := range warnings { | |
| 87 | + | warnf(cmd, "config: %s", w) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | e.file = f | |
| 91 | + | }) | |
| 92 | + | return e.file, e.filePath, e.fileErr | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // loadConfigFile parses the file at path (missing: empty). trust is nil when | |
| 96 | + | // token.cmd may be run from this file, else the reason why not. | |
| 97 | + | func loadConfigFile(path string) (f *cliFile, warnings []kvconf.Warning, trust, err error) { | |
| 98 | + | h, err := openConfig(path) | |
| 99 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 100 | + | return &cliFile{}, nil, nil, nil | |
| 101 | + | } | |
| 102 | + | if err != nil { | |
| 103 | + | return nil, nil, nil, err | |
| 104 | + | } | |
| 105 | + | defer h.Close() //nolint:errcheck // read only | |
| 106 | + | info, err := h.Stat() | |
| 107 | + | if err != nil { | |
| 108 | + | return nil, nil, nil, err | |
| 109 | + | } | |
| 110 | + | if !info.Mode().IsRegular() { | |
| 111 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrNotRegular) | |
| 112 | + | } | |
| 113 | + | trust = checkPrivate(path, info, os.Geteuid()) | |
| 114 | + | data, err := io.ReadAll(io.LimitReader(h, kvconf.DefaultMaxBytes+1)) | |
| 115 | + | if err != nil { | |
| 116 | + | return nil, nil, nil, err | |
| 117 | + | } | |
| 118 | + | if len(data) > kvconf.DefaultMaxBytes { | |
| 119 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrTooLarge) | |
| 120 | + | } | |
| 121 | + | f = &cliFile{} | |
| 122 | + | warnings, err = kvconf.Unmarshal(data, f, kvconf.Options{}) | |
| 123 | + | if err != nil { | |
| 124 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, err) | |
| 125 | + | } | |
| 126 | + | return f, warnings, trust, nil | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // urlSource says where the server address in use comes from, for error messages. | |
| 130 | + | func (e *cliEnv) urlSource() string { | |
| 131 | + | if strings.TrimSpace(e.getenv(client.EnvURL)) != "" { | |
| 132 | + | return client.EnvURL | |
| 133 | + | } | |
| 134 | + | return fmt.Sprintf("url in %s (fix it with `ermite config set url <url>`)", e.filePath) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // resolveURL is ERMITE_URL if set, else the url of the config file, else "". | |
| 138 | + | func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) { | |
| 139 | + | if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" { | |
| 140 | + | return v, nil | |
| 141 | + | } | |
| 142 | + | f, _, err := e.readFile(cmd, warn) | |
| 143 | + | if err != nil { | |
| 144 | + | return "", err | |
| 145 | + | } | |
| 146 | + | return strings.TrimSpace(f.URL), nil | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // tokenFromCmd is the output of token.cmd, or "" when the file sets none or | |
| 150 | + | // when ERMITE_URL points somewhere else than the url of the file: the token | |
| 151 | + | // the command fetches belongs to the server of the file, and must not be sent | |
| 152 | + | // to another one. | |
| 153 | + | func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | |
| 154 | + | f, _, err := e.readFile(cmd, warn) | |
| 155 | + | if err != nil { | |
| 156 | + | return "", err | |
| 157 | + | } | |
| 158 | + | line := strings.TrimSpace(f.TokenCmd) | |
| 159 | + | if line == "" { | |
| 160 | + | return "", nil | |
| 161 | + | } | |
| 162 | + | e.tokenOnce.Do(func() { | |
| 163 | + | if !e.urlIsFromFile(f) { | |
| 164 | + | if warn { | |
| 165 | + | noticef(cmd, "%s differs from the config file url: token.cmd not run (set %s)", client.EnvURL, client.EnvToken) | |
| 166 | + | } | |
| 167 | + | return | |
| 168 | + | } | |
| 169 | + | e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), line) | |
| 170 | + | }) | |
| 171 | + | return e.token, e.tokenErr | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // urlIsFromFile reports whether the server in use is the one of the config | |
| 175 | + | // file: ERMITE_URL is unset, or names the same address as the file's url. | |
| 176 | + | func (e *cliEnv) urlIsFromFile(f *cliFile) bool { | |
| 177 | + | envURL := strings.TrimSpace(e.getenv(client.EnvURL)) | |
| 178 | + | if envURL == "" { | |
| 179 | + | return true | |
| 180 | + | } | |
| 181 | + | a, err := client.ParseURL(envURL) | |
| 182 | + | if err != nil { | |
| 183 | + | return false | |
| 184 | + | } | |
| 185 | + | b, err := client.ParseURL(strings.TrimSpace(f.URL)) | |
| 186 | + | return err == nil && a.String() == b.String() | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // fetchToken runs token.cmd. Neither its output nor its error text reach the | |
| 190 | + | // caller's messages: the output is the secret. | |
| 191 | + | func (e *cliEnv) fetchToken(ctx context.Context, line string) (string, error) { | |
| 192 | + | if e.fileTrust != nil { | |
| 193 | + | return "", e.fileTrust | |
| 194 | + | } | |
| 195 | + | out, err := e.runTokenCmd(ctx, line) | |
| 196 | + | if err != nil { | |
| 197 | + | return "", fmt.Errorf("token.cmd failed: %w", err) | |
| 198 | + | } | |
| 199 | + | out = strings.TrimSpace(out) | |
| 200 | + | switch { | |
| 201 | + | case out == "": | |
| 202 | + | return "", errors.New("token.cmd printed nothing") | |
| 203 | + | case strings.ContainsAny(out, "\r\n"): | |
| 204 | + | return "", errors.New("token.cmd printed more than one line") | |
| 205 | + | } | |
| 206 | + | return out, nil | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // runTokenShell is the default runTokenCmd: the system shell, for at most | |
| 210 | + | // tokenCmdTimeout. | |
| 211 | + | func runTokenShell(ctx context.Context, line string) (string, error) { | |
| 212 | + | return runShell(ctx, line, tokenCmdTimeout) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // runShell runs line with the system shell. Standard error is the terminal's, | |
| 216 | + | // and so is standard input when it is a terminal, so a pinentry or a biometric | |
| 217 | + | // prompt works; a script or an agent gets no input. Standard output is | |
| 218 | + | // captured, up to tokenCmdMaxOutput bytes. The environment is the process's. | |
| 219 | + | // An interrupt or SIGTERM sent to ermite, or the end of ctx, stops the command | |
| 220 | + | // like a timeout does (see shellCommand for what is killed). | |
| 221 | + | func runShell(ctx context.Context, line string, timeout time.Duration) (string, error) { | |
| 222 | + | sigCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 223 | + | defer stop() | |
| 224 | + | runCtx, cancel := context.WithTimeout(sigCtx, timeout) | |
| 225 | + | defer cancel() | |
| 226 | + | ||
| 227 | + | foreground := term.IsTerminal(int(os.Stdin.Fd())) | |
| 228 | + | c := shellCommand(runCtx, line, foreground) | |
| 229 | + | out := &cappedBuffer{max: tokenCmdMaxOutput} | |
| 230 | + | c.Stdout, c.Stderr = out, os.Stderr | |
| 231 | + | if foreground { | |
| 232 | + | c.Stdin = os.Stdin | |
| 233 | + | } // else nil: the command reads os.DevNull | |
| 234 | + | // Do not wait forever for a background process that kept the pipe open. | |
| 235 | + | c.WaitDelay = 2 * time.Second | |
| 236 | + | err := c.Run() | |
| 237 | + | if err != nil { | |
| 238 | + | switch { | |
| 239 | + | case sigCtx.Err() != nil: | |
| 240 | + | return "", errors.New("interrupted") | |
| 241 | + | case errors.Is(runCtx.Err(), context.DeadlineExceeded): | |
| 242 | + | return "", fmt.Errorf("timed out after %s", timeout) | |
| 243 | + | } | |
| 244 | + | if exit := (*exec.ExitError)(nil); errors.As(err, &exit) { | |
| 245 | + | return "", errors.New(exit.String()) | |
| 246 | + | } | |
| 247 | + | return "", err | |
| 248 | + | } | |
| 249 | + | if out.over { | |
| 250 | + | return "", fmt.Errorf("printed more than %d bytes", tokenCmdMaxOutput) | |
| 251 | + | } | |
| 252 | + | return out.buf.String(), nil | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // cappedBuffer keeps the first max bytes written and notes an overflow. | |
| 256 | + | type cappedBuffer struct { | |
| 257 | + | buf bytes.Buffer | |
| 258 | + | max int | |
| 259 | + | over bool | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func (b *cappedBuffer) Write(p []byte) (int, error) { | |
| 263 | + | n := len(p) // report everything as written: a short write would abort the copy | |
| 264 | + | if room := b.max - b.buf.Len(); len(p) > room { | |
| 265 | + | b.over = true | |
| 266 | + | p = p[:room] | |
| 267 | + | } | |
| 268 | + | b.buf.Write(p) | |
| 269 | + | return n, nil | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | var _ io.Writer = (*cappedBuffer)(nil) | |
| @@ -0,0 +1,272 @@ | |||||
| 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 | + | "io/fs" | |||
| 12 | + | "os" | |||
| 13 | + | "os/exec" | |||
| 14 | + | "os/signal" | |||
| 15 | + | "strings" | |||
| 16 | + | "syscall" | |||
| 17 | + | "time" | |||
| 18 | + | ||||
| 19 | + | "github.com/bnema/kvconf" | |||
| 20 | + | "github.com/spf13/cobra" | |||
| 21 | + | "golang.org/x/term" | |||
| 22 | + | ||||
| 23 | + | "bnema.dev/ermite/internal/adapters/client" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | const ( | |||
| 27 | + | // envConfig selects the CLI config file instead of ~/.config/ermite/config. | |||
| 28 | + | envConfig = "ERMITE_CONFIG" | |||
| 29 | + | // Keys of the CLI config file. | |||
| 30 | + | keyURL = "url" | |||
| 31 | + | keyTokenCmd = "token.cmd" | |||
| 32 | + | ||||
| 33 | + | // tokenCmdTimeout bounds one run of token.cmd, prompts included. | |||
| 34 | + | tokenCmdTimeout = 60 * time.Second | |||
| 35 | + | // tokenCmdMaxOutput bounds what is read from its standard output. | |||
| 36 | + | tokenCmdMaxOutput = 4 << 10 | |||
| 37 | + | ) | |||
| 38 | + | ||||
| 39 | + | // cliFile is the CLI config file. It holds no secret: the token is fetched | |||
| 40 | + | // from a secret manager by the token.cmd command line. | |||
| 41 | + | type cliFile struct { | |||
| 42 | + | URL string `kv:"url"` | |||
| 43 | + | TokenCmd string `kv:"token.cmd"` | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // path is the config file: $ERMITE_CONFIG, else <config dir>/ermite/config. | |||
| 47 | + | func (e *cliEnv) path() (string, error) { | |||
| 48 | + | if e.configPath != nil { | |||
| 49 | + | return e.configPath() | |||
| 50 | + | } | |||
| 51 | + | if p := strings.TrimSpace(e.getenv(envConfig)); p != "" { | |||
| 52 | + | return p, nil | |||
| 53 | + | } | |||
| 54 | + | p, err := kvconf.DefaultPath("ermite", "config") | |||
| 55 | + | if err != nil { | |||
| 56 | + | return "", fmt.Errorf("no home directory to find the config file: set %s", envConfig) | |||
| 57 | + | } | |||
| 58 | + | return p, nil | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // readFile reads the config file, once per process. A missing file, or no | |||
| 62 | + | // place to look for it, gives an empty one. Problems in the file are warnings | |||
| 63 | + | // on stderr, printed once, and only when warn is set. The path is returned | |||
| 64 | + | // even when reading fails. | |||
| 65 | + | // | |||
| 66 | + | // The file is opened once: what is parsed and what checkPrivate vets (see | |||
| 67 | + | // fetchToken) are the same file, so token.cmd cannot come from a file that was | |||
| 68 | + | // swapped after the check. | |||
| 69 | + | func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) { | |||
| 70 | + | e.fileOnce.Do(func() { | |||
| 71 | + | p, err := e.path() | |||
| 72 | + | if err != nil { | |||
| 73 | + | // Reading is best effort when the location is unknown; the | |||
| 74 | + | // `ermite config` commands report this error. | |||
| 75 | + | e.file = &cliFile{} | |||
| 76 | + | return | |||
| 77 | + | } | |||
| 78 | + | e.filePath = p | |||
| 79 | + | f, warnings, trust, err := loadConfigFile(p) | |||
| 80 | + | if err != nil { | |||
| 81 | + | e.fileErr = fmt.Errorf("config: %w", err) | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 84 | + | e.fileTrust = trust | |||
| 85 | + | if warn { | |||
| 86 | + | for _, w := range warnings { | |||
| 87 | + | warnf(cmd, "config: %s", w) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | e.file = f | |||
| 91 | + | }) | |||
| 92 | + | return e.file, e.filePath, e.fileErr | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // loadConfigFile parses the file at path (missing: empty). trust is nil when | |||
| 96 | + | // token.cmd may be run from this file, else the reason why not. | |||
| 97 | + | func loadConfigFile(path string) (f *cliFile, warnings []kvconf.Warning, trust, err error) { | |||
| 98 | + | h, err := openConfig(path) | |||
| 99 | + | if errors.Is(err, fs.ErrNotExist) { | |||
| 100 | + | return &cliFile{}, nil, nil, nil | |||
| 101 | + | } | |||
| 102 | + | if err != nil { | |||
| 103 | + | return nil, nil, nil, err | |||
| 104 | + | } | |||
| 105 | + | defer h.Close() //nolint:errcheck // read only | |||
| 106 | + | info, err := h.Stat() | |||
| 107 | + | if err != nil { | |||
| 108 | + | return nil, nil, nil, err | |||
| 109 | + | } | |||
| 110 | + | if !info.Mode().IsRegular() { | |||
| 111 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrNotRegular) | |||
| 112 | + | } | |||
| 113 | + | trust = checkPrivate(path, info, os.Geteuid()) | |||
| 114 | + | data, err := io.ReadAll(io.LimitReader(h, kvconf.DefaultMaxBytes+1)) | |||
| 115 | + | if err != nil { | |||
| 116 | + | return nil, nil, nil, err | |||
| 117 | + | } | |||
| 118 | + | if len(data) > kvconf.DefaultMaxBytes { | |||
| 119 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrTooLarge) | |||
| 120 | + | } | |||
| 121 | + | f = &cliFile{} | |||
| 122 | + | warnings, err = kvconf.Unmarshal(data, f, kvconf.Options{}) | |||
| 123 | + | if err != nil { | |||
| 124 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, err) | |||
| 125 | + | } | |||
| 126 | + | return f, warnings, trust, nil | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // urlSource says where the server address in use comes from, for error messages. | |||
| 130 | + | func (e *cliEnv) urlSource() string { | |||
| 131 | + | if strings.TrimSpace(e.getenv(client.EnvURL)) != "" { | |||
| 132 | + | return client.EnvURL | |||
| 133 | + | } | |||
| 134 | + | return fmt.Sprintf("url in %s (fix it with `ermite config set url <url>`)", e.filePath) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // resolveURL is ERMITE_URL if set, else the url of the config file, else "". | |||
| 138 | + | func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) { | |||
| 139 | + | if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" { | |||
| 140 | + | return v, nil | |||
| 141 | + | } | |||
| 142 | + | f, _, err := e.readFile(cmd, warn) | |||
| 143 | + | if err != nil { | |||
| 144 | + | return "", err | |||
| 145 | + | } | |||
| 146 | + | return strings.TrimSpace(f.URL), nil | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // tokenFromCmd is the output of token.cmd, or "" when the file sets none or | |||
| 150 | + | // when ERMITE_URL points somewhere else than the url of the file: the token | |||
| 151 | + | // the command fetches belongs to the server of the file, and must not be sent | |||
| 152 | + | // to another one. | |||
| 153 | + | func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | |||
| 154 | + | f, _, err := e.readFile(cmd, warn) | |||
| 155 | + | if err != nil { | |||
| 156 | + | return "", err | |||
| 157 | + | } | |||
| 158 | + | line := strings.TrimSpace(f.TokenCmd) | |||
| 159 | + | if line == "" { | |||
| 160 | + | return "", nil | |||
| 161 | + | } | |||
| 162 | + | e.tokenOnce.Do(func() { | |||
| 163 | + | if !e.urlIsFromFile(f) { | |||
| 164 | + | if warn { | |||
| 165 | + | noticef(cmd, "%s differs from the config file url: token.cmd not run (set %s)", client.EnvURL, client.EnvToken) | |||
| 166 | + | } | |||
| 167 | + | return | |||
| 168 | + | } | |||
| 169 | + | e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), line) | |||
| 170 | + | }) | |||
| 171 | + | return e.token, e.tokenErr | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // urlIsFromFile reports whether the server in use is the one of the config | |||
| 175 | + | // file: ERMITE_URL is unset, or names the same address as the file's url. | |||
| 176 | + | func (e *cliEnv) urlIsFromFile(f *cliFile) bool { | |||
| 177 | + | envURL := strings.TrimSpace(e.getenv(client.EnvURL)) | |||
| 178 | + | if envURL == "" { | |||
| 179 | + | return true | |||
| 180 | + | } | |||
| 181 | + | a, err := client.ParseURL(envURL) | |||
| 182 | + | if err != nil { | |||
| 183 | + | return false | |||
| 184 | + | } | |||
| 185 | + | b, err := client.ParseURL(strings.TrimSpace(f.URL)) | |||
| 186 | + | return err == nil && a.String() == b.String() | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // fetchToken runs token.cmd. Neither its output nor its error text reach the | |||
| 190 | + | // caller's messages: the output is the secret. | |||
| 191 | + | func (e *cliEnv) fetchToken(ctx context.Context, line string) (string, error) { | |||
| 192 | + | if e.fileTrust != nil { | |||
| 193 | + | return "", e.fileTrust | |||
| 194 | + | } | |||
| 195 | + | out, err := e.runTokenCmd(ctx, line) | |||
| 196 | + | if err != nil { | |||
| 197 | + | return "", fmt.Errorf("token.cmd failed: %w", err) | |||
| 198 | + | } | |||
| 199 | + | out = strings.TrimSpace(out) | |||
| 200 | + | switch { | |||
| 201 | + | case out == "": | |||
| 202 | + | return "", errors.New("token.cmd printed nothing") | |||
| 203 | + | case strings.ContainsAny(out, "\r\n"): | |||
| 204 | + | return "", errors.New("token.cmd printed more than one line") | |||
| 205 | + | } | |||
| 206 | + | return out, nil | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // runTokenShell is the default runTokenCmd: the system shell, for at most | |||
| 210 | + | // tokenCmdTimeout. | |||
| 211 | + | func runTokenShell(ctx context.Context, line string) (string, error) { | |||
| 212 | + | return runShell(ctx, line, tokenCmdTimeout) | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // runShell runs line with the system shell. Standard error is the terminal's, | |||
| 216 | + | // and so is standard input when it is a terminal, so a pinentry or a biometric | |||
| 217 | + | // prompt works; a script or an agent gets no input. Standard output is | |||
| 218 | + | // captured, up to tokenCmdMaxOutput bytes. The environment is the process's. | |||
| 219 | + | // An interrupt or SIGTERM sent to ermite, or the end of ctx, stops the command | |||
| 220 | + | // like a timeout does (see shellCommand for what is killed). | |||
| 221 | + | func runShell(ctx context.Context, line string, timeout time.Duration) (string, error) { | |||
| 222 | + | sigCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |||
| 223 | + | defer stop() | |||
| 224 | + | runCtx, cancel := context.WithTimeout(sigCtx, timeout) | |||
| 225 | + | defer cancel() | |||
| 226 | + | ||||
| 227 | + | foreground := term.IsTerminal(int(os.Stdin.Fd())) | |||
| 228 | + | c := shellCommand(runCtx, line, foreground) | |||
| 229 | + | out := &cappedBuffer{max: tokenCmdMaxOutput} | |||
| 230 | + | c.Stdout, c.Stderr = out, os.Stderr | |||
| 231 | + | if foreground { | |||
| 232 | + | c.Stdin = os.Stdin | |||
| 233 | + | } // else nil: the command reads os.DevNull | |||
| 234 | + | // Do not wait forever for a background process that kept the pipe open. | |||
| 235 | + | c.WaitDelay = 2 * time.Second | |||
| 236 | + | err := c.Run() | |||
| 237 | + | if err != nil { | |||
| 238 | + | switch { | |||
| 239 | + | case sigCtx.Err() != nil: | |||
| 240 | + | return "", errors.New("interrupted") | |||
| 241 | + | case errors.Is(runCtx.Err(), context.DeadlineExceeded): | |||
| 242 | + | return "", fmt.Errorf("timed out after %s", timeout) | |||
| 243 | + | } | |||
| 244 | + | if exit := (*exec.ExitError)(nil); errors.As(err, &exit) { | |||
| 245 | + | return "", errors.New(exit.String()) | |||
| 246 | + | } | |||
| 247 | + | return "", err | |||
| 248 | + | } | |||
| 249 | + | if out.over { | |||
| 250 | + | return "", fmt.Errorf("printed more than %d bytes", tokenCmdMaxOutput) | |||
| 251 | + | } | |||
| 252 | + | return out.buf.String(), nil | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // cappedBuffer keeps the first max bytes written and notes an overflow. | |||
| 256 | + | type cappedBuffer struct { | |||
| 257 | + | buf bytes.Buffer | |||
| 258 | + | max int | |||
| 259 | + | over bool | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func (b *cappedBuffer) Write(p []byte) (int, error) { | |||
| 263 | + | n := len(p) // report everything as written: a short write would abort the copy | |||
| 264 | + | if room := b.max - b.buf.Len(); len(p) > room { | |||
| 265 | + | b.over = true | |||
| 266 | + | p = p[:room] | |||
| 267 | + | } | |||
| 268 | + | b.buf.Write(p) | |||
| 269 | + | return n, nil | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | var _ io.Writer = (*cappedBuffer)(nil) | |||
A internal/app/cli_configfile_test.go +450 -0
| @@ -0,0 +1,450 @@ | |||
| 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 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/assert" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | "golang.org/x/term" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // writeConfigFile writes the config file of env with the given mode. | |
| 23 | + | func writeConfigFile(t *testing.T, env *cliEnv, content string, mode os.FileMode) string { | |
| 24 | + | t.Helper() | |
| 25 | + | path, err := env.configPath() | |
| 26 | + | require.NoError(t, err) | |
| 27 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |
| 28 | + | require.NoError(t, os.WriteFile(path, []byte(content), mode)) | |
| 29 | + | require.NoError(t, os.Chmod(path, mode)) // umask | |
| 30 | + | return path | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // tokenCmdStub makes runTokenCmd return out and err, and counts the runs. | |
| 34 | + | func tokenCmdStub(env *cliEnv, out string, err error) *[]string { | |
| 35 | + | var ran []string | |
| 36 | + | env.runTokenCmd = func(_ context.Context, line string) (string, error) { | |
| 37 | + | ran = append(ran, line) | |
| 38 | + | return out, err | |
| 39 | + | } | |
| 40 | + | return &ran | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func listFake() *fakeAPI { | |
| 44 | + | return &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 45 | + | return &ermite.ListReposResponse{}, nil | |
| 46 | + | }} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func lastAuth(f *fakeAPI) string { | |
| 50 | + | f.mu.Lock() | |
| 51 | + | defer f.mu.Unlock() | |
| 52 | + | if len(f.header) == 0 { | |
| 53 | + | return "<no call>" | |
| 54 | + | } | |
| 55 | + | return f.header[len(f.header)-1].Get("Authorization") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestConfigFileSuppliesURLAndToken(t *testing.T) { | |
| 59 | + | f := listFake() | |
| 60 | + | url := f.serve(t) | |
| 61 | + | env := testEnv(t, nil) | |
| 62 | + | writeConfigFile(t, env, "# my server\nurl = "+url+"\ntoken.cmd = pass show ermite/token\n", 0o600) | |
| 63 | + | ran := tokenCmdStub(env, " ermite_fromcmd\n", nil) | |
| 64 | + | ||
| 65 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 66 | + | assert.Equal(t, "Bearer ermite_fromcmd", lastAuth(f)) | |
| 67 | + | assert.Equal(t, []string{"pass show ermite/token"}, *ran) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestConfigFileURLWithoutTokenCmdIsAnonymous(t *testing.T) { | |
| 71 | + | f := listFake() | |
| 72 | + | env := testEnv(t, nil) | |
| 73 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\n", 0o600) | |
| 74 | + | ||
| 75 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 76 | + | assert.Empty(t, lastAuth(f)) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestEnvironmentWinsOverConfigFile(t *testing.T) { | |
| 80 | + | fileSrv, envSrv := listFake(), listFake() | |
| 81 | + | env := testEnv(t, map[string]string{"ERMITE_URL": envSrv.serve(t), "ERMITE_TOKEN": "ermite_env"}) | |
| 82 | + | writeConfigFile(t, env, "url = "+fileSrv.serve(t)+"\ntoken.cmd = echo nope\n", 0o600) | |
| 83 | + | ||
| 84 | + | runCLIWith(t, env, "", "repo", "list").ok(t) // the default runTokenCmd of testEnv fails the test if called | |
| 85 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(envSrv)) | |
| 86 | + | calls, _ := fileSrv.snapshot() | |
| 87 | + | assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestEnvURLDifferentFromFileKeepsTokenCmdOut(t *testing.T) { | |
| 91 | + | f := listFake() | |
| 92 | + | tests := []struct { | |
| 93 | + | name string | |
| 94 | + | envURL string // "" means the fake server | |
| 95 | + | file string // the content of the config file | |
| 96 | + | wantErr bool | |
| 97 | + | }{ | |
| 98 | + | {name: "other url in the file", file: "url = http://file.invalid\ntoken.cmd = get-token\n"}, | |
| 99 | + | {name: "file without url", file: "token.cmd = get-token\n"}, | |
| 100 | + | {name: "invalid ERMITE_URL", envURL: "nope", file: "url = http://file.invalid\ntoken.cmd = get-token\n", wantErr: true}, | |
| 101 | + | } | |
| 102 | + | for _, tt := range tests { | |
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |
| 104 | + | envURL := tt.envURL | |
| 105 | + | if envURL == "" { | |
| 106 | + | envURL = f.serve(t) | |
| 107 | + | } | |
| 108 | + | env := testEnv(t, map[string]string{"ERMITE_URL": envURL}) | |
| 109 | + | writeConfigFile(t, env, tt.file, 0o600) | |
| 110 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 111 | + | ||
| 112 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 113 | + | assert.Empty(t, *ran, "the token of the file's server must not go to another server") | |
| 114 | + | if tt.wantErr { | |
| 115 | + | require.Error(t, res.err) | |
| 116 | + | return | |
| 117 | + | } | |
| 118 | + | require.NoError(t, res.err) | |
| 119 | + | assert.Empty(t, lastAuth(f)) | |
| 120 | + | assert.Equal(t, 1, strings.Count(res.stderr, "token.cmd not run"), res.stderr) | |
| 121 | + | assert.Contains(t, res.stderr, "ERMITE_TOKEN") | |
| 122 | + | }) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestEnvURLSameAsFileUsesTokenCmd(t *testing.T) { | |
| 127 | + | f := listFake() | |
| 128 | + | url := f.serve(t) | |
| 129 | + | // the same address, written differently | |
| 130 | + | env := testEnv(t, map[string]string{"ERMITE_URL": " " + strings.ToUpper(url[:4]) + url[4:] + "/ "}) | |
| 131 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 132 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 133 | + | ||
| 134 | + | res := runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 135 | + | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | |
| 136 | + | assert.Len(t, *ran, 1) | |
| 137 | + | assert.NotContains(t, res.stderr, "token.cmd") | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) { | |
| 141 | + | f := listFake() | |
| 142 | + | env := testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"}) | |
| 143 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o666) // even an unsafe file is not an issue | |
| 144 | + | ran := tokenCmdStub(env, "other", nil) | |
| 145 | + | ||
| 146 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 147 | + | assert.Empty(t, *ran) | |
| 148 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(f)) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestTokenCmdNotRunForVersionNorCompletion(t *testing.T) { | |
| 152 | + | f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 153 | + | return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil | |
| 154 | + | }} | |
| 155 | + | url := f.serve(t) | |
| 156 | + | ||
| 157 | + | for name, args := range map[string][]string{ | |
| 158 | + | "version": {"version"}, | |
| 159 | + | "version --json": {"version", "--json"}, | |
| 160 | + | "completion": {"completion", "bash"}, | |
| 161 | + | } { | |
| 162 | + | t.Run(name, func(t *testing.T) { | |
| 163 | + | env := testEnv(t, nil) | |
| 164 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 165 | + | ran := tokenCmdStub(env, "", errors.New("must not run")) | |
| 166 | + | runCLIWith(t, env, "", args...).ok(t) | |
| 167 | + | assert.Empty(t, *ran) | |
| 168 | + | }) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | t.Run("version without any url", func(t *testing.T) { | |
| 172 | + | env := testEnv(t, nil) | |
| 173 | + | writeConfigFile(t, env, "token.cmd = get-token\n", 0o600) | |
| 174 | + | ran := tokenCmdStub(env, "", errors.New("must not run")) | |
| 175 | + | res := runCLIWith(t, env, "", "version").ok(t) | |
| 176 | + | assert.Equal(t, "ermite v0.0.1\n", res.stdout) | |
| 177 | + | assert.Empty(t, *ran) | |
| 178 | + | }) | |
| 179 | + | ||
| 180 | + | t.Run("version uses the url of the file and sends no token", func(t *testing.T) { | |
| 181 | + | env := testEnv(t, nil) | |
| 182 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 183 | + | tokenCmdStub(env, "ermite_cmd", nil) | |
| 184 | + | res := runCLIWith(t, env, "", "version").ok(t) | |
| 185 | + | assert.Contains(t, res.stdout, "v0.4.0 at "+url) | |
| 186 | + | assert.Empty(t, lastAuth(f)) | |
| 187 | + | }) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestTokenCmdRunsOncePerProcess(t *testing.T) { | |
| 191 | + | f := listFake() | |
| 192 | + | env := testEnv(t, nil) | |
| 193 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |
| 194 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 195 | + | root := newRootCmdWith(env) | |
| 196 | + | for range 2 { | |
| 197 | + | c, _, err := root.Find([]string{"repo", "list"}) | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | _, err = env.client(c) | |
| 200 | + | require.NoError(t, err) | |
| 201 | + | } | |
| 202 | + | assert.Len(t, *ran, 1) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestTokenCmdRefusedWhenConfigFileIsWritableByOthers(t *testing.T) { | |
| 206 | + | if runtime.GOOS == "windows" { | |
| 207 | + | t.Skip("no Unix permission bits") | |
| 208 | + | } | |
| 209 | + | for _, mode := range []os.FileMode{0o666, 0o662, 0o620} { | |
| 210 | + | t.Run(mode.String(), func(t *testing.T) { | |
| 211 | + | env := testEnv(t, nil) | |
| 212 | + | path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", mode) | |
| 213 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 214 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 215 | + | require.Error(t, res.err) | |
| 216 | + | assert.Equal(t, "config file "+path+" is writable by others: token.cmd not run", res.err.Error()) | |
| 217 | + | assert.Empty(t, *ran) | |
| 218 | + | }) | |
| 219 | + | } | |
| 220 | + | t.Run("group readable is fine", func(t *testing.T) { | |
| 221 | + | f := listFake() | |
| 222 | + | env := testEnv(t, nil) | |
| 223 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o644) | |
| 224 | + | tokenCmdStub(env, "ermite_cmd", nil) | |
| 225 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 226 | + | }) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func TestTokenCmdFailuresNeverShowOutput(t *testing.T) { | |
| 230 | + | const secret = "s3cret-token-value" | |
| 231 | + | tests := []struct { | |
| 232 | + | name string | |
| 233 | + | out string | |
| 234 | + | err error | |
| 235 | + | want string | |
| 236 | + | }{ | |
| 237 | + | {"failure", secret, errors.New("exit status 3"), "token.cmd failed: exit status 3"}, | |
| 238 | + | {"empty", " \n", nil, "token.cmd printed nothing"}, | |
| 239 | + | {"multiline", secret + "\n" + secret + "\n", nil, "token.cmd printed more than one line"}, | |
| 240 | + | {"crlf multiline", secret + "\r\n" + secret, nil, "token.cmd printed more than one line"}, | |
| 241 | + | } | |
| 242 | + | for _, tt := range tests { | |
| 243 | + | t.Run(tt.name, func(t *testing.T) { | |
| 244 | + | env := testEnv(t, nil) | |
| 245 | + | writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600) | |
| 246 | + | tokenCmdStub(env, tt.out, tt.err) | |
| 247 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 248 | + | require.Error(t, res.err) | |
| 249 | + | assert.Equal(t, tt.want, res.err.Error()) | |
| 250 | + | assert.NotContains(t, res.err.Error()+res.stdout+res.stderr, secret) | |
| 251 | + | }) | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func TestMissingURLIsReportedBeforeTokenCmdRuns(t *testing.T) { | |
| 256 | + | env := testEnv(t, nil) | |
| 257 | + | writeConfigFile(t, env, "token.cmd = get-token\n", 0o600) | |
| 258 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 259 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 260 | + | require.Error(t, res.err) | |
| 261 | + | assert.Contains(t, res.err.Error(), "ERMITE_URL is not set") | |
| 262 | + | assert.Contains(t, res.err.Error(), "ermite config set url") | |
| 263 | + | assert.Empty(t, *ran) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func TestInvalidFileURLIsReported(t *testing.T) { | |
| 267 | + | env := testEnv(t, nil) | |
| 268 | + | path := writeConfigFile(t, env, "url = git.example.com\n", 0o600) | |
| 269 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 270 | + | require.Error(t, res.err) | |
| 271 | + | assert.Contains(t, res.err.Error(), "url in "+filepath.Dir(path)) // the file named | |
| 272 | + | assert.Contains(t, res.err.Error(), "ermite config set url") | |
| 273 | + | assert.Contains(t, res.err.Error(), "http") | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func TestConfigFileWarnings(t *testing.T) { | |
| 277 | + | f := listFake() | |
| 278 | + | env := testEnv(t, nil) | |
| 279 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ncolour = blue\n", 0o600) | |
| 280 | + | res := runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 281 | + | assert.Equal(t, 1, strings.Count(res.stderr, "ermite: warning: config: "), res.stderr) | |
| 282 | + | assert.Contains(t, res.stderr, "colour") | |
| 283 | + | assert.NotContains(t, res.stderr, "blue", "values are never echoed") | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func TestCompletionStaysSilentAboutTheConfigFile(t *testing.T) { | |
| 287 | + | env := testEnv(t, nil) | |
| 288 | + | writeConfigFile(t, env, "colour = blue\nurl = nope\n", 0o600) | |
| 289 | + | res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t) | |
| 290 | + | assert.NotContains(t, res.stderr, "warning") | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func TestERMITE_CONFIGSelectsTheFile(t *testing.T) { | |
| 294 | + | f := listFake() | |
| 295 | + | path := filepath.Join(t.TempDir(), "other", "conf") | |
| 296 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |
| 297 | + | require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\n"), 0o600)) | |
| 298 | + | ||
| 299 | + | env := testEnv(t, map[string]string{"ERMITE_CONFIG": path}) | |
| 300 | + | env.configPath = nil // the default resolution, which reads ERMITE_CONFIG | |
| 301 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func TestTokenCmdRunsThroughTheShell(t *testing.T) { | |
| 305 | + | if runtime.GOOS == "windows" { | |
| 306 | + | t.Skip("uses sh") | |
| 307 | + | } | |
| 308 | + | f := listFake() | |
| 309 | + | env := testEnv(t, nil) | |
| 310 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = printf secret\n", 0o600) | |
| 311 | + | env.runTokenCmd = runTokenShell // the real thing | |
| 312 | + | ||
| 313 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 314 | + | assert.Equal(t, "Bearer secret", lastAuth(f)) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func TestRunShell(t *testing.T) { | |
| 318 | + | if runtime.GOOS == "windows" { | |
| 319 | + | t.Skip("uses sh") | |
| 320 | + | } | |
| 321 | + | tests := []struct { | |
| 322 | + | name string | |
| 323 | + | line string | |
| 324 | + | want string // the output on success | |
| 325 | + | errHas string // the error on failure | |
| 326 | + | errNot string // what the error must not show | |
| 327 | + | wantErr bool | |
| 328 | + | }{ | |
| 329 | + | {name: "exit status", line: "echo s3cret; exit 3", wantErr: true, errHas: "exit status 3", errNot: "s3cret"}, | |
| 330 | + | {name: "too much output", line: "head -c 5000 /dev/zero | tr '\\0' a", wantErr: true, errHas: "more than 4096 bytes", errNot: "aaa"}, | |
| 331 | + | {name: "output", line: "echo ermite_ok", want: "ermite_ok\n"}, | |
| 332 | + | } | |
| 333 | + | for _, tt := range tests { | |
| 334 | + | t.Run(tt.name, func(t *testing.T) { | |
| 335 | + | out, err := runShell(t.Context(), tt.line, time.Minute) | |
| 336 | + | if !tt.wantErr { | |
| 337 | + | require.NoError(t, err) | |
| 338 | + | assert.Equal(t, tt.want, out) | |
| 339 | + | return | |
| 340 | + | } | |
| 341 | + | require.Error(t, err) | |
| 342 | + | assert.Contains(t, err.Error(), tt.errHas) | |
| 343 | + | assert.NotContains(t, err.Error(), tt.errNot) | |
| 344 | + | }) | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // requireNoTerminal skips a test of the non-terminal path when the tests run | |
| 349 | + | // with a terminal as standard input. | |
| 350 | + | func requireNoTerminal(t *testing.T) { | |
| 351 | + | t.Helper() | |
| 352 | + | if runtime.GOOS == "windows" { | |
| 353 | + | t.Skip("uses sh") | |
| 354 | + | } | |
| 355 | + | if term.IsTerminal(int(os.Stdin.Fd())) { | |
| 356 | + | t.Skip("standard input is a terminal") | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func TestTokenCmdTimesOut(t *testing.T) { | |
| 361 | + | requireNoTerminal(t) | |
| 362 | + | f := listFake() | |
| 363 | + | env := testEnv(t, nil) | |
| 364 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = sleep 30; :\n", 0o600) | |
| 365 | + | env.runTokenCmd = func(ctx context.Context, line string) (string, error) { | |
| 366 | + | return runShell(ctx, line, 100*time.Millisecond) | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | start := time.Now() | |
| 370 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 371 | + | require.Error(t, res.err) | |
| 372 | + | assert.Equal(t, "token.cmd failed: timed out after 100ms", res.err.Error()) | |
| 373 | + | assert.Less(t, time.Since(start), 10*time.Second) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func TestTokenCmdGetsNoInputWithoutATerminal(t *testing.T) { | |
| 377 | + | requireNoTerminal(t) | |
| 378 | + | out, err := runShell(t.Context(), "cat; echo done", time.Minute) | |
| 379 | + | require.NoError(t, err) | |
| 380 | + | assert.Equal(t, "done\n", out, "the command read nothing") | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func TestTokenCmdRefusedWhenDirectoryIsWritableByOthers(t *testing.T) { | |
| 384 | + | if runtime.GOOS == "windows" { | |
| 385 | + | t.Skip("no Unix permission bits") | |
| 386 | + | } | |
| 387 | + | env := testEnv(t, nil) | |
| 388 | + | path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600) | |
| 389 | + | dir := filepath.Dir(path) | |
| 390 | + | require.NoError(t, os.Chmod(dir, 0o777)) | |
| 391 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 392 | + | ||
| 393 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 394 | + | require.Error(t, res.err) | |
| 395 | + | assert.Equal(t, "config directory "+dir+" is writable by others: token.cmd not run", res.err.Error()) | |
| 396 | + | assert.Empty(t, *ran) | |
| 397 | + | ||
| 398 | + | t.Run("sticky directory is fine", func(t *testing.T) { | |
| 399 | + | require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky)) | |
| 400 | + | f := listFake() | |
| 401 | + | env := testEnvAt(t, path) | |
| 402 | + | require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\ntoken.cmd = get-token\n"), 0o600)) | |
| 403 | + | tokenCmdStub(env, "ermite_cmd", nil) | |
| 404 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 405 | + | }) | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) { | |
| 409 | + | if runtime.GOOS == "windows" { | |
| 410 | + | t.Skip("symlinks") | |
| 411 | + | } | |
| 412 | + | f := listFake() | |
| 413 | + | env := testEnv(t, nil) | |
| 414 | + | real := writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |
| 415 | + | link := filepath.Join(t.TempDir(), "dotfile-link") | |
| 416 | + | require.NoError(t, os.Symlink(real, link)) | |
| 417 | + | tokenCmdStub(env, "ermite_cmd", nil) | |
| 418 | + | env.configPath = func() (string, error) { return link, nil } | |
| 419 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 420 | + | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | func TestVersionWarnsAboutAnUnreadableConfigFile(t *testing.T) { | |
| 424 | + | env := testEnv(t, nil) | |
| 425 | + | path := writeConfigFile(t, env, "", 0o600) | |
| 426 | + | require.NoError(t, os.Remove(path)) | |
| 427 | + | require.NoError(t, os.Mkdir(path, 0o700)) | |
| 428 | + | res := runCLIWith(t, env, "", "version").ok(t) | |
| 429 | + | assert.Equal(t, "ermite v0.0.1\n", res.stdout) | |
| 430 | + | assert.Contains(t, res.stderr, "ermite: warning: config:") | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | func TestCompletionNeverRunsTokenCmd(t *testing.T) { | |
| 434 | + | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 435 | + | return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil | |
| 436 | + | }} | |
| 437 | + | env := testEnv(t, nil) | |
| 438 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |
| 439 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 440 | + | res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t) | |
| 441 | + | assert.Empty(t, *ran) | |
| 442 | + | assert.Contains(t, res.stdout, "alpha") | |
| 443 | + | assert.Empty(t, lastAuth(f)) | |
| 444 | + | ||
| 445 | + | // with ERMITE_TOKEN set the completion is authenticated | |
| 446 | + | env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"}) | |
| 447 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |
| 448 | + | runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t) | |
| 449 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(f)) | |
| 450 | + | } | |
| @@ -0,0 +1,450 @@ | |||||
| 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 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/stretchr/testify/assert" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | "golang.org/x/term" | |||
| 18 | + | ||||
| 19 | + | "bnema.dev/ermite-api/gen/ermite" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // writeConfigFile writes the config file of env with the given mode. | |||
| 23 | + | func writeConfigFile(t *testing.T, env *cliEnv, content string, mode os.FileMode) string { | |||
| 24 | + | t.Helper() | |||
| 25 | + | path, err := env.configPath() | |||
| 26 | + | require.NoError(t, err) | |||
| 27 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |||
| 28 | + | require.NoError(t, os.WriteFile(path, []byte(content), mode)) | |||
| 29 | + | require.NoError(t, os.Chmod(path, mode)) // umask | |||
| 30 | + | return path | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // tokenCmdStub makes runTokenCmd return out and err, and counts the runs. | |||
| 34 | + | func tokenCmdStub(env *cliEnv, out string, err error) *[]string { | |||
| 35 | + | var ran []string | |||
| 36 | + | env.runTokenCmd = func(_ context.Context, line string) (string, error) { | |||
| 37 | + | ran = append(ran, line) | |||
| 38 | + | return out, err | |||
| 39 | + | } | |||
| 40 | + | return &ran | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func listFake() *fakeAPI { | |||
| 44 | + | return &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |||
| 45 | + | return &ermite.ListReposResponse{}, nil | |||
| 46 | + | }} | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func lastAuth(f *fakeAPI) string { | |||
| 50 | + | f.mu.Lock() | |||
| 51 | + | defer f.mu.Unlock() | |||
| 52 | + | if len(f.header) == 0 { | |||
| 53 | + | return "<no call>" | |||
| 54 | + | } | |||
| 55 | + | return f.header[len(f.header)-1].Get("Authorization") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestConfigFileSuppliesURLAndToken(t *testing.T) { | |||
| 59 | + | f := listFake() | |||
| 60 | + | url := f.serve(t) | |||
| 61 | + | env := testEnv(t, nil) | |||
| 62 | + | writeConfigFile(t, env, "# my server\nurl = "+url+"\ntoken.cmd = pass show ermite/token\n", 0o600) | |||
| 63 | + | ran := tokenCmdStub(env, " ermite_fromcmd\n", nil) | |||
| 64 | + | ||||
| 65 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 66 | + | assert.Equal(t, "Bearer ermite_fromcmd", lastAuth(f)) | |||
| 67 | + | assert.Equal(t, []string{"pass show ermite/token"}, *ran) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestConfigFileURLWithoutTokenCmdIsAnonymous(t *testing.T) { | |||
| 71 | + | f := listFake() | |||
| 72 | + | env := testEnv(t, nil) | |||
| 73 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\n", 0o600) | |||
| 74 | + | ||||
| 75 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 76 | + | assert.Empty(t, lastAuth(f)) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestEnvironmentWinsOverConfigFile(t *testing.T) { | |||
| 80 | + | fileSrv, envSrv := listFake(), listFake() | |||
| 81 | + | env := testEnv(t, map[string]string{"ERMITE_URL": envSrv.serve(t), "ERMITE_TOKEN": "ermite_env"}) | |||
| 82 | + | writeConfigFile(t, env, "url = "+fileSrv.serve(t)+"\ntoken.cmd = echo nope\n", 0o600) | |||
| 83 | + | ||||
| 84 | + | runCLIWith(t, env, "", "repo", "list").ok(t) // the default runTokenCmd of testEnv fails the test if called | |||
| 85 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(envSrv)) | |||
| 86 | + | calls, _ := fileSrv.snapshot() | |||
| 87 | + | assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set") | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestEnvURLDifferentFromFileKeepsTokenCmdOut(t *testing.T) { | |||
| 91 | + | f := listFake() | |||
| 92 | + | tests := []struct { | |||
| 93 | + | name string | |||
| 94 | + | envURL string // "" means the fake server | |||
| 95 | + | file string // the content of the config file | |||
| 96 | + | wantErr bool | |||
| 97 | + | }{ | |||
| 98 | + | {name: "other url in the file", file: "url = http://file.invalid\ntoken.cmd = get-token\n"}, | |||
| 99 | + | {name: "file without url", file: "token.cmd = get-token\n"}, | |||
| 100 | + | {name: "invalid ERMITE_URL", envURL: "nope", file: "url = http://file.invalid\ntoken.cmd = get-token\n", wantErr: true}, | |||
| 101 | + | } | |||
| 102 | + | for _, tt := range tests { | |||
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 104 | + | envURL := tt.envURL | |||
| 105 | + | if envURL == "" { | |||
| 106 | + | envURL = f.serve(t) | |||
| 107 | + | } | |||
| 108 | + | env := testEnv(t, map[string]string{"ERMITE_URL": envURL}) | |||
| 109 | + | writeConfigFile(t, env, tt.file, 0o600) | |||
| 110 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 111 | + | ||||
| 112 | + | res := runCLIWith(t, env, "", "repo", "list") | |||
| 113 | + | assert.Empty(t, *ran, "the token of the file's server must not go to another server") | |||
| 114 | + | if tt.wantErr { | |||
| 115 | + | require.Error(t, res.err) | |||
| 116 | + | return | |||
| 117 | + | } | |||
| 118 | + | require.NoError(t, res.err) | |||
| 119 | + | assert.Empty(t, lastAuth(f)) | |||
| 120 | + | assert.Equal(t, 1, strings.Count(res.stderr, "token.cmd not run"), res.stderr) | |||
| 121 | + | assert.Contains(t, res.stderr, "ERMITE_TOKEN") | |||
| 122 | + | }) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestEnvURLSameAsFileUsesTokenCmd(t *testing.T) { | |||
| 127 | + | f := listFake() | |||
| 128 | + | url := f.serve(t) | |||
| 129 | + | // the same address, written differently | |||
| 130 | + | env := testEnv(t, map[string]string{"ERMITE_URL": " " + strings.ToUpper(url[:4]) + url[4:] + "/ "}) | |||
| 131 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |||
| 132 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 133 | + | ||||
| 134 | + | res := runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 135 | + | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | |||
| 136 | + | assert.Len(t, *ran, 1) | |||
| 137 | + | assert.NotContains(t, res.stderr, "token.cmd") | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) { | |||
| 141 | + | f := listFake() | |||
| 142 | + | env := testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"}) | |||
| 143 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o666) // even an unsafe file is not an issue | |||
| 144 | + | ran := tokenCmdStub(env, "other", nil) | |||
| 145 | + | ||||
| 146 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 147 | + | assert.Empty(t, *ran) | |||
| 148 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(f)) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestTokenCmdNotRunForVersionNorCompletion(t *testing.T) { | |||
| 152 | + | f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |||
| 153 | + | return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil | |||
| 154 | + | }} | |||
| 155 | + | url := f.serve(t) | |||
| 156 | + | ||||
| 157 | + | for name, args := range map[string][]string{ | |||
| 158 | + | "version": {"version"}, | |||
| 159 | + | "version --json": {"version", "--json"}, | |||
| 160 | + | "completion": {"completion", "bash"}, | |||
| 161 | + | } { | |||
| 162 | + | t.Run(name, func(t *testing.T) { | |||
| 163 | + | env := testEnv(t, nil) | |||
| 164 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |||
| 165 | + | ran := tokenCmdStub(env, "", errors.New("must not run")) | |||
| 166 | + | runCLIWith(t, env, "", args...).ok(t) | |||
| 167 | + | assert.Empty(t, *ran) | |||
| 168 | + | }) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | t.Run("version without any url", func(t *testing.T) { | |||
| 172 | + | env := testEnv(t, nil) | |||
| 173 | + | writeConfigFile(t, env, "token.cmd = get-token\n", 0o600) | |||
| 174 | + | ran := tokenCmdStub(env, "", errors.New("must not run")) | |||
| 175 | + | res := runCLIWith(t, env, "", "version").ok(t) | |||
| 176 | + | assert.Equal(t, "ermite v0.0.1\n", res.stdout) | |||
| 177 | + | assert.Empty(t, *ran) | |||
| 178 | + | }) | |||
| 179 | + | ||||
| 180 | + | t.Run("version uses the url of the file and sends no token", func(t *testing.T) { | |||
| 181 | + | env := testEnv(t, nil) | |||
| 182 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |||
| 183 | + | tokenCmdStub(env, "ermite_cmd", nil) | |||
| 184 | + | res := runCLIWith(t, env, "", "version").ok(t) | |||
| 185 | + | assert.Contains(t, res.stdout, "v0.4.0 at "+url) | |||
| 186 | + | assert.Empty(t, lastAuth(f)) | |||
| 187 | + | }) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func TestTokenCmdRunsOncePerProcess(t *testing.T) { | |||
| 191 | + | f := listFake() | |||
| 192 | + | env := testEnv(t, nil) | |||
| 193 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |||
| 194 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 195 | + | root := newRootCmdWith(env) | |||
| 196 | + | for range 2 { | |||
| 197 | + | c, _, err := root.Find([]string{"repo", "list"}) | |||
| 198 | + | require.NoError(t, err) | |||
| 199 | + | _, err = env.client(c) | |||
| 200 | + | require.NoError(t, err) | |||
| 201 | + | } | |||
| 202 | + | assert.Len(t, *ran, 1) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func TestTokenCmdRefusedWhenConfigFileIsWritableByOthers(t *testing.T) { | |||
| 206 | + | if runtime.GOOS == "windows" { | |||
| 207 | + | t.Skip("no Unix permission bits") | |||
| 208 | + | } | |||
| 209 | + | for _, mode := range []os.FileMode{0o666, 0o662, 0o620} { | |||
| 210 | + | t.Run(mode.String(), func(t *testing.T) { | |||
| 211 | + | env := testEnv(t, nil) | |||
| 212 | + | path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", mode) | |||
| 213 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 214 | + | res := runCLIWith(t, env, "", "repo", "list") | |||
| 215 | + | require.Error(t, res.err) | |||
| 216 | + | assert.Equal(t, "config file "+path+" is writable by others: token.cmd not run", res.err.Error()) | |||
| 217 | + | assert.Empty(t, *ran) | |||
| 218 | + | }) | |||
| 219 | + | } | |||
| 220 | + | t.Run("group readable is fine", func(t *testing.T) { | |||
| 221 | + | f := listFake() | |||
| 222 | + | env := testEnv(t, nil) | |||
| 223 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o644) | |||
| 224 | + | tokenCmdStub(env, "ermite_cmd", nil) | |||
| 225 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 226 | + | }) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func TestTokenCmdFailuresNeverShowOutput(t *testing.T) { | |||
| 230 | + | const secret = "s3cret-token-value" | |||
| 231 | + | tests := []struct { | |||
| 232 | + | name string | |||
| 233 | + | out string | |||
| 234 | + | err error | |||
| 235 | + | want string | |||
| 236 | + | }{ | |||
| 237 | + | {"failure", secret, errors.New("exit status 3"), "token.cmd failed: exit status 3"}, | |||
| 238 | + | {"empty", " \n", nil, "token.cmd printed nothing"}, | |||
| 239 | + | {"multiline", secret + "\n" + secret + "\n", nil, "token.cmd printed more than one line"}, | |||
| 240 | + | {"crlf multiline", secret + "\r\n" + secret, nil, "token.cmd printed more than one line"}, | |||
| 241 | + | } | |||
| 242 | + | for _, tt := range tests { | |||
| 243 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 244 | + | env := testEnv(t, nil) | |||
| 245 | + | writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600) | |||
| 246 | + | tokenCmdStub(env, tt.out, tt.err) | |||
| 247 | + | res := runCLIWith(t, env, "", "repo", "list") | |||
| 248 | + | require.Error(t, res.err) | |||
| 249 | + | assert.Equal(t, tt.want, res.err.Error()) | |||
| 250 | + | assert.NotContains(t, res.err.Error()+res.stdout+res.stderr, secret) | |||
| 251 | + | }) | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func TestMissingURLIsReportedBeforeTokenCmdRuns(t *testing.T) { | |||
| 256 | + | env := testEnv(t, nil) | |||
| 257 | + | writeConfigFile(t, env, "token.cmd = get-token\n", 0o600) | |||
| 258 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 259 | + | res := runCLIWith(t, env, "", "repo", "list") | |||
| 260 | + | require.Error(t, res.err) | |||
| 261 | + | assert.Contains(t, res.err.Error(), "ERMITE_URL is not set") | |||
| 262 | + | assert.Contains(t, res.err.Error(), "ermite config set url") | |||
| 263 | + | assert.Empty(t, *ran) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func TestInvalidFileURLIsReported(t *testing.T) { | |||
| 267 | + | env := testEnv(t, nil) | |||
| 268 | + | path := writeConfigFile(t, env, "url = git.example.com\n", 0o600) | |||
| 269 | + | res := runCLIWith(t, env, "", "repo", "list") | |||
| 270 | + | require.Error(t, res.err) | |||
| 271 | + | assert.Contains(t, res.err.Error(), "url in "+filepath.Dir(path)) // the file named | |||
| 272 | + | assert.Contains(t, res.err.Error(), "ermite config set url") | |||
| 273 | + | assert.Contains(t, res.err.Error(), "http") | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func TestConfigFileWarnings(t *testing.T) { | |||
| 277 | + | f := listFake() | |||
| 278 | + | env := testEnv(t, nil) | |||
| 279 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ncolour = blue\n", 0o600) | |||
| 280 | + | res := runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 281 | + | assert.Equal(t, 1, strings.Count(res.stderr, "ermite: warning: config: "), res.stderr) | |||
| 282 | + | assert.Contains(t, res.stderr, "colour") | |||
| 283 | + | assert.NotContains(t, res.stderr, "blue", "values are never echoed") | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func TestCompletionStaysSilentAboutTheConfigFile(t *testing.T) { | |||
| 287 | + | env := testEnv(t, nil) | |||
| 288 | + | writeConfigFile(t, env, "colour = blue\nurl = nope\n", 0o600) | |||
| 289 | + | res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t) | |||
| 290 | + | assert.NotContains(t, res.stderr, "warning") | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | func TestERMITE_CONFIGSelectsTheFile(t *testing.T) { | |||
| 294 | + | f := listFake() | |||
| 295 | + | path := filepath.Join(t.TempDir(), "other", "conf") | |||
| 296 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |||
| 297 | + | require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\n"), 0o600)) | |||
| 298 | + | ||||
| 299 | + | env := testEnv(t, map[string]string{"ERMITE_CONFIG": path}) | |||
| 300 | + | env.configPath = nil // the default resolution, which reads ERMITE_CONFIG | |||
| 301 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func TestTokenCmdRunsThroughTheShell(t *testing.T) { | |||
| 305 | + | if runtime.GOOS == "windows" { | |||
| 306 | + | t.Skip("uses sh") | |||
| 307 | + | } | |||
| 308 | + | f := listFake() | |||
| 309 | + | env := testEnv(t, nil) | |||
| 310 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = printf secret\n", 0o600) | |||
| 311 | + | env.runTokenCmd = runTokenShell // the real thing | |||
| 312 | + | ||||
| 313 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 314 | + | assert.Equal(t, "Bearer secret", lastAuth(f)) | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | func TestRunShell(t *testing.T) { | |||
| 318 | + | if runtime.GOOS == "windows" { | |||
| 319 | + | t.Skip("uses sh") | |||
| 320 | + | } | |||
| 321 | + | tests := []struct { | |||
| 322 | + | name string | |||
| 323 | + | line string | |||
| 324 | + | want string // the output on success | |||
| 325 | + | errHas string // the error on failure | |||
| 326 | + | errNot string // what the error must not show | |||
| 327 | + | wantErr bool | |||
| 328 | + | }{ | |||
| 329 | + | {name: "exit status", line: "echo s3cret; exit 3", wantErr: true, errHas: "exit status 3", errNot: "s3cret"}, | |||
| 330 | + | {name: "too much output", line: "head -c 5000 /dev/zero | tr '\\0' a", wantErr: true, errHas: "more than 4096 bytes", errNot: "aaa"}, | |||
| 331 | + | {name: "output", line: "echo ermite_ok", want: "ermite_ok\n"}, | |||
| 332 | + | } | |||
| 333 | + | for _, tt := range tests { | |||
| 334 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 335 | + | out, err := runShell(t.Context(), tt.line, time.Minute) | |||
| 336 | + | if !tt.wantErr { | |||
| 337 | + | require.NoError(t, err) | |||
| 338 | + | assert.Equal(t, tt.want, out) | |||
| 339 | + | return | |||
| 340 | + | } | |||
| 341 | + | require.Error(t, err) | |||
| 342 | + | assert.Contains(t, err.Error(), tt.errHas) | |||
| 343 | + | assert.NotContains(t, err.Error(), tt.errNot) | |||
| 344 | + | }) | |||
| 345 | + | } | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | // requireNoTerminal skips a test of the non-terminal path when the tests run | |||
| 349 | + | // with a terminal as standard input. | |||
| 350 | + | func requireNoTerminal(t *testing.T) { | |||
| 351 | + | t.Helper() | |||
| 352 | + | if runtime.GOOS == "windows" { | |||
| 353 | + | t.Skip("uses sh") | |||
| 354 | + | } | |||
| 355 | + | if term.IsTerminal(int(os.Stdin.Fd())) { | |||
| 356 | + | t.Skip("standard input is a terminal") | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func TestTokenCmdTimesOut(t *testing.T) { | |||
| 361 | + | requireNoTerminal(t) | |||
| 362 | + | f := listFake() | |||
| 363 | + | env := testEnv(t, nil) | |||
| 364 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = sleep 30; :\n", 0o600) | |||
| 365 | + | env.runTokenCmd = func(ctx context.Context, line string) (string, error) { | |||
| 366 | + | return runShell(ctx, line, 100*time.Millisecond) | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | start := time.Now() | |||
| 370 | + | res := runCLIWith(t, env, "", "repo", "list") | |||
| 371 | + | require.Error(t, res.err) | |||
| 372 | + | assert.Equal(t, "token.cmd failed: timed out after 100ms", res.err.Error()) | |||
| 373 | + | assert.Less(t, time.Since(start), 10*time.Second) | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | func TestTokenCmdGetsNoInputWithoutATerminal(t *testing.T) { | |||
| 377 | + | requireNoTerminal(t) | |||
| 378 | + | out, err := runShell(t.Context(), "cat; echo done", time.Minute) | |||
| 379 | + | require.NoError(t, err) | |||
| 380 | + | assert.Equal(t, "done\n", out, "the command read nothing") | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | func TestTokenCmdRefusedWhenDirectoryIsWritableByOthers(t *testing.T) { | |||
| 384 | + | if runtime.GOOS == "windows" { | |||
| 385 | + | t.Skip("no Unix permission bits") | |||
| 386 | + | } | |||
| 387 | + | env := testEnv(t, nil) | |||
| 388 | + | path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600) | |||
| 389 | + | dir := filepath.Dir(path) | |||
| 390 | + | require.NoError(t, os.Chmod(dir, 0o777)) | |||
| 391 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 392 | + | ||||
| 393 | + | res := runCLIWith(t, env, "", "repo", "list") | |||
| 394 | + | require.Error(t, res.err) | |||
| 395 | + | assert.Equal(t, "config directory "+dir+" is writable by others: token.cmd not run", res.err.Error()) | |||
| 396 | + | assert.Empty(t, *ran) | |||
| 397 | + | ||||
| 398 | + | t.Run("sticky directory is fine", func(t *testing.T) { | |||
| 399 | + | require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky)) | |||
| 400 | + | f := listFake() | |||
| 401 | + | env := testEnvAt(t, path) | |||
| 402 | + | require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\ntoken.cmd = get-token\n"), 0o600)) | |||
| 403 | + | tokenCmdStub(env, "ermite_cmd", nil) | |||
| 404 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 405 | + | }) | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) { | |||
| 409 | + | if runtime.GOOS == "windows" { | |||
| 410 | + | t.Skip("symlinks") | |||
| 411 | + | } | |||
| 412 | + | f := listFake() | |||
| 413 | + | env := testEnv(t, nil) | |||
| 414 | + | real := writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |||
| 415 | + | link := filepath.Join(t.TempDir(), "dotfile-link") | |||
| 416 | + | require.NoError(t, os.Symlink(real, link)) | |||
| 417 | + | tokenCmdStub(env, "ermite_cmd", nil) | |||
| 418 | + | env.configPath = func() (string, error) { return link, nil } | |||
| 419 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 420 | + | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | func TestVersionWarnsAboutAnUnreadableConfigFile(t *testing.T) { | |||
| 424 | + | env := testEnv(t, nil) | |||
| 425 | + | path := writeConfigFile(t, env, "", 0o600) | |||
| 426 | + | require.NoError(t, os.Remove(path)) | |||
| 427 | + | require.NoError(t, os.Mkdir(path, 0o700)) | |||
| 428 | + | res := runCLIWith(t, env, "", "version").ok(t) | |||
| 429 | + | assert.Equal(t, "ermite v0.0.1\n", res.stdout) | |||
| 430 | + | assert.Contains(t, res.stderr, "ermite: warning: config:") | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | func TestCompletionNeverRunsTokenCmd(t *testing.T) { | |||
| 434 | + | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |||
| 435 | + | return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil | |||
| 436 | + | }} | |||
| 437 | + | env := testEnv(t, nil) | |||
| 438 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |||
| 439 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 440 | + | res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t) | |||
| 441 | + | assert.Empty(t, *ran) | |||
| 442 | + | assert.Contains(t, res.stdout, "alpha") | |||
| 443 | + | assert.Empty(t, lastAuth(f)) | |||
| 444 | + | ||||
| 445 | + | // with ERMITE_TOKEN set the completion is authenticated | |||
| 446 | + | env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"}) | |||
| 447 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |||
| 448 | + | runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t) | |||
| 449 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(f)) | |||
| 450 | + | } | |||
M internal/app/cli_fake_test.go +31 -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" | |
| @@ -29,6 +31,7 @@ type fakeAPI struct { | |||
| 29 | 31 | ermiteconnect.UnimplementedServerServiceHandler | |
| 30 | 32 | ermiteconnect.UnimplementedEventServiceHandler | |
| 31 | 33 | ermiteconnect.UnimplementedMergeRequestServiceHandler | |
| 34 | + | ermiteconnect.UnimplementedRunServiceHandler | |
| 32 | 35 | ||
| 33 | 36 | mu sync.Mutex | |
| 34 | 37 | calls []string // procedure names, in order | |
| @@ -160,6 +163,8 @@ func (f *fakeAPI) serve(t *testing.T) string { | |||
| 160 | 163 | mount(p, h) | |
| 161 | 164 | p, h = ermiteconnect.NewMergeRequestServiceHandler(f) | |
| 162 | 165 | mount(p, h) | |
| 166 | + | p, h = ermiteconnect.NewRunServiceHandler(f) | |
| 167 | + | mount(p, h) | |
| 163 | 168 | srv := httptest.NewServer(mux) | |
| 164 | 169 | t.Cleanup(srv.Close) | |
| 165 | 170 | return srv.URL | |
| @@ -173,15 +178,35 @@ type result struct { | |||
| 173 | 178 | err error | |
| 174 | 179 | } | |
| 175 | 180 | ||
| 181 | + | // testEnv is a cliEnv with the given variables, the fixed clock, and a config | |
| 182 | + | // file path inside a fresh temp dir (no file yet) so that a test never reads | |
| 183 | + | // the developer's own ~/.config/ermite/config. token.cmd must not run unless | |
| 184 | + | // the test sets runTokenCmd. | |
| 185 | + | func testEnv(t *testing.T, vars map[string]string) *cliEnv { | |
| 186 | + | t.Helper() | |
| 187 | + | path := filepath.Join(t.TempDir(), "ermite", "config") | |
| 188 | + | return &cliEnv{ | |
| 189 | + | getenv: func(k string) string { return vars[k] }, | |
| 190 | + | now: func() time.Time { return testNow }, | |
| 191 | + | version: "v0.0.1", | |
| 192 | + | sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() }, | |
| 193 | + | configPath: func() (string, error) { return path, nil }, | |
| 194 | + | runTokenCmd: func(context.Context, string) (string, error) { | |
| 195 | + | t.Error("token.cmd must not run") | |
| 196 | + | return "", errors.New("token.cmd must not run") | |
| 197 | + | }, | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 176 | 201 | // runCLI runs the command tree in process with the given environment. | |
| 177 | 202 | func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result { | |
| 178 | 203 | 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 | - | } | |
| 204 | + | return runCLIWith(t, testEnv(t, vars), stdin, args...) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // runCLIWith runs the command tree in process with env. | |
| 208 | + | func runCLIWith(t *testing.T, env *cliEnv, stdin string, args ...string) result { | |
| 209 | + | t.Helper() | |
| 185 | 210 | root := newRootCmdWith(env) | |
| 186 | 211 | var out, errb bytes.Buffer | |
| 187 | 212 | 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" | ||
| @@ -29,6 +31,7 @@ type fakeAPI struct { | |||||
| 29 | ermiteconnect.UnimplementedServerServiceHandler | 31 | ermiteconnect.UnimplementedServerServiceHandler | ||
| 30 | ermiteconnect.UnimplementedEventServiceHandler | 32 | ermiteconnect.UnimplementedEventServiceHandler | ||
| 31 | ermiteconnect.UnimplementedMergeRequestServiceHandler | 33 | ermiteconnect.UnimplementedMergeRequestServiceHandler | ||
| 34 | + | ermiteconnect.UnimplementedRunServiceHandler | |||
| 32 | 35 | ||||
| 33 | mu sync.Mutex | 36 | mu sync.Mutex | ||
| 34 | calls []string // procedure names, in order | 37 | calls []string // procedure names, in order | ||
| @@ -160,6 +163,8 @@ func (f *fakeAPI) serve(t *testing.T) string { | |||||
| 160 | mount(p, h) | 163 | mount(p, h) | ||
| 161 | p, h = ermiteconnect.NewMergeRequestServiceHandler(f) | 164 | p, h = ermiteconnect.NewMergeRequestServiceHandler(f) | ||
| 162 | mount(p, h) | 165 | mount(p, h) | ||
| 166 | + | p, h = ermiteconnect.NewRunServiceHandler(f) | |||
| 167 | + | mount(p, h) | |||
| 163 | srv := httptest.NewServer(mux) | 168 | srv := httptest.NewServer(mux) | ||
| 164 | t.Cleanup(srv.Close) | 169 | t.Cleanup(srv.Close) | ||
| 165 | return srv.URL | 170 | return srv.URL | ||
| @@ -173,15 +178,35 @@ type result struct { | |||||
| 173 | err error | 178 | err error | ||
| 174 | } | 179 | } | ||
| 175 | 180 | ||||
| 181 | + | // testEnv is a cliEnv with the given variables, the fixed clock, and a config | |||
| 182 | + | // file path inside a fresh temp dir (no file yet) so that a test never reads | |||
| 183 | + | // the developer's own ~/.config/ermite/config. token.cmd must not run unless | |||
| 184 | + | // the test sets runTokenCmd. | |||
| 185 | + | func testEnv(t *testing.T, vars map[string]string) *cliEnv { | |||
| 186 | + | t.Helper() | |||
| 187 | + | path := filepath.Join(t.TempDir(), "ermite", "config") | |||
| 188 | + | return &cliEnv{ | |||
| 189 | + | getenv: func(k string) string { return vars[k] }, | |||
| 190 | + | now: func() time.Time { return testNow }, | |||
| 191 | + | version: "v0.0.1", | |||
| 192 | + | sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() }, | |||
| 193 | + | configPath: func() (string, error) { return path, nil }, | |||
| 194 | + | runTokenCmd: func(context.Context, string) (string, error) { | |||
| 195 | + | t.Error("token.cmd must not run") | |||
| 196 | + | return "", errors.New("token.cmd must not run") | |||
| 197 | + | }, | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | ||||
| 176 | // runCLI runs the command tree in process with the given environment. | 201 | // 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 { | 202 | func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result { | ||
| 178 | t.Helper() | 203 | t.Helper() | ||
| 179 | - | env := &cliEnv{ | 204 | + | return runCLIWith(t, testEnv(t, vars), stdin, args...) |
| 180 | - | getenv: func(k string) string { return vars[k] }, | 205 | + | } |
| 181 | - | now: func() time.Time { return testNow }, | 206 | + | |
| 182 | - | version: "v0.0.1", | 207 | + | // runCLIWith runs the command tree in process with env. |
| 183 | - | sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() }, | 208 | + | func runCLIWith(t *testing.T, env *cliEnv, stdin string, args ...string) result { |
| 184 | - | } | 209 | + | t.Helper() |
| 185 | root := newRootCmdWith(env) | 210 | root := newRootCmdWith(env) | ||
| 186 | var out, errb bytes.Buffer | 211 | var out, errb bytes.Buffer | ||
| 187 | root.SetOut(&out) | 212 | root.SetOut(&out) | ||
M internal/app/cli_mr.go +8 -8
| @@ -204,7 +204,7 @@ func completeMRRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string | |||
| 204 | 204 | case 0: | |
| 205 | 205 | return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp | |
| 206 | 206 | case 1: | |
| 207 | - | c, err := env.client(cmd, false) | |
| 207 | + | c, err := env.completionClient(cmd) | |
| 208 | 208 | if err != nil { | |
| 209 | 209 | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 210 | 210 | } | |
| @@ -265,7 +265,7 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command { | |||
| 265 | 265 | Repo: args[0], Title: title, Description: desc, | |
| 266 | 266 | SourceBranch: trimBranch(source), TargetBranch: trimBranch(target), | |
| 267 | 267 | } | |
| 268 | - | c, err := env.client(cmd, true) | |
| 268 | + | c, err := env.client(cmd) | |
| 269 | 269 | if err != nil { | |
| 270 | 270 | return err | |
| 271 | 271 | } | |
| @@ -310,7 +310,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command { | |||
| 310 | 310 | if err != nil { | |
| 311 | 311 | return err | |
| 312 | 312 | } | |
| 313 | - | c, err := env.client(cmd, true) | |
| 313 | + | c, err := env.client(cmd) | |
| 314 | 314 | if err != nil { | |
| 315 | 315 | return err | |
| 316 | 316 | } | |
| @@ -474,7 +474,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||
| 474 | 474 | if err != nil { | |
| 475 | 475 | return err | |
| 476 | 476 | } | |
| 477 | - | c, err := env.client(cmd, true) | |
| 477 | + | c, err := env.client(cmd) | |
| 478 | 478 | if err != nil { | |
| 479 | 479 | return err | |
| 480 | 480 | } | |
| @@ -687,7 +687,7 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command { | |||
| 687 | 687 | case line != 0 || cmd.Flags().Changed("side"): | |
| 688 | 688 | return errors.New("--line and --side need --path") | |
| 689 | 689 | } | |
| 690 | - | c, err := env.client(cmd, true) | |
| 690 | + | c, err := env.client(cmd) | |
| 691 | 691 | if err != nil { | |
| 692 | 692 | return err | |
| 693 | 693 | } | |
| @@ -722,7 +722,7 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command { | |||
| 722 | 722 | return err | |
| 723 | 723 | } | |
| 724 | 724 | id := strings.TrimPrefix(strings.TrimSpace(args[2]), "#") | |
| 725 | - | c, err := env.client(cmd, true) | |
| 725 | + | c, err := env.client(cmd) | |
| 726 | 726 | if err != nil { | |
| 727 | 727 | return err | |
| 728 | 728 | } | |
| @@ -781,7 +781,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 781 | 781 | return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+ | |
| 782 | 782 | "so that only the commit you reviewed is merged", args[0], n) | |
| 783 | 783 | } | |
| 784 | - | c, err := env.client(cmd, true) | |
| 784 | + | c, err := env.client(cmd) | |
| 785 | 785 | if err != nil { | |
| 786 | 786 | return err | |
| 787 | 787 | } | |
| @@ -830,7 +830,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command { | |||
| 830 | 830 | if err != nil { | |
| 831 | 831 | return err | |
| 832 | 832 | } | |
| 833 | - | c, err := env.client(cmd, true) | |
| 833 | + | c, err := env.client(cmd) | |
| 834 | 834 | if err != nil { | |
| 835 | 835 | return err | |
| 836 | 836 | } | |
| @@ -204,7 +204,7 @@ func completeMRRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string | |||||
| 204 | case 0: | 204 | case 0: | ||
| 205 | return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp | 205 | return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp | ||
| 206 | case 1: | 206 | case 1: | ||
| 207 | - | c, err := env.client(cmd, false) | 207 | + | c, err := env.completionClient(cmd) |
| 208 | if err != nil { | 208 | if err != nil { | ||
| 209 | return nil, cobra.ShellCompDirectiveNoFileComp | 209 | return nil, cobra.ShellCompDirectiveNoFileComp | ||
| 210 | } | 210 | } | ||
| @@ -265,7 +265,7 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 265 | Repo: args[0], Title: title, Description: desc, | 265 | Repo: args[0], Title: title, Description: desc, | ||
| 266 | SourceBranch: trimBranch(source), TargetBranch: trimBranch(target), | 266 | SourceBranch: trimBranch(source), TargetBranch: trimBranch(target), | ||
| 267 | } | 267 | } | ||
| 268 | - | c, err := env.client(cmd, true) | 268 | + | c, err := env.client(cmd) |
| 269 | if err != nil { | 269 | if err != nil { | ||
| 270 | return err | 270 | return err | ||
| 271 | } | 271 | } | ||
| @@ -310,7 +310,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command { | |||||
| 310 | if err != nil { | 310 | if err != nil { | ||
| 311 | return err | 311 | return err | ||
| 312 | } | 312 | } | ||
| 313 | - | c, err := env.client(cmd, true) | 313 | + | c, err := env.client(cmd) |
| 314 | if err != nil { | 314 | if err != nil { | ||
| 315 | return err | 315 | return err | ||
| 316 | } | 316 | } | ||
| @@ -474,7 +474,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||||
| 474 | if err != nil { | 474 | if err != nil { | ||
| 475 | return err | 475 | return err | ||
| 476 | } | 476 | } | ||
| 477 | - | c, err := env.client(cmd, true) | 477 | + | c, err := env.client(cmd) |
| 478 | if err != nil { | 478 | if err != nil { | ||
| 479 | return err | 479 | return err | ||
| 480 | } | 480 | } | ||
| @@ -687,7 +687,7 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command { | |||||
| 687 | case line != 0 || cmd.Flags().Changed("side"): | 687 | case line != 0 || cmd.Flags().Changed("side"): | ||
| 688 | return errors.New("--line and --side need --path") | 688 | return errors.New("--line and --side need --path") | ||
| 689 | } | 689 | } | ||
| 690 | - | c, err := env.client(cmd, true) | 690 | + | c, err := env.client(cmd) |
| 691 | if err != nil { | 691 | if err != nil { | ||
| 692 | return err | 692 | return err | ||
| 693 | } | 693 | } | ||
| @@ -722,7 +722,7 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command { | |||||
| 722 | return err | 722 | return err | ||
| 723 | } | 723 | } | ||
| 724 | id := strings.TrimPrefix(strings.TrimSpace(args[2]), "#") | 724 | id := strings.TrimPrefix(strings.TrimSpace(args[2]), "#") | ||
| 725 | - | c, err := env.client(cmd, true) | 725 | + | c, err := env.client(cmd) |
| 726 | if err != nil { | 726 | if err != nil { | ||
| 727 | return err | 727 | return err | ||
| 728 | } | 728 | } | ||
| @@ -781,7 +781,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||||
| 781 | return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+ | 781 | return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+ | ||
| 782 | "so that only the commit you reviewed is merged", args[0], n) | 782 | "so that only the commit you reviewed is merged", args[0], n) | ||
| 783 | } | 783 | } | ||
| 784 | - | c, err := env.client(cmd, true) | 784 | + | c, err := env.client(cmd) |
| 785 | if err != nil { | 785 | if err != nil { | ||
| 786 | return err | 786 | return err | ||
| 787 | } | 787 | } | ||
| @@ -830,7 +830,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command { | |||||
| 830 | if err != nil { | 830 | if err != nil { | ||
| 831 | return err | 831 | return err | ||
| 832 | } | 832 | } | ||
| 833 | - | c, err := env.client(cmd, true) | 833 | + | c, err := env.client(cmd) |
| 834 | if err != nil { | 834 | if err != nil { | ||
| 835 | return err | 835 | return err | ||
| 836 | } | 836 | } | ||
M internal/app/cli_repo.go +6 -6
| @@ -86,7 +86,7 @@ func newRepoCmd(env *cliEnv) *cobra.Command { | |||
| 86 | 86 | repo := &cobra.Command{ | |
| 87 | 87 | Use: "repo", | |
| 88 | 88 | Short: "Manage repositories on a remote server", | |
| 89 | - | Long: "Manage repositories on the server at ERMITE_URL, authenticated with ERMITE_TOKEN.", | |
| 89 | + | Long: "Manage repositories on the configured server (ERMITE_URL or the config file, see `ermite config`), authenticated with ERMITE_TOKEN or the token command of the config file.", | |
| 90 | 90 | } | |
| 91 | 91 | repo.AddCommand( | |
| 92 | 92 | newRepoListCmd(env), newRepoShowCmd(env), newRepoCreateCmd(env), | |
| @@ -146,7 +146,7 @@ func newRepoListCmd(env *cliEnv) *cobra.Command { | |||
| 146 | 146 | } | |
| 147 | 147 | asJSON := addJSONFlag(cmd) | |
| 148 | 148 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 149 | - | c, err := env.client(cmd, true) | |
| 149 | + | c, err := env.client(cmd) | |
| 150 | 150 | if err != nil { | |
| 151 | 151 | return err | |
| 152 | 152 | } | |
| @@ -205,7 +205,7 @@ func newRepoShowCmd(env *cliEnv) *cobra.Command { | |||
| 205 | 205 | } | |
| 206 | 206 | asJSON := addJSONFlag(cmd) | |
| 207 | 207 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 208 | - | c, err := env.client(cmd, true) | |
| 208 | + | c, err := env.client(cmd) | |
| 209 | 209 | if err != nil { | |
| 210 | 210 | return err | |
| 211 | 211 | } | |
| @@ -239,7 +239,7 @@ func newRepoCreateCmd(env *cliEnv) *cobra.Command { | |||
| 239 | 239 | if err != nil { | |
| 240 | 240 | return err | |
| 241 | 241 | } | |
| 242 | - | c, err := env.client(cmd, true) | |
| 242 | + | c, err := env.client(cmd) | |
| 243 | 243 | if err != nil { | |
| 244 | 244 | return err | |
| 245 | 245 | } | |
| @@ -295,7 +295,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 295 | 295 | if err != nil { | |
| 296 | 296 | return err | |
| 297 | 297 | } | |
| 298 | - | c, err := env.client(cmd, true) | |
| 298 | + | c, err := env.client(cmd) | |
| 299 | 299 | if err != nil { | |
| 300 | 300 | return err | |
| 301 | 301 | } | |
| @@ -377,7 +377,7 @@ func newRepoDeleteCmd(env *cliEnv) *cobra.Command { | |||
| 377 | 377 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 378 | 378 | name := args[0] | |
| 379 | 379 | // Check the configuration before asking anything. | |
| 380 | - | c, err := env.client(cmd, true) | |
| 380 | + | c, err := env.client(cmd) | |
| 381 | 381 | if err != nil { | |
| 382 | 382 | return err | |
| 383 | 383 | } | |
| @@ -86,7 +86,7 @@ func newRepoCmd(env *cliEnv) *cobra.Command { | |||||
| 86 | repo := &cobra.Command{ | 86 | repo := &cobra.Command{ | ||
| 87 | Use: "repo", | 87 | Use: "repo", | ||
| 88 | Short: "Manage repositories on a remote server", | 88 | Short: "Manage repositories on a remote server", | ||
| 89 | - | Long: "Manage repositories on the server at ERMITE_URL, authenticated with ERMITE_TOKEN.", | 89 | + | Long: "Manage repositories on the configured server (ERMITE_URL or the config file, see `ermite config`), authenticated with ERMITE_TOKEN or the token command of the config file.", |
| 90 | } | 90 | } | ||
| 91 | repo.AddCommand( | 91 | repo.AddCommand( | ||
| 92 | newRepoListCmd(env), newRepoShowCmd(env), newRepoCreateCmd(env), | 92 | newRepoListCmd(env), newRepoShowCmd(env), newRepoCreateCmd(env), | ||
| @@ -146,7 +146,7 @@ func newRepoListCmd(env *cliEnv) *cobra.Command { | |||||
| 146 | } | 146 | } | ||
| 147 | asJSON := addJSONFlag(cmd) | 147 | asJSON := addJSONFlag(cmd) | ||
| 148 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | 148 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | ||
| 149 | - | c, err := env.client(cmd, true) | 149 | + | c, err := env.client(cmd) |
| 150 | if err != nil { | 150 | if err != nil { | ||
| 151 | return err | 151 | return err | ||
| 152 | } | 152 | } | ||
| @@ -205,7 +205,7 @@ func newRepoShowCmd(env *cliEnv) *cobra.Command { | |||||
| 205 | } | 205 | } | ||
| 206 | asJSON := addJSONFlag(cmd) | 206 | asJSON := addJSONFlag(cmd) | ||
| 207 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | 207 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | ||
| 208 | - | c, err := env.client(cmd, true) | 208 | + | c, err := env.client(cmd) |
| 209 | if err != nil { | 209 | if err != nil { | ||
| 210 | return err | 210 | return err | ||
| 211 | } | 211 | } | ||
| @@ -239,7 +239,7 @@ func newRepoCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 239 | if err != nil { | 239 | if err != nil { | ||
| 240 | return err | 240 | return err | ||
| 241 | } | 241 | } | ||
| 242 | - | c, err := env.client(cmd, true) | 242 | + | c, err := env.client(cmd) |
| 243 | if err != nil { | 243 | if err != nil { | ||
| 244 | return err | 244 | return err | ||
| 245 | } | 245 | } | ||
| @@ -295,7 +295,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||||
| 295 | if err != nil { | 295 | if err != nil { | ||
| 296 | return err | 296 | return err | ||
| 297 | } | 297 | } | ||
| 298 | - | c, err := env.client(cmd, true) | 298 | + | c, err := env.client(cmd) |
| 299 | if err != nil { | 299 | if err != nil { | ||
| 300 | return err | 300 | return err | ||
| 301 | } | 301 | } | ||
| @@ -377,7 +377,7 @@ func newRepoDeleteCmd(env *cliEnv) *cobra.Command { | |||||
| 377 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | 377 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | ||
| 378 | name := args[0] | 378 | name := args[0] | ||
| 379 | // Check the configuration before asking anything. | 379 | // Check the configuration before asking anything. | ||
| 380 | - | c, err := env.client(cmd, true) | 380 | + | c, err := env.client(cmd) |
| 381 | if err != nil { | 381 | if err != nil { | ||
| 382 | return err | 382 | return err | ||
| 383 | } | 383 | } | ||
M internal/app/cli_run.go +5 -5
| @@ -186,7 +186,7 @@ func newRunListCmd(env *cliEnv) *cobra.Command { | |||
| 186 | 186 | if limit < 1 { | |
| 187 | 187 | return fmt.Errorf("--limit must be at least 1") | |
| 188 | 188 | } | |
| 189 | - | c, err := env.client(cmd, true) | |
| 189 | + | c, err := env.client(cmd) | |
| 190 | 190 | if err != nil { | |
| 191 | 191 | return err | |
| 192 | 192 | } | |
| @@ -273,7 +273,7 @@ func newRunViewCmd(env *cliEnv) *cobra.Command { | |||
| 273 | 273 | if err != nil { | |
| 274 | 274 | return err | |
| 275 | 275 | } | |
| 276 | - | c, err := env.client(cmd, true) | |
| 276 | + | c, err := env.client(cmd) | |
| 277 | 277 | if err != nil { | |
| 278 | 278 | return err | |
| 279 | 279 | } | |
| @@ -434,7 +434,7 @@ func newRunWatchCmd(env *cliEnv) *cobra.Command { | |||
| 434 | 434 | if err != nil { | |
| 435 | 435 | return err | |
| 436 | 436 | } | |
| 437 | - | c, err := env.client(cmd, true) | |
| 437 | + | c, err := env.client(cmd) | |
| 438 | 438 | if err != nil { | |
| 439 | 439 | return err | |
| 440 | 440 | } | |
| @@ -494,7 +494,7 @@ func newRunCancelCmd(env *cliEnv) *cobra.Command { | |||
| 494 | 494 | if err != nil { | |
| 495 | 495 | return err | |
| 496 | 496 | } | |
| 497 | - | c, err := env.client(cmd, true) | |
| 497 | + | c, err := env.client(cmd) | |
| 498 | 498 | if err != nil { | |
| 499 | 499 | return err | |
| 500 | 500 | } | |
| @@ -527,7 +527,7 @@ func newRunRerunCmd(env *cliEnv) *cobra.Command { | |||
| 527 | 527 | if err != nil { | |
| 528 | 528 | return err | |
| 529 | 529 | } | |
| 530 | - | c, err := env.client(cmd, true) | |
| 530 | + | c, err := env.client(cmd) | |
| 531 | 531 | if err != nil { | |
| 532 | 532 | return err | |
| 533 | 533 | } | |
| @@ -186,7 +186,7 @@ func newRunListCmd(env *cliEnv) *cobra.Command { | |||||
| 186 | if limit < 1 { | 186 | if limit < 1 { | ||
| 187 | return fmt.Errorf("--limit must be at least 1") | 187 | return fmt.Errorf("--limit must be at least 1") | ||
| 188 | } | 188 | } | ||
| 189 | - | c, err := env.client(cmd, true) | 189 | + | c, err := env.client(cmd) |
| 190 | if err != nil { | 190 | if err != nil { | ||
| 191 | return err | 191 | return err | ||
| 192 | } | 192 | } | ||
| @@ -273,7 +273,7 @@ func newRunViewCmd(env *cliEnv) *cobra.Command { | |||||
| 273 | if err != nil { | 273 | if err != nil { | ||
| 274 | return err | 274 | return err | ||
| 275 | } | 275 | } | ||
| 276 | - | c, err := env.client(cmd, true) | 276 | + | c, err := env.client(cmd) |
| 277 | if err != nil { | 277 | if err != nil { | ||
| 278 | return err | 278 | return err | ||
| 279 | } | 279 | } | ||
| @@ -434,7 +434,7 @@ func newRunWatchCmd(env *cliEnv) *cobra.Command { | |||||
| 434 | if err != nil { | 434 | if err != nil { | ||
| 435 | return err | 435 | return err | ||
| 436 | } | 436 | } | ||
| 437 | - | c, err := env.client(cmd, true) | 437 | + | c, err := env.client(cmd) |
| 438 | if err != nil { | 438 | if err != nil { | ||
| 439 | return err | 439 | return err | ||
| 440 | } | 440 | } | ||
| @@ -494,7 +494,7 @@ func newRunCancelCmd(env *cliEnv) *cobra.Command { | |||||
| 494 | if err != nil { | 494 | if err != nil { | ||
| 495 | return err | 495 | return err | ||
| 496 | } | 496 | } | ||
| 497 | - | c, err := env.client(cmd, true) | 497 | + | c, err := env.client(cmd) |
| 498 | if err != nil { | 498 | if err != nil { | ||
| 499 | return err | 499 | return err | ||
| 500 | } | 500 | } | ||
| @@ -527,7 +527,7 @@ func newRunRerunCmd(env *cliEnv) *cobra.Command { | |||||
| 527 | if err != nil { | 527 | if err != nil { | ||
| 528 | return err | 528 | return err | ||
| 529 | } | 529 | } | ||
| 530 | - | c, err := env.client(cmd, true) | 530 | + | c, err := env.client(cmd) |
| 531 | if err != nil { | 531 | if err != nil { | ||
| 532 | return err | 532 | return err | ||
| 533 | } | 533 | } | ||
M internal/app/cli_runner.go +2 -2
| @@ -55,7 +55,7 @@ func newRunnerListCmd(env *cliEnv) *cobra.Command { | |||
| 55 | 55 | } | |
| 56 | 56 | asJSON := addJSONFlag(cmd) | |
| 57 | 57 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 58 | - | c, err := env.client(cmd, true) | |
| 58 | + | c, err := env.client(cmd) | |
| 59 | 59 | if err != nil { | |
| 60 | 60 | return err | |
| 61 | 61 | } | |
| @@ -129,7 +129,7 @@ func newRunnerRemoveCmd(env *cliEnv) *cobra.Command { | |||
| 129 | 129 | }, | |
| 130 | 130 | } | |
| 131 | 131 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 132 | - | c, err := env.client(cmd, true) | |
| 132 | + | c, err := env.client(cmd) | |
| 133 | 133 | if err != nil { | |
| 134 | 134 | return err | |
| 135 | 135 | } | |
| @@ -55,7 +55,7 @@ func newRunnerListCmd(env *cliEnv) *cobra.Command { | |||||
| 55 | } | 55 | } | ||
| 56 | asJSON := addJSONFlag(cmd) | 56 | asJSON := addJSONFlag(cmd) | ||
| 57 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | 57 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | ||
| 58 | - | c, err := env.client(cmd, true) | 58 | + | c, err := env.client(cmd) |
| 59 | if err != nil { | 59 | if err != nil { | ||
| 60 | return err | 60 | return err | ||
| 61 | } | 61 | } | ||
| @@ -129,7 +129,7 @@ func newRunnerRemoveCmd(env *cliEnv) *cobra.Command { | |||||
| 129 | }, | 129 | }, | ||
| 130 | } | 130 | } | ||
| 131 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | 131 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | ||
| 132 | - | c, err := env.client(cmd, true) | 132 | + | c, err := env.client(cmd) |
| 133 | if err != nil { | 133 | if err != nil { | ||
| 134 | return err | 134 | return err | ||
| 135 | } | 135 | } | ||
A internal/app/cli_shell_unix.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | //go:build !windows | |
| 4 | + | ||
| 5 | + | package app | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "fmt" | |
| 10 | + | "io/fs" | |
| 11 | + | "os" | |
| 12 | + | "os/exec" | |
| 13 | + | "path/filepath" | |
| 14 | + | "syscall" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // shellCommand runs line with `sh -c`. | |
| 18 | + | // | |
| 19 | + | // When foreground is false (no terminal: a script or an agent), the command | |
| 20 | + | // gets its own process group and a cancellation, a timeout or a signal kills | |
| 21 | + | // the whole group, so a grandchild such as `sleep` or a secret manager helper | |
| 22 | + | // cannot outlive ermite or keep the output pipe open. | |
| 23 | + | // | |
| 24 | + | // When foreground is true (standard input is a terminal) the command stays in | |
| 25 | + | // the foreground process group of the terminal: a pinentry or a password | |
| 26 | + | // prompt then works, and a background group would be stopped by SIGTTIN | |
| 27 | + | // when it reads the terminal. Ctrl+C reaches the whole group. The limit: on a | |
| 28 | + | // timeout or a SIGTERM only sh is killed, not what it started. | |
| 29 | + | func shellCommand(ctx context.Context, line string, foreground bool) *exec.Cmd { | |
| 30 | + | c := exec.CommandContext(ctx, "sh", "-c", line) | |
| 31 | + | if !foreground { | |
| 32 | + | c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 33 | + | c.Cancel = func() error { | |
| 34 | + | // A negative pid is the process group, led by sh. | |
| 35 | + | return syscall.Kill(-c.Process.Pid, syscall.SIGKILL) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | return c | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // openConfig opens the config file without blocking on a FIFO swapped in for it. | |
| 42 | + | func openConfig(path string) (*os.File, error) { | |
| 43 | + | return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // checkPrivate says whether token.cmd may be run from the config file at | |
| 47 | + | // path, whose open handle reported info: whoever can change the file can make | |
| 48 | + | // token.cmd any command. It returns nil when the file belongs to euid (the | |
| 49 | + | // effective user id of the process) and cannot be written by group or others, | |
| 50 | + | // and when its directory belongs to euid or root and cannot be written by | |
| 51 | + | // group or others (a sticky directory excepted), as they could replace the | |
| 52 | + | // file. | |
| 53 | + | func checkPrivate(path string, info fs.FileInfo, euid int) error { | |
| 54 | + | if st, ok := info.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid { | |
| 55 | + | return fmt.Errorf("config file %s is owned by another user: token.cmd not run", path) | |
| 56 | + | } | |
| 57 | + | if info.Mode().Perm()&0o022 != 0 { | |
| 58 | + | return fmt.Errorf("config file %s is writable by others: token.cmd not run", path) | |
| 59 | + | } | |
| 60 | + | dir := filepath.Dir(path) | |
| 61 | + | if resolved, err := filepath.EvalSymlinks(path); err == nil { | |
| 62 | + | dir = filepath.Dir(resolved) // a symlinked dotfile: the directory of the target | |
| 63 | + | } | |
| 64 | + | return checkPrivateDir(dir, euid) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // checkPrivateDir is the directory half of checkPrivate. | |
| 68 | + | func checkPrivateDir(dir string, euid int) error { | |
| 69 | + | di, err := os.Stat(dir) | |
| 70 | + | if err != nil { | |
| 71 | + | return fmt.Errorf("config directory %s: %w: token.cmd not run", dir, err) | |
| 72 | + | } | |
| 73 | + | if st, ok := di.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid && st.Uid != 0 { | |
| 74 | + | return fmt.Errorf("config directory %s is owned by another user: token.cmd not run", dir) | |
| 75 | + | } | |
| 76 | + | if di.Mode().Perm()&0o022 != 0 && di.Mode()&fs.ModeSticky == 0 { | |
| 77 | + | return fmt.Errorf("config directory %s is writable by others: token.cmd not run", dir) | |
| 78 | + | } | |
| 79 | + | return nil | |
| 80 | + | } | |
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | //go:build !windows | |||
| 4 | + | ||||
| 5 | + | package app | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "fmt" | |||
| 10 | + | "io/fs" | |||
| 11 | + | "os" | |||
| 12 | + | "os/exec" | |||
| 13 | + | "path/filepath" | |||
| 14 | + | "syscall" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // shellCommand runs line with `sh -c`. | |||
| 18 | + | // | |||
| 19 | + | // When foreground is false (no terminal: a script or an agent), the command | |||
| 20 | + | // gets its own process group and a cancellation, a timeout or a signal kills | |||
| 21 | + | // the whole group, so a grandchild such as `sleep` or a secret manager helper | |||
| 22 | + | // cannot outlive ermite or keep the output pipe open. | |||
| 23 | + | // | |||
| 24 | + | // When foreground is true (standard input is a terminal) the command stays in | |||
| 25 | + | // the foreground process group of the terminal: a pinentry or a password | |||
| 26 | + | // prompt then works, and a background group would be stopped by SIGTTIN | |||
| 27 | + | // when it reads the terminal. Ctrl+C reaches the whole group. The limit: on a | |||
| 28 | + | // timeout or a SIGTERM only sh is killed, not what it started. | |||
| 29 | + | func shellCommand(ctx context.Context, line string, foreground bool) *exec.Cmd { | |||
| 30 | + | c := exec.CommandContext(ctx, "sh", "-c", line) | |||
| 31 | + | if !foreground { | |||
| 32 | + | c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |||
| 33 | + | c.Cancel = func() error { | |||
| 34 | + | // A negative pid is the process group, led by sh. | |||
| 35 | + | return syscall.Kill(-c.Process.Pid, syscall.SIGKILL) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | return c | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // openConfig opens the config file without blocking on a FIFO swapped in for it. | |||
| 42 | + | func openConfig(path string) (*os.File, error) { | |||
| 43 | + | return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // checkPrivate says whether token.cmd may be run from the config file at | |||
| 47 | + | // path, whose open handle reported info: whoever can change the file can make | |||
| 48 | + | // token.cmd any command. It returns nil when the file belongs to euid (the | |||
| 49 | + | // effective user id of the process) and cannot be written by group or others, | |||
| 50 | + | // and when its directory belongs to euid or root and cannot be written by | |||
| 51 | + | // group or others (a sticky directory excepted), as they could replace the | |||
| 52 | + | // file. | |||
| 53 | + | func checkPrivate(path string, info fs.FileInfo, euid int) error { | |||
| 54 | + | if st, ok := info.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid { | |||
| 55 | + | return fmt.Errorf("config file %s is owned by another user: token.cmd not run", path) | |||
| 56 | + | } | |||
| 57 | + | if info.Mode().Perm()&0o022 != 0 { | |||
| 58 | + | return fmt.Errorf("config file %s is writable by others: token.cmd not run", path) | |||
| 59 | + | } | |||
| 60 | + | dir := filepath.Dir(path) | |||
| 61 | + | if resolved, err := filepath.EvalSymlinks(path); err == nil { | |||
| 62 | + | dir = filepath.Dir(resolved) // a symlinked dotfile: the directory of the target | |||
| 63 | + | } | |||
| 64 | + | return checkPrivateDir(dir, euid) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // checkPrivateDir is the directory half of checkPrivate. | |||
| 68 | + | func checkPrivateDir(dir string, euid int) error { | |||
| 69 | + | di, err := os.Stat(dir) | |||
| 70 | + | if err != nil { | |||
| 71 | + | return fmt.Errorf("config directory %s: %w: token.cmd not run", dir, err) | |||
| 72 | + | } | |||
| 73 | + | if st, ok := di.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid && st.Uid != 0 { | |||
| 74 | + | return fmt.Errorf("config directory %s is owned by another user: token.cmd not run", dir) | |||
| 75 | + | } | |||
| 76 | + | if di.Mode().Perm()&0o022 != 0 && di.Mode()&fs.ModeSticky == 0 { | |||
| 77 | + | return fmt.Errorf("config directory %s is writable by others: token.cmd not run", dir) | |||
| 78 | + | } | |||
| 79 | + | return nil | |||
| 80 | + | } | |||
A internal/app/cli_shell_unix_test.go +160 -0
| @@ -0,0 +1,160 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | //go:build !windows | |
| 4 | + | ||
| 5 | + | package app | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "errors" | |
| 10 | + | "io/fs" | |
| 11 | + | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strconv" | |
| 14 | + | "strings" | |
| 15 | + | "syscall" | |
| 16 | + | "testing" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/stretchr/testify/assert" | |
| 20 | + | "github.com/stretchr/testify/require" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // processGone reports whether pid is gone (a zombie that nobody reaped counts). | |
| 24 | + | func processGone(pid int) bool { | |
| 25 | + | if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) { | |
| 26 | + | return true | |
| 27 | + | } | |
| 28 | + | stat, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") | |
| 29 | + | if err != nil { | |
| 30 | + | return false | |
| 31 | + | } | |
| 32 | + | _, after, _ := strings.Cut(string(stat), ") ") | |
| 33 | + | return strings.HasPrefix(after, "Z") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // readPid waits for the pid file written by a grandchild. | |
| 37 | + | func readPid(t *testing.T, path string) int { | |
| 38 | + | t.Helper() | |
| 39 | + | var pid int | |
| 40 | + | require.Eventually(t, func() bool { | |
| 41 | + | data, err := os.ReadFile(path) | |
| 42 | + | if err != nil { | |
| 43 | + | return false | |
| 44 | + | } | |
| 45 | + | pid, err = strconv.Atoi(strings.TrimSpace(string(data))) | |
| 46 | + | return err == nil && pid > 0 | |
| 47 | + | }, 5*time.Second, 10*time.Millisecond) | |
| 48 | + | return pid | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestRunShellKillsTheWholeProcessTreeOnTimeoutAndCancel(t *testing.T) { | |
| 52 | + | requireNoTerminal(t) | |
| 53 | + | for name, tc := range map[string]struct { | |
| 54 | + | ctx func(t *testing.T) context.Context | |
| 55 | + | timeout time.Duration | |
| 56 | + | want string | |
| 57 | + | }{ | |
| 58 | + | "timeout": {func(t *testing.T) context.Context { return t.Context() }, 700 * time.Millisecond, "timed out after 700ms"}, | |
| 59 | + | "cancel": {func(t *testing.T) context.Context { | |
| 60 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 61 | + | time.AfterFunc(700*time.Millisecond, cancel) | |
| 62 | + | return ctx | |
| 63 | + | }, time.Minute, "interrupted"}, | |
| 64 | + | } { | |
| 65 | + | t.Run(name, func(t *testing.T) { | |
| 66 | + | pidFile := filepath.Join(t.TempDir(), "pid") | |
| 67 | + | // sh starts a grandchild, records its pid and waits for it | |
| 68 | + | line := "sleep 30 & echo $! > '" + pidFile + "'; wait" | |
| 69 | + | _, err := runShell(tc.ctx(t), line, tc.timeout) | |
| 70 | + | require.Error(t, err) | |
| 71 | + | assert.Equal(t, tc.want, err.Error()) | |
| 72 | + | ||
| 73 | + | pid := readPid(t, pidFile) | |
| 74 | + | require.Eventually(t, func() bool { return processGone(pid) }, 5*time.Second, 20*time.Millisecond, | |
| 75 | + | "the grandchild %d must not outlive the command", pid) | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestRunShellStopsOnSIGTERM(t *testing.T) { | |
| 81 | + | requireNoTerminal(t) | |
| 82 | + | pidFile := filepath.Join(t.TempDir(), "pid") | |
| 83 | + | line := "sleep 30 & echo $! > '" + pidFile + "'; wait" | |
| 84 | + | errc := make(chan error, 1) | |
| 85 | + | go func() { | |
| 86 | + | _, err := runShell(t.Context(), line, time.Minute) | |
| 87 | + | errc <- err | |
| 88 | + | }() | |
| 89 | + | pid := readPid(t, pidFile) | |
| 90 | + | // the signal is delivered to the test process itself: runShell catches it | |
| 91 | + | require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGTERM)) | |
| 92 | + | select { | |
| 93 | + | case err := <-errc: | |
| 94 | + | require.Error(t, err) | |
| 95 | + | assert.Equal(t, "interrupted", err.Error()) | |
| 96 | + | case <-time.After(10 * time.Second): | |
| 97 | + | t.Fatal("runShell did not stop on SIGTERM") | |
| 98 | + | } | |
| 99 | + | require.Eventually(t, func() bool { return processGone(pid) }, 5*time.Second, 20*time.Millisecond) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // fakeInfo is a file whose owner and mode are chosen by the test. | |
| 103 | + | type fakeInfo struct { | |
| 104 | + | fs.FileInfo | |
| 105 | + | mode fs.FileMode | |
| 106 | + | uid uint32 | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (f fakeInfo) Mode() fs.FileMode { return f.mode } | |
| 110 | + | func (f fakeInfo) Sys() any { return &syscall.Stat_t{Uid: f.uid} } | |
| 111 | + | ||
| 112 | + | func TestCheckPrivate(t *testing.T) { | |
| 113 | + | dir := t.TempDir() // owned by the test user, mode 0700 | |
| 114 | + | path := filepath.Join(dir, "config") | |
| 115 | + | require.NoError(t, os.WriteFile(path, nil, 0o600)) | |
| 116 | + | real, err := os.Stat(path) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | me := os.Geteuid() | |
| 119 | + | ||
| 120 | + | tests := []struct { | |
| 121 | + | name string | |
| 122 | + | mode fs.FileMode | |
| 123 | + | uid int // owner of the file | |
| 124 | + | euid int | |
| 125 | + | want string // error text, "" for accepted | |
| 126 | + | }{ | |
| 127 | + | {"own private file", 0o600, me, me, ""}, | |
| 128 | + | {"file of another user", 0o600, me + 1, me, "is owned by another user"}, | |
| 129 | + | {"file owned by root, not me", 0o600, 0, me + 1, "is owned by another user"}, | |
| 130 | + | {"group writable", 0o620, me, me, "is writable by others"}, | |
| 131 | + | {"world writable", 0o602, me, me, "is writable by others"}, | |
| 132 | + | {"group readable", 0o640, me, me, ""}, | |
| 133 | + | // the directory is the real one: owned by me | |
| 134 | + | {"directory of another user", 0o600, me + 1, me + 1, "config directory"}, | |
| 135 | + | } | |
| 136 | + | for _, tt := range tests { | |
| 137 | + | t.Run(tt.name, func(t *testing.T) { | |
| 138 | + | if tt.want == "config directory" && me == 0 { | |
| 139 | + | t.Skip("root owns every directory it creates") | |
| 140 | + | } | |
| 141 | + | err := checkPrivate(path, fakeInfo{FileInfo: real, mode: tt.mode, uid: uint32(tt.uid)}, tt.euid) | |
| 142 | + | if tt.want == "" { | |
| 143 | + | assert.NoError(t, err) | |
| 144 | + | return | |
| 145 | + | } | |
| 146 | + | require.Error(t, err) | |
| 147 | + | assert.Contains(t, err.Error(), tt.want) | |
| 148 | + | }) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | t.Run("directory owned by root is fine", func(t *testing.T) { | |
| 152 | + | // /tmp and friends are root's; use a root-owned directory when there is one | |
| 153 | + | fi, err := os.Stat("/") | |
| 154 | + | require.NoError(t, err) | |
| 155 | + | if st, ok := fi.Sys().(*syscall.Stat_t); !ok || st.Uid != 0 || fi.Mode().Perm()&0o022 != 0 { | |
| 156 | + | t.Skip("/ is not a private root directory here") | |
| 157 | + | } | |
| 158 | + | assert.NoError(t, checkPrivateDir("/", me+1)) | |
| 159 | + | }) | |
| 160 | + | } | |
| @@ -0,0 +1,160 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | //go:build !windows | |||
| 4 | + | ||||
| 5 | + | package app | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "errors" | |||
| 10 | + | "io/fs" | |||
| 11 | + | "os" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "strconv" | |||
| 14 | + | "strings" | |||
| 15 | + | "syscall" | |||
| 16 | + | "testing" | |||
| 17 | + | "time" | |||
| 18 | + | ||||
| 19 | + | "github.com/stretchr/testify/assert" | |||
| 20 | + | "github.com/stretchr/testify/require" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // processGone reports whether pid is gone (a zombie that nobody reaped counts). | |||
| 24 | + | func processGone(pid int) bool { | |||
| 25 | + | if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) { | |||
| 26 | + | return true | |||
| 27 | + | } | |||
| 28 | + | stat, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") | |||
| 29 | + | if err != nil { | |||
| 30 | + | return false | |||
| 31 | + | } | |||
| 32 | + | _, after, _ := strings.Cut(string(stat), ") ") | |||
| 33 | + | return strings.HasPrefix(after, "Z") | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // readPid waits for the pid file written by a grandchild. | |||
| 37 | + | func readPid(t *testing.T, path string) int { | |||
| 38 | + | t.Helper() | |||
| 39 | + | var pid int | |||
| 40 | + | require.Eventually(t, func() bool { | |||
| 41 | + | data, err := os.ReadFile(path) | |||
| 42 | + | if err != nil { | |||
| 43 | + | return false | |||
| 44 | + | } | |||
| 45 | + | pid, err = strconv.Atoi(strings.TrimSpace(string(data))) | |||
| 46 | + | return err == nil && pid > 0 | |||
| 47 | + | }, 5*time.Second, 10*time.Millisecond) | |||
| 48 | + | return pid | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestRunShellKillsTheWholeProcessTreeOnTimeoutAndCancel(t *testing.T) { | |||
| 52 | + | requireNoTerminal(t) | |||
| 53 | + | for name, tc := range map[string]struct { | |||
| 54 | + | ctx func(t *testing.T) context.Context | |||
| 55 | + | timeout time.Duration | |||
| 56 | + | want string | |||
| 57 | + | }{ | |||
| 58 | + | "timeout": {func(t *testing.T) context.Context { return t.Context() }, 700 * time.Millisecond, "timed out after 700ms"}, | |||
| 59 | + | "cancel": {func(t *testing.T) context.Context { | |||
| 60 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 61 | + | time.AfterFunc(700*time.Millisecond, cancel) | |||
| 62 | + | return ctx | |||
| 63 | + | }, time.Minute, "interrupted"}, | |||
| 64 | + | } { | |||
| 65 | + | t.Run(name, func(t *testing.T) { | |||
| 66 | + | pidFile := filepath.Join(t.TempDir(), "pid") | |||
| 67 | + | // sh starts a grandchild, records its pid and waits for it | |||
| 68 | + | line := "sleep 30 & echo $! > '" + pidFile + "'; wait" | |||
| 69 | + | _, err := runShell(tc.ctx(t), line, tc.timeout) | |||
| 70 | + | require.Error(t, err) | |||
| 71 | + | assert.Equal(t, tc.want, err.Error()) | |||
| 72 | + | ||||
| 73 | + | pid := readPid(t, pidFile) | |||
| 74 | + | require.Eventually(t, func() bool { return processGone(pid) }, 5*time.Second, 20*time.Millisecond, | |||
| 75 | + | "the grandchild %d must not outlive the command", pid) | |||
| 76 | + | }) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestRunShellStopsOnSIGTERM(t *testing.T) { | |||
| 81 | + | requireNoTerminal(t) | |||
| 82 | + | pidFile := filepath.Join(t.TempDir(), "pid") | |||
| 83 | + | line := "sleep 30 & echo $! > '" + pidFile + "'; wait" | |||
| 84 | + | errc := make(chan error, 1) | |||
| 85 | + | go func() { | |||
| 86 | + | _, err := runShell(t.Context(), line, time.Minute) | |||
| 87 | + | errc <- err | |||
| 88 | + | }() | |||
| 89 | + | pid := readPid(t, pidFile) | |||
| 90 | + | // the signal is delivered to the test process itself: runShell catches it | |||
| 91 | + | require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGTERM)) | |||
| 92 | + | select { | |||
| 93 | + | case err := <-errc: | |||
| 94 | + | require.Error(t, err) | |||
| 95 | + | assert.Equal(t, "interrupted", err.Error()) | |||
| 96 | + | case <-time.After(10 * time.Second): | |||
| 97 | + | t.Fatal("runShell did not stop on SIGTERM") | |||
| 98 | + | } | |||
| 99 | + | require.Eventually(t, func() bool { return processGone(pid) }, 5*time.Second, 20*time.Millisecond) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // fakeInfo is a file whose owner and mode are chosen by the test. | |||
| 103 | + | type fakeInfo struct { | |||
| 104 | + | fs.FileInfo | |||
| 105 | + | mode fs.FileMode | |||
| 106 | + | uid uint32 | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (f fakeInfo) Mode() fs.FileMode { return f.mode } | |||
| 110 | + | func (f fakeInfo) Sys() any { return &syscall.Stat_t{Uid: f.uid} } | |||
| 111 | + | ||||
| 112 | + | func TestCheckPrivate(t *testing.T) { | |||
| 113 | + | dir := t.TempDir() // owned by the test user, mode 0700 | |||
| 114 | + | path := filepath.Join(dir, "config") | |||
| 115 | + | require.NoError(t, os.WriteFile(path, nil, 0o600)) | |||
| 116 | + | real, err := os.Stat(path) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | me := os.Geteuid() | |||
| 119 | + | ||||
| 120 | + | tests := []struct { | |||
| 121 | + | name string | |||
| 122 | + | mode fs.FileMode | |||
| 123 | + | uid int // owner of the file | |||
| 124 | + | euid int | |||
| 125 | + | want string // error text, "" for accepted | |||
| 126 | + | }{ | |||
| 127 | + | {"own private file", 0o600, me, me, ""}, | |||
| 128 | + | {"file of another user", 0o600, me + 1, me, "is owned by another user"}, | |||
| 129 | + | {"file owned by root, not me", 0o600, 0, me + 1, "is owned by another user"}, | |||
| 130 | + | {"group writable", 0o620, me, me, "is writable by others"}, | |||
| 131 | + | {"world writable", 0o602, me, me, "is writable by others"}, | |||
| 132 | + | {"group readable", 0o640, me, me, ""}, | |||
| 133 | + | // the directory is the real one: owned by me | |||
| 134 | + | {"directory of another user", 0o600, me + 1, me + 1, "config directory"}, | |||
| 135 | + | } | |||
| 136 | + | for _, tt := range tests { | |||
| 137 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 138 | + | if tt.want == "config directory" && me == 0 { | |||
| 139 | + | t.Skip("root owns every directory it creates") | |||
| 140 | + | } | |||
| 141 | + | err := checkPrivate(path, fakeInfo{FileInfo: real, mode: tt.mode, uid: uint32(tt.uid)}, tt.euid) | |||
| 142 | + | if tt.want == "" { | |||
| 143 | + | assert.NoError(t, err) | |||
| 144 | + | return | |||
| 145 | + | } | |||
| 146 | + | require.Error(t, err) | |||
| 147 | + | assert.Contains(t, err.Error(), tt.want) | |||
| 148 | + | }) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | t.Run("directory owned by root is fine", func(t *testing.T) { | |||
| 152 | + | // /tmp and friends are root's; use a root-owned directory when there is one | |||
| 153 | + | fi, err := os.Stat("/") | |||
| 154 | + | require.NoError(t, err) | |||
| 155 | + | if st, ok := fi.Sys().(*syscall.Stat_t); !ok || st.Uid != 0 || fi.Mode().Perm()&0o022 != 0 { | |||
| 156 | + | t.Skip("/ is not a private root directory here") | |||
| 157 | + | } | |||
| 158 | + | assert.NoError(t, checkPrivateDir("/", me+1)) | |||
| 159 | + | }) | |||
| 160 | + | } | |||
A internal/app/cli_shell_windows.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | //go:build windows | |
| 4 | + | ||
| 5 | + | package app | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "io/fs" | |
| 10 | + | "os" | |
| 11 | + | "os/exec" | |
| 12 | + | "syscall" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // shellCommand runs line with `cmd /C`. The command line is passed as is: | |
| 16 | + | // Go's argument quoting would mangle quotes cmd.exe means to read itself. | |
| 17 | + | // | |
| 18 | + | // The command is killed with exec.CommandContext on a timeout or a signal: | |
| 19 | + | // only cmd.exe itself, not what it started (there is no process group kill | |
| 20 | + | // here); foreground is ignored. | |
| 21 | + | func shellCommand(ctx context.Context, line string, _ bool) *exec.Cmd { | |
| 22 | + | c := exec.CommandContext(ctx, "cmd") | |
| 23 | + | c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line} | |
| 24 | + | return c | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // openConfig opens the config file. | |
| 28 | + | func openConfig(path string) (*os.File, error) { return os.Open(path) } | |
| 29 | + | ||
| 30 | + | // checkPrivate is a no-op on Windows: it has no Unix mode bits or owner ids to | |
| 31 | + | // check, so token.cmd is run from a config file whoever can write it. The | |
| 32 | + | // per-user config directory (%AppData%) is private to the user by default; | |
| 33 | + | // keep ERMITE_CONFIG out of shared folders. | |
| 34 | + | func checkPrivate(string, fs.FileInfo, int) error { return nil } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | //go:build windows | |||
| 4 | + | ||||
| 5 | + | package app | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "io/fs" | |||
| 10 | + | "os" | |||
| 11 | + | "os/exec" | |||
| 12 | + | "syscall" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // shellCommand runs line with `cmd /C`. The command line is passed as is: | |||
| 16 | + | // Go's argument quoting would mangle quotes cmd.exe means to read itself. | |||
| 17 | + | // | |||
| 18 | + | // The command is killed with exec.CommandContext on a timeout or a signal: | |||
| 19 | + | // only cmd.exe itself, not what it started (there is no process group kill | |||
| 20 | + | // here); foreground is ignored. | |||
| 21 | + | func shellCommand(ctx context.Context, line string, _ bool) *exec.Cmd { | |||
| 22 | + | c := exec.CommandContext(ctx, "cmd") | |||
| 23 | + | c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line} | |||
| 24 | + | return c | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // openConfig opens the config file. | |||
| 28 | + | func openConfig(path string) (*os.File, error) { return os.Open(path) } | |||
| 29 | + | ||||
| 30 | + | // checkPrivate is a no-op on Windows: it has no Unix mode bits or owner ids to | |||
| 31 | + | // check, so token.cmd is run from a config file whoever can write it. The | |||
| 32 | + | // per-user config directory (%AppData%) is private to the user by default; | |||
| 33 | + | // keep ERMITE_CONFIG out of shared folders. | |||
| 34 | + | func checkPrivate(string, fs.FileInfo, int) error { return nil } | |||
M internal/app/cli_signing.go +1 -1
| @@ -55,7 +55,7 @@ func newSigningKeyShowCmd(env *cliEnv) *cobra.Command { | |||
| 55 | 55 | cmd.MarkFlagsMutuallyExclusive("json", "public-key") | |
| 56 | 56 | cmd.MarkFlagsMutuallyExclusive("json", "allowed-signers") | |
| 57 | 57 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 58 | - | c, err := env.client(cmd, true) | |
| 58 | + | c, err := env.client(cmd) | |
| 59 | 59 | if err != nil { | |
| 60 | 60 | return err | |
| 61 | 61 | } | |
| @@ -55,7 +55,7 @@ func newSigningKeyShowCmd(env *cliEnv) *cobra.Command { | |||||
| 55 | cmd.MarkFlagsMutuallyExclusive("json", "public-key") | 55 | cmd.MarkFlagsMutuallyExclusive("json", "public-key") | ||
| 56 | cmd.MarkFlagsMutuallyExclusive("json", "allowed-signers") | 56 | cmd.MarkFlagsMutuallyExclusive("json", "allowed-signers") | ||
| 57 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | 57 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | ||
| 58 | - | c, err := env.client(cmd, true) | 58 | + | c, err := env.client(cmd) |
| 59 | if err != nil { | 59 | if err != nil { | ||
| 60 | return err | 60 | return err | ||
| 61 | } | 61 | } | ||
M internal/app/cli_token.go +3 -3
| @@ -108,7 +108,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 108 | 108 | if err != nil { | |
| 109 | 109 | return err | |
| 110 | 110 | } | |
| 111 | - | c, err := env.client(cmd, true) | |
| 111 | + | c, err := env.client(cmd) | |
| 112 | 112 | if err != nil { | |
| 113 | 113 | return err | |
| 114 | 114 | } | |
| @@ -186,7 +186,7 @@ func newTokenListCmd(env *cliEnv) *cobra.Command { | |||
| 186 | 186 | } | |
| 187 | 187 | asJSON := addJSONFlag(cmd) | |
| 188 | 188 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 189 | - | c, err := env.client(cmd, true) | |
| 189 | + | c, err := env.client(cmd) | |
| 190 | 190 | if err != nil { | |
| 191 | 191 | return err | |
| 192 | 192 | } | |
| @@ -240,7 +240,7 @@ func newTokenRevokeCmd(env *cliEnv) *cobra.Command { | |||
| 240 | 240 | return ids, cobra.ShellCompDirectiveNoFileComp | |
| 241 | 241 | }, | |
| 242 | 242 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 243 | - | c, err := env.client(cmd, true) | |
| 243 | + | c, err := env.client(cmd) | |
| 244 | 244 | if err != nil { | |
| 245 | 245 | return err | |
| 246 | 246 | } | |
| @@ -108,7 +108,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 108 | if err != nil { | 108 | if err != nil { | ||
| 109 | return err | 109 | return err | ||
| 110 | } | 110 | } | ||
| 111 | - | c, err := env.client(cmd, true) | 111 | + | c, err := env.client(cmd) |
| 112 | if err != nil { | 112 | if err != nil { | ||
| 113 | return err | 113 | return err | ||
| 114 | } | 114 | } | ||
| @@ -186,7 +186,7 @@ func newTokenListCmd(env *cliEnv) *cobra.Command { | |||||
| 186 | } | 186 | } | ||
| 187 | asJSON := addJSONFlag(cmd) | 187 | asJSON := addJSONFlag(cmd) | ||
| 188 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | 188 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | ||
| 189 | - | c, err := env.client(cmd, true) | 189 | + | c, err := env.client(cmd) |
| 190 | if err != nil { | 190 | if err != nil { | ||
| 191 | return err | 191 | return err | ||
| 192 | } | 192 | } | ||
| @@ -240,7 +240,7 @@ func newTokenRevokeCmd(env *cliEnv) *cobra.Command { | |||||
| 240 | return ids, cobra.ShellCompDirectiveNoFileComp | 240 | return ids, cobra.ShellCompDirectiveNoFileComp | ||
| 241 | }, | 241 | }, | ||
| 242 | RunE: func(cmd *cobra.Command, args []string) error { | 242 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 243 | - | c, err := env.client(cmd, true) | 243 | + | c, err := env.client(cmd) |
| 244 | if err != nil { | 244 | if err != nil { | ||
| 245 | return err | 245 | return err | ||
| 246 | } | 246 | } | ||
M internal/app/cli_version.go +16 -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,13 @@ 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 | + | // The client version does not depend on the file: say what is wrong | |
| 54 | + | // and go on as if no URL were configured. | |
| 55 | + | warnf(cmd, "%s", err) | |
| 56 | + | url = "" | |
| 57 | + | } | |
| 50 | 58 | if url == "" { | |
| 51 | 59 | if *asJSON { | |
| 52 | 60 | return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}}) | |
| @@ -55,7 +63,8 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||
| 55 | 63 | return err | |
| 56 | 64 | } | |
| 57 | 65 | ||
| 58 | - | c, err := env.client(cmd, true) | |
| 66 | + | // GetVersion is anonymous: token.cmd is not run for it. | |
| 67 | + | c, err := env.clientNoTokenCmd(cmd, true) | |
| 59 | 68 | if err != nil { | |
| 60 | 69 | return err | |
| 61 | 70 | } | |
| @@ -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,13 @@ 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 | + | // The client version does not depend on the file: say what is wrong | |||
| 54 | + | // and go on as if no URL were configured. | |||
| 55 | + | warnf(cmd, "%s", err) | |||
| 56 | + | url = "" | |||
| 57 | + | } | |||
| 50 | if url == "" { | 58 | if url == "" { | ||
| 51 | if *asJSON { | 59 | if *asJSON { | ||
| 52 | return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}}) | 60 | return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}}) | ||
| @@ -55,7 +63,8 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||||
| 55 | return err | 63 | return err | ||
| 56 | } | 64 | } | ||
| 57 | 65 | ||||
| 58 | - | c, err := env.client(cmd, true) | 66 | + | // GetVersion is anonymous: token.cmd is not run for it. |
| 67 | + | c, err := env.clientNoTokenCmd(cmd, true) | |||
| 59 | if err != nil { | 68 | if err != nil { | ||
| 60 | return err | 69 | return err | ||
| 61 | } | 70 | } | ||
M internal/app/cli_watch.go +1 -1
| @@ -163,7 +163,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command { | |||
| 163 | 163 | if len(args) == 1 { | |
| 164 | 164 | topics = []string{"repo:" + args[0]} | |
| 165 | 165 | } | |
| 166 | - | c, err := env.client(cmd, true) | |
| 166 | + | c, err := env.client(cmd) | |
| 167 | 167 | if err != nil { | |
| 168 | 168 | return err | |
| 169 | 169 | } | |
| @@ -163,7 +163,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command { | |||||
| 163 | if len(args) == 1 { | 163 | if len(args) == 1 { | ||
| 164 | topics = []string{"repo:" + args[0]} | 164 | topics = []string{"repo:" + args[0]} | ||
| 165 | } | 165 | } | ||
| 166 | - | c, err := env.client(cmd, true) | 166 | + | c, err := env.client(cmd) |
| 167 | if err != nil { | 167 | if err != nil { | ||
| 168 | return err | 168 | return err | ||
| 169 | } | 169 | } | ||
M internal/app/cli_web.go +2 -2
| @@ -43,7 +43,7 @@ func newWebLoginCmd(env *cliEnv) *cobra.Command { | |||
| 43 | 43 | } | |
| 44 | 44 | asJSON := addJSONFlag(cmd) | |
| 45 | 45 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 46 | - | c, err := env.client(cmd, true) | |
| 46 | + | c, err := env.client(cmd) | |
| 47 | 47 | if err != nil { | |
| 48 | 48 | return err | |
| 49 | 49 | } | |
| @@ -75,7 +75,7 @@ func newWebLogoutCmd(env *cliEnv) *cobra.Command { | |||
| 75 | 75 | if !all { | |
| 76 | 76 | return errors.New("--all is required: this signs every browser out (one browser signs out from its page)") | |
| 77 | 77 | } | |
| 78 | - | c, err := env.client(cmd, true) | |
| 78 | + | c, err := env.client(cmd) | |
| 79 | 79 | if err != nil { | |
| 80 | 80 | return err | |
| 81 | 81 | } | |
| @@ -43,7 +43,7 @@ func newWebLoginCmd(env *cliEnv) *cobra.Command { | |||||
| 43 | } | 43 | } | ||
| 44 | asJSON := addJSONFlag(cmd) | 44 | asJSON := addJSONFlag(cmd) | ||
| 45 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | 45 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | ||
| 46 | - | c, err := env.client(cmd, true) | 46 | + | c, err := env.client(cmd) |
| 47 | if err != nil { | 47 | if err != nil { | ||
| 48 | return err | 48 | return err | ||
| 49 | } | 49 | } | ||
| @@ -75,7 +75,7 @@ func newWebLogoutCmd(env *cliEnv) *cobra.Command { | |||||
| 75 | if !all { | 75 | if !all { | ||
| 76 | return errors.New("--all is required: this signs every browser out (one browser signs out from its page)") | 76 | return errors.New("--all is required: this signs every browser out (one browser signs out from its page)") | ||
| 77 | } | 77 | } | ||
| 78 | - | c, err := env.client(cmd, true) | 78 | + | c, err := env.client(cmd) |
| 79 | if err != nil { | 79 | if err != nil { | ||
| 80 | return err | 80 | return err | ||
| 81 | } | 81 | } | ||
M internal/app/root.go +9 -3
| @@ -18,15 +18,21 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||
| 18 | 18 | Short: "A single-user Git forge", | |
| 19 | 19 | Long: "ermite is a single-user Git forge: `ermite serve` runs the server, the other commands\n" + | |
| 20 | 20 | "drive a running server over its API.\n\n" + | |
| 21 | - | "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" + | |
| 22 | - | "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.", | |
| 21 | + | "Remote commands need the server address (http:// or https://) and, except for public reads,\n" + | |
| 22 | + | "a bearer token (see `ermite token create`). Each comes from, in order:\n\n" + | |
| 23 | + | " ERMITE_URL / ERMITE_TOKEN the environment\n" + | |
| 24 | + | " url / token.cmd in ~/.config/ermite/config the config file (ERMITE_CONFIG selects another;\n `ermite config show` prints its path)\n\n" + | |
| 25 | + | "token.cmd is a command line that prints the token, run only when a command needs it, so the\n" + | |
| 26 | + | "token stays in your secret manager (pass, gopass, op, bw, secret-tool…), never in the file.\n" + | |
| 27 | + | "See `ermite config --help`.", | |
| 23 | 28 | SilenceUsage: true, | |
| 24 | 29 | SilenceErrors: true, // cmd/ermite prints the error once | |
| 25 | 30 | } | |
| 26 | 31 | root.AddCommand( | |
| 27 | 32 | newServeCmd(), newHookCmd(), | |
| 28 | 33 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | |
| 29 | - | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newRunCmd(env), newRunnerCmd(env), | |
| 34 | + | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), | |
| 35 | + | newRunCmd(env), newRunnerCmd(env), | |
| 30 | 36 | ) | |
| 31 | 37 | return root | |
| 32 | 38 | } | |
| @@ -18,15 +18,21 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||||
| 18 | Short: "A single-user Git forge", | 18 | Short: "A single-user Git forge", | ||
| 19 | Long: "ermite is a single-user Git forge: `ermite serve` runs the server, the other commands\n" + | 19 | Long: "ermite is a single-user Git forge: `ermite serve` runs the server, the other commands\n" + | ||
| 20 | "drive a running server over its API.\n\n" + | 20 | "drive a running server over its API.\n\n" + | ||
| 21 | - | "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" + | 21 | + | "Remote commands need the server address (http:// or https://) and, except for public reads,\n" + |
| 22 | - | "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.", | 22 | + | "a bearer token (see `ermite token create`). Each comes from, in order:\n\n" + |
| 23 | + | " ERMITE_URL / ERMITE_TOKEN the environment\n" + | |||
| 24 | + | " url / token.cmd in ~/.config/ermite/config the config file (ERMITE_CONFIG selects another;\n `ermite config show` prints its path)\n\n" + | |||
| 25 | + | "token.cmd is a command line that prints the token, run only when a command needs it, so the\n" + | |||
| 26 | + | "token stays in your secret manager (pass, gopass, op, bw, secret-tool…), never in the file.\n" + | |||
| 27 | + | "See `ermite config --help`.", | |||
| 23 | SilenceUsage: true, | 28 | SilenceUsage: true, | ||
| 24 | SilenceErrors: true, // cmd/ermite prints the error once | 29 | SilenceErrors: true, // cmd/ermite prints the error once | ||
| 25 | } | 30 | } | ||
| 26 | root.AddCommand( | 31 | root.AddCommand( | ||
| 27 | newServeCmd(), newHookCmd(), | 32 | newServeCmd(), newHookCmd(), | ||
| 28 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | 33 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | ||
| 29 | - | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newRunCmd(env), newRunnerCmd(env), | 34 | + | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), |
| 35 | + | newRunCmd(env), newRunnerCmd(env), | |||
| 30 | ) | 36 | ) | ||
| 31 | return root | 37 | return root | ||
| 32 | } | 38 | } | ||
M skills/ermite/CLI.md +3 -2
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # ermite CLI reference | |
| 2 | 2 | ||
| 3 | - | Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads). Run `ermite <command> --help` for the authoritative text of a command and its JSON fields. | |
| 3 | + | Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, the platform config dir on macOS and Windows, or the file named by `ERMITE_CONFIG`; `ermite config show` prints the path), the environment first (`token.cmd` is only run for the server of the file: with a different `ERMITE_URL` set `ERMITE_TOKEN` too). Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields. | |
| 4 | 4 | ||
| 5 | 5 | Errors are one line on standard error (`ermite: ...`) with exit status 1. `--json` prints stable field names. `ermite completion bash|zsh|fish` completes repository names from the server. | |
| 6 | 6 | ||
| @@ -77,7 +77,8 @@ Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, ` | |||
| 77 | 77 | | Command | Notes | | |
| 78 | 78 | |---|---| | |
| 79 | 79 | | `serve` | configured by `ERMITE_*` environment variables only (see the project README) | | |
| 80 | - | | `version [--json]` | client version; with `ERMITE_URL`, the server version and features | | |
| 80 | + | | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features | | |
| 81 | + | | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it | | |
| 81 | 82 | | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` | | |
| 82 | 83 | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope and no repository list; the link is a secret | | |
| 83 | 84 | | `web logout --all` | signs every browser out | | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | # ermite CLI reference | 1 | # ermite CLI reference | ||
| 2 | 2 | ||||
| 3 | - | Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads). Run `ermite <command> --help` for the authoritative text of a command and its JSON fields. | 3 | + | Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, the platform config dir on macOS and Windows, or the file named by `ERMITE_CONFIG`; `ermite config show` prints the path), the environment first (`token.cmd` is only run for the server of the file: with a different `ERMITE_URL` set `ERMITE_TOKEN` too). Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields. |
| 4 | 4 | ||||
| 5 | Errors are one line on standard error (`ermite: ...`) with exit status 1. `--json` prints stable field names. `ermite completion bash|zsh|fish` completes repository names from the server. | 5 | Errors are one line on standard error (`ermite: ...`) with exit status 1. `--json` prints stable field names. `ermite completion bash|zsh|fish` completes repository names from the server. | ||
| 6 | 6 | ||||
| @@ -77,7 +77,8 @@ Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, ` | |||||
| 77 | | Command | Notes | | 77 | | Command | Notes | | ||
| 78 | |---|---| | 78 | |---|---| | ||
| 79 | | `serve` | configured by `ERMITE_*` environment variables only (see the project README) | | 79 | | `serve` | configured by `ERMITE_*` environment variables only (see the project README) | | ||
| 80 | - | | `version [--json]` | client version; with `ERMITE_URL`, the server version and features | | 80 | + | | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features | |
| 81 | + | | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it | | |||
| 81 | | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` | | 82 | | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` | | ||
| 82 | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope and no repository list; the link is a secret | | 83 | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope and no repository list; the link is a secret | | ||
| 83 | | `web logout --all` | signs every browser out | | 84 | | `web logout --all` | signs every browser out | | ||
M skills/ermite/SKILL.md +2 -0
| @@ -16,6 +16,8 @@ ermite version # client and server versions, warns about | |||
| 16 | 16 | ermite repo list | |
| 17 | 17 | ``` | |
| 18 | 18 | ||
| 19 | + | Instead of exporting them, the owner can keep `url` and a `token.cmd` (a command that prints the token, such as `pass show ermite/token`) in `~/.config/ermite/config`; as an agent, set `ERMITE_TOKEN` or point `ERMITE_CONFIG` at your own file. | |
| 20 | + | ||
| 19 | 21 | Never print a token, commit it, log it or paste it into a chat. Prefer a Git credential helper to a token in a URL. Use `https://`: a token sent over plain `http://` to a remote host triggers a warning. | |
| 20 | 22 | ||
| 21 | 23 | ## Git | |
| @@ -16,6 +16,8 @@ ermite version # client and server versions, warns about | |||||
| 16 | ermite repo list | 16 | ermite repo list | ||
| 17 | ``` | 17 | ``` | ||
| 18 | 18 | ||||
| 19 | + | Instead of exporting them, the owner can keep `url` and a `token.cmd` (a command that prints the token, such as `pass show ermite/token`) in `~/.config/ermite/config`; as an agent, set `ERMITE_TOKEN` or point `ERMITE_CONFIG` at your own file. | |||
| 20 | + | ||||
| 19 | Never print a token, commit it, log it or paste it into a chat. Prefer a Git credential helper to a token in a URL. Use `https://`: a token sent over plain `http://` to a remote host triggers a warning. | 21 | Never print a token, commit it, log it or paste it into a chat. Prefer a Git credential helper to a token in a URL. Use `https://`: a token sent over plain `http://` to a remote host triggers a warning. | ||
| 20 | 22 | ||||
| 21 | ## Git | 23 | ## Git | ||