package analytics import ( "crypto/rand" "encoding/hex" "errors" "fmt" "os" "path/filepath" "strings" "time" ) const saltPrefix = "analytics-salt-" const tmpInfix = ".tmp-" // DaySalt returns the box's visitor salt for date (YYYY-MM-DD), creating // /analytics-salt- with 32 random bytes at 0600 when absent and // deleting any other day's file (spec ยง3.3: restart-stable within the day, // gone on rotation, never in the bucket). dir is the box data directory. // // The file is created atomically so concurrent callers never observe a // short or empty salt while another goroutine/process is still writing: // a candidate is written in full to a private // analytics-salt-.tmp- file first, then linked into place // with os.Link. Link fails with os.ErrExist when a sibling has already // won the race; the loser then polls the winner's file for up to 200ms // until it has settled to 32 bytes, rather than trust a possibly-partial // read. Filesystems without hard-link support (some Windows // configurations) fall back to os.Rename, which is atomic but โ€” unlike // Link โ€” can clobber a concurrent winner's file; that's an accepted // trade-off on those filesystems. func DaySalt(dir, date string) ([]byte, error) { path := filepath.Join(dir, saltPrefix+date) if b, err := os.ReadFile(path); err == nil && len(b) == 32 { return b, nil } b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return nil, err } var suffix [8]byte if _, err := rand.Read(suffix[:]); err != nil { return nil, err } tmp := path + tmpInfix + hex.EncodeToString(suffix[:]) f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return nil, err } _, werr := f.Write(b) cerr := f.Close() if werr != nil || cerr != nil { os.Remove(tmp) return nil, errors.Join(werr, cerr) } switch linkErr := os.Link(tmp, path); { case linkErr == nil: // We won: tmp and path now name the same inode. os.Remove(tmp) case errors.Is(linkErr, os.ErrExist): // A sibling process/goroutine won the race. os.Remove(tmp) for i := 0; i < 20; i++ { if got, rerr := os.ReadFile(path); rerr == nil && len(got) == 32 { return got, nil } time.Sleep(10 * time.Millisecond) } return nil, fmt.Errorf("analytics: timed out waiting for %s", path) default: // No hard-link support: fall back to an atomic rename. if rerr := os.Rename(tmp, path); rerr != nil { os.Remove(tmp) return nil, rerr } } // Rotation: every other day's salt goes, so nothing on disk can join a // hash across days. In-flight tmpInfix files are skipped โ€” they also // share saltPrefix (possibly even today's date, from a sibling // goroutine/process still mid-write) and must not be reaped out from // under their owner. A failed ReadDir here is deliberately ignored: // dir is local per-box state, so a stale salt file left behind // self-heals the next time rotation runs. entries, _ := os.ReadDir(dir) for _, e := range entries { n := e.Name() if strings.HasPrefix(n, saltPrefix) && n != saltPrefix+date && !strings.Contains(n, tmpInfix) { os.Remove(filepath.Join(dir, n)) } } return b, nil }