| 1 | package analytics |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "encoding/hex" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | const saltPrefix = "analytics-salt-" |
| 15 | const tmpInfix = ".tmp-" |
| 16 | |
| 17 | // DaySalt returns the box's visitor salt for date (YYYY-MM-DD), creating |
| 18 | // <dir>/analytics-salt-<date> with 32 random bytes at 0600 when absent and |
| 19 | // deleting any other day's file (spec §3.3: restart-stable within the day, |
| 20 | // gone on rotation, never in the bucket). dir is the box data directory. |
| 21 | // |
| 22 | // The file is created atomically so concurrent callers never observe a |
| 23 | // short or empty salt while another goroutine/process is still writing: |
| 24 | // a candidate is written in full to a private |
| 25 | // analytics-salt-<date>.tmp-<random> file first, then linked into place |
| 26 | // with os.Link. Link fails with os.ErrExist when a sibling has already |
| 27 | // won the race; the loser then polls the winner's file for up to 200ms |
| 28 | // until it has settled to 32 bytes, rather than trust a possibly-partial |
| 29 | // read. Filesystems without hard-link support (some Windows |
| 30 | // configurations) fall back to os.Rename, which is atomic but — unlike |
| 31 | // Link — can clobber a concurrent winner's file; that's an accepted |
| 32 | // trade-off on those filesystems. |
| 33 | func DaySalt(dir, date string) ([]byte, error) { |
| 34 | path := filepath.Join(dir, saltPrefix+date) |
| 35 | if b, err := os.ReadFile(path); err == nil && len(b) == 32 { |
| 36 | return b, nil |
| 37 | } |
| 38 | |
| 39 | b := make([]byte, 32) |
| 40 | if _, err := rand.Read(b); err != nil { |
| 41 | return nil, err |
| 42 | } |
| 43 | |
| 44 | var suffix [8]byte |
| 45 | if _, err := rand.Read(suffix[:]); err != nil { |
| 46 | return nil, err |
| 47 | } |
| 48 | tmp := path + tmpInfix + hex.EncodeToString(suffix[:]) |
| 49 | |
| 50 | f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) |
| 51 | if err != nil { |
| 52 | return nil, err |
| 53 | } |
| 54 | _, werr := f.Write(b) |
| 55 | cerr := f.Close() |
| 56 | if werr != nil || cerr != nil { |
| 57 | os.Remove(tmp) |
| 58 | return nil, errors.Join(werr, cerr) |
| 59 | } |
| 60 | |
| 61 | switch linkErr := os.Link(tmp, path); { |
| 62 | case linkErr == nil: |
| 63 | // We won: tmp and path now name the same inode. |
| 64 | os.Remove(tmp) |
| 65 | case errors.Is(linkErr, os.ErrExist): |
| 66 | // A sibling process/goroutine won the race. |
| 67 | os.Remove(tmp) |
| 68 | for i := 0; i < 20; i++ { |
| 69 | if got, rerr := os.ReadFile(path); rerr == nil && len(got) == 32 { |
| 70 | return got, nil |
| 71 | } |
| 72 | time.Sleep(10 * time.Millisecond) |
| 73 | } |
| 74 | return nil, fmt.Errorf("analytics: timed out waiting for %s", path) |
| 75 | default: |
| 76 | // No hard-link support: fall back to an atomic rename. |
| 77 | if rerr := os.Rename(tmp, path); rerr != nil { |
| 78 | os.Remove(tmp) |
| 79 | return nil, rerr |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // Rotation: every other day's salt goes, so nothing on disk can join a |
| 84 | // hash across days. In-flight tmpInfix files are skipped — they also |
| 85 | // share saltPrefix (possibly even today's date, from a sibling |
| 86 | // goroutine/process still mid-write) and must not be reaped out from |
| 87 | // under their owner. A failed ReadDir here is deliberately ignored: |
| 88 | // dir is local per-box state, so a stale salt file left behind |
| 89 | // self-heals the next time rotation runs. |
| 90 | entries, _ := os.ReadDir(dir) |
| 91 | for _, e := range entries { |
| 92 | n := e.Name() |
| 93 | if strings.HasPrefix(n, saltPrefix) && n != saltPrefix+date && !strings.Contains(n, tmpInfix) { |
| 94 | os.Remove(filepath.Join(dir, n)) |
| 95 | } |
| 96 | } |
| 97 | return b, nil |
| 98 | } |
| 99 | |