Day 16 — io Interfaces, bufio & encoding/json
Day 16 — io Interfaces, bufio & encoding/json
Day 39 — io interfaces
Stage V · ~3h (composition-heavy)
Goal: Internalize Go’s streaming I/O model—minimal interfaces, composition, and correct error/EOF handling—then build a transforming Reader you can plug into io.Copy.
Stage V is stdlib that pays rent. Almost every network, file, and encoding path you touch for the rest of this volume is an io.Reader or io.Writer in disguise.
Why this day exists
fmt.Println and os.ReadFile hide the real model. Production Go streams bytes:
- Files, sockets, HTTP bodies, compressors, encoders
- Pipes between processes
- In-memory buffers that still speak the same interfaces
If you only ever load whole files into []byte, you will write fragile services when payloads grow or when you must chain transforms.
Theory 1 — The minimal contracts
From io (simplified):
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
type ReadCloser interface {
Reader
Closer
}
type WriteCloser interface {
Writer
Closer
}Rules that prevent bugs
Readmay returnn > 0anderr != nilin the same call. Process the bytes first, then the error.
io.EOFmeans “no more data,” not failure. Callers that treat EOF as fatal are wrong.
Writemust report short writes. Ifn < len(p)anderr == nil, that is a contract violation by the writer;io.ErrShortWriteexists for helpers that detect it.
- Interfaces are satisfied implicitly. Your type only needs the methods.
Why small interfaces win
Anything that can fill a buffer is a Reader. You can:
io.Copy(dst, src) // works for files, net.Conn, bytes.Buffer, http.Response.Body, ...without caring what src or dst really are.
Theory 2 — Copy, Limit, Multi, Tee
io.Copy
// Copy copies from src to dst until EOF or error.
// Returns bytes written and the first error encountered (EOF is not an error).
n, err := io.Copy(dst, src)Internally it uses a buffer (or WriterTo / ReaderFrom fast paths when available). Prefer Copy over hand-rolled loops unless you need special control.
Useful wrappers
| Helper | Role |
|---|---|
io.LimitReader(r, n) |
At most n bytes from r |
io.MultiReader(r1, r2, …) |
Concatenate readers |
io.MultiWriter(w1, w2, …) |
Fan-out writes |
io.TeeReader(r, w) |
Read from r while writing a copy to w |
io.NopCloser(r) |
Add a no-op Close to a bare Reader |
io.SectionReader |
Window into an io.ReaderAt |
Example — cap HTTP body size
const maxBody = 1 << 20 // 1 MiB
limited := io.LimitReader(r.Body, maxBody+1)
data, err := io.ReadAll(limited)
if err != nil {
return err
}
if len(data) > maxBody {
return fmt.Errorf("body too large")
}Theory 3 — io.Pipe and backpressure
pr, pw := io.Pipe()- Writes to
pwblock until someone reads frompr(buffer is small).
Close/CloseWithErrorunblocks the other side.
- Ideal for connecting a producer that wants a
Writerto a consumer that wants aReaderwithout buffering everything.
Pattern
pr, pw := io.Pipe()
go func() {
defer pw.Close()
// encode JSON, gzip, etc. into pw
_ = json.NewEncoder(pw).Encode(payload)
}()
// pass pr to http.Request.Body or io.CopyAlways handle goroutine lifecycle and errors (CloseWithError).
Theory 4 — Building a custom Reader
A transforming reader holds an inner io.Reader and rewrites bytes as they flow.
Contract checklist for custom Read:
- Copy at most
len(p)bytes intop.
- Return
(n, err)withn= bytes placed inp.
- Preserve EOF semantics of the inner reader.
- Do not retain
pafter return (caller owns the buffer).
Worked example — rot13 stream
package main
import (
"fmt"
"io"
"os"
"strings"
)
type rot13Reader struct {
r io.Reader
}
func rot13(b byte) byte {
switch {
case b >= 'A' && b <= 'Z':
return 'A' + (b-'A'+13)%26
case b >= 'a' && b <= 'z':
return 'a' + (b-'a'+13)%26
default:
return b
}
}
func (r rot13Reader) Read(p []byte) (int, error) {
n, err := r.r.Read(p)
for i := 0; i < n; i++ {
p[i] = rot13(p[i])
}
return n, err
}
func main() {
src := strings.NewReader("Hello, Stage V!")
var out strings.Builder
if _, err := io.Copy(&out, rot13Reader{r: src}); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(out.String())
}Run, then reverse by wrapping again (rot13 is involutive).
Theory 5 — ReaderFrom / WriterTo (efficiency awareness)
Optional interfaces:
type WriterTo interface {
WriteTo(w Writer) (n int64, err error)
}
type ReaderFrom interface {
ReadFrom(r Reader) (n int64, err error)
}io.Copy checks for these and skips intermediate buffering when both sides support a fast path (e.g. *os.File ↔︎ net.TCPConn on some platforms via sendfile-like paths). You rarely implement these on day one—but knowing why Copy is preferred over naive loops matters.
Theory 6 — Discard and black-hole patterns
io.Copy(io.Discard, resp.Body) // drain body so connection can be reused
resp.Body.Close()Leaving HTTP response bodies unread can stall connection reuse. Closing without draining is often wrong for keep-alive clients (Day 46 deepens this).
Lab 1 — Workspace + baseline Copy
mkdir -p ~/lab/90daysofx/01-go/day39
cd ~/lab/90daysofx/01-go/day39
go mod init example.com/day39Create copyfile.go:
package main
import (
"fmt"
"io"
"os"
)
func copyFile(dstPath, srcPath string) (int64, error) {
src, err := os.Open(srcPath)
if err != nil {
return 0, err
}
defer src.Close()
dst, err := os.Create(dstPath)
if err != nil {
return 0, err
}
defer dst.Close()
return io.Copy(dst, src)
}
func main() {
if len(os.Args) != 3 {
fmt.Fprintf(os.Stderr, "usage: %s <src> <dst>\n", os.Args[0])
os.Exit(2)
}
n, err := copyFile(os.Args[2], os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("copied %d bytes\n", n)
}echo 'stream me' > in.txt
go run . in.txt out.txt
cmp in.txt out.txt && echo OKLab 2 — Custom transforming Reader
Implement upperReader that uppercases ASCII letters as data is read (reuse the rot13 pattern).
Requirements:
type upperReader struct { r io.Reader }withReadmethod.
- Unit test using
strings.NewReader+io.ReadAll.
- Composition:
io.LimitReader(upperReader{r: src}, 5)and assert only 5 bytes emerge, already uppercased.
// upper_test.go
package main
import (
"io"
"strings"
"testing"
)
func TestUpperReader(t *testing.T) {
r := upperReader{r: strings.NewReader("abC12")}
got, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if string(got) != "ABC12" {
t.Fatalf("got %q", got)
}
}
func TestUpperLimited(t *testing.T) {
r := io.LimitReader(upperReader{r: strings.NewReader("hello world")}, 5)
got, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if string(got) != "HELLO" {
t.Fatalf("got %q", got)
}
}go test ./...Lab 3 — Tee + MultiWriter journal
Build a small tool that reads stdin, writes a copy to a log file and stdout:
mw := io.MultiWriter(os.Stdout, logFile)
if _, err := io.Copy(mw, os.Stdin); err != nil {
// handle
}Or use io.TeeReader(os.Stdin, logFile) then io.Copy(os.Stdout, tee).
Document which approach buffers less for your OS (both stream; difference is API shape).
Lab 4 — Pipe encode (stretch)
In a goroutine, write a multi-line payload through json.NewEncoder(pw). In main, io.Copy(os.Stdout, pr). Ensure:
- Producer calls
pw.Close()orCloseWithError
- Consumer sees clean EOF
- Injecting an encode error surfaces via
CloseWithError
Common gotchas
| Gotcha | Fix |
|---|---|
Ignoring n when err != nil on Read |
Always process p[:n] first |
Treating io.EOF as hard failure |
EOF ends the stream successfully for Copy |
| Buffering multi-GB files in memory | Stream with Copy / limited readers |
Forgetting Close on ReadCloser |
defer resp.Body.Close() (and drain when needed) |
Retaining the Read buffer |
Copy out data you need; never store p |
| Pipe without consumer | Deadlock—always have a reader side |
Assuming Write always writes all |
Check n and err; use io.Copy helpers |
Mental model diagram (text)
[source] --Read--> [transform Reader] --Read--> [LimitReader] --Copy--> [Writer]
^ composition: each layer only implements Read
Checkpoint
- Explain
Readreturning(n>0, err)in one sentence
- List five concrete types that implement
io.Reader
- Custom transforming
Readertested withgo test
- Demonstrated
LimitReadercomposition
- Used
io.Copyinstead of a hand-rolled full-buffer loop
- Can explain when to use
io.Pipe
Commit
git add .
git commit -m "day39: io interfaces, composition, transforming Reader"Write three personal gotchas before continuing.
Tomorrow
Day 40 — bufio / bytes / strings: buffering for efficient line/word scanning, bytes.Buffer, and strings.Builder for a streaming record parser.
Day 40 — bufio, bytes & strings
Stage V · ~3h
Goal: Parse streams efficiently with bufio, manage byte slices with bytes, and build strings without quadratic concat—then ship a small record parser with tests.
Why this day exists
Raw io.Reader gives you arbitrary chunks. Real formats need lines, tokens, and delimited records. Doing that with tiny Read loops is error-prone and slow; loading everything into memory reintroduces Day 39’s failure mode.
bufio, bytes, and strings are the stdlib trio for text-ish I/O at the right level.
Theory 1 — Why buffer?
Each Read on a file or socket can be a syscall. bufio.Reader amortizes:
br := bufio.NewReader(r) // default 4096-byte buffer
line, err := br.ReadString('\n')| Type | Role |
|---|---|
bufio.Reader |
Buffered reads; Peek, ReadByte, ReadSlice, ReadString |
bufio.Writer |
Buffered writes; must Flush |
bufio.Scanner |
Tokenize with split funcs (lines, words, custom) |
bufio.ReadWriter |
Both sides |
Scanner vs manual ReadString
Prefer Scanner for line/word protocols unless you need partial control or binary data:
sc := bufio.NewScanner(r)
// Optional: raise token size for long lines (default ~64KiB)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := sc.Text() // or sc.Bytes() — careful: Bytes is overwritten next Scan
_ = line
}
if err := sc.Err(); err != nil {
// handle scan error (not EOF)
}sc.Bytes() returns a slice owned by the scanner. Copy it (append([]byte(nil), sc.Bytes()...)) if you retain it past the next Scan.
Theory 2 — Split functions
sc.Split(bufio.ScanLines) // default
sc.Split(bufio.ScanWords)
sc.Split(bufio.ScanRunes)
sc.Split(bufio.ScanBytes)Custom split for comma-separated records (simplified):
func scanComma(data []byte, atEOF bool) (advance int, token []byte, err error) {
if i := bytes.IndexByte(data, ','); i >= 0 {
return i + 1, data[:i], nil
}
if atEOF && len(data) > 0 {
return len(data), data, nil
}
return 0, nil, nil // need more data
}Theory 3 — bytes package
bytes mirrors many strings functions for []byte without converting to string (and back).
| Need | API |
|---|---|
| Search | Contains, Index, HasPrefix, Count |
| Cut | Cut, CutPrefix, CutSuffix (modern, clear) |
| Fields | Fields, Split, SplitN |
| Trim | TrimSpace, TrimPrefix |
| Compare | Equal, Compare |
| Buffer | bytes.Buffer — grows, implements io.Reader/Writer |
bytes.Buffer
var buf bytes.Buffer
buf.WriteString("hello")
buf.WriteByte(' ')
fmt.Fprintf(&buf, "day %d", 40)
s := buf.String() // copies; or buf.Bytes() (valid until next mutate)Use Buffer when you need an in-memory Reader/Writer. For pure string assembly, prefer strings.Builder.
Theory 4 — strings package & Builder
var b strings.Builder
b.Grow(64) // optional hint
b.WriteString("id=")
b.WriteString(id)
out := b.String()Never build large strings with s = s + piece in a loop—quadratic copies.
Useful modern helpers
before, after, ok := strings.Cut(line, "=")
prefix, ok := strings.CutPrefix(s, "Bearer ")Cut is clearer than SplitN(..., 2) for “key/value once.”
Theory 5 — String vs []byte cost
- Conversion
string(bs)/[]byte(s)copies data.
- Hot parsers often stay in
[]byteuntil a final string is needed.
Scanner.Text()allocates a string;Bytes()does not (but is ephemeral).
For Day 40 labs, clarity beats micro-opts—but know the cost model.
Worked example — NDJSON-ish record parser
Format (one record per line):
name=ada role=eng active=true
name=alan role=research active=false
package parse
import (
"bufio"
"fmt"
"io"
"strings"
)
type Record struct {
Name string
Role string
Active bool
}
func ParseRecords(r io.Reader) ([]Record, error) {
sc := bufio.NewScanner(r)
var out []Record
lineNo := 0
for sc.Scan() {
lineNo++
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
rec, err := parseLine(line)
if err != nil {
return nil, fmt.Errorf("line %d: %w", lineNo, err)
}
out = append(out, rec)
}
return out, sc.Err()
}
func parseLine(line string) (Record, error) {
var rec Record
fields := strings.Fields(line)
for _, f := range fields {
key, val, ok := strings.Cut(f, "=")
if !ok {
return rec, fmt.Errorf("bad field %q", f)
}
switch key {
case "name":
rec.Name = val
case "role":
rec.Role = val
case "active":
switch val {
case "true":
rec.Active = true
case "false":
rec.Active = false
default:
return rec, fmt.Errorf("active=%q", val)
}
default:
return rec, fmt.Errorf("unknown key %q", key)
}
}
if rec.Name == "" {
return rec, fmt.Errorf("missing name")
}
return rec, nil
}Lab 1 — Setup + golden parse
mkdir -p ~/lab/90daysofx/01-go/day40
cd ~/lab/90daysofx/01-go/day40
go mod init example.com/day40Put parser in parse/parse.go and tests in parse/parse_test.go.
Sample input testdata/people.txt:
# staff
name=ada role=eng active=true
name=alan role=research active=false
Table-driven tests:
func TestParseRecords(t *testing.T) {
in := "name=ada role=eng active=true\nname=x role=y active=maybe\n"
_, err := ParseRecords(strings.NewReader(in))
if err == nil {
t.Fatal("expected error on bad active")
}
}Also test: empty lines, comments, missing name, unknown keys.
go test ./...Lab 2 — Streaming CLI
go run . < people.txt
# print: ada eng true
Use bufio.NewWriter(os.Stdout) and Flush in defer (or after loop). Compare wall time on a large generated file vs reading with os.ReadFile + strings.Split—observe memory with a big file if you wish (/usr/bin/time -l on macOS).
Generate:
python3 -c 'print("name=u role=r active=true\n"*200000)' > big.txtLab 3 — Custom Scanner split
Implement a split that yields paragraphs (blocks separated by blank lines). Print paragraph lengths. Edge cases: trailing content without final blank line; multiple blank lines.
Lab 4 — Builder report
From parsed records, emit a summary string with strings.Builder:
count=2 active=1
Benchmark (optional) Builder vs + concatenation for N=10_000 field appends:
go test -bench=Builder -benchmem ./...Common gotchas
| Gotcha | Fix |
|---|---|
Forgetting sc.Err() |
Always check after the loop |
Keeping sc.Bytes() |
Copy if storing |
| Token too long | sc.Buffer(buf, max) |
Forgetting Writer.Flush |
defer bw.Flush() and check error |
strings.Split empty edge |
Prefer Cut / Fields for clarity |
Quadratic s += |
strings.Builder |
| Assuming Latin-1 lines | UTF-8 is fine; runes need ScanRunes or utf8 |
Checkpoint
- Explain why
bufioexists in terms of syscalls
- Parser handles comments, blanks, and bad fields with line numbers
- Tests cover at least four failure modes
- CLI streams stdin → stdout without
ReadAllof whole file
- Can state when to use
bytes.Buffervsstrings.Builder
- Know Scanner’s default max token size risk
Commit
git add .
git commit -m "day40: bufio scanner parser + bytes/strings tools"Write three personal gotchas before continuing.
Tomorrow
Day 41 — encoding/json: tags, omitempty, custom marshalers, streaming encode/decode, and awareness only of the json/v2 direction—without rewriting production code onto experimental APIs.
Day 41 — encoding/json
Stage V · ~3h
Goal: Load and validate JSON configuration with struct tags, custom types, and streaming APIs—using today’s production stdlib encoding/json, while knowing where json/v2 is headed.
Why this day exists
JSON is the default interchange for HTTP APIs, configs, and cloud control planes. Go’s encoding/json is powerful but full of sharp edges: exported fields only, surprising nil vs empty, number → float64 defaults, and timezone formats.
Master the v1 package thoroughly; treat v2 as direction awareness, not a rewrite mandate for this volume.
Theory 1 — Marshal / Unmarshal basics
type Config struct {
Host string `json:"host"`
Port int `json:"port"`
}
b, err := json.Marshal(Config{Host: "localhost", Port: 8080})
// b == []byte(`{"host":"localhost","port":8080}`)
var c Config
err = json.Unmarshal(b, &c)Tag grammar (common)
| Tag | Effect |
|---|---|
json:"name" |
Wire name |
json:"-" |
Skip field |
json:"name,omitempty" |
Omit if empty |
json:",omitempty" |
Default name + omitempty |
json:"name,string" |
Encode number/bool as JSON string |
Empty for omitempty: false, 0, "", nil pointer/slice/map, empty slice/map, zero struct.
Visibility rule
Only exported fields participate. Unexported fields are silently ignored—a classic “why is my field missing?” bug.
Theory 2 — Streaming Encoder / Decoder
dec := json.NewDecoder(r)
dec.DisallowUnknownFields() // optional strictness
var cfg Config
if err := dec.Decode(&cfg); err != nil {
return err
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(cfg); err != nil { // Encode adds trailing newline
return err
}Prefer Decoder for large streams and NDJSON:
for {
var rec Record
if err := dec.Decode(&rec); err == io.EOF {
break
} else if err != nil {
return err
}
// handle rec
}Theory 3 — Custom marshaling
type DurationMS time.Duration
func (d DurationMS) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).Milliseconds())
}
func (d *DurationMS) UnmarshalJSON(b []byte) error {
var ms int64
if err := json.Unmarshal(b, &ms); err != nil {
return err
}
*d = DurationMS(time.Duration(ms) * time.Millisecond)
return nil
}Also available: MarshalText / UnmarshalText for types used as map keys or textual forms.
json.RawMessage
Delay parsing of nested blobs:
type Envelope struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}Theory 4 — Numbers, time, and maps
| Pattern | Behavior |
|---|---|
Decode into interface{} / any |
Numbers become float64 |
| Large integers | Prefer json.Number via Decoder.UseNumber() or typed fields |
time.Time |
RFC3339 by default |
map[string]any |
Flexible but weakly typed—validate yourself |
dec := json.NewDecoder(r)
dec.UseNumber()Theory 5 — json/v2 awareness (only)
Go’s ecosystem is evolving a second JSON implementation (often discussed as encoding/json/v2 / experimental packages). High-level intent:
- Cleaner performance and behavior defaults
- More consistent case handling and nil semantics
- Opt-in migration path rather than silent break
For this volume (Go 1.26 baseline):
- Ship production code on
encoding/json(v1).
- Read release notes when v2 lands in your toolchain.
- Do not churn working services onto experimental APIs for homework.
- When you evaluate v2 later: re-run tests, check
omitempty/nilslice differences, and benchmark hot paths.
Awareness ≠ adoption. Interview answer: “I know v2 is the modernization path; production today is v1 with explicit tests around edge cases.”
Worked example — config loader
package config
import (
"encoding/json"
"fmt"
"io"
"os"
"time"
)
type Config struct {
Service string `json:"service"`
HTTPAddr string `json:"http_addr"`
Timeout time.Duration `json:"timeout"` // custom via wrapper below if needed
Features FeatureFlags `json:"features"`
Upstream []string `json:"upstream,omitempty"`
}
type FeatureFlags struct {
BetaUI bool `json:"beta_ui"`
Debug bool `json:"debug"`
}
// Duration as string like "5s" in JSON:
type Duration struct{ time.Duration }
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
func (d *Duration) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
parsed, err := time.ParseDuration(s)
if err != nil {
return err
}
d.Duration = parsed
return nil
}
type FileConfig struct {
Service string `json:"service"`
HTTPAddr string `json:"http_addr"`
Timeout Duration `json:"timeout"`
Features FeatureFlags `json:"features"`
Upstream []string `json:"upstream,omitempty"`
}
func Load(r io.Reader) (FileConfig, error) {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
var cfg FileConfig
if err := dec.Decode(&cfg); err != nil {
return cfg, err
}
if err := cfg.Validate(); err != nil {
return cfg, err
}
return cfg, nil
}
func (c FileConfig) Validate() error {
if c.Service == "" {
return fmt.Errorf("service is required")
}
if c.HTTPAddr == "" {
return fmt.Errorf("http_addr is required")
}
if c.Timeout.Duration <= 0 {
return fmt.Errorf("timeout must be positive")
}
return nil
}
func LoadFile(path string) (FileConfig, error) {
f, err := os.Open(path)
if err != nil {
return FileConfig{}, err
}
defer f.Close()
return Load(f)
}Example config.json:
{
"service": "demo",
"http_addr": ":8080",
"timeout": "3s",
"features": { "beta_ui": true, "debug": false },
"upstream": ["http://127.0.0.1:9000"]
}Lab 1 — Module + happy path
mkdir -p ~/lab/90daysofx/01-go/day41
cd ~/lab/90daysofx/01-go/day41
go mod init example.com/day41Implement config package above. CLI:
go run ./cmd/loadconfig -config config.jsonPrint validated fields. Exit non-zero on error.
Lab 2 — Strictness & errors
Tests must cover:
- Unknown field rejected (
DisallowUnknownFields)
- Missing
service
- Bad duration string (
"banana")
- Empty file / invalid JSON syntax
- Round-trip:
MarshalIndent→Unmarshalequality for timeout
func TestUnknownField(t *testing.T) {
_, err := Load(strings.NewReader(`{"service":"x","http_addr":":1","timeout":"1s","nope":1}`))
if err == nil {
t.Fatal("expected unknown field error")
}
}Lab 3 — NDJSON metrics stream
Write StreamDecode(r io.Reader, fn func(Rec) error) error that decodes one JSON object per call until EOF. Feed:
{"n":1}
{"n":2}
Assert fn invoked twice. Handle trailing garbage as error.
Lab 4 — Interface decode discipline
Decode {"x": 9007199254740993} into map[string]any and observe float precision issues. Re-decode with UseNumber and show json.Number.Int64(). Write a 5-line note in README: when to use typed structs vs any.
Common gotchas
| Gotcha | Fix |
|---|---|
| Unexported fields missing | Export + tag |
nil slice vs [] |
Decide API; omitempty omits both empty and nil |
json.Marshal of channel/func |
Error—don’t |
| Floating large ints | Typed ints or UseNumber |
Forgetting pointer receiver on UnmarshalJSON |
Must be *T to mutate |
Encode trailing newline |
Expected; trim in tests if comparing exact bytes |
| Blind trust of external JSON | Validate after decode |
Checkpoint
- Config loads from file with duration strings
DisallowUnknownFieldsproven by test
- Custom
MarshalJSON/UnmarshalJSONworks
- Can explain float64 default for
anynumbers
- Can state a one-paragraph json/v2 awareness answer
- NDJSON stream decoder handles EOF correctly
Commit
git add .
git commit -m "day41: encoding/json config loader + custom types"Write three personal gotchas before continuing.
Tomorrow
Day 42 — os / os/exec: files, env, permissions, and subprocesses with CommandContext timeouts—without shell injection footguns.