mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
a99a040cf3
* fix(cli): credentials.json is replaced atomically at 0600 and updated under a lock (BUG-3648, BUG-3649) [WIP: no Codex round yet]
Save used os.WriteFile, which applies 0600 only when it creates the file
(BUG-3648) and truncates the live file first, so a concurrent reader could
see an empty store or half a document (BUG-3649). Save now writes a 0600
temp file in the same directory, syncs it and renames it over
credentials.json, and creates ~/.pad 0700.
The four load-modify-save callers (device login, saveCredentials, logout,
2FA session rotation) go through the new cli.UpdateStore, which holds a
cross-process lock on credentials.json.lock (flock on unix, LockFileEx on
windows, none elsewhere) across load, mutate and save. Logout makes its
server call outside the lock and re-reads the file under it to delete.
Tests: credentials_bug3648_test.go, plus a guard that fails on any
LoadStore-then-Save outside UpdateStore. Mutants C1-C4 and the guard's
counterfactuals are recorded on BUG-3649.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): the credentials lock file is tightened to 0600, and a wipe takes the lock (BUG-3648, BUG-3649) [WIP]
Codex round 1 Lows: OpenFile's mode applies only on creation, so an existing
lock file kept a loose mode; WipeCredentialsFile removed the file outside
the lock, so a concurrent UpdateStore could rename it back.
Test: an existing 0666 lock file reads 0600 after UpdateStore.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* test(cli): a credentials wipe waits for the credentials lock (BUG-3649)
Lead ruling on Codex round 1 L1: hold the lock, start the wipe, assert it
has not returned and the file is still there, release, assert it finishes.
Dropping the wipe's lock reddens it 3/3.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* docs(cli): UpdateStore callbacks must not call WipeCredentialsFile (BUG-3649)
Codex round 2 (Medium): a wipe inside an UpdateStore callback would wait on
the lock that UpdateStore holds. No caller does this (the wipe has no
production caller); both doc comments now say so.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): tightening the credentials lock file is best effort (BUG-3649)
Both pilot bug-hunters (Haiku and Sonnet, blind): fchmod on a lock file
another uid owns returns EPERM, which failed every UpdateStore and so
every login. The lock file holds no secret, and the token's protection
is credentials.json's own 0600 temp-plus-rename, so the chmod error is
ignored and the lock is still taken. The owner-case tightening test
stays. (Lead ruling: accepted.)
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* docs(cli): the best-effort chmod covers a writable foreign-owned lock file, not a sudo-created one (BUG-3649)
Both pilot hunters on d164f57aa (Haiku Medium, Sonnet Low): a lock file
a sudo run creates is root-owned 0600, so the open fails with EACCES
before the chmod. The comment named that as the case it fixes. It now
names the case it does cover (another uid's file this user can write)
and says the sudo case fails at the open, as a foreign-owned
credentials.json always did. Comment only.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): credentials Save writes through a symlink and retries a shared rename on Windows; a wipe always locks (BUG-3648, BUG-3649)
Final whole-diff review round (Codex, plus both pilot hunters):
- Codex (Medium): WipeCredentialsFile skipped the lock when ~/.pad did not
exist, so an update creating it could land after the wipe. It now
creates the directory and always locks, as UpdateStore does.
- Haiku (Medium) and Sonnet (Low): renaming over a symlinked
credentials.json replaced the link with a plain file; os.WriteFile wrote
through it. Save now resolves the link and replaces the target.
- Haiku (Medium) and Sonnet (Low): Go opens files on Windows without
FILE_SHARE_DELETE, so the replace fails while another pad process reads
the file. On Windows the rename retries for about a second.
Test: a symlinked credentials.json stays a link and its target receives the
save; removing the resolution reddens it. The Windows retry has no test on
this box.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): a symlinked or bind-mounted credentials.json is written in place, as os.WriteFile did (BUG-3648, BUG-3649)
Per the population table on BUG-3649: the temp-plus-rename path applies
only to an absent or regular file. A symlink, live or dangling, is written
through in place (a dangling link stays a link; a target in a read-only
directory stays writable), and a rename that fails EBUSY (a bind-mounted
file) falls back to the same in-place write. The in-place write chmods to
0600 before truncating.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): on Windows a rename refused by an open handle falls back to the in-place write (BUG-3649)
Row 8 of the population table on BUG-3649: os.WriteFile succeeded on a
Windows credentials.json another process held open, so after the rename
retries ERROR_ACCESS_DENIED and ERROR_SHARING_VIOLATION write in place
under the lock at 0600, as EBUSY does. Any other rename error is returned.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): Save takes the credentials lock itself; UpdateStore saves through saveLocked (BUG-3649)
The in-place fallback writes the live file, so a direct Save must not
interleave with a locked update (scoped Codex round on the row 8 fix).
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): credentials are written in place under an exclusive lock and read under a shared one (BUG-3648, BUG-3649)
Option B on BUG-3649: the temp-plus-rename write was worse than
os.WriteFile for symlinked, bind-mounted, foreign-owned and ACL-carrying
files and for a read-only ~/.pad, one shape at a time. Save now writes in
place, the shape os.WriteFile had, chmodding to 0600 before it truncates,
under the exclusive side of credentials.json.lock (flock LOCK_EX, or
LockFileEx with LOCKFILE_EXCLUSIVE_LOCK). LoadStore reads under the shared
side (LOCK_SH, or LockFileEx with no flags), so a reader never sees the
truncated file. When the lock file cannot be created or opened (EACCES,
EPERM, EROFS) the write goes ahead unlocked with a stderr warning and the
read silently; any other lock error is returned. The rename path, its
EBUSY and Windows fallbacks and both test seams are gone.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): the credentials lock never fails a command; a read racing the first save takes the lock (BUG-3649)
Per the ruling on BUG-3649: any lock error (a filesystem without flock,
a read-only or foreign-owned ~/.pad) falls back to the old unlocked
behaviour, reads silently and writes with a one-line warning; EINTR is
retried first. A read that finds no ~/.pad answers "no credentials"
without opening the file, and one whose ~/.pad appeared meanwhile takes
the lock (Codex, 3a round on option B).
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): the credentials lock is tried without blocking for up to 5s; a link to a non-regular file and a missing ~/.pad behave as before (BUG-3648, BUG-3649)
Final review round on BUG-3649, under the lead's equal-or-better rule:
a stuck holder (a stopped process, a hung NFS lock) no longer hangs every
command, since the lock is tried with LOCK_NB / LOCKFILE_FAIL_IMMEDIATELY
for up to 5s and then the unlocked fallback applies; only a regular file
is chmodded and truncated, and the sync is best effort, so a link to
/dev/null or a FIFO takes the write as os.WriteFile did; and a wipe with
no ~/.pad returns without creating it.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
11 lines
347 B
Go
11 lines
347 B
Go
//go:build !unix && !windows
|
|
|
|
package cli
|
|
|
|
// lockCredentialsFile has no cross-process lock on this platform, so two
|
|
// concurrent logins can still lose an entry here (BUG-3649), and a reader
|
|
// can see a save half-written, as before.
|
|
func lockCredentialsFile(lockPath string, exclusive bool) (unlock func(), err error) {
|
|
return func() {}, nil
|
|
}
|