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 }