v0.10.1 e4c85c7

cli_configfile.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
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
26const (
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.
41type 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.
47func (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.
69func (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.
97func 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.
130func (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 "".
138func (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.
153func (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.
176func (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.
191func (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.
211func 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).
221func 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.
256type cappedBuffer struct {
257	buf  bytes.Buffer
258	max  int
259	over bool
260}
261
262func (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
272var _ io.Writer = (*cappedBuffer)(nil)

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 45

↑↓ moveenter openesc close