package main import ( "bytes" "context" "errors" "fmt" "os/exec" "regexp" "strings" "time" ) // 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, so every tool can be tested without a daemon. type Runner func(ctx context.Context, name string, args ...string) Ran // ListTimeout is how long listing the store's files may take: below the runtime's thirty-second call // limit, so a store that hangs is answered as such rather than as a call the runtime gave up on. const ListTimeout = 15 * time.Second // ExecRunner runs a command on this machine, bounded by ListTimeout. func ExecRunner(ctx context.Context, name string, args ...string) Ran { ctx, cancel := context.WithTimeout(ctx, ListTimeout) defer cancel() cmd := exec.CommandContext(ctx, name, args...) 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, fmt.Sprintf("no answer within %d s", int(ListTimeout/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 } var socketRefused = regexp.MustCompile(`(?i)permission denied.*docker.*sock|docker\.sock.*permission denied`) // docker runs one docker command as this account and answers its stdout. A socket that refuses the // account is asked again through `sudo -n`, as the container runtime's own tools do; never as root // otherwise, and never with a prompt. func docker(ctx context.Context, run Runner, uid int, args ...string) (string, error) { r := run(ctx, "docker", args...) program := "docker" if r.Status != 0 && r.Err == "" && uid != 0 && socketRefused.MatchString(r.Stderr) { program = "sudo" r = run(ctx, "sudo", append([]string{"-n", "docker"}, args...)...) } if r.Status == 0 && r.Err == "" { return r.Stdout, nil } said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) switch { case r.Err == "ENOENT" && program == "sudo": return "", errors.New("the runtime's socket refused this account, and sudo is not installed here to escalate with") case r.Err == "ENOENT": return "", errors.New("docker is not installed on this machine, so the store's files cannot be listed") case r.Err != "": return "", fmt.Errorf("listing the store's files did not answer: %s", r.Err) case program == "sudo" && strings.HasPrefix(said, "sudo:"): return "", fmt.Errorf("the runtime's socket refused this account and it may not escalate without a prompt: %s", firstLine(said)) case strings.Contains(said, "No such container"): return "", fmt.Errorf("the store's container is not on this machine: %s", firstLine(said)) case strings.Contains(said, "is not running"): return "", fmt.Errorf("the store's container is not running: %s", firstLine(said)) } if l := firstLine(said); l != "" { return "", fmt.Errorf("listing the store's files failed (%d): %s", r.Status, l) } return "", fmt.Errorf("listing the store's files failed with status %d", r.Status) } func firstLine(s string) string { for _, l := range strings.Split(s, "\n") { if l = strings.TrimSpace(l); l != "" { return l } } return "" }