mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* docs(audit): 2026-06-20 deep adversarial audit report
Audit-only deliverable (no source changed). Fresh origin/main (bfbdbb1) deep
read across 13 subsystems with per-finding adversarial re-verification, plus
reconciliation of the 2026-06-10/2026-06-13 audits.
Result: build/vet clean; race detector finds 0 data races (67/68 pkgs; the
3 internal/tools failures are -race-only timing flakes). 35 surviving findings
(1 high, 15 medium, 17 low, 2 info); 64 prior findings fixed, 14 partial,
12 still-open.
* fix(providerhealth): strip credentials on cross-host redirect (AUDIT-M10)
The connectivity probe's CheckRedirect re-validated the redirect host against
the SSRF blocklist but never stripped auth headers. Go's stdlib only auto-strips
Authorization/Cookie/WWW-Authenticate on a host change — not x-api-key,
x-goog-api-key, or custom provider headers — so a baseURL that 3xx-redirects to
an attacker-controlled public host received the provider API key verbatim.
Thread the exact set of auth-bearing header names (kind default + profile
AuthHeader + CustomHeaders keys + well-known) into the client and Header.Del
them whenever the redirect target's host:port differs from the original.
Regression test asserts the creds are dropped cross-host and kept same-host.
* fix(sessions): fsync events.jsonl append for crash durability (AUDIT-M12)
appendEventLocked wrote the event into the page cache and returned success, then
durably wrote (fsync'd) the derived metadata. A crash after the metadata fsync
but before the events.jsonl page flushed left metadata.EventCount ahead of the
log — silently dropping the last appended event, including the checkpoint a
/rewind targets. Add file.Sync() before Close so the log is at least as durable
as the metadata derived from it.
(Note: the fsync itself has no portable unit-test seam; covered by the existing
sessions append/durability suite for no-regression.)
* fix(cron): DST fall-back double-fire + claim double-grant (AUDIT-M4, AUDIT-M5)
M4: fireJob/claimFire computed the next slot from the raw sub-second now(), so
schedule.Next's DST fall-back collapse guard (which only engages for a
minute-aligned 'after') never fired — a daily job in the repeated wall-clock hour
ran twice on the fall-back day. Feed sched.Next a minute-truncated instant at the
claim and the post-exec advance (the due-check still uses the real instant so
sub-minute NextRunAt jobs are unaffected).
M5: claimFire left NextRunAt unchanged when the schedule couldn't advance
(unparseable/exhausted spec), so two concurrent schedulers both saw the job due
and both fired it. Pause the job inside the same locked claim instead: the winner
still fires once and the post-exec keeps it paused; the loser sees a non-active
job and is refused.
Tests: claimFire double-claim on an unadvanceable job (second refused, job paused)
and a DST fall-back daily job advancing past the repeat (not re-firing it).
* fix(daemon): bound the control handshake + track bridge conns for Shutdown (AUDIT-M7, M8, I1)
The remote bridge clears the connection deadline before handing the conn to
Server.ServeConn, and the local socket set none, so handleConn's two ReadControl
handshake calls could block forever — an authenticated-but-idle remote peer
pinned a bridge connection slot (32 of them exhaust the bridge), and the local
path had the same unbounded read. Set a handshake deadline in handleConn and
clear it before the (long-lived) streaming dispatch (M7, I1).
ServeConn also bypassed trackConn, so Shutdown's conns-close sweep couldn't close
a remote conn stalled in that handshake read. Track/untrack it like the local
accept loop does (M8).
Tests: handleConn returns under a shortened handshake deadline when the peer
never sends hello; Shutdown closes a ServeConn'd conn well under the 10s deadline.
* fix(swarm): atomic rename-with-verify mailbox stale-lock break (AUDIT-M13)
The stale-break compared two consecutive reads of the SAME lock file
(string(data) == string(stale)), so the equality was always true and gave no
protection: two waiters could both observe a stale lock and both os.Remove it
while a third recreated it via O_EXCL, leaving two live writers to the same
per-agent inbox (split-brain). Replace with the proven rename-with-verify used by
the cron/hooks/oauth locks: rename the stale file aside (only one racer wins the
rename), re-check the moved file's mtime is still stale before deleting, and
rename it back if a holder rotated a fresh lock in the gap.
Tests: a genuinely stale lock is reclaimed (no .stale.* leftover); a fresh held
lock is never broken. (The double-break race itself is not deterministically
unit-testable; the fix mirrors the cron/hooks/oauth pattern.)
* fix(streamjson): anchor api_key redaction so prose isn't mangled (AUDIT-M1)
The api_key secret pattern used ["'=:\s]+ as the delimiter class, so a bare
space after the marker counted — 'the api_key: foo setting' and 'apiKey value
spans' had the following word replaced with [REDACTED] in every emitted
stream-json event (text deltas, tool output, final answer). Require a real
assignment delimiter (= or :) and a credential-length body, mirroring the
already-anchored sk-/bearer patterns. Prose survives; real api_key=... secrets
still redact.
* fix(tools): snapshot exec-session lastUsedAt under its lock to fix prune race (AUDIT-L15)
sessionToPruneLocked sorted on execSession.lastUsedAt while holding only
manager.mu, but touch() writes lastUsedAt under session.mu — a data race on a
time.Time that could torn-read and mis-select the prune victim. Snapshot each
session's lastUsedAt via a session.mu-guarded getter before sorting (lock order
stays manager.mu -> session.mu; no path takes them the other way). Adds a -race
regression test driving touch() concurrently with the prune selection.
* fix(swarm): release per-job context on MaxRuns completion (AUDIT-L13)
Scheduler.run returned on MaxRuns completion without calling job.cancel(), so the
job's derived context (and the goroutine context.WithCancel spawns to propagate
parent cancellation) leaked until the whole scheduler was cancelled at session
end. Add defer job.cancel() (idempotent with Cancel/Close) so it is released on
every exit path.
* fix(agent): route post-compaction and max-turns connects through streamWithReconnect (AUDIT-L1)
The main turn connect already retries transient disconnects via
streamWithReconnect, but the post-compaction retry connect, the reactive
mid-stream reconnect, and finalAnswerAfterMaxTurns called provider.StreamCompletion
directly — a single transient hiccup on those (e.g. the final summary after a long
autonomous/cron run, or the turn right after a context-limit compaction) failed the
whole run with no retry. All three are pre-content connects, so routing them through
the helper carries no OnText-duplication risk.
* fix(cli): reject trailing args after --skip-permissions-unsafe (AUDIT-L3)
zero --skip-permissions-unsafe "fix bug" silently discarded the prompt and
launched the interactive TUI, so a scripted one-shot unsafe invocation appeared
to hang. Reject any trailing positional arg loudly (after checking for a
misplaced --add-dir first, which keeps its specific error), pointing users at
the working form: zero exec --skip-permissions-unsafe -p "...".
* fix(provideroauth): error on present-but-wrong-shape chatgpt account-id claim (AUDIT-L11)
extractChatGPTAccountID returned ("", nil) both when the id_token had no
account-id claim AND when OpenAI's auth namespace was present but the account id
was missing/empty/non-string. The latter is a claim-shape change: the caller only
warns on a non-nil error, so it silently omitted the chatgpt-account-id header and
every Codex call 401'd with no diagnostic, indistinguishable from an expired
token. Return an error for the present-but-unusable case so the login warning
fires; a genuinely absent claim still soft-skips ("", nil).
* test(tools): widen exec-session poll windows so the suite is -race-clean (AUDIT report §2)
Three exec-session timing tests assumed a 250ms-sleep / immediate child would
finish within a fixed 1000ms yield. Under -race the child (the race-instrumented
test binary) starts slowly enough to overrun it, so the tool correctly returned a
still-running session_id instead of an exit_code and the asserts failed — the
non-green -race suite the audit flagged in §2. collect() returns as soon as the
session completes, so widening the poll window to 30s makes them deterministic
under -race without slowing the passing case. Test-only; no behavior change.
* docs(audit): mark remediation status (fixed/deferred/skipped) for the 2026-06-20 audit
* docs(audit): strip trailing whitespace from the report (fix git diff --check)
* fix(tools): drain PTY output before marking an exec session done (AUDIT-M14)
A TTY exec session's stdout was copied into the buffer by a fire-and-forget
io.Copy goroutine, but startSession's Wait goroutine called markDone (and then
removed the session) as soon as command.Wait() returned — before that goroutine
had necessarily drained the master FD. The command's final output chunk (e.g. a
test runner's last PASS/FAIL line) could be lost on exit, and since the exited
session is removed it could not be polled back. This also surfaced as the flaky
TestExecCommandTTYSessionAcceptsInputOnLinux on loaded CI.
Make the PTY cleanup (called after command.Wait, before markDone) join the copy
goroutine before closing the master: the child has exited so the master EOFs once
io.Copy finishes draining; waiting for that (bounded by bashWaitDelay) guarantees
all output is buffered before the session is marked done. Closing the master
first would truncate the unread tail, so the join precedes the Close.
Linux-only path (exec_pty_linux.go); verified via GOOS=linux build+vet (the TTY
test only runs on linux/CI).
* docs(audit): mark M14 fixed (PTY drain) in remediation status
---------
Co-authored-by: gnanam1990 <[email protected]>
395 lines
14 KiB
Go
395 lines
14 KiB
Go
package swarm
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"sync/atomic"
|
||
"time"
|
||
)
|
||
|
||
// Mailbox is a per-agent, per-team message inbox persisted as a JSON array on
|
||
// disk at <baseDir>/<team>/inboxes/<agent>.json, written atomically under a
|
||
// lock file. It is hardened for untrusted input:
|
||
//
|
||
// - inbox files and their parent dirs are owner-only (0600/0700);
|
||
// - every write takes an exclusive lock file (bounded retry, stale-break);
|
||
// - message bodies and whole inboxes are size-capped (fail closed on oversize);
|
||
// - team/agent names are sanitized so a name can never escape the base dir;
|
||
// - malformed inbox JSON is rejected rather than silently reset (fail closed).
|
||
type Mailbox struct {
|
||
// BaseDir is the root under which <team>/inboxes/<agent>.json live. It must
|
||
// be set (NewMailbox enforces this).
|
||
BaseDir string
|
||
// MaxMessageBytes caps a single serialized message. 0 => defaultMaxMessageBytes.
|
||
MaxMessageBytes int
|
||
// MaxMessages caps the number of messages retained in one inbox. 0 =>
|
||
// defaultMaxMessages. Sends past the cap fail closed.
|
||
MaxMessages int
|
||
// LockTimeout bounds how long Send/MarkRead wait for the inbox lock.
|
||
LockTimeout time.Duration
|
||
}
|
||
|
||
const (
|
||
defaultMaxMessageBytes = 64 * 1024 // 64 KiB per message
|
||
defaultMaxMessages = 1000 // per inbox
|
||
defaultLockTimeout = 5 * time.Second
|
||
lockStaleAfter = 30 * time.Second
|
||
inboxFileMode = 0o600
|
||
inboxDirMode = 0o700
|
||
)
|
||
|
||
// ErrMailboxFull is returned when an inbox is at MaxMessages.
|
||
var ErrMailboxFull = errors.New("swarm: mailbox is full")
|
||
|
||
// ErrMessageTooLarge is returned when a single message exceeds MaxMessageBytes.
|
||
var ErrMessageTooLarge = errors.New("swarm: message exceeds size cap")
|
||
|
||
// Message is one inbox entry. Time is RFC3339 and set on Send if empty.
|
||
type Message struct {
|
||
From string `json:"from"`
|
||
To string `json:"to"`
|
||
Subject string `json:"subject,omitempty"`
|
||
Body string `json:"body"`
|
||
Type string `json:"type"`
|
||
Read bool `json:"read"`
|
||
Time string `json:"time,omitempty"`
|
||
}
|
||
|
||
// NewMailbox validates baseDir and returns a Mailbox with defaults applied.
|
||
func NewMailbox(baseDir string) (*Mailbox, error) {
|
||
if strings.TrimSpace(baseDir) == "" {
|
||
return nil, errors.New("swarm: mailbox base dir is required")
|
||
}
|
||
abs, err := filepath.Abs(baseDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("swarm: resolve mailbox base dir: %w", err)
|
||
}
|
||
return &Mailbox{
|
||
BaseDir: abs,
|
||
MaxMessageBytes: defaultMaxMessageBytes,
|
||
MaxMessages: defaultMaxMessages,
|
||
LockTimeout: defaultLockTimeout,
|
||
}, nil
|
||
}
|
||
|
||
var nameUnsafe = regexp.MustCompile(`[^a-zA-Z0-9\-_]`)
|
||
|
||
// sanitizeName replaces any char outside [A-Za-z0-9-_] with '-', defaulting
|
||
// empty input to "default" and an all-unsafe result to "unknown". This
|
||
// guarantees the name is a single safe path segment (no '/', '\\', '.', '..').
|
||
func sanitizeName(name string) string {
|
||
trimmed := strings.TrimSpace(name)
|
||
if trimmed == "" {
|
||
trimmed = "default"
|
||
}
|
||
cleaned := nameUnsafe.ReplaceAllString(trimmed, "-")
|
||
if cleaned == "" {
|
||
return "unknown"
|
||
}
|
||
return cleaned
|
||
}
|
||
|
||
func (m *Mailbox) maxMessageBytes() int {
|
||
if m.MaxMessageBytes > 0 {
|
||
return m.MaxMessageBytes
|
||
}
|
||
return defaultMaxMessageBytes
|
||
}
|
||
|
||
func (m *Mailbox) maxMessages() int {
|
||
if m.MaxMessages > 0 {
|
||
return m.MaxMessages
|
||
}
|
||
return defaultMaxMessages
|
||
}
|
||
|
||
func (m *Mailbox) lockTimeout() time.Duration {
|
||
if m.LockTimeout > 0 {
|
||
return m.LockTimeout
|
||
}
|
||
return defaultLockTimeout
|
||
}
|
||
|
||
// inboxPath returns the sanitized, base-confined inbox path for (team, agent)
|
||
// and verifies it cannot escape BaseDir lexically (defense in depth on top of
|
||
// sanitize). Symlink-aware confinement happens in ensureInboxDir.
|
||
func (m *Mailbox) inboxPath(team, agent string) (string, error) {
|
||
dir := filepath.Join(m.BaseDir, sanitizeName(team), "inboxes")
|
||
path := filepath.Join(dir, sanitizeName(agent)+".json")
|
||
if within, err := pathWithin(m.BaseDir, path); err != nil || !within {
|
||
return "", fmt.Errorf("swarm: inbox path escapes base dir (team=%q agent=%q)", team, agent)
|
||
}
|
||
return path, nil
|
||
}
|
||
|
||
// pathWithin reports whether target is base or lives under it (lexically).
|
||
func pathWithin(base, target string) (bool, error) {
|
||
rel, err := filepath.Rel(base, target)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||
return false, nil
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
// ensureInboxDir creates the inbox's parent directories owner-only, then
|
||
// verifies — after resolving symlinks — that the real directory stays within the
|
||
// real base dir. This defends against a symlink planted under BaseDir (e.g. a
|
||
// pre-existing "inboxes" -> /etc) that the lexical inboxPath check cannot catch.
|
||
// Only after confinement is confirmed are the directories tightened to 0700, so
|
||
// a chmod can never land outside the base via a symlink.
|
||
func (m *Mailbox) ensureInboxDir(inboxPath string) error {
|
||
dir := filepath.Dir(inboxPath) // <base>/<team>/inboxes
|
||
if err := os.MkdirAll(dir, inboxDirMode); err != nil {
|
||
return fmt.Errorf("swarm: create inbox dir: %w", err)
|
||
}
|
||
realBase, err := filepath.EvalSymlinks(m.BaseDir)
|
||
if err != nil {
|
||
return fmt.Errorf("swarm: resolve base dir: %w", err)
|
||
}
|
||
realDir, err := filepath.EvalSymlinks(dir)
|
||
if err != nil {
|
||
return fmt.Errorf("swarm: resolve inbox dir: %w", err)
|
||
}
|
||
if within, err := pathWithin(realBase, realDir); err != nil || !within {
|
||
return fmt.Errorf("swarm: inbox dir escapes base dir via symlink")
|
||
}
|
||
// MkdirAll leaves an existing dir's permissions untouched; tighten the team
|
||
// and inboxes dirs to owner-only so mailbox contents are never group/world
|
||
// readable even if a dir pre-existed with broad perms.
|
||
_ = os.Chmod(dir, inboxDirMode)
|
||
_ = os.Chmod(filepath.Dir(dir), inboxDirMode) // <base>/<team>
|
||
return nil
|
||
}
|
||
|
||
// Send appends msg to the recipient's inbox under the team, taking the inbox
|
||
// lock. It fails closed on oversize messages or a full inbox.
|
||
//
|
||
// Each send rewrites the whole inbox (read all → append one → write all), so it
|
||
// is O(N) in the inbox size. That is intentional given the small bounds
|
||
// (MaxMessages × MaxMessageBytes); if those caps are raised substantially,
|
||
// switch to an append-only log with periodic compaction.
|
||
func (m *Mailbox) Send(team, recipient string, msg Message) error {
|
||
path, err := m.inboxPath(team, recipient)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if msg.Type == "" {
|
||
msg.Type = "message"
|
||
}
|
||
if msg.To == "" {
|
||
msg.To = sanitizeName(recipient)
|
||
}
|
||
if msg.Time == "" {
|
||
msg.Time = time.Now().UTC().Format(time.RFC3339)
|
||
}
|
||
encoded, err := json.Marshal(msg)
|
||
if err != nil {
|
||
return fmt.Errorf("swarm: encode message: %w", err)
|
||
}
|
||
if len(encoded) > m.maxMessageBytes() {
|
||
return fmt.Errorf("%w: %d > %d bytes", ErrMessageTooLarge, len(encoded), m.maxMessageBytes())
|
||
}
|
||
if err := m.ensureInboxDir(path); err != nil {
|
||
return err
|
||
}
|
||
release, err := acquireLock(path+".lock", m.lockTimeout())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer release()
|
||
|
||
messages, err := m.readLocked(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(messages) >= m.maxMessages() {
|
||
return fmt.Errorf("%w: %d messages", ErrMailboxFull, len(messages))
|
||
}
|
||
messages = append(messages, msg)
|
||
return atomicWriteJSON(path, messages)
|
||
}
|
||
|
||
// ReadAndConsume reads the recipient's inbox and marks every previously-unread
|
||
// message read under one lock, returning the messages with their read flags as
|
||
// they were BEFORE this read (so the caller can tell which were new). The inbox
|
||
// is empty (no error) if it does not yet exist; malformed inbox JSON is reported
|
||
// as an error (fail closed) and nothing is written.
|
||
func (m *Mailbox) ReadAndConsume(team, recipient string) ([]Message, error) {
|
||
path, err := m.inboxPath(team, recipient)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// The lock file lives beside the inbox; create the dir so it can be taken
|
||
// even on first read of a not-yet-existing inbox.
|
||
if err := m.ensureInboxDir(path); err != nil {
|
||
return nil, err
|
||
}
|
||
release, err := acquireLock(path+".lock", m.lockTimeout())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer release()
|
||
|
||
messages, err := m.readLocked(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(messages) == 0 {
|
||
return messages, nil
|
||
}
|
||
// Snapshot pre-consume state to return, then flip unread -> read on disk.
|
||
snapshot := make([]Message, len(messages))
|
||
copy(snapshot, messages)
|
||
changed := false
|
||
for i := range messages {
|
||
if !messages[i].Read {
|
||
messages[i].Read = true
|
||
changed = true
|
||
}
|
||
}
|
||
if changed {
|
||
if err := atomicWriteJSON(path, messages); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return snapshot, nil
|
||
}
|
||
|
||
// readLocked reads and decodes an inbox file. Callers that mutate must hold the
|
||
// lock; pure readers may call it directly (a torn read surfaces as a decode
|
||
// error and is retried by the caller, never silently treated as empty).
|
||
func (m *Mailbox) readLocked(path string) ([]Message, error) {
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return nil, nil
|
||
}
|
||
return nil, fmt.Errorf("swarm: read inbox: %w", err)
|
||
}
|
||
if len(raw) == 0 {
|
||
return nil, nil
|
||
}
|
||
// Reject an oversized inbox file outright rather than decoding untrusted,
|
||
// unbounded JSON into memory. The bound is the cap on total message bytes
|
||
// (maxMessages × maxMessageBytes) doubled to allow for pretty-printed JSON
|
||
// (indentation, field names, brackets) overhead versus the compact bytes the
|
||
// per-message cap measures.
|
||
if maxFile := m.maxMessages() * m.maxMessageBytes() * 2; len(raw) > maxFile {
|
||
return nil, fmt.Errorf("swarm: inbox file too large (%d bytes)", len(raw))
|
||
}
|
||
var messages []Message
|
||
if err := json.Unmarshal(raw, &messages); err != nil {
|
||
return nil, fmt.Errorf("swarm: malformed inbox %s: %w", filepath.Base(path), err)
|
||
}
|
||
for i := range messages {
|
||
if messages[i].Type == "" {
|
||
messages[i].Type = "message"
|
||
}
|
||
}
|
||
return messages, nil
|
||
}
|
||
|
||
// atomicWriteJSON writes data as pretty JSON to a sibling temp file (0600) then
|
||
// renames it over path, so a reader never observes a partial write.
|
||
func atomicWriteJSON(path string, data any) error {
|
||
encoded, err := json.MarshalIndent(data, "", " ")
|
||
if err != nil {
|
||
return fmt.Errorf("swarm: encode inbox: %w", err)
|
||
}
|
||
dir := filepath.Dir(path)
|
||
tmp, err := os.CreateTemp(dir, ".inbox-*.tmp")
|
||
if err != nil {
|
||
return fmt.Errorf("swarm: create temp inbox: %w", err)
|
||
}
|
||
tmpName := tmp.Name()
|
||
defer os.Remove(tmpName) // no-op after a successful rename
|
||
if err := tmp.Chmod(inboxFileMode); err != nil {
|
||
tmp.Close()
|
||
return fmt.Errorf("swarm: chmod temp inbox: %w", err)
|
||
}
|
||
if _, err := tmp.Write(encoded); err != nil {
|
||
tmp.Close()
|
||
return fmt.Errorf("swarm: write temp inbox: %w", err)
|
||
}
|
||
if err := tmp.Close(); err != nil {
|
||
return fmt.Errorf("swarm: close temp inbox: %w", err)
|
||
}
|
||
if err := os.Rename(tmpName, path); err != nil {
|
||
return fmt.Errorf("swarm: commit inbox: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// lockSeq makes each lock token unique within the process even when two
|
||
// goroutines acquire in the same nanosecond.
|
||
var lockSeq atomic.Uint64
|
||
|
||
// acquireLock takes an exclusive lock by creating lockPath with O_EXCL. It
|
||
// retries with a short backoff until timeout, breaking a lock whose file is
|
||
// older than lockStaleAfter (so a crashed holder cannot deadlock the swarm).
|
||
//
|
||
// Each holder writes a unique token into the lock file and release is
|
||
// OWNERSHIP-AWARE: it removes the lock only if it still holds our token. After a
|
||
// stale-break another writer may legitimately own the lock; unconditionally
|
||
// removing it would let a third writer in and create split-brain writers that
|
||
// can corrupt or drop mailbox updates.
|
||
func acquireLock(lockPath string, timeout time.Duration) (func(), error) {
|
||
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), lockSeq.Add(1))
|
||
deadline := time.Now().Add(timeout)
|
||
for {
|
||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, inboxFileMode)
|
||
if err == nil {
|
||
// Record our ownership token; the exclusive create already holds the
|
||
// lock, so a write error only weakens diagnostics, not safety.
|
||
_, _ = f.WriteString(token)
|
||
f.Close()
|
||
var released bool
|
||
return func() {
|
||
if released {
|
||
return
|
||
}
|
||
released = true
|
||
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
|
||
os.Remove(lockPath)
|
||
}
|
||
}, nil
|
||
}
|
||
if !isLockContended(err) {
|
||
return nil, fmt.Errorf("swarm: acquire lock: %w", err)
|
||
}
|
||
// Lock held: break it if stale, otherwise wait. Reclaim ATOMICALLY via
|
||
// rename-with-verify. The previous content==content check read the same file
|
||
// twice microseconds apart, so it was always "unchanged" and gave no
|
||
// protection — two waiters could both Remove the lock while a third recreated
|
||
// it via O_EXCL, leaving two live holders. Renaming the file aside means only
|
||
// one racer wins the rename of a given inode; the moved file's mtime is then
|
||
// re-checked stale before deletion, and if a holder rotated a fresh lock in
|
||
// the gap it is renamed back rather than stolen. (AUDIT-M13)
|
||
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter {
|
||
reclaimed := lockPath + ".stale." + token
|
||
if os.Rename(lockPath, reclaimed) == nil {
|
||
if rinfo, rerr := os.Stat(reclaimed); rerr == nil && time.Since(rinfo.ModTime()) > lockStaleAfter {
|
||
os.Remove(reclaimed) // genuinely stale — drop it
|
||
} else {
|
||
_ = os.Rename(reclaimed, lockPath) // young again — restore, don't steal
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
if time.Now().After(deadline) {
|
||
return nil, fmt.Errorf("swarm: timed out acquiring lock %s", filepath.Base(lockPath))
|
||
}
|
||
// Short retry so a freed lock is re-acquired promptly under heavy
|
||
// contention (Windows file ops are slow; a coarse sleep starves waiters).
|
||
time.Sleep(2 * time.Millisecond)
|
||
}
|
||
}
|