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
}