// SPDX-License-Identifier: MIT // Package client sends plans from the unprivileged CLI to the privileged // helper, either in-process (when already root) or through pkexec with a sudo // fallback, so prx works on systems with or without polkit. package client import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "prx/internal/execx" "prx/internal/helper" "prx/internal/proto" ) // Dispatch executes plan with the appropriate privilege level. func Dispatch(ctx context.Context, plan proto.Plan) (proto.Result, error) { // Already root: run the privileged logic in-process with no copy overhead. if os.Geteuid() == 0 { return helper.Execute(ctx, execx.OSRunner{}, plan), nil } raw, err := json.Marshal(plan) if err != nil { return proto.Result{}, fmt.Errorf("encode plan: %w", err) } helperPath, err := FindHelper() if err != nil { return proto.Result{}, err } out, err := runPrivileged(ctx, helperPath, raw) if err != nil { return proto.Result{}, err } var res proto.Result if err := json.Unmarshal(out, &res); err != nil { return proto.Result{}, fmt.Errorf("decode helper response: %w", err) } return res, nil } // runPrivileged executes the helper with elevated privileges. // // polkit's pkexec is preferred because it can prompt graphically; sudo is the // fallback so that minimal systems without polkit still work. sudo is invoked // non-interactively because its password prompt would otherwise consume the // JSON plan that is delivered on stdin. func runPrivileged(ctx context.Context, helperPath string, stdin []byte) ([]byte, error) { if _, err := exec.LookPath("pkexec"); err == nil { return runElevated(ctx, "pkexec", helperPath, stdin) } if _, err := exec.LookPath("sudo"); err == nil { return runElevated(ctx, "sudo", helperPath, stdin) } return nil, errors.New("neither pkexec nor sudo is available; install polkit or sudo, or run prx as root") } // runElevated runs the helper through tool and returns its stdout. // // The helper always writes a structured JSON Result to stdout, even when the // operation failed (a failed operation is still a valid answer), so a // structured answer on stdout always wins over the process exit status. The // returned error is reserved for transport-level problems: cancellation of the // authorization prompt, a helper that could not start, or a helper that // produced nothing usable. func runElevated(ctx context.Context, tool, helperPath string, stdin []byte) ([]byte, error) { var args []string switch tool { case "sudo": args = []string{"-n", "--", helperPath} default: args = []string{helperPath} } cmd := exec.CommandContext(ctx, tool, args...) cmd.Stdin = bytes.NewReader(stdin) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr runErr := cmd.Run() out := stdout.Bytes() if hasStructuredResult(out) { return out, nil } if runErr == nil { return nil, fmt.Errorf("%s: helper produced no result", tool) } return nil, classifyFailure(tool, runErr, out, stderr.Bytes()) } // hasStructuredResult reports whether out carries a well-formed JSON Result // from the helper. func hasStructuredResult(out []byte) bool { if len(bytes.TrimSpace(out)) == 0 { return false } var probe proto.Result return json.Unmarshal(out, &probe) == nil } // classifyFailure turns an elevated-run failure into an actionable message. // stdout is included because a helper or wrapper may report the reason there. func classifyFailure(tool string, runErr error, stdout, stderr []byte) error { msg := strings.TrimSpace(string(stderr)) out := strings.TrimSpace(string(stdout)) var ee *exec.ExitError switch tool { case "pkexec": if errors.As(runErr, &ee) { switch ee.ExitCode() { case 126: return errors.New("authorization was cancelled or not permitted; retry with 'sudo prx ...' if polkit is not set up") case 127: return fmt.Errorf("failed to start prx-helper: %s", firstNonEmptyString(msg, out, runErr.Error())) } } case "sudo": // `sudo -n` never prompts, so a missing credential surfaces as a // message containing one of these phrases. lower := strings.ToLower(msg) if strings.Contains(lower, "a password is required") || strings.Contains(lower, "a terminal is required") || strings.Contains(lower, "no tty present") { return errors.New("sudo needs a password; run 'sudo prx ...' directly, or install polkit (pkexec) for a graphical prompt") } } return fmt.Errorf("%s failed: %s", tool, firstNonEmptyString(msg, out, runErr.Error())) } // firstNonEmptyString returns the first non-blank value. func firstNonEmptyString(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return v } } return "" } // helperCandidates are the locations searched for prx-helper, covering the // common FHS layouts across distributions (Arch/Debian use /usr/lib, // Fedora/RHEL/openSUSE use /usr/libexec) plus local prefixes. func helperCandidates() []string { var paths []string if exe, err := os.Executable(); err == nil { dir := filepath.Dir(exe) paths = append(paths, filepath.Join(dir, "prx-helper"), filepath.Join(dir, "..", "libexec", "prx", "prx-helper"), filepath.Join(dir, "..", "lib", "prx", "prx-helper"), ) } return append(paths, "/usr/libexec/prx/prx-helper", "/usr/lib/prx/prx-helper", "/usr/local/libexec/prx/prx-helper", "/usr/local/lib/prx/prx-helper", ) } // FindHelper locates the prx-helper binary, honouring $PRX_HELPER first. func FindHelper() (string, error) { if p := os.Getenv("PRX_HELPER"); p != "" { if info, err := os.Stat(p); err != nil || info.IsDir() { return "", fmt.Errorf("PRX_HELPER points at %q, which is not an executable file", p) } return p, nil } for _, candidate := range helperCandidates() { if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 { return filepath.Clean(candidate), nil } } if p, err := exec.LookPath("prx-helper"); err == nil { return p, nil } return "", errors.New("prx-helper not found (searched $PATH, /usr/lib/prx and /usr/libexec/prx); install prx, set $PRX_HELPER, or re-run with sudo") }