carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package analytics
2
3import (
4 "crypto/rand"
5 "encoding/hex"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12)
13
14const saltPrefix = "analytics-salt-"
15const 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.
33func 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