d780643015
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
3.6 KiB
Go
142 lines
3.6 KiB
Go
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()
|
|
}
|
|
|
|
// 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
|
|
}
|