diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..fccb921 --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,186 @@ +// 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") +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..dd206f9 --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT + +package client + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "prx/internal/proto" +) + +// writeFakeTool installs an executable shell script as name in a temporary +// directory and prepends that directory to PATH for the duration of the test. +func writeFakeTool(t *testing.T, name, script string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatalf("write fake %s: %v", name, err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +// The helper exits non-zero when an operation fails, but its structured +// answer on stdout must still reach the caller — otherwise the real reason +// for the failure is replaced by a bare "exit status 1". +func TestRunElevatedPreservesStructuredResult(t *testing.T) { + writeFakeTool(t, "sudo", `#!/bin/sh +echo '{"action":"apply","ok":false,"error":"engine start failed"}' +exit 1 +`) + out, err := runElevated(context.Background(), "sudo", "/usr/lib/prx/prx-helper", []byte(`{"action":"apply"}`)) + if err != nil { + t.Fatalf("structured result should be preferred over the exit status: %v", err) + } + var res proto.Result + if err := json.Unmarshal(out, &res); err != nil { + t.Fatalf("decode: %v", err) + } + if res.OK || res.Error != "engine start failed" { + t.Fatalf("result = %+v, want OK=false with the helper's error", res) + } +} + +func TestRunElevatedSudoPasswordRequired(t *testing.T) { + writeFakeTool(t, "sudo", `#!/bin/sh +echo "sudo: a password is required" >&2 +exit 1 +`) + _, err := runElevated(context.Background(), "sudo", "/usr/lib/prx/prx-helper", []byte("{}")) + if err == nil || !strings.Contains(err.Error(), "password") { + t.Fatalf("err = %v, want a password hint", err) + } + if !strings.Contains(err.Error(), "sudo prx") { + t.Fatalf("error should tell the user what to do: %v", err) + } +} + +func TestRunElevatedHelperProducesNothing(t *testing.T) { + writeFakeTool(t, "sudo", "#!/bin/sh\nexit 0\n") + _, err := runElevated(context.Background(), "sudo", "/usr/lib/prx/prx-helper", []byte("{}")) + if err == nil || !strings.Contains(err.Error(), "no result") { + t.Fatalf("err = %v, want 'no result'", err) + } +} + +func TestRunElevatedNonJSONOutputIsAnError(t *testing.T) { + writeFakeTool(t, "sudo", "#!/bin/sh\necho 'boom: not json'\nexit 3\n") + _, err := runElevated(context.Background(), "sudo", "/usr/lib/prx/prx-helper", []byte("{}")) + if err == nil || !strings.Contains(err.Error(), "boom: not json") { + t.Fatalf("err = %v, want the tool's output", err) + } +} + +func TestClassifyFailurePKexecCancellation(t *testing.T) { + cmd := exec.Command("sh", "-c", "exit 126") + err := cmd.Run() + if err == nil { + t.Fatal("expected 'sh -c exit 126' to fail") + } + got := classifyFailure("pkexec", err, nil, nil) + if !strings.Contains(got.Error(), "cancelled") { + t.Fatalf("got %q, want a cancellation message", got) + } +} + +func TestClassifyFailureUnknownToolError(t *testing.T) { + got := classifyFailure("sudo", exec.Command("sh", "-c", "exit 5").Run(), nil, []byte("sudo: unknown user: ahp")) + if !strings.Contains(got.Error(), "unknown user") { + t.Fatalf("got %q, want the stderr detail", got) + } +} + +func TestClassifyFailureIncludesStdout(t *testing.T) { + got := classifyFailure("pkexec", exec.Command("sh", "-c", "exit 5").Run(), []byte("helper exploded"), nil) + if !strings.Contains(got.Error(), "helper exploded") { + t.Fatalf("got %q, want the stdout detail", got) + } +} + +func TestHasStructuredResult(t *testing.T) { + if !hasStructuredResult([]byte(`{"ok":true}`)) { + t.Error("valid Result should be detected") + } + for _, bad := range []string{"", " \n", "not json", "[]", "42"} { + if hasStructuredResult([]byte(bad)) { + t.Errorf("hasStructuredResult(%q) = true, want false", bad) + } + } +} + +func TestFindHelperHonoursEnvOverride(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "prx-helper") + if err := os.WriteFile(p, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PRX_HELPER", p) + got, err := FindHelper() + if err != nil { + t.Fatalf("FindHelper: %v", err) + } + if got != p { + t.Fatalf("got %q, want %q", got, p) + } + + t.Setenv("PRX_HELPER", filepath.Join(dir, "does-not-exist")) + if _, err := FindHelper(); err == nil { + t.Fatal("expected an error for a broken PRX_HELPER override") + } +} + +// The helper must be discoverable on every common FHS layout, not just the one +// used by the machine prx was developed on. +func TestHelperCandidatesCoverCommonLayouts(t *testing.T) { + joined := strings.Join(helperCandidates(), "\n") + for _, want := range []string{ + "/usr/lib/prx/prx-helper", + "/usr/libexec/prx/prx-helper", + "/usr/local/lib/prx/prx-helper", + "/usr/local/libexec/prx/prx-helper", + } { + if !strings.Contains(joined, want) { + t.Errorf("helperCandidates missing %s", want) + } + } +}