Day 42 — os & os/exec

Updated

July 30, 2026

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-local

Prefer 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 main

Pattern:

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

  1. Fixed binary path when possible (/usr/bin/git) or resolve once.
  2. No user-controlled executable name without allowlist.
  3. Timeouts always for network-bound tools.
  4. Env scrubbing: cmd.Env = append(os.Environ(), "KEY=val") or minimal env.
  5. Working directory: cmd.Dir = dir.
  6. Signals: understand that killing parent may leave children unless process groups are set (SysProcAttr on 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/day42

Implement 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:

  1. Reads APP_ENV (default dev)
  2. Loads ./config.$APP_ENV.json via os.ReadFile
  3. Runs echo with 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 / MkdirAll used intentionally
  • Subprocess helper with timeout + stdout/stderr capture
  • Test proves timeout kills long sleep
  • Can explain why sh -c is dangerous
  • Exit codes inspected via ExitError
  • main returns 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.