diff --git a/internal/execx/execx.go b/internal/execx/execx.go new file mode 100644 index 0000000..389d970 --- /dev/null +++ b/internal/execx/execx.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT + +// Package execx provides a thin, testable abstraction over external command +// execution. +// +// All privileged work in prx goes through a Runner so that the orchestration +// logic can be unit tested without touching the real system. +package execx + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" +) + +// Result is the outcome of running an external command. +type Result struct { + Stdout []byte + Stderr []byte + Code int +} + +// Runner abstracts external command execution. +type Runner interface { + // Run executes name with args. If stdin is non-nil it is fed to the + // process standard input. A non-nil error is returned when the command + // could not be started or exited with a non-zero status (*ExitError). + Run(ctx context.Context, stdin []byte, name string, args ...string) (Result, error) + // LookPath resolves the absolute path of an executable. + LookPath(name string) (string, error) +} + +// ExitError indicates that a command started but exited with a non-zero code. +type ExitError struct { + Name string + Args []string + Code int + Stderr []byte +} + +func (e *ExitError) Error() string { + msg := strings.TrimSpace(string(e.Stderr)) + if msg == "" { + msg = "no stderr" + } + return fmt.Sprintf("%s %s: exit status %d: %s", e.Name, strings.Join(e.Args, " "), e.Code, msg) +} + +// OSRunner runs commands on the host. +type OSRunner struct{} + +// Run implements Runner. +func (OSRunner) Run(ctx context.Context, stdin []byte, name string, args ...string) (Result, error) { + cmd := exec.CommandContext(ctx, name, args...) + if stdin != nil { + cmd.Stdin = bytes.NewReader(stdin) + } + var out, errb bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errb + err := cmd.Run() + res := Result{Stdout: out.Bytes(), Stderr: errb.Bytes()} + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + res.Code = ee.ExitCode() + return res, &ExitError{Name: name, Args: args, Code: res.Code, Stderr: res.Stderr} + } + return res, err + } + return res, nil +} + +// LookPath implements Runner. +func (OSRunner) LookPath(name string) (string, error) { return exec.LookPath(name) } + +// Call records a single fake invocation. +type Call struct { + Name string + Args []string + Stdin []byte +} + +// Fake is an in-memory Runner for tests. +type Fake struct { + // Handler produces the result for a call. When nil an empty successful + // result is returned. + Handler func(name string, args []string, stdin []byte) (Result, error) + // Calls records every invocation in order. + Calls []Call +} + +// Run implements Runner. +func (f *Fake) Run(_ context.Context, stdin []byte, name string, args ...string) (Result, error) { + f.Calls = append(f.Calls, Call{Name: name, Args: append([]string(nil), args...), Stdin: append([]byte(nil), stdin...)}) + if f.Handler == nil { + return Result{}, nil + } + return f.Handler(name, args, stdin) +} + +// LookPath implements Runner. +func (f *Fake) LookPath(name string) (string, error) { return "/usr/bin/" + name, nil }