package store import ( "bufio" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "strings" ) const genesisHash = "0000000000000000000000000000000000000000000000000000000000000000" // BucketTotal is per-(class, scrubbed-title) accumulated time in a session. type BucketTotal struct { Class string `json:"class"` Title string `json:"title"` // scrubbed Seconds int64 `json:"seconds"` } // SessionSummary is one permanent, hash-chained record of a finished session. type SessionSummary struct { Seq int `json:"seq"` PrevHash string `json:"prev_hash"` SessionID string `json:"session_id"` NextAction string `json:"next_action"` SuccessCond string `json:"success_condition"` Outcome string `json:"outcome"` // "completed" | "expired" StartedUnix int64 `json:"started_unix"` EndedUnix int64 `json:"ended_unix"` SwitchCount int `json:"switch_count"` Buckets []BucketTotal `json:"buckets"` // sorted desc by seconds Hash string `json:"hash"` } // computeHash returns SHA-256(prevHash || canonicalJSON(s with Hash zeroed)). // encoding/json emits struct fields in declaration order, so this is stable. func computeHash(s SessionSummary) (string, error) { s.Hash = "" canonical, err := json.Marshal(s) if err != nil { return "", err } h := sha256.New() h.Write([]byte(s.PrevHash)) h.Write(canonical) return hex.EncodeToString(h.Sum(nil)), nil } func readSummaries(path string) ([]SessionSummary, error) { f, err := os.Open(path) if os.IsNotExist(err) { return nil, nil } if err != nil { return nil, err } defer f.Close() var out []SessionSummary sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" { continue } var s SessionSummary if err := json.Unmarshal([]byte(line), &s); err != nil { return nil, err } out = append(out, s) } return out, sc.Err() } // RecentSessions returns up to n most-recent summaries from the audit chain in // oldest-first order. A missing/empty chain or n <= 0 yields (nil, nil). func RecentSessions(path string, n int) ([]SessionSummary, error) { if n <= 0 { return nil, nil } all, err := readSummaries(path) if err != nil { return nil, err } if len(all) == 0 { return nil, nil } if len(all) > n { all = all[len(all)-n:] } return all, nil } // AppendSession links s onto the chain (genesis prev_hash = 64 zeros), assigns // the next contiguous Seq, computes its Hash, and appends one JSON line with an // fsync. func AppendSession(path string, s SessionSummary) error { existing, err := readSummaries(path) if err != nil { return err } if len(existing) == 0 { s.Seq = 1 s.PrevHash = genesisHash } else { last := existing[len(existing)-1] s.Seq = last.Seq + 1 s.PrevHash = last.Hash } hash, err := computeHash(s) if err != nil { return err } s.Hash = hash line, err := json.Marshal(s) if err != nil { return err } f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } defer f.Close() if _, err := f.Write(append(line, '\n')); err != nil { return err } return f.Sync() } // VerifyChain re-walks every line, recomputing hashes and confirming each // prev_hash links to the prior hash and Seq is contiguous from 1. It returns an // error naming the first broken line (1-based); nil if intact or empty. func VerifyChain(path string) error { summaries, err := readSummaries(path) if err != nil { return err } prev := genesisHash for i, s := range summaries { lineNo := i + 1 if s.Seq != lineNo { return fmt.Errorf("audit chain broken at line %d: seq %d, want %d", lineNo, s.Seq, lineNo) } if s.PrevHash != prev { return fmt.Errorf("audit chain broken at line %d: prev_hash mismatch", lineNo) } want, err := computeHash(s) if err != nil { return err } if want != s.Hash { return fmt.Errorf("audit chain broken at line %d: hash mismatch", lineNo) } prev = s.Hash } return nil }