// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later // Package runs stores uploaded measurement documents. // // The premise (and the reason uploads exist at all) is that an engineer runs their own server: // uploading a run there is how history, diffing and "it was fine last Tuesday" work. That makes // the storage deliberately dumb — one JSON file per run, on disk, greppable, deletable with rm — // and puts the interesting policy in two places instead: // // - who may upload (Policy.Mode), because a public server is a different proposition from a // private one; and // - how much identifying detail the client must strip first (Policy.MinAnonymization), because // someone measuring against a stranger's server should not be shipping their SSIDs there. // // Retention is enforced on every upload, not by a sweeper, so a server left alone does not grow. package runs import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" ) // Mode says who may upload. type Mode string const ( // ModeOff refuses every upload. The endpoint still answers, with 403 and a reason, so the // app can say "this server does not accept uploads" instead of showing a network error. ModeOff Mode = "off" // ModeAnonymous accepts uploads from any enrolled device. The default: enrollment already // required an admin-minted token, so "anyone enrolled" is not "anyone". ModeAnonymous Mode = "anonymous" // ModeAccount accepts uploads only from a device where somebody has signed in (see // /v1/account/link). Enrollment alone is not enough: the operator's token admits a device, // an account attributes it to a person. ModeAccount Mode = "account" ) // Anonymization levels, mirroring the client's redaction levels (measurement-schema.md §8). // Ordered: full < balanced < strict. const ( AnonFull = "full" // nothing removed — for your own server AnonBalanced = "balanced" // network names and device identity pseudonymized, neighbours dropped AnonStrict = "strict" // metrics and findings only ) func anonRank(level string) int { switch level { case AnonStrict: return 2 case AnonBalanced: return 1 case AnonFull: return 0 } return -1 // unknown } // Policy is the operator's upload configuration. type Policy struct { Mode Mode `json:"mode"` MaxBytes int64 `json:"max_bytes"` RetentionDays int `json:"retention_days"` MaxRunsPerDevice int `json:"max_runs_per_device"` MinAnonymization string `json:"min_anonymization"` } func DefaultPolicy() Policy { return Policy{ Mode: ModeAnonymous, MaxBytes: 4 << 20, RetentionDays: 90, MaxRunsPerDevice: 200, MinAnonymization: AnonFull, } } var ( ErrDisabled = errors.New("uploads are disabled on this server") ErrNeedAccount = errors.New("this server only accepts uploads from signed-in accounts") ErrTooLarge = errors.New("run exceeds the server's upload size limit") ErrNotAnonEnough = errors.New("run is less anonymized than this server requires") ErrMalformed = errors.New("run is not a measurement document") ) // Meta is the index entry for one stored run — enough to list history without opening the files. type Meta struct { ID string `json:"id"` DeviceID string `json:"device_id"` UploadedAt time.Time `json:"uploaded_at"` StartedAt string `json:"started_at,omitempty"` Anonymization string `json:"anonymization"` SizeBytes int64 `json:"size_bytes"` Verdict string `json:"verdict,omitempty"` FindingCount int `json:"finding_count"` } type Store struct { mu sync.Mutex dir string policy Policy } func Open(stateDir string, p Policy) (*Store, error) { dir := filepath.Join(stateDir, "runs") if err := os.MkdirAll(dir, 0o700); err != nil { return nil, err } return &Store{dir: dir, policy: p}, nil } func (s *Store) Policy() Policy { return s.policy } // Accepts reports whether an upload from this caller would be allowed at all, so callers can // answer the capability question without a body. // // linked says whether a person has signed in on the uploading device. It is the only thing that // distinguishes ModeAccount from ModeOff — and the reason the check takes an argument at all. func (s *Store) Accepts(linked bool) error { switch s.policy.Mode { case ModeOff: return ErrDisabled case ModeAccount: if !linked { return ErrNeedAccount } } return nil } // Put validates and stores one uploaded document. body is the raw JSON as received: it is stored // byte-for-byte so what the device signed off on is what sits on disk. func (s *Store) Put(deviceID string, body []byte, linked bool) (Meta, error) { if err := s.Accepts(linked); err != nil { return Meta{}, err } if s.policy.MaxBytes > 0 && int64(len(body)) > s.policy.MaxBytes { return Meta{}, ErrTooLarge } // Peek at the parts we index on. Unknown fields are ignored: the server must not become a // second schema authority that rejects documents a newer client legitimately produces. var doc struct { Run struct { ID string `json:"id"` StartedAt string `json:"started_at"` Privacy struct { Anonymization string `json:"anonymization"` } `json:"privacy"` } `json:"run"` Findings []json.RawMessage `json:"findings"` Summary struct { // measurement-schema.md §7.3 calls this "overall"; "verdict" is the per-category // field one level down. Reading the wrong one stored an empty verdict on every run // ever uploaded, which the UI showed as "not recorded" — a claim about the document // that was really a bug in this parser. Overall string `json:"overall"` } `json:"summary"` } if err := json.Unmarshal(body, &doc); err != nil || doc.Run.ID == "" { return Meta{}, ErrMalformed } level := doc.Run.Privacy.Anonymization if level == "" { level = AnonFull // no declaration means nothing was stripped } if anonRank(level) < anonRank(s.policy.MinAnonymization) { return Meta{}, fmt.Errorf("%w: got %q, need at least %q", ErrNotAnonEnough, level, s.policy.MinAnonymization) } id := sanitizeID(doc.Run.ID) if id == "" { return Meta{}, ErrMalformed } s.mu.Lock() defer s.mu.Unlock() devDir := filepath.Join(s.dir, sanitizeID(deviceID)) if err := os.MkdirAll(devDir, 0o700); err != nil { return Meta{}, err } if err := os.WriteFile(filepath.Join(devDir, id+".json"), body, 0o600); err != nil { return Meta{}, err } meta := Meta{ ID: id, DeviceID: deviceID, UploadedAt: time.Now().UTC(), StartedAt: doc.Run.StartedAt, Anonymization: level, SizeBytes: int64(len(body)), Verdict: doc.Summary.Overall, FindingCount: len(doc.Findings), } if err := os.WriteFile(filepath.Join(devDir, id+".meta.json"), mustJSON(meta), 0o600); err != nil { return Meta{}, err } s.enforceRetentionLocked(devDir) return meta, nil } // List returns one device's runs, newest first. func (s *Store) List(deviceID string) []Meta { s.mu.Lock() defer s.mu.Unlock() return s.listLocked(filepath.Join(s.dir, sanitizeID(deviceID))) } // ListFor returns the runs of several devices at once, newest first. // // This is what makes an account mean something: three phones signed in to one account produce one // history, which is the main reason to have accounts beyond upload permission. func (s *Store) ListFor(deviceIDs []string) []Meta { s.mu.Lock() defer s.mu.Unlock() var out []Meta for _, id := range deviceIDs { out = append(out, s.listLocked(filepath.Join(s.dir, sanitizeID(id)))...) } sort.Slice(out, func(i, j int) bool { return out[i].UploadedAt.After(out[j].UploadedAt) }) return out } // OwnerOf reports which of these devices holds runID, so a caller can be granted access to a run // belonging to a sibling device without being able to name an arbitrary device. // // The search is over an allow-list the caller never supplies directly — it comes from the account // — so a run id from another account simply is not found. func (s *Store) OwnerOf(deviceIDs []string, runID string) (string, bool) { s.mu.Lock() defer s.mu.Unlock() for _, id := range deviceIDs { p := filepath.Join(s.dir, sanitizeID(id), sanitizeID(runID)+".json") if fi, err := os.Stat(p); err == nil && !fi.IsDir() { return id, true } } return "", false } // Get returns the stored document bytes for one run. func (s *Store) Get(deviceID, runID string) ([]byte, error) { s.mu.Lock() defer s.mu.Unlock() return os.ReadFile(filepath.Join(s.dir, sanitizeID(deviceID), sanitizeID(runID)+".json")) } // Delete removes one run. Missing is not an error: delete is idempotent so a client retrying // after a dropped response does not see a spurious failure. func (s *Store) Delete(deviceID, runID string) error { s.mu.Lock() defer s.mu.Unlock() dev, run := sanitizeID(deviceID), sanitizeID(runID) for _, suffix := range []string{".json", ".meta.json"} { if err := os.Remove(filepath.Join(s.dir, dev, run+suffix)); err != nil && !errors.Is(err, os.ErrNotExist) { return err } } return nil } func (s *Store) listLocked(devDir string) []Meta { entries, err := os.ReadDir(devDir) if err != nil { return nil } out := make([]Meta, 0, len(entries)) for _, e := range entries { if !strings.HasSuffix(e.Name(), ".meta.json") { continue } b, err := os.ReadFile(filepath.Join(devDir, e.Name())) if err != nil { continue } var m Meta if json.Unmarshal(b, &m) == nil { out = append(out, m) } } sort.Slice(out, func(i, j int) bool { return out[i].UploadedAt.After(out[j].UploadedAt) }) return out } // enforceRetentionLocked drops runs past the age limit, then past the count limit. Age first, so // a burst of uploads cannot push out runs that are still inside the retention window. func (s *Store) enforceRetentionLocked(devDir string) { metas := s.listLocked(devDir) drop := func(m Meta) { _ = os.Remove(filepath.Join(devDir, m.ID+".json")) _ = os.Remove(filepath.Join(devDir, m.ID+".meta.json")) } kept := metas[:0] if s.policy.RetentionDays > 0 { cutoff := time.Now().Add(-time.Duration(s.policy.RetentionDays) * 24 * time.Hour) for _, m := range metas { if m.UploadedAt.Before(cutoff) { drop(m) continue } kept = append(kept, m) } } else { kept = metas } if s.policy.MaxRunsPerDevice > 0 && len(kept) > s.policy.MaxRunsPerDevice { for _, m := range kept[s.policy.MaxRunsPerDevice:] { // listLocked is newest-first drop(m) } } } // sanitizeID keeps ids to characters that cannot escape the directory or collide with the // .meta.json suffix convention. Ids are uuids and device ids in practice; anything else is // truncated to nothing and rejected upstream. func sanitizeID(s string) string { var b strings.Builder for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': b.WriteRune(r) } if b.Len() >= 64 { break } } return b.String() } func mustJSON(v any) []byte { b, _ := json.Marshal(v) return b }