Every uploaded run showed "not recorded" in the web UI because the meta extractor read summary.verdict. Schema §7.3 calls that field summary.overall; "verdict" is the per-category field one level down. So the verdict was never stored, and the UI faithfully reported a gap that was this parser's doing rather than the document's. The test encoded the same mistake — its fixture posted summary.verdict:"warn" — so it passed throughout against a parser that read a field nothing writes. Corrected to summary.overall, and to a verdict that exists: §7.3 defines green|yellow|red|inconclusive, and "warn" was never one of them. The eleven runs already stored had their meta backfilled from the documents, which are kept byte-for-byte and still carry the real value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
341 lines
11 KiB
Go
341 lines
11 KiB
Go
// 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
|
|
}
|