1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//go:build !windows
4
5package app
6
7import (
8 "context"
9 "fmt"
10 "io/fs"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "syscall"
15)
16
17// shellCommand runs line with `sh -c`.
18//
19// When foreground is false (no terminal: a script or an agent), the command
20// gets its own process group and a cancellation, a timeout or a signal kills
21// the whole group, so a grandchild such as `sleep` or a secret manager helper
22// cannot outlive ermite or keep the output pipe open.
23//
24// When foreground is true (standard input is a terminal) the command stays in
25// the foreground process group of the terminal: a pinentry or a password
26// prompt then works, and a background group would be stopped by SIGTTIN
27// when it reads the terminal. Ctrl+C reaches the whole group. The limit: on a
28// timeout or a SIGTERM only sh is killed, not what it started.
29func shellCommand(ctx context.Context, line string, foreground bool) *exec.Cmd {
30 c := exec.CommandContext(ctx, "sh", "-c", line)
31 if !foreground {
32 c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
33 c.Cancel = func() error {
34 // A negative pid is the process group, led by sh.
35 return syscall.Kill(-c.Process.Pid, syscall.SIGKILL)
36 }
37 }
38 return c
39}
40
41// openConfig opens the config file without blocking on a FIFO swapped in for it.
42func openConfig(path string) (*os.File, error) {
43 return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
44}
45
46// checkPrivate says whether token.cmd may be run from the config file at
47// path, whose open handle reported info: whoever can change the file can make
48// token.cmd any command. It returns nil when the file belongs to euid (the
49// effective user id of the process) and cannot be written by group or others,
50// and when its directory belongs to euid or root and cannot be written by
51// group or others (a sticky directory excepted), as they could replace the
52// file.
53func checkPrivate(path string, info fs.FileInfo, euid int) error {
54 if st, ok := info.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid {
55 return fmt.Errorf("config file %s is owned by another user: token.cmd not run", path)
56 }
57 if info.Mode().Perm()&0o022 != 0 {
58 return fmt.Errorf("config file %s is writable by others: token.cmd not run", path)
59 }
60 dir := filepath.Dir(path)
61 if resolved, err := filepath.EvalSymlinks(path); err == nil {
62 dir = filepath.Dir(resolved) // a symlinked dotfile: the directory of the target
63 }
64 return checkPrivateDir(dir, euid)
65}
66
67// checkPrivateDir is the directory half of checkPrivate.
68func checkPrivateDir(dir string, euid int) error {
69 di, err := os.Stat(dir)
70 if err != nil {
71 return fmt.Errorf("config directory %s: %w: token.cmd not run", dir, err)
72 }
73 if st, ok := di.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid && st.Uid != 0 {
74 return fmt.Errorf("config directory %s is owned by another user: token.cmd not run", dir)
75 }
76 if di.Mode().Perm()&0o022 != 0 && di.Mode()&fs.ModeSticky == 0 {
77 return fmt.Errorf("config directory %s is writable by others: token.cmd not run", dir)
78 }
79 return nil
80}