detached 7fad59b

fix(cli): kill the whole token.cmd process tree and simplify the client setup

Without a terminal, token.cmd runs in its own process group and a timeout, an interrupt or a SIGTERM kills the group. With a terminal it keeps the foreground group so prompts work. Terminal detection uses golang.org/x/term. runTokenCmd defaults to the shell runner, client() loses its warn parameter and the token is resolved in buildConfig. An invalid URL error names ERMITE_URL or the config file. checkPrivate takes the euid so the ownership checks are tested without root.
commit
7fad59b26b870397c841b7147a1d00a2626f1992
author
brice · · 2026-10-12 16:17 UTC
parent
ea28447
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +369 -174
M go.mod +1 -1
@@ -26,6 +26,7 @@ require (
2626 github.com/yuin/goldmark v1.8.6
2727 golang.org/x/crypto v0.57.0
2828 golang.org/x/sys v0.49.0
29+ golang.org/x/term v0.46.0
2930 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4
3031 google.golang.org/protobuf v1.36.12
3132 )
@@ -152,7 +153,6 @@ require (
152153 golang.org/x/mod v0.41.0 // indirect
153154 golang.org/x/net v0.59.0 // indirect
154155 golang.org/x/sync v0.23.0 // indirect
155- golang.org/x/term v0.46.0 // indirect
156156 golang.org/x/text v0.42.0 // indirect
157157 golang.org/x/tools v0.51.0 // indirect
158158 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect
@@ -26,6 +26,7 @@ require (
26 github.com/yuin/goldmark v1.8.626 github.com/yuin/goldmark v1.8.6
27 golang.org/x/crypto v0.57.027 golang.org/x/crypto v0.57.0
28 golang.org/x/sys v0.49.028 golang.org/x/sys v0.49.0
29+ golang.org/x/term v0.46.0
29 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a430 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4
30 google.golang.org/protobuf v1.36.1231 google.golang.org/protobuf v1.36.12
31 )32 )
@@ -152,7 +153,6 @@ require (
152 golang.org/x/mod v0.41.0 // indirect153 golang.org/x/mod v0.41.0 // indirect
153 golang.org/x/net v0.59.0 // indirect154 golang.org/x/net v0.59.0 // indirect
154 golang.org/x/sync v0.23.0 // indirect155 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 // indirect156 golang.org/x/text v0.42.0 // indirect
157 golang.org/x/tools v0.51.0 // indirect157 golang.org/x/tools v0.51.0 // indirect
158 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect158 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect
M internal/adapters/client/config.go +1 -1
@@ -58,7 +58,7 @@ func NewConfig(rawURL, token string) (Config, error) {
5858 }
5959 base, err := ParseURL(raw)
6060 if err != nil {
61- return Config{}, fmt.Errorf("invalid server URL (%s, or `ermite config set url <url>`): %w", EnvURL, err)
61+ return Config{}, fmt.Errorf("invalid server URL: %w", err)
6262 }
6363 return Config{URL: base.String(), Token: strings.TrimSpace(token)}, nil
6464 }
@@ -58,7 +58,7 @@ func NewConfig(rawURL, token string) (Config, error) {
58 }58 }
59 base, err := ParseURL(raw)59 base, err := ParseURL(raw)
60 if err != nil {60 if err != nil {
61- return Config{}, fmt.Errorf("invalid server URL (%s, or `ermite config set url <url>`): %w", EnvURL, err)61+ return Config{}, fmt.Errorf("invalid server URL: %w", err)
62 }62 }
63 return Config{URL: base.String(), Token: strings.TrimSpace(token)}, nil63 return Config{URL: base.String(), Token: strings.TrimSpace(token)}, nil
64 }64 }
M internal/adapters/client/config_test.go +7 -2
@@ -18,6 +18,13 @@ func TestNewConfig(t *testing.T) {
1818 assert.Equal(t, "ermite_abc", cfg.Token)
1919 }
2020
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+
2128 func TestNewConfigRejects(t *testing.T) {
2229 tests := []struct {
2330 name, url, want string
@@ -36,8 +43,6 @@ func TestNewConfigRejects(t *testing.T) {
3643 _, err := client.NewConfig(tt.url, "")
3744 require.Error(t, err)
3845 assert.Contains(t, err.Error(), tt.want)
39- assert.Contains(t, err.Error(), "ermite config set url", "the message tells how to fix it")
40- assert.Contains(t, err.Error(), "ERMITE_URL")
4146 assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed")
4247 })
4348 }
@@ -18,6 +18,13 @@ func TestNewConfig(t *testing.T) {
18 assert.Equal(t, "ermite_abc", cfg.Token)18 assert.Equal(t, "ermite_abc", cfg.Token)
19 }19 }
20 20
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+
21 func TestNewConfigRejects(t *testing.T) {28 func TestNewConfigRejects(t *testing.T) {
22 tests := []struct {29 tests := []struct {
23 name, url, want string30 name, url, want string
@@ -36,8 +43,6 @@ func TestNewConfigRejects(t *testing.T) {
36 _, err := client.NewConfig(tt.url, "")43 _, err := client.NewConfig(tt.url, "")
37 require.Error(t, err)44 require.Error(t, err)
38 assert.Contains(t, err.Error(), tt.want)45 assert.Contains(t, err.Error(), tt.want)
39- assert.Contains(t, err.Error(), "ermite config set url", "the message tells how to fix it")
40- assert.Contains(t, err.Error(), "ERMITE_URL")
41 assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed")46 assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed")
42 })47 })
43 }48 }
M internal/app/cli.go +22 -24
@@ -36,10 +36,8 @@ type cliEnv struct {
3636 // (ERMITE_CONFIG, else ~/.config/ermite/config).
3737 configPath func() (string, error)
3838 // runTokenCmd runs the token.cmd command line and returns its raw standard
39- // output; nil means running it with the system shell. Tests replace it.
39+ // output. Tests replace it.
4040 runTokenCmd func(ctx context.Context, cmd string) (string, error)
41- // tokenTimeout bounds the default runTokenCmd; zero means 60 s.
42- tokenTimeout time.Duration
4341
4442 // The config file is read at most once and token.cmd run at most once per
4543 // process, and only when a command needs them (see cli_configfile.go).
@@ -54,7 +52,7 @@ type cliEnv struct {
5452 }
5553
5654 func defaultCLIEnv() *cliEnv {
57- return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion()}
55+ return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion(), runTokenCmd: runTokenShell}
5856 }
5957
6058 // userAgent identifies this CLI to the server.
@@ -75,43 +73,43 @@ func (e *cliEnv) ctx(cmd *cobra.Command) context.Context {
7573 return ctx
7674 }
7775
78-// config builds the client configuration: ERMITE_URL and ERMITE_TOKEN win,
79-// then the config file (url, and the output of token.cmd for the token).
80-func (e *cliEnv) config(cmd *cobra.Command, warn bool) (client.Config, error) {
81- return e.buildConfig(cmd, warn, true)
82-}
83-
84-// buildConfig is config; runTokenCmd false leaves token.cmd alone (ERMITE_TOKEN
85-// is still used), for the calls that need no authentication.
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.
8680 func (e *cliEnv) buildConfig(cmd *cobra.Command, warn, runTokenCmd bool) (client.Config, error) {
8781 rawURL, err := e.resolveURL(cmd, warn)
8882 if err != nil {
8983 return client.Config{}, err
9084 }
91- // A missing or invalid URL is reported before token.cmd gets to prompt.
92- if _, err := client.NewConfig(rawURL, ""); err != nil {
93- return client.Config{}, err
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+ }
9489 }
95- token, err := e.resolveToken(cmd, warn, runTokenCmd)
90+ // A missing or invalid URL is reported before token.cmd gets to prompt.
91+ cfg, err := client.NewConfig(rawURL, "")
9692 if err != nil {
9793 return client.Config{}, err
9894 }
99- cfg, err := client.NewConfig(rawURL, token)
100- if err != nil {
101- return cfg, err
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+ }
102100 }
103101 cfg.UserAgent = e.userAgent()
104102 return cfg, nil
105103 }
106104
107-// client builds the API client. Commands warn when the token would travel in
108-// the clear; completion stays silent.
109-func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) {
110- cfg, err := e.config(cmd, warn)
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)
111109 if err != nil {
112110 return nil, err
113111 }
114- return newClient(cmd, cfg, warn)
112+ return newClient(cmd, cfg, true)
115113 }
116114
117115 // clientNoTokenCmd is client without running token.cmd (ERMITE_TOKEN is still
@@ -36,10 +36,8 @@ type cliEnv struct {
36 // (ERMITE_CONFIG, else ~/.config/ermite/config).36 // (ERMITE_CONFIG, else ~/.config/ermite/config).
37 configPath func() (string, error)37 configPath func() (string, error)
38 // runTokenCmd runs the token.cmd command line and returns its raw standard38 // runTokenCmd runs the token.cmd command line and returns its raw standard
39- // output; nil means running it with the system shell. Tests replace it.39+ // output. Tests replace it.
40 runTokenCmd func(ctx context.Context, cmd string) (string, error)40 runTokenCmd func(ctx context.Context, cmd string) (string, error)
41- // tokenTimeout bounds the default runTokenCmd; zero means 60 s.
42- tokenTimeout time.Duration
43 41
44 // The config file is read at most once and token.cmd run at most once per42 // The config file is read at most once and token.cmd run at most once per
45 // process, and only when a command needs them (see cli_configfile.go).43 // process, and only when a command needs them (see cli_configfile.go).
@@ -54,7 +52,7 @@ type cliEnv struct {
54 }52 }
55 53
56 func defaultCLIEnv() *cliEnv {54 func defaultCLIEnv() *cliEnv {
57- return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion()}55+ return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion(), runTokenCmd: runTokenShell}
58 }56 }
59 57
60 // userAgent identifies this CLI to the server.58 // userAgent identifies this CLI to the server.
@@ -75,43 +73,43 @@ func (e *cliEnv) ctx(cmd *cobra.Command) context.Context {
75 return ctx73 return ctx
76 }74 }
77 75
78-// config builds the client configuration: ERMITE_URL and ERMITE_TOKEN win,76+// buildConfig builds the client configuration: ERMITE_URL and ERMITE_TOKEN
79-// then the config file (url, and the output of token.cmd for the token).77+// win, then the config file (url, and the output of token.cmd for the token).
80-func (e *cliEnv) config(cmd *cobra.Command, warn bool) (client.Config, error) {78+// runTokenCmd false leaves token.cmd alone (ERMITE_TOKEN is still used), for
81- return e.buildConfig(cmd, warn, true)79+// the calls that need no authentication.
82-}
83-
84-// buildConfig is config; runTokenCmd false leaves token.cmd alone (ERMITE_TOKEN
85-// is still used), for the calls that need no authentication.
86 func (e *cliEnv) buildConfig(cmd *cobra.Command, warn, runTokenCmd bool) (client.Config, error) {80 func (e *cliEnv) buildConfig(cmd *cobra.Command, warn, runTokenCmd bool) (client.Config, error) {
87 rawURL, err := e.resolveURL(cmd, warn)81 rawURL, err := e.resolveURL(cmd, warn)
88 if err != nil {82 if err != nil {
89 return client.Config{}, err83 return client.Config{}, err
90 }84 }
91- // A missing or invalid URL is reported before token.cmd gets to prompt.85+ if strings.TrimSpace(rawURL) != "" {
92- if _, err := client.NewConfig(rawURL, ""); err != nil {86+ if _, err := client.ParseURL(strings.TrimSpace(rawURL)); err != nil {
93- return client.Config{}, err87+ return client.Config{}, fmt.Errorf("%s: %w", e.urlSource(), err)
88+ }
94 }89 }
95- token, err := e.resolveToken(cmd, warn, runTokenCmd)90+ // A missing or invalid URL is reported before token.cmd gets to prompt.
91+ cfg, err := client.NewConfig(rawURL, "")
96 if err != nil {92 if err != nil {
97 return client.Config{}, err93 return client.Config{}, err
98 }94 }
99- cfg, err := client.NewConfig(rawURL, token)95+ cfg.Token = strings.TrimSpace(e.getenv(client.EnvToken))
100- if err != nil {96+ if cfg.Token == "" && runTokenCmd {
101- return cfg, err97+ if cfg.Token, err = e.tokenFromCmd(cmd, warn); err != nil {
98+ return client.Config{}, err
99+ }
102 }100 }
103 cfg.UserAgent = e.userAgent()101 cfg.UserAgent = e.userAgent()
104 return cfg, nil102 return cfg, nil
105 }103 }
106 104
107-// client builds the API client. Commands warn when the token would travel in105+// client builds the API client. It warns when the token would travel in the
108-// the clear; completion stays silent.106+// clear.
109-func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) {107+func (e *cliEnv) client(cmd *cobra.Command) (*client.Client, error) {
110- cfg, err := e.config(cmd, warn)108+ cfg, err := e.buildConfig(cmd, true, true)
111 if err != nil {109 if err != nil {
112 return nil, err110 return nil, err
113 }111 }
114- return newClient(cmd, cfg, warn)112+ return newClient(cmd, cfg, true)
115 }113 }
116 114
117 // clientNoTokenCmd is client without running token.cmd (ERMITE_TOKEN is still115 // clientNoTokenCmd is client without running token.cmd (ERMITE_TOKEN is still
M internal/app/cli_configfile.go +36 -49
@@ -11,11 +11,14 @@ import (
1111 "io/fs"
1212 "os"
1313 "os/exec"
14+ "os/signal"
1415 "strings"
16+ "syscall"
1517 "time"
1618
1719 "github.com/bnema/kvconf"
1820 "github.com/spf13/cobra"
21+ "golang.org/x/term"
1922
2023 "bnema.dev/ermite/internal/adapters/client"
2124 )
@@ -107,7 +110,7 @@ func loadConfigFile(path string) (f *cliFile, warnings []kvconf.Warning, trust,
107110 if !info.Mode().IsRegular() {
108111 return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrNotRegular)
109112 }
110- trust = checkPrivate(path, info)
113+ trust = checkPrivate(path, info, os.Geteuid())
111114 data, err := io.ReadAll(io.LimitReader(h, kvconf.DefaultMaxBytes+1))
112115 if err != nil {
113116 return nil, nil, nil, err
@@ -123,6 +126,14 @@ func loadConfigFile(path string) (f *cliFile, warnings []kvconf.Warning, trust,
123126 return f, warnings, trust, nil
124127 }
125128
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+
126137 // resolveURL is ERMITE_URL if set, else the url of the config file, else "".
127138 func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) {
128139 if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" {
@@ -135,18 +146,6 @@ func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) {
135146 return strings.TrimSpace(f.URL), nil
136147 }
137148
138-// resolveToken is ERMITE_TOKEN if set, else (when runCmd is set) the output
139-// of token.cmd, else "" (anonymous). token.cmd runs at most once per process.
140-func (e *cliEnv) resolveToken(cmd *cobra.Command, warn, runCmd bool) (string, error) {
141- if v := strings.TrimSpace(e.getenv(client.EnvToken)); v != "" {
142- return v, nil
143- }
144- if !runCmd {
145- return "", nil
146- }
147- return e.tokenFromCmd(cmd, warn)
148-}
149-
150149 // tokenFromCmd is the output of token.cmd, or "" when the file sets none or
151150 // when ERMITE_URL points somewhere else than the url of the file: the token
152151 // the command fetches belongs to the server of the file, and must not be sent
@@ -193,11 +192,7 @@ func (e *cliEnv) fetchToken(ctx context.Context, line string) (string, error) {
193192 if e.fileTrust != nil {
194193 return "", e.fileTrust
195194 }
196- run := e.runTokenCmd
197- if run == nil {
198- run = e.shell
199- }
200- out, err := run(ctx, line)
195+ out, err := e.runTokenCmd(ctx, line)
201196 if err != nil {
202197 return "", fmt.Errorf("token.cmd failed: %w", err)
203198 }
@@ -211,60 +206,52 @@ func (e *cliEnv) fetchToken(ctx context.Context, line string) (string, error) {
211206 return out, nil
212207 }
213208
214-// shell runs a token.cmd command line, with the timeout of the environment.
215-func (e *cliEnv) shell(ctx context.Context, line string) (string, error) {
216- timeout := e.tokenTimeout
217- if timeout <= 0 {
218- timeout = tokenCmdTimeout
219- }
220- return runShell(ctx, line, timeout)
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)
221213 }
222214
223215 // runShell runs line with the system shell. Standard error is the terminal's,
224216 // and so is standard input when it is a terminal, so a pinentry or a biometric
225217 // prompt works; a script or an agent gets no input. Standard output is
226218 // 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).
227221 func runShell(ctx context.Context, line string, timeout time.Duration) (string, error) {
228- parent := ctx
229- ctx, cancel := context.WithTimeout(parent, timeout)
222+ sigCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
223+ defer stop()
224+ runCtx, cancel := context.WithTimeout(sigCtx, timeout)
230225 defer cancel()
231- c := shellCommand(ctx, line)
226+
227+ foreground := term.IsTerminal(int(os.Stdin.Fd()))
228+ c := shellCommand(runCtx, line, foreground)
232229 out := &cappedBuffer{max: tokenCmdMaxOutput}
233230 c.Stdout, c.Stderr = out, os.Stderr
234- if stdinIsTerminal() {
231+ if foreground {
235232 c.Stdin = os.Stdin
236233 } // else nil: the command reads os.DevNull
237234 // Do not wait forever for a background process that kept the pipe open.
238235 c.WaitDelay = 2 * time.Second
239236 err := c.Run()
240- switch {
241- case parent.Err() != nil:
242- return "", errors.New("interrupted")
243- case errors.Is(ctx.Err(), context.DeadlineExceeded):
244- return "", fmt.Errorf("timed out after %s", timeout)
245- case err != nil:
246- var exit *exec.ExitError
247- if errors.As(err, &exit) {
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) {
248245 return "", errors.New(exit.String())
249246 }
250247 return "", err
251- case out.over:
248+ }
249+ if out.over {
252250 return "", fmt.Errorf("printed more than %d bytes", tokenCmdMaxOutput)
253251 }
254252 return out.buf.String(), nil
255253 }
256254
257-// stdinIsTerminal reports whether standard input is a terminal: a character
258-// device other than the null device.
259-func stdinIsTerminal() bool {
260- fi, err := os.Stdin.Stat()
261- if err != nil || fi.Mode()&os.ModeCharDevice == 0 {
262- return false
263- }
264- null, err := os.Stat(os.DevNull)
265- return err != nil || !os.SameFile(fi, null)
266-}
267-
268255 // cappedBuffer keeps the first max bytes written and notes an overflow.
269256 type cappedBuffer struct {
270257 buf bytes.Buffer
@@ -11,11 +11,14 @@ import (
11 "io/fs"11 "io/fs"
12 "os"12 "os"
13 "os/exec"13 "os/exec"
14+ "os/signal"
14 "strings"15 "strings"
16+ "syscall"
15 "time"17 "time"
16 18
17 "github.com/bnema/kvconf"19 "github.com/bnema/kvconf"
18 "github.com/spf13/cobra"20 "github.com/spf13/cobra"
21+ "golang.org/x/term"
19 22
20 "bnema.dev/ermite/internal/adapters/client"23 "bnema.dev/ermite/internal/adapters/client"
21 )24 )
@@ -107,7 +110,7 @@ func loadConfigFile(path string) (f *cliFile, warnings []kvconf.Warning, trust,
107 if !info.Mode().IsRegular() {110 if !info.Mode().IsRegular() {
108 return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrNotRegular)111 return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrNotRegular)
109 }112 }
110- trust = checkPrivate(path, info)113+ trust = checkPrivate(path, info, os.Geteuid())
111 data, err := io.ReadAll(io.LimitReader(h, kvconf.DefaultMaxBytes+1))114 data, err := io.ReadAll(io.LimitReader(h, kvconf.DefaultMaxBytes+1))
112 if err != nil {115 if err != nil {
113 return nil, nil, nil, err116 return nil, nil, nil, err
@@ -123,6 +126,14 @@ func loadConfigFile(path string) (f *cliFile, warnings []kvconf.Warning, trust,
123 return f, warnings, trust, nil126 return f, warnings, trust, nil
124 }127 }
125 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+
126 // resolveURL is ERMITE_URL if set, else the url of the config file, else "".137 // resolveURL is ERMITE_URL if set, else the url of the config file, else "".
127 func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) {138 func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) {
128 if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" {139 if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" {
@@ -135,18 +146,6 @@ func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) {
135 return strings.TrimSpace(f.URL), nil146 return strings.TrimSpace(f.URL), nil
136 }147 }
137 148
138-// resolveToken is ERMITE_TOKEN if set, else (when runCmd is set) the output
139-// of token.cmd, else "" (anonymous). token.cmd runs at most once per process.
140-func (e *cliEnv) resolveToken(cmd *cobra.Command, warn, runCmd bool) (string, error) {
141- if v := strings.TrimSpace(e.getenv(client.EnvToken)); v != "" {
142- return v, nil
143- }
144- if !runCmd {
145- return "", nil
146- }
147- return e.tokenFromCmd(cmd, warn)
148-}
149-
150 // tokenFromCmd is the output of token.cmd, or "" when the file sets none or149 // tokenFromCmd is the output of token.cmd, or "" when the file sets none or
151 // when ERMITE_URL points somewhere else than the url of the file: the token150 // when ERMITE_URL points somewhere else than the url of the file: the token
152 // the command fetches belongs to the server of the file, and must not be sent151 // the command fetches belongs to the server of the file, and must not be sent
@@ -193,11 +192,7 @@ func (e *cliEnv) fetchToken(ctx context.Context, line string) (string, error) {
193 if e.fileTrust != nil {192 if e.fileTrust != nil {
194 return "", e.fileTrust193 return "", e.fileTrust
195 }194 }
196- run := e.runTokenCmd195+ out, err := e.runTokenCmd(ctx, line)
197- if run == nil {
198- run = e.shell
199- }
200- out, err := run(ctx, line)
201 if err != nil {196 if err != nil {
202 return "", fmt.Errorf("token.cmd failed: %w", err)197 return "", fmt.Errorf("token.cmd failed: %w", err)
203 }198 }
@@ -211,60 +206,52 @@ func (e *cliEnv) fetchToken(ctx context.Context, line string) (string, error) {
211 return out, nil206 return out, nil
212 }207 }
213 208
214-// shell runs a token.cmd command line, with the timeout of the environment.209+// runTokenShell is the default runTokenCmd: the system shell, for at most
215-func (e *cliEnv) shell(ctx context.Context, line string) (string, error) {210+// tokenCmdTimeout.
216- timeout := e.tokenTimeout211+func runTokenShell(ctx context.Context, line string) (string, error) {
217- if timeout <= 0 {212+ return runShell(ctx, line, tokenCmdTimeout)
218- timeout = tokenCmdTimeout
219- }
220- return runShell(ctx, line, timeout)
221 }213 }
222 214
223 // runShell runs line with the system shell. Standard error is the terminal's,215 // runShell runs line with the system shell. Standard error is the terminal's,
224 // and so is standard input when it is a terminal, so a pinentry or a biometric216 // and so is standard input when it is a terminal, so a pinentry or a biometric
225 // prompt works; a script or an agent gets no input. Standard output is217 // prompt works; a script or an agent gets no input. Standard output is
226 // captured, up to tokenCmdMaxOutput bytes. The environment is the process's.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).
227 func runShell(ctx context.Context, line string, timeout time.Duration) (string, error) {221 func runShell(ctx context.Context, line string, timeout time.Duration) (string, error) {
228- parent := ctx222+ sigCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
229- ctx, cancel := context.WithTimeout(parent, timeout)223+ defer stop()
224+ runCtx, cancel := context.WithTimeout(sigCtx, timeout)
230 defer cancel()225 defer cancel()
231- c := shellCommand(ctx, line)226+
227+ foreground := term.IsTerminal(int(os.Stdin.Fd()))
228+ c := shellCommand(runCtx, line, foreground)
232 out := &cappedBuffer{max: tokenCmdMaxOutput}229 out := &cappedBuffer{max: tokenCmdMaxOutput}
233 c.Stdout, c.Stderr = out, os.Stderr230 c.Stdout, c.Stderr = out, os.Stderr
234- if stdinIsTerminal() {231+ if foreground {
235 c.Stdin = os.Stdin232 c.Stdin = os.Stdin
236 } // else nil: the command reads os.DevNull233 } // else nil: the command reads os.DevNull
237 // Do not wait forever for a background process that kept the pipe open.234 // Do not wait forever for a background process that kept the pipe open.
238 c.WaitDelay = 2 * time.Second235 c.WaitDelay = 2 * time.Second
239 err := c.Run()236 err := c.Run()
240- switch {237+ if err != nil {
241- case parent.Err() != nil:238+ switch {
242- return "", errors.New("interrupted")239+ case sigCtx.Err() != nil:
243- case errors.Is(ctx.Err(), context.DeadlineExceeded):240+ return "", errors.New("interrupted")
244- return "", fmt.Errorf("timed out after %s", timeout)241+ case errors.Is(runCtx.Err(), context.DeadlineExceeded):
245- case err != nil:242+ return "", fmt.Errorf("timed out after %s", timeout)
246- var exit *exec.ExitError243+ }
247- if errors.As(err, &exit) {244+ if exit := (*exec.ExitError)(nil); errors.As(err, &exit) {
248 return "", errors.New(exit.String())245 return "", errors.New(exit.String())
249 }246 }
250 return "", err247 return "", err
251- case out.over:248+ }
249+ if out.over {
252 return "", fmt.Errorf("printed more than %d bytes", tokenCmdMaxOutput)250 return "", fmt.Errorf("printed more than %d bytes", tokenCmdMaxOutput)
253 }251 }
254 return out.buf.String(), nil252 return out.buf.String(), nil
255 }253 }
256 254
257-// stdinIsTerminal reports whether standard input is a terminal: a character
258-// device other than the null device.
259-func stdinIsTerminal() bool {
260- fi, err := os.Stdin.Stat()
261- if err != nil || fi.Mode()&os.ModeCharDevice == 0 {
262- return false
263- }
264- null, err := os.Stat(os.DevNull)
265- return err != nil || !os.SameFile(fi, null)
266-}
267-
268 // cappedBuffer keeps the first max bytes written and notes an overflow.255 // cappedBuffer keeps the first max bytes written and notes an overflow.
269 type cappedBuffer struct {256 type cappedBuffer struct {
270 buf bytes.Buffer257 buf bytes.Buffer
M internal/app/cli_configfile_test.go +79 -65
@@ -14,6 +14,7 @@ import (
1414
1515 "github.com/stretchr/testify/assert"
1616 "github.com/stretchr/testify/require"
17+ "golang.org/x/term"
1718
1819 "bnema.dev/ermite-api/gen/ermite"
1920 )
@@ -88,30 +89,38 @@ func TestEnvironmentWinsOverConfigFile(t *testing.T) {
8889
8990 func TestEnvURLDifferentFromFileKeepsTokenCmdOut(t *testing.T) {
9091 f := listFake()
91- env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)})
92- writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600)
93- ran := tokenCmdStub(env, "ermite_cmd", nil)
94-
95- res := runCLIWith(t, env, "", "repo", "list").ok(t)
96- assert.Empty(t, *ran, "the token of the file's server must not go to another server")
97- assert.Empty(t, lastAuth(f))
98- assert.Equal(t, 1, strings.Count(res.stderr, "ermite: ERMITE_URL differs from the config file url: token.cmd not run (set ERMITE_TOKEN)\n"), res.stderr)
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)
99111
100- t.Run("file without url", func(t *testing.T) {
101- env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)})
102- writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
103- ran := tokenCmdStub(env, "ermite_cmd", nil)
104- res := runCLIWith(t, env, "", "repo", "list").ok(t)
105- assert.Empty(t, *ran)
106- assert.Contains(t, res.stderr, "token.cmd not run")
107- })
108- t.Run("invalid ERMITE_URL", func(t *testing.T) {
109- env := testEnv(t, map[string]string{"ERMITE_URL": "nope"})
110- writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600)
111- ran := tokenCmdStub(env, "ermite_cmd", nil)
112- require.Error(t, runCLIWith(t, env, "", "repo", "list").err)
113- assert.Empty(t, *ran)
114- })
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+ }
115124 }
116125
117126 func TestEnvURLSameAsFileUsesTokenCmd(t *testing.T) {
@@ -187,7 +196,7 @@ func TestTokenCmdRunsOncePerProcess(t *testing.T) {
187196 for range 2 {
188197 c, _, err := root.Find([]string{"repo", "list"})
189198 require.NoError(t, err)
190- _, err = env.client(c, true)
199+ _, err = env.client(c)
191200 require.NoError(t, err)
192201 }
193202 assert.Len(t, *ran, 1)
@@ -256,10 +265,11 @@ func TestMissingURLIsReportedBeforeTokenCmdRuns(t *testing.T) {
256265
257266 func TestInvalidFileURLIsReported(t *testing.T) {
258267 env := testEnv(t, nil)
259- writeConfigFile(t, env, "url = git.example.com\n", 0o600)
268+ path := writeConfigFile(t, env, "url = git.example.com\n", 0o600)
260269 res := runCLIWith(t, env, "", "repo", "list")
261270 require.Error(t, res.err)
262- assert.Contains(t, res.err.Error(), "invalid server URL")
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")
263273 assert.Contains(t, res.err.Error(), "http")
264274 }
265275
@@ -298,40 +308,63 @@ func TestTokenCmdRunsThroughTheShell(t *testing.T) {
298308 f := listFake()
299309 env := testEnv(t, nil)
300310 writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = printf secret\n", 0o600)
301- env.runTokenCmd = nil // the real thing
311+ env.runTokenCmd = runTokenShell // the real thing
302312
303313 runCLIWith(t, env, "", "repo", "list").ok(t)
304314 assert.Equal(t, "Bearer secret", lastAuth(f))
305315 }
306316
307-func TestRunShellReportsFailuresWithoutOutput(t *testing.T) {
317+func TestRunShell(t *testing.T) {
308318 if runtime.GOOS == "windows" {
309319 t.Skip("uses sh")
310320 }
311- _, err := runShell(t.Context(), "echo s3cret; exit 3", time.Minute)
312- require.Error(t, err)
313- assert.Contains(t, err.Error(), "exit status 3")
314- assert.NotContains(t, err.Error(), "s3cret")
315-
316- _, err = runShell(t.Context(), "head -c 5000 /dev/zero | tr '\\0' a", time.Minute)
317- require.Error(t, err)
318- assert.Contains(t, err.Error(), "more than 4096 bytes")
319- assert.NotContains(t, err.Error(), "aaa")
320-
321- out, err := runShell(t.Context(), "echo ermite_ok", time.Minute)
322- require.NoError(t, err)
323- assert.Equal(t, "ermite_ok\n", out)
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+ }
324346 }
325347
326-func TestTokenCmdTimesOut(t *testing.T) {
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()
327352 if runtime.GOOS == "windows" {
328353 t.Skip("uses sh")
329354 }
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)
330362 f := listFake()
331363 env := testEnv(t, nil)
332- writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = exec sleep 30\n", 0o600)
333- env.runTokenCmd = nil
334- env.tokenTimeout = 100 * time.Millisecond
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+ }
335368
336369 start := time.Now()
337370 res := runCLIWith(t, env, "", "repo", "list")
@@ -341,11 +374,7 @@ func TestTokenCmdTimesOut(t *testing.T) {
341374 }
342375
343376 func TestTokenCmdGetsNoInputWithoutATerminal(t *testing.T) {
344- if runtime.GOOS == "windows" {
345- t.Skip("uses sh")
346- }
347- // under `go test` stdin is the null device or a pipe, never a terminal
348- require.False(t, stdinIsTerminal())
377+ requireNoTerminal(t)
349378 out, err := runShell(t.Context(), "cat; echo done", time.Minute)
350379 require.NoError(t, err)
351380 assert.Equal(t, "done\n", out, "the command read nothing")
@@ -376,21 +405,6 @@ func TestTokenCmdRefusedWhenDirectoryIsWritableByOthers(t *testing.T) {
376405 })
377406 }
378407
379-func TestTokenCmdRefusedWhenFileIsOwnedByAnotherUser(t *testing.T) {
380- if runtime.GOOS == "windows" || os.Geteuid() != 0 {
381- t.Skip("needs root to give a file away")
382- }
383- env := testEnv(t, nil)
384- path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600)
385- require.NoError(t, os.Chown(path, 65534, -1))
386- ran := tokenCmdStub(env, "ermite_cmd", nil)
387-
388- res := runCLIWith(t, env, "", "repo", "list")
389- require.Error(t, res.err)
390- assert.Equal(t, "config file "+path+" is owned by another user: token.cmd not run", res.err.Error())
391- assert.Empty(t, *ran)
392-}
393-
394408 func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) {
395409 if runtime.GOOS == "windows" {
396410 t.Skip("symlinks")
@@ -14,6 +14,7 @@ import (
14 14
15 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
17+ "golang.org/x/term"
17 18
18 "bnema.dev/ermite-api/gen/ermite"19 "bnema.dev/ermite-api/gen/ermite"
19 )20 )
@@ -88,30 +89,38 @@ func TestEnvironmentWinsOverConfigFile(t *testing.T) {
88 89
89 func TestEnvURLDifferentFromFileKeepsTokenCmdOut(t *testing.T) {90 func TestEnvURLDifferentFromFileKeepsTokenCmdOut(t *testing.T) {
90 f := listFake()91 f := listFake()
91- env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)})92+ tests := []struct {
92- writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600)93+ name string
93- ran := tokenCmdStub(env, "ermite_cmd", nil)94+ envURL string // "" means the fake server
94-95+ file string // the content of the config file
95- res := runCLIWith(t, env, "", "repo", "list").ok(t)96+ wantErr bool
96- assert.Empty(t, *ran, "the token of the file's server must not go to another server")97+ }{
97- assert.Empty(t, lastAuth(f))98+ {name: "other url in the file", file: "url = http://file.invalid\ntoken.cmd = get-token\n"},
98- assert.Equal(t, 1, strings.Count(res.stderr, "ermite: ERMITE_URL differs from the config file url: token.cmd not run (set ERMITE_TOKEN)\n"), res.stderr)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)
99 111
100- t.Run("file without url", func(t *testing.T) {112+ res := runCLIWith(t, env, "", "repo", "list")
101- env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)})113+ assert.Empty(t, *ran, "the token of the file's server must not go to another server")
102- writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)114+ if tt.wantErr {
103- ran := tokenCmdStub(env, "ermite_cmd", nil)115+ require.Error(t, res.err)
104- res := runCLIWith(t, env, "", "repo", "list").ok(t)116+ return
105- assert.Empty(t, *ran)117+ }
106- assert.Contains(t, res.stderr, "token.cmd not run")118+ require.NoError(t, res.err)
107- })119+ assert.Empty(t, lastAuth(f))
108- t.Run("invalid ERMITE_URL", func(t *testing.T) {120+ assert.Equal(t, 1, strings.Count(res.stderr, "token.cmd not run"), res.stderr)
109- env := testEnv(t, map[string]string{"ERMITE_URL": "nope"})121+ assert.Contains(t, res.stderr, "ERMITE_TOKEN")
110- writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600)122+ })
111- ran := tokenCmdStub(env, "ermite_cmd", nil)123+ }
112- require.Error(t, runCLIWith(t, env, "", "repo", "list").err)
113- assert.Empty(t, *ran)
114- })
115 }124 }
116 125
117 func TestEnvURLSameAsFileUsesTokenCmd(t *testing.T) {126 func TestEnvURLSameAsFileUsesTokenCmd(t *testing.T) {
@@ -187,7 +196,7 @@ func TestTokenCmdRunsOncePerProcess(t *testing.T) {
187 for range 2 {196 for range 2 {
188 c, _, err := root.Find([]string{"repo", "list"})197 c, _, err := root.Find([]string{"repo", "list"})
189 require.NoError(t, err)198 require.NoError(t, err)
190- _, err = env.client(c, true)199+ _, err = env.client(c)
191 require.NoError(t, err)200 require.NoError(t, err)
192 }201 }
193 assert.Len(t, *ran, 1)202 assert.Len(t, *ran, 1)
@@ -256,10 +265,11 @@ func TestMissingURLIsReportedBeforeTokenCmdRuns(t *testing.T) {
256 265
257 func TestInvalidFileURLIsReported(t *testing.T) {266 func TestInvalidFileURLIsReported(t *testing.T) {
258 env := testEnv(t, nil)267 env := testEnv(t, nil)
259- writeConfigFile(t, env, "url = git.example.com\n", 0o600)268+ path := writeConfigFile(t, env, "url = git.example.com\n", 0o600)
260 res := runCLIWith(t, env, "", "repo", "list")269 res := runCLIWith(t, env, "", "repo", "list")
261 require.Error(t, res.err)270 require.Error(t, res.err)
262- assert.Contains(t, res.err.Error(), "invalid server URL")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")
263 assert.Contains(t, res.err.Error(), "http")273 assert.Contains(t, res.err.Error(), "http")
264 }274 }
265 275
@@ -298,40 +308,63 @@ func TestTokenCmdRunsThroughTheShell(t *testing.T) {
298 f := listFake()308 f := listFake()
299 env := testEnv(t, nil)309 env := testEnv(t, nil)
300 writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = printf secret\n", 0o600)310 writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = printf secret\n", 0o600)
301- env.runTokenCmd = nil // the real thing311+ env.runTokenCmd = runTokenShell // the real thing
302 312
303 runCLIWith(t, env, "", "repo", "list").ok(t)313 runCLIWith(t, env, "", "repo", "list").ok(t)
304 assert.Equal(t, "Bearer secret", lastAuth(f))314 assert.Equal(t, "Bearer secret", lastAuth(f))
305 }315 }
306 316
307-func TestRunShellReportsFailuresWithoutOutput(t *testing.T) {317+func TestRunShell(t *testing.T) {
308 if runtime.GOOS == "windows" {318 if runtime.GOOS == "windows" {
309 t.Skip("uses sh")319 t.Skip("uses sh")
310 }320 }
311- _, err := runShell(t.Context(), "echo s3cret; exit 3", time.Minute)321+ tests := []struct {
312- require.Error(t, err)322+ name string
313- assert.Contains(t, err.Error(), "exit status 3")323+ line string
314- assert.NotContains(t, err.Error(), "s3cret")324+ want string // the output on success
315-325+ errHas string // the error on failure
316- _, err = runShell(t.Context(), "head -c 5000 /dev/zero | tr '\\0' a", time.Minute)326+ errNot string // what the error must not show
317- require.Error(t, err)327+ wantErr bool
318- assert.Contains(t, err.Error(), "more than 4096 bytes")328+ }{
319- assert.NotContains(t, err.Error(), "aaa")329+ {name: "exit status", line: "echo s3cret; exit 3", wantErr: true, errHas: "exit status 3", errNot: "s3cret"},
320-330+ {name: "too much output", line: "head -c 5000 /dev/zero | tr '\\0' a", wantErr: true, errHas: "more than 4096 bytes", errNot: "aaa"},
321- out, err := runShell(t.Context(), "echo ermite_ok", time.Minute)331+ {name: "output", line: "echo ermite_ok", want: "ermite_ok\n"},
322- require.NoError(t, err)332+ }
323- assert.Equal(t, "ermite_ok\n", out)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+ }
324 }346 }
325 347
326-func TestTokenCmdTimesOut(t *testing.T) {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()
327 if runtime.GOOS == "windows" {352 if runtime.GOOS == "windows" {
328 t.Skip("uses sh")353 t.Skip("uses sh")
329 }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)
330 f := listFake()362 f := listFake()
331 env := testEnv(t, nil)363 env := testEnv(t, nil)
332- writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = exec sleep 30\n", 0o600)364+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = sleep 30; :\n", 0o600)
333- env.runTokenCmd = nil365+ env.runTokenCmd = func(ctx context.Context, line string) (string, error) {
334- env.tokenTimeout = 100 * time.Millisecond366+ return runShell(ctx, line, 100*time.Millisecond)
367+ }
335 368
336 start := time.Now()369 start := time.Now()
337 res := runCLIWith(t, env, "", "repo", "list")370 res := runCLIWith(t, env, "", "repo", "list")
@@ -341,11 +374,7 @@ func TestTokenCmdTimesOut(t *testing.T) {
341 }374 }
342 375
343 func TestTokenCmdGetsNoInputWithoutATerminal(t *testing.T) {376 func TestTokenCmdGetsNoInputWithoutATerminal(t *testing.T) {
344- if runtime.GOOS == "windows" {377+ requireNoTerminal(t)
345- t.Skip("uses sh")
346- }
347- // under `go test` stdin is the null device or a pipe, never a terminal
348- require.False(t, stdinIsTerminal())
349 out, err := runShell(t.Context(), "cat; echo done", time.Minute)378 out, err := runShell(t.Context(), "cat; echo done", time.Minute)
350 require.NoError(t, err)379 require.NoError(t, err)
351 assert.Equal(t, "done\n", out, "the command read nothing")380 assert.Equal(t, "done\n", out, "the command read nothing")
@@ -376,21 +405,6 @@ func TestTokenCmdRefusedWhenDirectoryIsWritableByOthers(t *testing.T) {
376 })405 })
377 }406 }
378 407
379-func TestTokenCmdRefusedWhenFileIsOwnedByAnotherUser(t *testing.T) {
380- if runtime.GOOS == "windows" || os.Geteuid() != 0 {
381- t.Skip("needs root to give a file away")
382- }
383- env := testEnv(t, nil)
384- path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600)
385- require.NoError(t, os.Chown(path, 65534, -1))
386- ran := tokenCmdStub(env, "ermite_cmd", nil)
387-
388- res := runCLIWith(t, env, "", "repo", "list")
389- require.Error(t, res.err)
390- assert.Equal(t, "config file "+path+" is owned by another user: token.cmd not run", res.err.Error())
391- assert.Empty(t, *ran)
392-}
393-
394 func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) {408 func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) {
395 if runtime.GOOS == "windows" {409 if runtime.GOOS == "windows" {
396 t.Skip("symlinks")410 t.Skip("symlinks")
M internal/app/cli_mr.go +7 -7
@@ -265,7 +265,7 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command {
265265 Repo: args[0], Title: title, Description: desc,
266266 SourceBranch: trimBranch(source), TargetBranch: trimBranch(target),
267267 }
268- c, err := env.client(cmd, true)
268+ c, err := env.client(cmd)
269269 if err != nil {
270270 return err
271271 }
@@ -310,7 +310,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command {
310310 if err != nil {
311311 return err
312312 }
313- c, err := env.client(cmd, true)
313+ c, err := env.client(cmd)
314314 if err != nil {
315315 return err
316316 }
@@ -474,7 +474,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
474474 if err != nil {
475475 return err
476476 }
477- c, err := env.client(cmd, true)
477+ c, err := env.client(cmd)
478478 if err != nil {
479479 return err
480480 }
@@ -687,7 +687,7 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
687687 case line != 0 || cmd.Flags().Changed("side"):
688688 return errors.New("--line and --side need --path")
689689 }
690- c, err := env.client(cmd, true)
690+ c, err := env.client(cmd)
691691 if err != nil {
692692 return err
693693 }
@@ -722,7 +722,7 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command {
722722 return err
723723 }
724724 id := strings.TrimPrefix(strings.TrimSpace(args[2]), "#")
725- c, err := env.client(cmd, true)
725+ c, err := env.client(cmd)
726726 if err != nil {
727727 return err
728728 }
@@ -781,7 +781,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
781781 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
782782 "so that only the commit you reviewed is merged", args[0], n)
783783 }
784- c, err := env.client(cmd, true)
784+ c, err := env.client(cmd)
785785 if err != nil {
786786 return err
787787 }
@@ -830,7 +830,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
830830 if err != nil {
831831 return err
832832 }
833- c, err := env.client(cmd, true)
833+ c, err := env.client(cmd)
834834 if err != nil {
835835 return err
836836 }
@@ -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 err270 return err
271 }271 }
@@ -310,7 +310,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command {
310 if err != nil {310 if err != nil {
311 return err311 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 err315 return err
316 }316 }
@@ -474,7 +474,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
474 if err != nil {474 if err != nil {
475 return err475 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 err479 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 err692 return err
693 }693 }
@@ -722,7 +722,7 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command {
722 return err722 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 err727 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 err786 return err
787 }787 }
@@ -830,7 +830,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
830 if err != nil {830 if err != nil {
831 return err831 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 err835 return err
836 }836 }
M internal/app/cli_repo.go +5 -5
@@ -146,7 +146,7 @@ func newRepoListCmd(env *cliEnv) *cobra.Command {
146146 }
147147 asJSON := addJSONFlag(cmd)
148148 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
149- c, err := env.client(cmd, true)
149+ c, err := env.client(cmd)
150150 if err != nil {
151151 return err
152152 }
@@ -205,7 +205,7 @@ func newRepoShowCmd(env *cliEnv) *cobra.Command {
205205 }
206206 asJSON := addJSONFlag(cmd)
207207 cmd.RunE = func(cmd *cobra.Command, args []string) error {
208- c, err := env.client(cmd, true)
208+ c, err := env.client(cmd)
209209 if err != nil {
210210 return err
211211 }
@@ -239,7 +239,7 @@ func newRepoCreateCmd(env *cliEnv) *cobra.Command {
239239 if err != nil {
240240 return err
241241 }
242- c, err := env.client(cmd, true)
242+ c, err := env.client(cmd)
243243 if err != nil {
244244 return err
245245 }
@@ -295,7 +295,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
295295 if err != nil {
296296 return err
297297 }
298- c, err := env.client(cmd, true)
298+ c, err := env.client(cmd)
299299 if err != nil {
300300 return err
301301 }
@@ -377,7 +377,7 @@ func newRepoDeleteCmd(env *cliEnv) *cobra.Command {
377377 cmd.RunE = func(cmd *cobra.Command, args []string) error {
378378 name := args[0]
379379 // Check the configuration before asking anything.
380- c, err := env.client(cmd, true)
380+ c, err := env.client(cmd)
381381 if err != nil {
382382 return err
383383 }
@@ -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 err151 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 err210 return err
211 }211 }
@@ -239,7 +239,7 @@ func newRepoCreateCmd(env *cliEnv) *cobra.Command {
239 if err != nil {239 if err != nil {
240 return err240 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 err244 return err
245 }245 }
@@ -295,7 +295,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
295 if err != nil {295 if err != nil {
296 return err296 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 err300 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 err382 return err
383 }383 }
M internal/app/cli_shell_unix.go +34 -10
@@ -15,8 +15,27 @@ import (
1515 )
1616
1717 // shellCommand runs line with `sh -c`.
18-func shellCommand(ctx context.Context, line string) *exec.Cmd {
19- return exec.CommandContext(ctx, "sh", "-c", line)
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
2039 }
2140
2241 // openConfig opens the config file without blocking on a FIFO swapped in for it.
@@ -26,13 +45,13 @@ func openConfig(path string) (*os.File, error) {
2645
2746 // checkPrivate says whether token.cmd may be run from the config file at
2847 // path, whose open handle reported info: whoever can change the file can make
29-// token.cmd any command. It returns nil when the file belongs to the current
30-// user and cannot be written by group or others, and when its directory
31-// belongs to the current user or root and cannot be written by group or
32-// others (a sticky directory excepted), as they could replace the file.
33-func checkPrivate(path string, info fs.FileInfo) error {
34- euid := uint32(os.Geteuid())
35- if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Uid != euid {
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 {
3655 return fmt.Errorf("config file %s is owned by another user: token.cmd not run", path)
3756 }
3857 if info.Mode().Perm()&0o022 != 0 {
@@ -42,11 +61,16 @@ func checkPrivate(path string, info fs.FileInfo) error {
4261 if resolved, err := filepath.EvalSymlinks(path); err == nil {
4362 dir = filepath.Dir(resolved) // a symlinked dotfile: the directory of the target
4463 }
64+ return checkPrivateDir(dir, euid)
65+}
66+
67+// checkPrivateDir is the directory half of checkPrivate.
68+func checkPrivateDir(dir string, euid int) error {
4569 di, err := os.Stat(dir)
4670 if err != nil {
4771 return fmt.Errorf("config directory %s: %w: token.cmd not run", dir, err)
4872 }
49- if st, ok := di.Sys().(*syscall.Stat_t); ok && st.Uid != euid && st.Uid != 0 {
73+ if st, ok := di.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid && st.Uid != 0 {
5074 return fmt.Errorf("config directory %s is owned by another user: token.cmd not run", dir)
5175 }
5276 if di.Mode().Perm()&0o022 != 0 && di.Mode()&fs.ModeSticky == 0 {
@@ -15,8 +15,27 @@ import (
15 )15 )
16 16
17 // shellCommand runs line with `sh -c`.17 // shellCommand runs line with `sh -c`.
18-func shellCommand(ctx context.Context, line string) *exec.Cmd {18+//
19- return exec.CommandContext(ctx, "sh", "-c", line)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
20 }39 }
21 40
22 // openConfig opens the config file without blocking on a FIFO swapped in for it.41 // openConfig opens the config file without blocking on a FIFO swapped in for it.
@@ -26,13 +45,13 @@ func openConfig(path string) (*os.File, error) {
26 45
27 // checkPrivate says whether token.cmd may be run from the config file at46 // checkPrivate says whether token.cmd may be run from the config file at
28 // path, whose open handle reported info: whoever can change the file can make47 // path, whose open handle reported info: whoever can change the file can make
29-// token.cmd any command. It returns nil when the file belongs to the current48+// token.cmd any command. It returns nil when the file belongs to euid (the
30-// user and cannot be written by group or others, and when its directory49+// effective user id of the process) and cannot be written by group or others,
31-// belongs to the current user or root and cannot be written by group or50+// and when its directory belongs to euid or root and cannot be written by
32-// others (a sticky directory excepted), as they could replace the file.51+// group or others (a sticky directory excepted), as they could replace the
33-func checkPrivate(path string, info fs.FileInfo) error {52+// file.
34- euid := uint32(os.Geteuid())53+func checkPrivate(path string, info fs.FileInfo, euid int) error {
35- if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Uid != euid {54+ if st, ok := info.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid {
36 return fmt.Errorf("config file %s is owned by another user: token.cmd not run", path)55 return fmt.Errorf("config file %s is owned by another user: token.cmd not run", path)
37 }56 }
38 if info.Mode().Perm()&0o022 != 0 {57 if info.Mode().Perm()&0o022 != 0 {
@@ -42,11 +61,16 @@ func checkPrivate(path string, info fs.FileInfo) error {
42 if resolved, err := filepath.EvalSymlinks(path); err == nil {61 if resolved, err := filepath.EvalSymlinks(path); err == nil {
43 dir = filepath.Dir(resolved) // a symlinked dotfile: the directory of the target62 dir = filepath.Dir(resolved) // a symlinked dotfile: the directory of the target
44 }63 }
64+ return checkPrivateDir(dir, euid)
65+}
66+
67+// checkPrivateDir is the directory half of checkPrivate.
68+func checkPrivateDir(dir string, euid int) error {
45 di, err := os.Stat(dir)69 di, err := os.Stat(dir)
46 if err != nil {70 if err != nil {
47 return fmt.Errorf("config directory %s: %w: token.cmd not run", dir, err)71 return fmt.Errorf("config directory %s: %w: token.cmd not run", dir, err)
48 }72 }
49- if st, ok := di.Sys().(*syscall.Stat_t); ok && st.Uid != euid && st.Uid != 0 {73+ if st, ok := di.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid && st.Uid != 0 {
50 return fmt.Errorf("config directory %s is owned by another user: token.cmd not run", dir)74 return fmt.Errorf("config directory %s is owned by another user: token.cmd not run", dir)
51 }75 }
52 if di.Mode().Perm()&0o022 != 0 && di.Mode()&fs.ModeSticky == 0 {76 if di.Mode().Perm()&0o022 != 0 && di.Mode()&fs.ModeSticky == 0 {
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+}
M internal/app/cli_shell_windows.go +10 -3
@@ -14,7 +14,11 @@ import (
1414
1515 // shellCommand runs line with `cmd /C`. The command line is passed as is:
1616 // Go's argument quoting would mangle quotes cmd.exe means to read itself.
17-func shellCommand(ctx context.Context, line string) *exec.Cmd {
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 {
1822 c := exec.CommandContext(ctx, "cmd")
1923 c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line}
2024 return c
@@ -23,5 +27,8 @@ func shellCommand(ctx context.Context, line string) *exec.Cmd {
2327 // openConfig opens the config file.
2428 func openConfig(path string) (*os.File, error) { return os.Open(path) }
2529
26-// checkPrivate: Windows has no Unix mode bits to check.
27-func checkPrivate(string, fs.FileInfo) error { return nil }
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 }
@@ -14,7 +14,11 @@ import (
14 14
15 // shellCommand runs line with `cmd /C`. The command line is passed as is: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.16 // Go's argument quoting would mangle quotes cmd.exe means to read itself.
17-func shellCommand(ctx context.Context, line string) *exec.Cmd {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 {
18 c := exec.CommandContext(ctx, "cmd")22 c := exec.CommandContext(ctx, "cmd")
19 c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line}23 c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line}
20 return c24 return c
@@ -23,5 +27,8 @@ func shellCommand(ctx context.Context, line string) *exec.Cmd {
23 // openConfig opens the config file.27 // openConfig opens the config file.
24 func openConfig(path string) (*os.File, error) { return os.Open(path) }28 func openConfig(path string) (*os.File, error) { return os.Open(path) }
25 29
26-// checkPrivate: Windows has no Unix mode bits to check.30+// checkPrivate is a no-op on Windows: it has no Unix mode bits or owner ids to
27-func checkPrivate(string, fs.FileInfo) error { return nil }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 {
5555 cmd.MarkFlagsMutuallyExclusive("json", "public-key")
5656 cmd.MarkFlagsMutuallyExclusive("json", "allowed-signers")
5757 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
58- c, err := env.client(cmd, true)
58+ c, err := env.client(cmd)
5959 if err != nil {
6060 return err
6161 }
@@ -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 err60 return err
61 }61 }
M internal/app/cli_token.go +3 -3
@@ -107,7 +107,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
107107 if err != nil {
108108 return err
109109 }
110- c, err := env.client(cmd, true)
110+ c, err := env.client(cmd)
111111 if err != nil {
112112 return err
113113 }
@@ -185,7 +185,7 @@ func newTokenListCmd(env *cliEnv) *cobra.Command {
185185 }
186186 asJSON := addJSONFlag(cmd)
187187 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
188- c, err := env.client(cmd, true)
188+ c, err := env.client(cmd)
189189 if err != nil {
190190 return err
191191 }
@@ -239,7 +239,7 @@ func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
239239 return ids, cobra.ShellCompDirectiveNoFileComp
240240 },
241241 RunE: func(cmd *cobra.Command, args []string) error {
242- c, err := env.client(cmd, true)
242+ c, err := env.client(cmd)
243243 if err != nil {
244244 return err
245245 }
@@ -107,7 +107,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
107 if err != nil {107 if err != nil {
108 return err108 return err
109 }109 }
110- c, err := env.client(cmd, true)110+ c, err := env.client(cmd)
111 if err != nil {111 if err != nil {
112 return err112 return err
113 }113 }
@@ -185,7 +185,7 @@ func newTokenListCmd(env *cliEnv) *cobra.Command {
185 }185 }
186 asJSON := addJSONFlag(cmd)186 asJSON := addJSONFlag(cmd)
187 cmd.RunE = func(cmd *cobra.Command, _ []string) error {187 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
188- c, err := env.client(cmd, true)188+ c, err := env.client(cmd)
189 if err != nil {189 if err != nil {
190 return err190 return err
191 }191 }
@@ -239,7 +239,7 @@ func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
239 return ids, cobra.ShellCompDirectiveNoFileComp239 return ids, cobra.ShellCompDirectiveNoFileComp
240 },240 },
241 RunE: func(cmd *cobra.Command, args []string) error {241 RunE: func(cmd *cobra.Command, args []string) error {
242- c, err := env.client(cmd, true)242+ c, err := env.client(cmd)
243 if err != nil {243 if err != nil {
244 return err244 return err
245 }245 }
M internal/app/cli_watch.go +1 -1
@@ -163,7 +163,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
163163 if len(args) == 1 {
164164 topics = []string{"repo:" + args[0]}
165165 }
166- c, err := env.client(cmd, true)
166+ c, err := env.client(cmd)
167167 if err != nil {
168168 return err
169169 }
@@ -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 err168 return err
169 }169 }
M internal/app/cli_web.go +2 -2
@@ -43,7 +43,7 @@ func newWebLoginCmd(env *cliEnv) *cobra.Command {
4343 }
4444 asJSON := addJSONFlag(cmd)
4545 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
46- c, err := env.client(cmd, true)
46+ c, err := env.client(cmd)
4747 if err != nil {
4848 return err
4949 }
@@ -75,7 +75,7 @@ func newWebLogoutCmd(env *cliEnv) *cobra.Command {
7575 if !all {
7676 return errors.New("--all is required: this signs every browser out (one browser signs out from its page)")
7777 }
78- c, err := env.client(cmd, true)
78+ c, err := env.client(cmd)
7979 if err != nil {
8080 return err
8181 }
@@ -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 err48 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 err80 return err
81 }81 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close