package forgekey import ( "bytes" "context" "errors" "fmt" "os" "os/exec" "strings" "time" "unicode/utf8" ) // Ran is what a command did: its output, its exit status, and why it never ran to an answer. type Ran struct { Stdout string Stderr string Status int // Err is "ENOENT" when the program is not installed, or says it was ended for taking too long. Err string } // Runner runs one command with extra environment words, so every tool can be tested without ssh. type Runner func(ctx context.Context, env []string, name string, args ...string) Ran // CallTimeout bounds one command of ExecRunner: below the runtime's thirty-second call limit. A variable so a // test can lower it. var CallTimeout = 20 * time.Second // ExecRunner runs a command on this machine, with no terminal and nothing on its stdin, bounded by // CallTimeout. func ExecRunner(ctx context.Context, env []string, name string, args ...string) Ran { return ExecRunnerFor(CallTimeout)(ctx, env, name, args...) } // ExecRunnerFor is ExecRunner with its own bound (issue 1497): a build module's long step passes 0, so only the // step's own context ends a command, and a fetch may outlast a tool call's thirty seconds. func ExecRunnerFor(bound time.Duration) Runner { return func(ctx context.Context, env []string, name string, args ...string) Ran { return execRun(ctx, bound, false, env, name, args...) } } // ExecRunnerClean is ExecRunnerFor whose commands get exactly the env argument as their environment, and nothing of // this process's (issue 1497; novox/hq ADR 0466 §4: no registry password in a fetch's environment). The caller // gives PATH and HOME itself. A directory is the command's own business (`git -C`, `go -C`): it is started in the HOME // it is given, or the machine's temporary directory, never in this process's (stableDir). func ExecRunnerClean(bound time.Duration) Runner { return func(ctx context.Context, env []string, name string, args ...string) Ran { return execRun(ctx, bound, true, env, name, args...) } } func execRun(ctx context.Context, bound time.Duration, clean bool, env []string, name string, args ...string) Ran { if bound > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, bound) defer cancel() } cmd := exec.CommandContext(ctx, name, args...) if clean { cmd.Env = append([]string{}, env...) // non-nil, so an empty list is an empty environment } else { cmd.Env = append(os.Environ(), env...) } cmd.Dir = stableDir(clean, env) var out, errb bytes.Buffer cmd.Stdout, cmd.Stderr = &out, &errb err := cmd.Run() r := Ran{Stdout: out.String(), Stderr: errb.String()} var exitErr *exec.ExitError switch { case errors.Is(ctx.Err(), context.DeadlineExceeded): r.Status, r.Err = 124, "ended by its step's deadline" if bound > 0 { r.Err = fmt.Sprintf("no answer within %d s", int(bound/time.Second)) } case errors.Is(err, exec.ErrNotFound): r.Status, r.Err = 127, "ENOENT" case errors.As(err, &exitErr): r.Status = exitErr.ExitCode() case err != nil: r.Status, r.Err = 1, err.Error() } return r } // stableDir is the directory a command starts in when it names none (novox/hq issue 734). **Never one that may be // deleted under it**: a module runs in the directory its build was unpacked in, which a new build replaces, and a // command started there inherits it — `go version` then fails "getwd: no such file or directory" before it reads // anything. A clean command starts in the HOME it is given, or the temporary directory; any other keeps this // process's directory while it exists, and starts in the temporary directory once it is gone. "" is this process's. func stableDir(clean bool, env []string) string { if !clean { if _, err := os.Getwd(); err == nil { return "" } return os.TempDir() } for i := len(env) - 1; i >= 0; i-- { if home, ok := strings.CutPrefix(env[i], "HOME="); ok { if info, err := os.Stat(home); err == nil && info.IsDir() { return home } break } } return os.TempDir() } // SaidBound bounds what Said keeps of a command's words, so a reason stays a line of a step's record and of a log. const SaidBound = 600 // Said is what a command said of its failure in its own words, for a reason: every line that says something, trimmed // and joined with "; ", bounded at SaidBound bytes. A line that is a bare prefix and nothing else (git writes "error:" // alone before and after the forge's own message, "remote:" or "fatal:" likewise) is left out, so the cause is never // cut off behind it: the first line alone of `git fetch` from a repository the account cannot read was "error:". func Said(s string) string { var keep []string for _, l := range strings.Split(s, "\n") { l = strings.TrimSpace(l) if l == "" || barePrefix(l) { continue } keep = append(keep, l) } out := strings.Join(keep, "; ") if len(out) > SaidBound { cut := SaidBound for cut > 0 && !utf8.RuneStart(out[cut]) { cut-- } out = out[:cut] + " …" } return out } // barePrefix says whether a line is one word and a colon, such as "error:" or "remote:", with nothing after it. func barePrefix(l string) bool { if !strings.HasSuffix(l, ":") { return false } return !strings.ContainsAny(strings.TrimSuffix(l, ":"), " \t:") }