Files
pad/internal/cli/credentials_bug3829_test.go
T
xarmian 1d08594dc0 fix(cli): a locked credentials load re-reads an empty file before believing it (BUG-3829) (#2195)
* fix(cli): a locked credentials load re-reads an empty file before believing it (BUG-3829)

A writer that waits out credentialsLockWait saves without the lock,
truncating in place before it writes. A loader holding the lock read that
truncate as an empty store on its first read, and an UpdateStore landing
there saved its one change over every other server's entry.

loadStoreLocked now re-reads an empty file with the bound the unlocked load
already uses (10 x 20ms). One that stays empty is genuinely empty (a crash
between a save's truncate and its write) and loads as an empty store, so
login still works over it; the unlocked path keeps refusing. A genuinely
empty file pays the bound once per process: the confirmed file state
(same file, same mtime) is remembered, and a save that touches the file
moves its mtime and is re-read again.

Residual, in the code comment too: an unlocked writer that stalls longer
than the bound between its truncate and its write is still read as empty.

TestBUG3828_LockedEmptyFileIsEmpty's premise ("under the lock nothing can
be mid-save") is what this bug falsifies; it now expects the re-reads.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* fix(cli): the confirmed-empty memo only remembers a file emptied 2s or more before (BUG-3829)

Codex r1 (Medium): on a filesystem with coarse timestamps, a save landing in
the same tick as the confirmation could leave the mtime unchanged, so the
memo would match the save's truncate and skip its re-read. A later save is
stamped no earlier than the confirmation, so remembering only a file whose
mtime is two seconds older than that rules the collision out. A file
emptied more recently pays the re-read bound on each load until it ages.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(cli): a credentials file with a future mtime is never remembered as empty (BUG-3829)

Codex r2 (Medium): a future mtime reads as recent to the age check, so such
a file pays the re-read bound on every load. That is the safe direction,
re-reading rather than trusting it, so the policy is now stated in the
comment and pinned by a test instead of changed.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* fix(cli): the confirmed-empty memo records only an empty file state (BUG-3829)

Codex r4 (Medium): the stat that records a confirmed-empty file follows the
empty read, so a save completing in between could be recorded, and a later
truncate keeping its mtime could match it. set now requires size 0.

The memo comment now states the measured fact the hunter questioned: a
truncate moves the mtime even of an already-empty file (ext4, Linux 6.17).

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-15 07:15:49 -04:00

187 lines
5.5 KiB
Go

package cli
import (
"os"
"testing"
"time"
)
// BUG-3829: a writer that waits out credentialsLockWait saves without the
// lock, truncating in place before it writes. A loader that DOES hold the
// lock used to read that truncate as an empty store on the first read, and
// an UpdateStore landing there saved its one change over every other
// server's entry.
func resetEmptyConfirmed(t *testing.T) {
emptyConfirmed.set("", nil)
t.Cleanup(func() { emptyConfirmed.set("", nil) })
}
// A locked update whose read lands inside an unlocked writer's save re-reads
// until that save has written, then applies to what it wrote.
func TestBUG3829_LockedUpdateRereadsAcrossAnUnlockedSave(t *testing.T) {
home := withTempHome(t)
path := writeCredsFile(t, home, twoServers)
resetEmptyConfirmed(t)
shortRereads(t, 5, time.Millisecond)
reads := midSave(t, path, 1, 3)
if err := UpdateStore(func(s *CredentialStore) error {
s.Set("http://c:7777", &Credentials{Token: "tc"})
return nil
}); err != nil {
t.Fatalf("UpdateStore: %v", err)
}
if *reads != 3 {
t.Errorf("reads = %d, want 3 (empty, empty, then the save's body)", *reads)
}
store, err := LoadStore()
if err != nil {
t.Fatal(err)
}
for _, url := range []string{"http://a:7777", "http://b:7777", "http://c:7777"} {
if store.Get(url) == nil {
t.Errorf("%s missing after the update: %+v", url, store.Credentials)
}
}
}
// A file that stays empty under the lock is genuinely empty (a crash between
// a save's truncate and its write): it loads as an empty store, and login
// still saves over it.
func TestBUG3829_LockedGenuinelyEmptyFileStillLogsIn(t *testing.T) {
home := withTempHome(t)
path := writeCredsFile(t, home, "")
resetEmptyConfirmed(t)
shortRereads(t, 3, time.Millisecond)
store, err := LoadStore()
if err != nil {
t.Fatalf("LoadStore over an empty locked file: %v", err)
}
if len(store.Credentials) != 0 {
t.Fatalf("store = %+v, want empty", store.Credentials)
}
if err := UpdateStore(func(s *CredentialStore) error {
s.Set("http://c:7777", &Credentials{Token: "tc"})
return nil
}); err != nil {
t.Fatalf("UpdateStore over an empty locked file: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
saved, err := parseStore(data)
if err != nil {
t.Fatal(err)
}
if saved.Get("http://c:7777") == nil {
t.Errorf("the login was not saved: %s", data)
}
}
// A genuinely empty file pays the re-read bound once per process, not once
// per load: a command that loads credentials several times waits once. A
// save that touches the file (its mtime moves) is re-read again.
func TestBUG3829_GenuinelyEmptyFileWaitsOncePerProcess(t *testing.T) {
home := withTempHome(t)
path := writeCredsFile(t, home, "")
resetEmptyConfirmed(t)
shortRereads(t, 3, time.Millisecond)
aged := time.Now().Add(-time.Hour)
if err := os.Chtimes(path, aged, aged); err != nil {
t.Fatal(err)
}
reads := countReads(t, nil)
for i := 0; i < 3; i++ {
if _, err := LoadStore(); err != nil {
t.Fatal(err)
}
}
if *reads != 4+1+1 {
t.Errorf("reads over three loads = %d, want 6 (1+3 re-reads, then 1 each)", *reads)
}
// Another save truncates the file again: a new state, waited out again.
later := time.Now().Add(time.Hour)
if err := os.Chtimes(path, later, later); err != nil {
t.Fatal(err)
}
*reads = 0
if _, err := LoadStore(); err != nil {
t.Fatal(err)
}
if *reads != 4 {
t.Errorf("reads after the file changed = %d, want 4 (1+3 re-reads)", *reads)
}
}
// A file emptied within emptyConfirmedMinAge is not remembered: a save in the
// same timestamp tick could leave its mtime unchanged on a coarse filesystem,
// and a remembered file would then skip the re-read of that save's truncate.
func TestBUG3829_RecentlyEmptiedFileIsNotRemembered(t *testing.T) {
home := withTempHome(t)
writeCredsFile(t, home, "")
resetEmptyConfirmed(t)
shortRereads(t, 3, time.Millisecond)
reads := countReads(t, nil)
for i := 0; i < 2; i++ {
if _, err := LoadStore(); err != nil {
t.Fatal(err)
}
}
if *reads != 4+4 {
t.Errorf("reads over two loads of a just-emptied file = %d, want 8 (1+3 re-reads each)", *reads)
}
}
// A file whose mtime is in the future (clock skew) is not remembered either:
// it is re-read on every load rather than trusted.
func TestBUG3829_FutureMtimeIsNotRemembered(t *testing.T) {
home := withTempHome(t)
path := writeCredsFile(t, home, "")
resetEmptyConfirmed(t)
shortRereads(t, 3, time.Millisecond)
future := time.Now().Add(time.Hour)
if err := os.Chtimes(path, future, future); err != nil {
t.Fatal(err)
}
reads := countReads(t, nil)
for i := 0; i < 2; i++ {
if _, err := LoadStore(); err != nil {
t.Fatal(err)
}
}
if *reads != 4+4 {
t.Errorf("reads over two loads of a future-mtime file = %d, want 8 (1+3 re-reads each)", *reads)
}
}
// The stat that records a confirmed-empty file follows the empty read, so a
// save can land in between: a non-empty state is never remembered, or a later
// truncate keeping its mtime could match it (Codex r4).
func TestBUG3829_NonEmptyStateIsNotRemembered(t *testing.T) {
home := withTempHome(t)
path := writeCredsFile(t, home, twoServers)
resetEmptyConfirmed(t)
aged := time.Now().Add(-time.Hour)
if err := os.Chtimes(path, aged, aged); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
emptyConfirmed.set(path, fi)
emptyConfirmed.mu.Lock()
remembered := emptyConfirmed.fi != nil
emptyConfirmed.mu.Unlock()
if remembered {
t.Error("a non-empty file state was remembered as confirmed empty")
}
}