R-124 recipe root namespace as PBS spells it; R-118 no root size for an absent drive; R-269 rotated-out token rejected at once; R-317 dnsmasq install probed by its unit (burn-down round 2)
gates / gates (push) Successful in 47s
gates / gates (push) Successful in 47s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -398,9 +398,16 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
di.Smart = &sm
|
||||
}
|
||||
}
|
||||
if total, used, okc := statfsCapacity(d.MountPath); okc {
|
||||
di.TotalBytes, di.UsedBytes = total, used
|
||||
di.UsedFraction = float64(used) / float64(total)
|
||||
// R-118: statfs ONLY while the drive's device is present. With the device gone the raw
|
||||
// mountpoint reverts to a bare directory on the ROOT filesystem, and statfs would report
|
||||
// pve-root's size as this drive's (measured: a 4 GB drive advertising 46 GiB). Same trap
|
||||
// observe.go guards on the Observe path. Absent → capacity left zero (unknown), never root's.
|
||||
// Pinned by TestDisks_UnionPath_AbsentDeviceReportsNoRootCapacity.
|
||||
if s.devicePresent(d.MountPath) {
|
||||
if total, used, okc := statfsCapacity(d.MountPath); okc {
|
||||
di.TotalBytes, di.UsedBytes = total, used
|
||||
di.UsedFraction = float64(used) / float64(total)
|
||||
}
|
||||
}
|
||||
out = append(out, di)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -184,3 +185,39 @@ func TestDisks_DevicePresence_WireFieldIsFalseOnDeviceLoss(t *testing.T) {
|
||||
t.Fatalf("the drive never reached the wire: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// ── R-118 — an absent drive must not advertise the ROOT filesystem's capacity ───────────────────
|
||||
|
||||
// TestDisks_UnionPath_AbsentDeviceReportsNoRootCapacity drives the REAL statfsCapacity (no capacity
|
||||
// seam): the registry drive's mount path is a real, bare temp directory — exactly what /mnt/<name>
|
||||
// becomes once its device is gone (a plain directory on the host's filesystem). With the device absent
|
||||
// the row must carry NO capacity; before R-118 the union path statfs'd that bare directory and reported
|
||||
// the host filesystem's size and usage as the drive's (46 GiB at 9.2 % for a 4 GB drive, measured).
|
||||
// The present half proves the test is not hollow: the same directory DOES yield capacity when the
|
||||
// device is there, so a zero on the absent half is the guard's doing, not a statfs failure.
|
||||
//
|
||||
// RED-PROOF: drop the `if s.devicePresent(d.MountPath)` guard around statfsCapacity in disks.go → the
|
||||
// absent subtest fails with "advertises ... bytes".
|
||||
func TestDisks_UnionPath_AbsentDeviceReportsNoRootCapacity(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("statfsCapacity is linux-only; production target is linux")
|
||||
}
|
||||
bare := t.TempDir()
|
||||
known := []storage.KnownTarget{
|
||||
{Name: "cel", Type: hub.StorageTypeUSB, MountPath: bare, DurableID: "uuid:4242", UUID: "4242"},
|
||||
}
|
||||
t.Run("absent", func(t *testing.T) {
|
||||
di := diskByMount(t, presenceServer(t, nil, known, true, false), bare)
|
||||
if di.TotalBytes != 0 || di.UsedBytes != 0 || di.UsedFraction != 0 {
|
||||
t.Errorf("absent drive advertises total=%d used=%d frac=%.3f — that is the filesystem UNDER "+
|
||||
"the bare mountpoint, not the drive (R-118)", di.TotalBytes, di.UsedBytes, di.UsedFraction)
|
||||
}
|
||||
})
|
||||
t.Run("present", func(t *testing.T) {
|
||||
di := diskByMount(t, presenceServer(t, nil, known, true, true), bare)
|
||||
if di.TotalBytes <= 0 {
|
||||
t.Errorf("present drive reports no capacity (total=%d) — the guard over-corrected and the "+
|
||||
"size bar is gone for every healthy registry drive", di.TotalBytes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,12 +154,15 @@ func (s *TokenStore) Mint(vmid int) (string, error) {
|
||||
// looks it up; the per-candidate comparison is constant-time to avoid a timing oracle on the
|
||||
// stored hash. ok is false for an unknown/empty token.
|
||||
//
|
||||
// Reload-on-miss (B3): the store FILE is shared across processes — the one-shot provisioner
|
||||
// (`--selftest=provision`) Mints into it while the long-lived daemon serves Lookup from an index
|
||||
// built at open. On a miss, re-read the file ONCE and re-check, so a token minted after this
|
||||
// process started authorizes without a daemon restart (the drill's fresh-install 401). The
|
||||
// append-only log makes an unchanged file size proof of no new records, so a genuinely unknown
|
||||
// token costs at most one stat once the index is current — never a reload loop.
|
||||
// Reload-on-change (B3, R-269): the store FILE is shared across processes — the one-shot
|
||||
// provisioner (`--selftest=provision`) Mints into it while the long-lived daemon serves Lookup from
|
||||
// an index built at open. Every Lookup stats the file first and re-reads it when the append-only
|
||||
// log has grown, BEFORE answering — so a token minted elsewhere authorizes without a restart AND a
|
||||
// token rotated out elsewhere stops authorizing on its very next presentation. (Before R-269 the
|
||||
// re-read ran only on a MISS, so a superseded token was a direct map hit and kept authorizing until
|
||||
// some unrelated miss forced the reload.) An unchanged size is proof of no new records, so the
|
||||
// steady state costs one stat per call and never a reload loop. Pinned by
|
||||
// TestTokenStore_RotatedOutTokenRejectedFirst.
|
||||
func (s *TokenStore) Lookup(token string) (int, bool) {
|
||||
if token == "" {
|
||||
return 0, false
|
||||
@@ -167,23 +170,34 @@ func (s *TokenStore) Lookup(token string) (int, bool) {
|
||||
want := hashToken(token)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// Direct map hit is the common path; the constant-time compare guards against a timing
|
||||
// side-channel by re-checking the matched key (map lookup itself is not the secret-bearing
|
||||
// comparison — the hash of a random 256-bit token is not feasibly guessable regardless).
|
||||
if vmid, ok := s.byHash[want]; ok {
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(s.byVMID[vmid])) == 1 {
|
||||
return vmid, true
|
||||
st, statErr := os.Stat(s.path)
|
||||
if statErr == nil && st.Size() != s.loadedSize {
|
||||
// The log changed under us (another process minted/rotated): converge first, then answer.
|
||||
s.reloads++
|
||||
if err := s.reloadLocked(); err != nil {
|
||||
return 0, false // unreadable store: fail closed, never crash the auth path
|
||||
}
|
||||
return s.matchLocked(want)
|
||||
}
|
||||
// Miss: skip the re-read when the append-only log has not grown (nothing new to see).
|
||||
// A stat error falls through to the reload, which handles a missing file as empty.
|
||||
if st, err := os.Stat(s.path); err == nil && st.Size() == s.loadedSize {
|
||||
return 0, false
|
||||
if vmid, ok := s.matchLocked(want); ok {
|
||||
return vmid, true
|
||||
}
|
||||
if statErr == nil {
|
||||
return 0, false // file unchanged since the last (re)load: genuinely unknown
|
||||
}
|
||||
// Stat failed (e.g. the file vanished): reload, which treats a missing file as empty.
|
||||
s.reloads++
|
||||
if err := s.reloadLocked(); err != nil {
|
||||
return 0, false // unreadable store: fail closed, never crash the auth path
|
||||
return 0, false
|
||||
}
|
||||
return s.matchLocked(want)
|
||||
}
|
||||
|
||||
// matchLocked answers from the in-memory index. Direct map hit is the common path; the
|
||||
// constant-time compare re-checks the matched key against the guest's CURRENT hash (map lookup
|
||||
// itself is not the secret-bearing comparison — the hash of a random 256-bit token is not
|
||||
// feasibly guessable regardless). Caller holds the mutex.
|
||||
func (s *TokenStore) matchLocked(want string) (int, bool) {
|
||||
if vmid, ok := s.byHash[want]; ok {
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(s.byVMID[vmid])) == 1 {
|
||||
return vmid, true
|
||||
|
||||
@@ -210,6 +210,51 @@ func TestTokenStore_ReloadOnMiss_RemintCoherence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// R-269: a token rotated out by ANOTHER process must stop authorizing on its very next
|
||||
// presentation — with NO intervening lookup of the new token. This is the order the operator hits
|
||||
// after rotating a leaked token: the leaked one is presented first. RemintCoherence above looks the
|
||||
// NEW token up first, and that miss is what used to evict the old hash, so it passed while the leaked
|
||||
// token kept returning HTTP 200 on hardware (2026-08-09) until something unrelated forced a reload.
|
||||
//
|
||||
// RED-PROOF: restore the reload-on-MISS-only Lookup (answer a map hit before stat-ing the file) and
|
||||
// this fails with "rotated-out token still authorizes".
|
||||
func TestTokenStore_RotatedOutTokenRejectedFirst(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tokens.log")
|
||||
daemon, err := OpenTokenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open daemon store: %v", err)
|
||||
}
|
||||
defer daemon.Close()
|
||||
minter, err := OpenTokenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open minter store: %v", err)
|
||||
}
|
||||
defer minter.Close()
|
||||
|
||||
old, err := minter.Mint(130)
|
||||
if err != nil {
|
||||
t.Fatalf("mint old: %v", err)
|
||||
}
|
||||
if vmid, ok := daemon.Lookup(old); !ok || vmid != 130 { // the daemon has learned the old token
|
||||
t.Fatalf("old token before rotation: (%d,%v), want (130,true)", vmid, ok)
|
||||
}
|
||||
fresh, err := minter.Mint(130) // rotation, written by another process
|
||||
if err != nil {
|
||||
t.Fatalf("mint fresh: %v", err)
|
||||
}
|
||||
|
||||
if vmid, ok := daemon.Lookup(old); ok { // the leaked token FIRST
|
||||
t.Fatalf("rotated-out token still authorizes vmid %d on its first presentation after rotation — "+
|
||||
"Mint's 'any previous token for this guest is revoked' is false across processes (R-269)", vmid)
|
||||
}
|
||||
if vmid, ok := daemon.Lookup(fresh); !ok || vmid != 130 {
|
||||
t.Fatalf("fresh token after rotation: (%d,%v), want (130,true)", vmid, ok)
|
||||
}
|
||||
if vmid, ok := daemon.Lookup(old); ok {
|
||||
t.Fatalf("rotated-out token authorizes vmid %d after the fresh one was seen", vmid)
|
||||
}
|
||||
}
|
||||
|
||||
// §8 edge: the store file deleted between open and a miss — reload treats it as empty; Lookup
|
||||
// fails closed, no crash.
|
||||
func TestTokenStore_ReloadOnMiss_MissingFile(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user