A CSV row with a status code outside 3xx — most likely a typo like 0 or 30 — was accepted at load. On the first request matching that row, w.WriteHeader(statusCode) panics (net/http rejects codes < 100), taking down that request.
ReadCsvRedirects now rejects any code that isn't 300–399 at load time, with the line number in the message, and reports a non-numeric code clearly instead of surfacing the raw strconv error.
If you'd rather allow non-3xx (e.g. 410), loosen the check to 100–599 — that still prevents the panic. Noting it so the 3xx choice is deliberate.
Also
defer reader.Close() — the file was held open for the process lifetime.
Dropped the unreachable row == nil branch (csv.Reader signals end with io.EOF, already handled).
### The bug
A CSV row with a status code outside 3xx — most likely a typo like `0` or `30` — was accepted at load. On the first request matching that row, `w.WriteHeader(statusCode)` panics (`net/http` rejects codes `< 100`), taking down that request.
`ReadCsvRedirects` now rejects any code that isn't `300`–`399` at load time, with the line number in the message, and reports a non-numeric code clearly instead of surfacing the raw `strconv` error.
If you'd rather allow non-3xx (e.g. `410`), loosen the check to `100`–`599` — that still prevents the panic. Noting it so the 3xx choice is deliberate.
### Also
- `defer reader.Close()` — the file was held open for the process lifetime.
- Dropped the unreachable `row == nil` branch (`csv.Reader` signals end with `io.EOF`, already handled).
### Tests (first in the repo)
- `internal/csv_test.go` — valid file, reordered headings, header-only, missing heading, non-numeric status, non-3xx status, ragged row, missing file; plus `Headings.IndexOf`.
- `internal/redirect_test.go` — `Redirects.Find` (hit, miss, case-sensitivity) and `Redirect.Match`.
- `internal/http_test.go` — `ServeHTTP`: redirect with `Location`, 404 with none, query string dropped.
`go vet` / `go build` / `go test ./...` green.
Broader review notes are in a comment below — this repo has its issue tracker disabled, so they could not be filed as an issue.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
A row with a status code outside 3xx (e.g. "0" from a typo) was accepted
at load and then panicked net/http's WriteHeader on the first matching
request. ReadCsvRedirects now rejects anything that isn't 300-399, with a
line number in the message, and reports a non-numeric code clearly.
Also defer reader.Close() (the file was left open for the process
lifetime) and drop the unreachable "row == nil" check.
Adds internal/{csv,redirect,http}_test.go covering CSV parsing (valid,
reordered headings, header-only, missing heading, non-numeric and non-3xx
status, ragged row, missing file), Redirects.Find / Redirect.Match, and
ServeHTTP (redirect, 404, query string dropped).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review notes (the issue tracker is off on this repo, so leaving these here).
Review notes from a pass over the whole repo. The two PRs alongside handle the module path and the invalid-status panic + a starter test suite; this issue collects the judgement calls and smaller items.
Behaviour
Query string is dropped.ServeHTTP redirects to redirect.To verbatim, so /old?ref=x → Location: <To> with no ?ref=x. Fine if intended; worth a line in the README either way. Forwarding it would be To + "?" + req.URL.RawQuery when RawQuery != "".
No path normalisation. Matching is exact (from == r.From) against req.URL.Path, which always has a leading /. A CSV From of old (no slash) silently never matches; /old and /old/ are distinct. Consider trimming/normalising both sides at load, or at least documenting that From must be written exactly as the path arrives.
main.go panics on user error. A bad --file path prints a stack trace rather than a message + non-zero exit. And the server goroutine ends in panic(<-errc), so a failed bind (port in use) is also a stack trace. There's no signal handling / graceful shutdown. For a tool this small that may be acceptable, but a log.Fatal-style exit for the CSV-load and bind errors would be friendlier.
Efficiency
Redirects.Find is a linear scan per request. Matching is exact-string, so a map[string]*Redirect built once in ReadCsvRedirects would make lookups O(1). Keep the slice too if insertion order matters for logging.
CI
ci.yml runs only on pull_request; a direct push to main gets no vet/test/build. Add push: branches: [main].
govulncheck is go install ...@latest on every run — unpinned and re-downloaded each time. Pin a version, and ideally cache $GOPATH/bin or use a prebuilt action.
Smaller
CLI.Host defaults to localhost but there's no short flag and the Dockerfile has to pass --host 0.0.0.0; a 0.0.0.0 default (or an --all-interfaces convenience) would remove that footgun for the containerised case.
No LICENSE file (other repos here have one).
Remaining test gaps (after the test-suite PR)
main.go wiring (kong parsing, JoinHostPort) is untested — could be extracted into a testable run() function.
CSV with quoted fields / embedded commas, UTF-8 BOM, CRLF line endings.
ServeHTTP with a non-GET method (currently redirects regardless of method — is that wanted for POST?).
**Review notes** (the issue tracker is off on this repo, so leaving these here).
Review notes from a pass over the whole repo. The two PRs alongside handle the module path and the invalid-status panic + a starter test suite; this issue collects the judgement calls and smaller items.
## Behaviour
- **Query string is dropped.** `ServeHTTP` redirects to `redirect.To` verbatim, so `/old?ref=x` → `Location: <To>` with no `?ref=x`. Fine if intended; worth a line in the README either way. Forwarding it would be `To + "?" + req.URL.RawQuery` when `RawQuery != ""`.
- **No path normalisation.** Matching is exact (`from == r.From`) against `req.URL.Path`, which always has a leading `/`. A CSV `From` of `old` (no slash) silently never matches; `/old` and `/old/` are distinct. Consider trimming/normalising both sides at load, or at least documenting that `From` must be written exactly as the path arrives.
- **`main.go` panics on user error.** A bad `--file` path prints a stack trace rather than a message + non-zero exit. And the server goroutine ends in `panic(<-errc)`, so a failed bind (port in use) is also a stack trace. There's no signal handling / graceful shutdown. For a tool this small that may be acceptable, but a `log.Fatal`-style exit for the CSV-load and bind errors would be friendlier.
## Efficiency
- **`Redirects.Find` is a linear scan per request.** Matching is exact-string, so a `map[string]*Redirect` built once in `ReadCsvRedirects` would make lookups O(1). Keep the slice too if insertion order matters for logging.
## CI
- `ci.yml` runs only on `pull_request`; a direct push to `main` gets no vet/test/build. Add `push: branches: [main]`.
- `govulncheck` is `go install ...@latest` on every run — unpinned and re-downloaded each time. Pin a version, and ideally cache `$GOPATH/bin` or use a prebuilt action.
## Smaller
- `CLI.Host` defaults to `localhost` but there's no short flag and the Dockerfile has to pass `--host 0.0.0.0`; a `0.0.0.0` default (or an `--all-interfaces` convenience) would remove that footgun for the containerised case.
- No `LICENSE` file (other repos here have one).
## Remaining test gaps (after the test-suite PR)
- `main.go` wiring (kong parsing, `JoinHostPort`) is untested — could be extracted into a testable `run()` function.
- CSV with quoted fields / embedded commas, UTF-8 BOM, CRLF line endings.
- `ServeHTTP` with a non-GET method (currently redirects regardless of method — is that wanted for POST?).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The bug
A CSV row with a status code outside 3xx — most likely a typo like
0or30— was accepted at load. On the first request matching that row,w.WriteHeader(statusCode)panics (net/httprejects codes< 100), taking down that request.ReadCsvRedirectsnow rejects any code that isn't300–399at load time, with the line number in the message, and reports a non-numeric code clearly instead of surfacing the rawstrconverror.If you'd rather allow non-3xx (e.g.
410), loosen the check to100–599— that still prevents the panic. Noting it so the 3xx choice is deliberate.Also
defer reader.Close()— the file was held open for the process lifetime.row == nilbranch (csv.Readersignals end withio.EOF, already handled).Tests (first in the repo)
internal/csv_test.go— valid file, reordered headings, header-only, missing heading, non-numeric status, non-3xx status, ragged row, missing file; plusHeadings.IndexOf.internal/redirect_test.go—Redirects.Find(hit, miss, case-sensitivity) andRedirect.Match.internal/http_test.go—ServeHTTP: redirect withLocation, 404 with none, query string dropped.go vet/go build/go test ./...green.Broader review notes are in a comment below — this repo has its issue tracker disabled, so they could not be filed as an issue.
🤖 Generated with Claude Code
A row with a status code outside 3xx (e.g. "0" from a typo) was accepted at load and then panicked net/http's WriteHeader on the first matching request. ReadCsvRedirects now rejects anything that isn't 300-399, with a line number in the message, and reports a non-numeric code clearly. Also defer reader.Close() (the file was left open for the process lifetime) and drop the unreachable "row == nil" check. Adds internal/{csv,redirect,http}_test.go covering CSV parsing (valid, reordered headings, header-only, missing heading, non-numeric and non-3xx status, ragged row, missing file), Redirects.Find / Redirect.Match, and ServeHTTP (redirect, 404, query string dropped). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>Review notes (the issue tracker is off on this repo, so leaving these here).
Review notes from a pass over the whole repo. The two PRs alongside handle the module path and the invalid-status panic + a starter test suite; this issue collects the judgement calls and smaller items.
Behaviour
ServeHTTPredirects toredirect.Toverbatim, so/old?ref=x→Location: <To>with no?ref=x. Fine if intended; worth a line in the README either way. Forwarding it would beTo + "?" + req.URL.RawQuerywhenRawQuery != "".from == r.From) againstreq.URL.Path, which always has a leading/. A CSVFromofold(no slash) silently never matches;/oldand/old/are distinct. Consider trimming/normalising both sides at load, or at least documenting thatFrommust be written exactly as the path arrives.main.gopanics on user error. A bad--filepath prints a stack trace rather than a message + non-zero exit. And the server goroutine ends inpanic(<-errc), so a failed bind (port in use) is also a stack trace. There's no signal handling / graceful shutdown. For a tool this small that may be acceptable, but alog.Fatal-style exit for the CSV-load and bind errors would be friendlier.Efficiency
Redirects.Findis a linear scan per request. Matching is exact-string, so amap[string]*Redirectbuilt once inReadCsvRedirectswould make lookups O(1). Keep the slice too if insertion order matters for logging.CI
ci.ymlruns only onpull_request; a direct push tomaingets no vet/test/build. Addpush: branches: [main].govulncheckisgo install ...@lateston every run — unpinned and re-downloaded each time. Pin a version, and ideally cache$GOPATH/binor use a prebuilt action.Smaller
CLI.Hostdefaults tolocalhostbut there's no short flag and the Dockerfile has to pass--host 0.0.0.0; a0.0.0.0default (or an--all-interfacesconvenience) would remove that footgun for the containerised case.LICENSEfile (other repos here have one).Remaining test gaps (after the test-suite PR)
main.gowiring (kong parsing,JoinHostPort) is untested — could be extracted into a testablerun()function.ServeHTTPwith a non-GET method (currently redirects regardless of method — is that wanted for POST?).Approved. Go ahead and merge and extract the remaining test gaps into issues for another day.
Test gaps filed as #4 (it also points back here for the broader review notes). Merging.