Skip to content

fix(lock): stop leaking bash's redirection error on a root-owned lock - #38

Merged
johnny4young merged 3 commits into
mainfrom
fix/lock-pid-stderr-leak
Sep 10, 2026
Merged

johnny4young merged 3 commits into
mainfrom
fix/lock-pid-stderr-leak

Conversation

@johnny4young

Copy link
Copy Markdown
Owner

What

gos latest on a host where the Go install slot sits under a root-owned parent (/usr/local on macOS with Homebrew at /opt/homebrew) printed a bare permission error before doing the right thing:

Password:
/opt/homebrew/bin/gos: line 1342: /usr/local/go.gos-lock/pid: Permission denied
Fetching latest stable Go version...
...
Done! go version go1.27.1 darwin/arm64

The Password: prompt is expected — creating /usr/local/go.gos-lock needs root, like the backup rename that follows. The line after it was not.

Why it happened

_gos_acquire_lock wrote the pid with:

if ! printf '%s\n' "$$" >"$pid_file" 2>/dev/null; then

Bash performs redirections left to right and reports a failed one on the stderr in effect at that moment. >"$pid_file" is attempted while stderr is still the terminal, so the 2>/dev/null that was meant to silence it never applies. The lock directory was created by root through the sudo escalation, so the user shell cannot write inside it and the redirection fails.

A brace group puts the guard in effect first:

if ! { printf '%s\n' "$$" >"$pid_file"; } 2>/dev/null; then

Everything else is untouched: the lock is still acquired, and the sudo tee fallback still records the pid.

cmd_completions had the identical ordering when --install targets a directory that exists but is not writable — bash's error printed alongside gos's own could not write completion file. Same fix. These were the only two occurrences in gos.sh.

The defect dates to 266c8d0 (the mutation-lock feature), so every release since carries it, including v1.11.0.

Impact

Cosmetic in practice — the update completes and the lock is released. The one edge it protects: if the sudo tee fallback also fails, the lock keeps no pid, and a later run reports "another gos operation appears to be running (the lock has no pid recorded)", which _gos_lock_state already documents.

Tests

Both paths get a regression that fails on the previous code and passes on this one.

  • tests/lock-rollback.bash emulates a root-created lock without root: a mkdir wrapper leaves any .gos-lock directory read-only, and a sudo stand-in lifts the write bit for the command and restores it — the real sudo is never invoked, so the suite never prompts for a password. It then runs gos prune --rollback and asserts stderr stays clean, the pid still reaches the lock through the sudo fallback, and the lock is released.
  • tests/completions.bash runs completions bash --install against a read-only XDG directory and asserts only gos's own error appears.

Both cases skip with an ok - line when the suite runs as root, since root writes into a read-only directory regardless.

Verified on the previous gos.sh:

not ok - root-owned lock pid stderr: unexpected 'Permission denied'. Output: gos.sh: line 1342: .../go.gos-lock/pid: Permission denied
not ok - unwritable completion redirection leak: unexpected 'Permission denied'. Output: gos.sh: line 5267: .../completions/gos: Permission denied

Local gates: scripts/validate-local.bash passes — 27 suites, 2 skipped on macOS, plus ShellCheck and shfmt -d -i 2 -ci -bn ..

Note on the changelog

tests/changelog.bash was already failing on main: the three Dependabot action bumps are post-tag commits and the guard requires Unreleased notes for them. The Unreleased section here covers both this fix and those bumps, so the gate goes green.

No release in this PR.

Writing the pid into the mutation lock used `>"$pid_file" 2>/dev/null`.
Bash performs redirections left to right and reports a failed one on the
stderr in effect at that moment, so the guard was applied too late: when
the lock directory belongs to root -- the sudo escalation gos takes under
a protected parent such as /usr/local -- `gos latest` printed

    gos: line 1342: /usr/local/go.gos-lock/pid: Permission denied

before going on to install normally. Brace-group the write so 2>/dev/null
is in effect before the inner redirection is attempted. The sudo fallback
that records the pid is unchanged, and the lock is still acquired.

`gos completions <shell> --install` had the same ordering against an
unwritable target, printing bash's error next to its own; fix it the same
way.

Both paths get a regression: the lock case emulates a root-created lock
(a mkdir wrapper that leaves .gos-lock read-only plus a sudo stand-in
that never calls the real sudo) and asserts stderr stays clean while the
pid still reaches the lock through sudo. Both fail on the previous code.

The Unreleased notes also cover the three Dependabot action bumps already
on main, which the changelog guard counts.
The new unwritable-target case turned a denied write into an assertion,
but tests/completions.bash runs on Windows too, where chmod 555 does not
stop the owner from writing. `--install` succeeded there and CI failed:

    not ok - completions --install should fail when the target is not writable

Replace the root check in both new cases with readonly_bit_enforced(),
which writes into a mode 555 directory and reports whether the write was
actually denied, restoring the mode either way. That covers Windows and
root in one guard, and asserts the platform property rather than guessing
it from the OS name. A file-level skip-os=windows is not an option here:
the rest of the completions suite is exactly what Windows needs to cover.

The helper brace-groups its own probe write for the reason this branch
exists in the first place.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The functional change is minimal and well-covered by targeted regressions, with only a minor readability tweak suggested in the new test helper.

Pull request overview

This PR fixes a Bash redirection-ordering issue that leaked a raw Permission denied message when writing lock/completion files in unwritable directories, while preserving the existing sudo fallback behavior and adding regressions to prevent reintroduction.

Changes:

  • Brace-groups the lock PID write and completions --install write so 2>/dev/null applies before the guarded redirection is attempted.
  • Adds regression coverage for (1) a root-created (user-unwritable) lock directory and (2) an existing-but-unwritable completions install directory, with filesystem capability probing.
  • Updates CHANGELOG.md Unreleased notes to cover the fix and existing Dependabot bumps.
File summaries
File Description
tests/lock-rollback.bash Adds a regression that simulates a root-created lock dir and asserts stderr stays clean while the sudo fallback still records the PID and the lock is released.
tests/lib.bash Introduces readonly_bit_enforced() helper to probe whether chmod read-only semantics are enforced on the current filesystem.
tests/completions.bash Adds a regression ensuring completions bash --install reports only gos’s own error on an unwritable target (no leaked Bash redirection error).
gos.sh Fixes redirection ordering in _gos_acquire_lock and cmd_completions --install by brace-grouping to silence the redirection failure itself.
CHANGELOG.md Adds Unreleased notes for the redirection leak fix and for existing Dependabot action bumps.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/lib.bash
readonly_bit_enforced() tracked the probe in a variable called `enforced`
that was set to 1 exactly when the filesystem did *not* enforce the mode,
and worked only because the value was returned as an exit status. The
return codes were right, but the name stated the opposite of its content,
which is a trap for anyone editing the helper later.

Track `probe_wrote` in the repository's usual "true"/"false" spelling and
end on the test that answers the function's own question. Behavior is
unchanged: 0 when the write was denied, 1 when it went through or the
chmod itself failed.
@johnny4young
johnny4young merged commit 7917df9 into main Sep 10, 2026
11 checks passed
@johnny4young
johnny4young deleted the fix/lock-pid-stderr-leak branch September 10, 2026 14:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants