143 lines
4.1 KiB
Go
143 lines
4.1 KiB
Go
package osexec
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"regexp"
|
|
"time"
|
|
)
|
|
|
|
// Typed errors
|
|
var (
|
|
ErrTimeout = errors.New("command timeout")
|
|
)
|
|
|
|
// ErrExitNonZero is returned when a command returned a non-zero exit status.
|
|
type ErrExitNonZero struct {
|
|
ExitCode int
|
|
Stdout string
|
|
Stderr string
|
|
}
|
|
|
|
func (e ErrExitNonZero) Error() string {
|
|
return fmt.Sprintf("command exit status %d", e.ExitCode)
|
|
}
|
|
|
|
// Runner defines the behavior for executing a command. Implementations can be injected for tests.
|
|
type Runner interface {
|
|
Run(ctx context.Context, name string, args ...string) (stdout string, stderr string, exitCode int, err error)
|
|
}
|
|
|
|
// DefaultMaxOutputBytes limits stdout/stderr returned by Exec.
|
|
const DefaultMaxOutputBytes = 64 * 1024
|
|
|
|
// DefaultRunner executes real OS commands.
|
|
type DefaultRunner struct {
|
|
RedactPatterns []*regexp.Regexp
|
|
MaxOutputBytes int
|
|
}
|
|
|
|
func NewDefaultRunner(patterns []string) *DefaultRunner {
|
|
var compiled []*regexp.Regexp
|
|
for _, p := range patterns {
|
|
r, err := regexp.Compile(p)
|
|
if err == nil {
|
|
compiled = append(compiled, r)
|
|
}
|
|
}
|
|
return &DefaultRunner{RedactPatterns: compiled, MaxOutputBytes: DefaultMaxOutputBytes}
|
|
}
|
|
|
|
func (r *DefaultRunner) redact(s string) string {
|
|
if len(r.RedactPatterns) == 0 {
|
|
return s
|
|
}
|
|
out := s
|
|
for _, re := range r.RedactPatterns {
|
|
out = re.ReplaceAllString(out, "[REDACTED]")
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (r *DefaultRunner) truncate(s string) (string, bool) {
|
|
max := r.MaxOutputBytes
|
|
if max <= 0 || len(s) <= max {
|
|
return s, false
|
|
}
|
|
return s[:max] + "... (truncated)", true
|
|
}
|
|
|
|
// Run executes a command using os/exec and returns captured output and exit code.
|
|
func (r *DefaultRunner) Run(ctx context.Context, name string, args ...string) (string, string, int, error) {
|
|
start := time.Now()
|
|
// use exec.CommandContext to respect context deadlines
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
outBytes, err := cmd.Output() // only captures stdout; we rely on combined output for simplicity
|
|
var stderr []byte
|
|
if err != nil {
|
|
if ee, ok := err.(*exec.ExitError); ok {
|
|
stderr = ee.Stderr
|
|
}
|
|
}
|
|
// If context timed out
|
|
if ctx.Err() == context.DeadlineExceeded || ctx.Err() == context.Canceled {
|
|
return string(outBytes), string(stderr), -1, ErrTimeout
|
|
}
|
|
|
|
stdout := string(outBytes)
|
|
sErrStr := string(stderr)
|
|
// Truncate outputs if needed
|
|
stdout, _ = r.truncate(stdout)
|
|
sErrStr, _ = r.truncate(sErrStr)
|
|
|
|
// If command failed with non-zero exit
|
|
if err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
code := exitErr.ExitCode()
|
|
return stdout, sErrStr, code, ErrExitNonZero{ExitCode: code, Stdout: stdout, Stderr: sErrStr}
|
|
}
|
|
return stdout, sErrStr, -1, err
|
|
}
|
|
|
|
_ = time.Since(start) // measure duration; can be logged by caller
|
|
return stdout, sErrStr, 0, nil
|
|
}
|
|
|
|
// Exec runs using DefaultRunner; default patterns are warmed up empty
|
|
var Default = NewDefaultRunner(nil)
|
|
|
|
// Exec is a convenience function using the Default runner.
|
|
func Exec(ctx context.Context, name string, args ...string) (string, string, int, error) {
|
|
return Default.Run(ctx, name, args...)
|
|
}
|
|
|
|
// ExecWithRunner uses the provided Runner to execute and normalizes errors.
|
|
func ExecWithRunner(r Runner, ctx context.Context, name string, args ...string) (string, string, int, error) {
|
|
out, errOut, code, err := r.Run(ctx, name, args...)
|
|
// timeout
|
|
if ctx.Err() == context.DeadlineExceeded || ctx.Err() == context.Canceled {
|
|
return out, errOut, -1, ErrTimeout
|
|
}
|
|
// runner returned an error
|
|
if err != nil {
|
|
// If error implements ErrExitNonZero (already constructed by runner), return as is
|
|
var exitErr ErrExitNonZero
|
|
if errors.As(err, &exitErr) {
|
|
return exitErr.Stdout, exitErr.Stderr, exitErr.ExitCode, exitErr
|
|
}
|
|
// If it's an exec.ExitError, wrap in ErrExitNonZero
|
|
var execExit *exec.ExitError
|
|
if errors.As(err, &execExit) {
|
|
return out, errOut, execExit.ExitCode(), ErrExitNonZero{ExitCode: execExit.ExitCode(), Stdout: out, Stderr: errOut}
|
|
}
|
|
return out, errOut, code, err
|
|
}
|
|
// no error, but a non-zero exit code
|
|
if code != 0 {
|
|
return out, errOut, code, ErrExitNonZero{ExitCode: code, Stdout: out, Stderr: errOut}
|
|
}
|
|
return out, errOut, code, nil
|
|
}
|