Day 42 — os & os/exec
Day 42 — os & os/exec
Stage V · ~3h
Goal: Cross the process boundary safely—read env and files, spawn subprocesses with timeouts, capture stdout/stderr, and never shell-inject yourself.
Why this day exists
Services and CLIs constantly touch the OS:
- Config paths, PID files, temporary dirs
- Feature flags via environment
- Wrapping
git,ffmpeg, cloud CLIs, or health probes
os/exec is powerful and easy to misuse (hangs, leaked processes, injection via bash -c).
Theory 1 — os essentials
Files
data, err := os.ReadFile("config.json") // whole file
err = os.WriteFile("out.txt", data, 0o644) // create/truncate
f, err := os.Open("in.txt") // read-only
defer f.Close()
f, err = os.OpenFile("a.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)Paths & dirs
dir := os.TempDir()
err := os.MkdirAll("state/cache", 0o755)
info, err := os.Stat(path)
if errors.Is(err, os.ErrNotExist) { /* ... */ }
if info.IsDir() { /* ... */ }Environment
val, ok := os.LookupEnv("API_TOKEN")
port := os.Getenv("PORT") // "" if unset — often ambiguous
os.Setenv("DEBUG", "1") // process-localPrefer explicit LookupEnv when empty string is valid.
Args & exit
os.Args[0] // program name
os.Exit(1) // skips deferred calls in this goroutine's stack — prefer returning errors from mainPattern:
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}Theory 2 — os/exec model
cmd := exec.Command("echo", "hello", "world") // argv list — no shell
out, err := cmd.Output() // stdout; err may be *exec.ExitError| API | Captures | Notes |
|---|---|---|
Run |
nothing | Start+wait |
Output |
stdout | stderr discarded unless set |
CombinedOutput |
stdout+stderr merged | Handy for logs |
Start/Wait |
manual | Streaming pipes |
Always prefer argv over shell
// BAD — injection if user controls s
exec.Command("sh", "-c", "echo "+s)
// GOOD
exec.Command("echo", s)If you truly need a shell, document why and sanitize ruthlessly.
Theory 3 — Context deadlines
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sleep", "30")
err := cmd.Run()
// ctx exceeded → process killed (implementation: kill process group nuances on Unix)CommandContext sends kill when ctx is done. Always set timeouts on untrusted or external tools.
Capture streams with pipes
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return fmt.Errorf("git status: %w\nstderr=%s", err, stderr.String())
}Exit codes
var ee *exec.ExitError
if errors.As(err, &ee) {
code := ee.ExitCode()
_ = code
}Theory 4 — Stdin and interactive-ish tools
cmd := exec.Command("grep", "foo")
cmd.Stdin = strings.NewReader("foo\nbar\n")
out, err := cmd.Output()For large data, use cmd.StdinPipe() and write in a goroutine; close the pipe when done so the child sees EOF.
Theory 5 — Security & operations checklist
- Fixed binary path when possible (
/usr/bin/git) or resolve once.
- No user-controlled executable name without allowlist.
- Timeouts always for network-bound tools.
- Env scrubbing:
cmd.Env = append(os.Environ(), "KEY=val")or minimal env.
- Working directory:
cmd.Dir = dir.
- Signals: understand that killing parent may leave children unless process groups are set (
SysProcAttron Unix—advanced).
Worked example — run with timeout helper
package runx
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"time"
)
type Result struct {
Stdout string
Stderr string
ExitCode int
}
func Command(ctx context.Context, timeout time.Duration, name string, args ...string) (Result, error) {
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
cmd := exec.CommandContext(ctx, name, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
res := Result{Stdout: stdout.String(), Stderr: stderr.String()}
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
res.ExitCode = ee.ExitCode()
}
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return res, fmt.Errorf("timeout after %s running %s: %w", timeout, name, err)
}
return res, fmt.Errorf("%s: %w (stderr=%q)", name, err, res.Stderr)
}
return res, nil
}Lab 1 — Setup
mkdir -p ~/lab/90daysofx/01-go/day42
cd ~/lab/90daysofx/01-go/day42
go mod init example.com/day42Implement runx and a small CLI cmd/runxdemo.
Lab 2 — Happy path tests
Use portable commands:
func TestEcho(t *testing.T) {
res, err := Command(context.Background(), 2*time.Second, "echo", "hi")
if err != nil {
t.Fatal(err)
}
if got := strings.TrimSpace(res.Stdout); got != "hi" {
t.Fatalf("got %q", got)
}
}On Windows, adjust binary names; this volume assumes a Unix-like lab shell.
Lab 3 — Timeout behavior
func TestTimeout(t *testing.T) {
_, err := Command(context.Background(), 200*time.Millisecond, "sleep", "5")
if err == nil {
t.Fatal("expected timeout error")
}
}Confirm the error chain mentions timeout. Optionally check no long-lived sleep remains (pgrep manually).
Lab 4 — File + env integration
Write a tool that:
- Reads
APP_ENV(defaultdev)
- Loads
./config.$APP_ENV.jsonviaos.ReadFile
- Runs
echowith a summary line of config size and env
Reject path traversal if you accept a user -config flag (filepath.Clean, stay under workdir).
Lab 5 — Combined stderr (stretch)
Run a command that writes to stderr (ls of missing path). Ensure your helper surfaces stderr in the returned error string. Assert with errors.As on exit code ≠ 0.
Common gotchas
| Gotcha | Fix |
|---|---|
Command(userInput) as shell |
Argv list only; allowlist |
| No timeout | CommandContext + deadline |
| Ignoring stderr on failure | Capture and wrap |
os.Exit in libraries |
Return error |
| Relative binary relying on PATH | Document PATH; consider absolute |
| Deadlock with full pipe buffers | Copy stdout/stderr concurrently if large |
Assuming Output includes stderr |
It doesn’t—set cmd.Stderr |
Checkpoint
ReadFile/WriteFile/MkdirAllused intentionally
- Subprocess helper with timeout + stdout/stderr capture
- Test proves timeout kills long sleep
- Can explain why
sh -cis dangerous
- Exit codes inspected via
ExitError
mainreturns errors without losing defers carelessly
Commit
git add .
git commit -m "day42: os/exec with timeouts and safe argv"Write three personal gotchas before continuing.
Tomorrow
Day 43 — time: durations, timers, tickers, locations, monotonic clocks, and a cancellable mini-scheduler.