package main import ( "bytes" "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "syscall" "time" ) // Bounds on a command a tool runs: well below the runtime's 30 s call limit, and an answer that // fits in a tool's reply. const ( callTimeout = 5 * time.Second outputCap = 64 << 10 ) // Runner runs a command with an environment and answers its standard output; a failure carries // what the command said on standard error. Injected, so the tools are tested without a machine. type Runner func(ctx context.Context, env []string, name string, args ...string) (string, error) // Machine is everything the tools read. Root is "" on the machine and a fake tree in tests; every // other path is as the machine names it. type Machine struct { Root string Home string UID int Desktop string // the session's XDG_CURRENT_DESKTOP, lower case Run Runner Timeout time.Duration } // NewMachine is the real machine, as the operator's account the runtime serves the tools as. func NewMachine() *Machine { home := os.Getenv("MESH_OPERATOR_HOME") if home == "" { home, _ = os.UserHomeDir() } desktop := strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP")) if desktop == "" { // The window manager's session says i3; the runtime is not in the session, so it says nothing. desktop = "i3" } return &Machine{Home: home, UID: os.Getuid(), Desktop: desktop, Run: run, Timeout: callTimeout} } // run is the machine's Runner: the command in its own process group, killed with everything it // started at the deadline, each stream cut at outputCap. func run(ctx context.Context, env []string, name string, args ...string) (string, error) { cmd := exec.Command(name, args...) cmd.Env = env cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} out, errb := &capped{}, &capped{} cmd.Stdout, cmd.Stderr = out, errb if err := cmd.Start(); err != nil { return "", err } done := make(chan error, 1) go func() { done <- cmd.Wait() }() var err error select { case err = <-done: case <-ctx.Done(): _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) <-done return out.String(), fmt.Errorf("%s did not finish in time", name) } if err != nil { said := strings.TrimSpace(errb.String()) if said == "" { said = strings.TrimSpace(out.String()) } var exit *exec.ExitError if errors.As(err, &exit) { return out.String(), fmt.Errorf("%s exited %d: %s", name, exit.ExitCode(), said) } return out.String(), fmt.Errorf("%s: %v", name, err) } return out.String(), nil } // capped keeps the first outputCap bytes written to it. type capped struct { buf bytes.Buffer cut bool } func (c *capped) Write(p []byte) (int, error) { if room := outputCap - c.buf.Len(); room < len(p) { if room > 0 { c.buf.Write(p[:room]) } c.cut = true return len(p), nil } return c.buf.Write(p) } func (c *capped) String() string { return c.buf.String() } // cmd runs one command, bounded, with the account's XDG environment. func (m *Machine) cmd(name string, args ...string) (string, error) { t := m.Timeout if t == 0 { t = callTimeout } ctx, cancel := context.WithTimeout(context.Background(), t) defer cancel() return m.Run(ctx, m.env(), name, args...) } // env is what xdg-mime and gio are given: the account's home and the session's data directories, // so they look where the session looks. Nothing else of the runtime's environment is passed on. func (m *Machine) env() []string { return []string{ "HOME=" + m.Home, "PATH=" + strings.Join(m.pathDirs(), ":"), "LANG=C.UTF-8", "XDG_CONFIG_HOME=" + m.configHome(), "XDG_DATA_HOME=" + m.dataHome(), "XDG_DATA_DIRS=" + strings.Join(m.dataDirs(), ":"), "XDG_CURRENT_DESKTOP=" + m.Desktop, } } // p is a machine path inside Root. func (m *Machine) p(path string) string { return filepath.Join(m.Root, path) } func (m *Machine) configHome() string { return filepath.Join(m.Home, ".config") } func (m *Machine) dataHome() string { return filepath.Join(m.Home, ".local", "share") } // configDirs are the system's configuration directories: XDG_CONFIG_DIRS is unset in both // workstations' sessions or says only this. func (m *Machine) configDirs() []string { return []string{"/etc/xdg"} } // dataDirs are the session's XDG_DATA_DIRS on the workstations, those that exist. func (m *Machine) dataDirs() []string { candidates := []string{ filepath.Join(m.Home, ".local", "share", "flatpak", "exports", "share"), "/var/lib/flatpak/exports/share", "/usr/local/share", "/usr/share", "/var/lib/snapd/desktop", } var out []string for _, d := range candidates { if d == "/usr/share" || d == "/usr/local/share" || m.isDir(d) { out = append(out, d) } } return out } // pathDirs is where a program is looked for: the account's own bin, then the system's. func (m *Machine) pathDirs() []string { return []string{filepath.Join(m.Home, ".local", "bin"), "/usr/local/sbin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/lib/flatpak/bin"} } func (m *Machine) isDir(path string) bool { info, err := os.Stat(m.p(path)) return err == nil && info.IsDir() } func (m *Machine) read(path string) (string, bool) { b, err := os.ReadFile(m.p(path)) if err != nil { return "", false } return string(b), true } // executable is whether a program is there to run: an absolute path that is an executable file, // or a name found on the path. func (m *Machine) executable(program string) (string, bool) { if program == "" { return "", false } if strings.Contains(program, "/") { return program, isExecutable(m.p(program)) } for _, d := range m.pathDirs() { full := filepath.Join(d, program) if isExecutable(m.p(full)) { return full, true } } return program, false } func isExecutable(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() && info.Mode()&0o111 != 0 } // tilde writes a path under the home as ~/…, which is how the README and the person name it. func (m *Machine) tilde(path string) string { if path == m.Home { return "~" } if strings.HasPrefix(path, m.Home+"/") { return "~/" + strings.TrimPrefix(path, m.Home+"/") } return path }