feat: FileLedger for local and shared scopes

JSON file ledger with Stamp/Verify/HashReport, idempotent hash+org+scope,
mutex safety, mode 0600 / parent 0700.
This commit is contained in:
George Lambert 2026-09-29 12:01:57 -04:00
commit 793746fcd0
2 changed files with 524 additions and 0 deletions

273
internal/ledger/file.go Normal file
View file

@ -0,0 +1,273 @@
package ledger
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/pkg/digest"
)
const sha256Empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
var scopeRank = map[string]int{
"local": 1,
"shared": 2,
"global": 3,
}
// Receipt is a local/shared timestamp receipt persisted in the ledger file.
type Receipt struct {
ReceiptID string `json:"receipt_id"`
Hash string `json:"hash"`
Scope string `json:"scope"`
CubeID string `json:"cube_id,omitempty"`
OrgID string `json:"org_id,omitempty"`
CreatedAt string `json:"created_at"`
Merkle string `json:"merkle"`
RestampOf string `json:"restamp_of,omitempty"`
}
// VerifyResult is the outcome of Verify.
type VerifyResult struct {
Valid bool
Error string
Receipt *Receipt
}
// FileLedger persists receipts to a JSON file (mode 0600; parent dir 0700).
// Stamp is idempotent for the same hash+org+scope.
type FileLedger struct {
mu sync.Mutex
path string
byID map[string]Receipt
order []string
}
// DefaultPath returns VERAE_LEDGER_PATH or $HOME/.verae/mcp-timestamp-local.json.
func DefaultPath() string {
if v := strings.TrimSpace(os.Getenv("VERAE_LEDGER_PATH")); v != "" {
return v
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return filepath.Join(".verae", "mcp-timestamp-local.json")
}
return filepath.Join(home, ".verae", "mcp-timestamp-local.json")
}
// Open loads an existing ledger file (if any) at path.
func Open(path string) (*FileLedger, error) {
if path == "" {
path = DefaultPath()
}
l := &FileLedger{
path: path,
byID: make(map[string]Receipt),
}
if err := l.load(); err != nil {
return nil, err
}
return l, nil
}
func (l *FileLedger) load() error {
raw, err := os.ReadFile(l.path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var items []Receipt
if err := json.Unmarshal(raw, &items); err != nil {
var wrap struct {
Receipts []Receipt `json:"receipts"`
}
if err2 := json.Unmarshal(raw, &wrap); err2 != nil {
return fmt.Errorf("ledger: parse %s: %w", l.path, err)
}
items = wrap.Receipts
}
for _, rec := range items {
if _, ok := l.byID[rec.ReceiptID]; ok {
continue
}
l.byID[rec.ReceiptID] = rec
l.order = append(l.order, rec.ReceiptID)
}
return nil
}
func (l *FileLedger) save() error {
if err := os.MkdirAll(filepath.Dir(l.path), 0o700); err != nil {
return err
}
payload := make([]Receipt, 0, len(l.order))
for _, rid := range l.order {
payload = append(payload, l.byID[rid])
}
raw, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
tmp := l.path + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := f.Write(raw); err != nil {
f.Close()
_ = os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
_ = os.Chmod(tmp, 0o600)
if err := os.Rename(tmp, l.path); err != nil {
_ = os.Remove(tmp)
return err
}
_ = os.Chmod(l.path, 0o600)
return nil
}
// Stamp creates or returns an existing receipt for digest+orgID+scope.
func (l *FileLedger) Stamp(dig, scope, cubeID, orgID string) ([]Receipt, error) {
normalized, err := digest.NormalizeSHA256(dig)
if err != nil {
return nil, err
}
if _, ok := scopeRank[scope]; !ok {
return nil, fmt.Errorf("scope must be local, shared, or global")
}
l.mu.Lock()
defer l.mu.Unlock()
for _, rid := range l.order {
rec := l.byID[rid]
if rec.Hash == normalized && rec.OrgID == orgID && rec.Scope == scope {
return []Receipt{rec}, nil
}
}
created := time.Now().UTC().Format("2006-01-02T15:04:05Z")
sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%s|%s", normalized, created, scope, orgID)))
receiptID := hex.EncodeToString(sum[:])
ids := append(append([]string{}, l.order...), receiptID)
rec := Receipt{
ReceiptID: receiptID,
Hash: normalized,
Scope: scope,
CubeID: cubeID,
OrgID: orgID,
CreatedAt: created,
Merkle: merkle(ids),
}
l.byID[receiptID] = rec
l.order = append(l.order, receiptID)
if err := l.save(); err != nil {
delete(l.byID, receiptID)
l.order = l.order[:len(l.order)-1]
return nil, err
}
return []Receipt{rec}, nil
}
// Verify looks up receiptID and optionally checks wantScope rank.
func (l *FileLedger) Verify(receiptID, wantScope string) VerifyResult {
l.mu.Lock()
defer l.mu.Unlock()
rec, ok := l.byID[receiptID]
if !ok {
return VerifyResult{Valid: false, Error: "unknown receipt"}
}
if wantScope != "" {
if scopeRank[rec.Scope] < scopeRankOr(wantScope, 99) {
cp := rec
return VerifyResult{
Valid: false,
Error: fmt.Sprintf("receipt scope %s cannot satisfy %s", rec.Scope, wantScope),
Receipt: &cp,
}
}
}
cp := rec
return VerifyResult{Valid: true, Receipt: &cp}
}
// HashReport returns receipts matching digest, filtered by orgID and wantScope.
func (l *FileLedger) HashReport(dig, orgID, wantScope string) ([]Receipt, error) {
normalized, err := digest.NormalizeSHA256(dig)
if err != nil {
return nil, err
}
l.mu.Lock()
defer l.mu.Unlock()
var out []Receipt
wantRank := 0
if wantScope != "" {
wantRank = scopeRankOr(wantScope, 99)
}
for _, rid := range l.order {
rec := l.byID[rid]
if rec.Hash != normalized {
continue
}
if orgID != "" && rec.OrgID != orgID {
continue
}
if wantScope != "" && scopeRank[rec.Scope] < wantRank {
continue
}
out = append(out, rec)
}
return out, nil
}
func scopeRankOr(scope string, fallback int) int {
if r, ok := scopeRank[scope]; ok {
return r
}
return fallback
}
func merkle(ids []string) string {
if len(ids) == 0 {
return "sha256:" + sha256Empty
}
sorted := append([]string{}, ids...)
sort.Strings(sorted)
level := make([][]byte, len(sorted))
for i, id := range sorted {
sum := sha256.Sum256([]byte(id))
level[i] = sum[:]
}
for len(level) > 1 {
nxt := make([][]byte, 0, (len(level)+1)/2)
for i := 0; i < len(level); i += 2 {
if i+1 == len(level) {
nxt = append(nxt, level[i])
} else {
sum := sha256.Sum256(append(append([]byte{}, level[i]...), level[i+1]...))
nxt = append(nxt, sum[:])
}
}
level = nxt
}
return "sha256:" + hex.EncodeToString(level[0])
}

View file

@ -0,0 +1,251 @@
package ledger_test
import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/ledger"
)
const sha256Empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
func TestStamp_ReturnsOneReceipt(t *testing.T) {
path := filepath.Join(t.TempDir(), "receipts.json")
l, err := ledger.Open(path)
if err != nil {
t.Fatal(err)
}
recs, err := l.Stamp(sha256Empty, "local", "", "")
if err != nil {
t.Fatal(err)
}
if len(recs) != 1 {
t.Fatalf("len=%d", len(recs))
}
rec := recs[0]
if rec.Hash != sha256Empty || rec.Scope != "local" {
t.Fatalf("rec=%+v", rec)
}
if rec.ReceiptID == "" || rec.CreatedAt == "" || rec.Merkle == "" {
t.Fatalf("missing fields: %+v", rec)
}
}
func TestStamp_IdempotentSameHashOrgScope(t *testing.T) {
path := filepath.Join(t.TempDir(), "receipts.json")
l, err := ledger.Open(path)
if err != nil {
t.Fatal(err)
}
first, err := l.Stamp(sha256Empty, "local", "cube-a", "org-1")
if err != nil {
t.Fatal(err)
}
second, err := l.Stamp(sha256Empty, "local", "cube-b", "org-1")
if err != nil {
t.Fatal(err)
}
if first[0].ReceiptID != second[0].ReceiptID || first[0].CreatedAt != second[0].CreatedAt {
t.Fatalf("not idempotent: %+v vs %+v", first[0], second[0])
}
}
func TestStamp_DifferentOrgOrScopeIsNew(t *testing.T) {
path := filepath.Join(t.TempDir(), "receipts.json")
l, err := ledger.Open(path)
if err != nil {
t.Fatal(err)
}
a, err := l.Stamp(sha256Empty, "local", "", "org-1")
if err != nil {
t.Fatal(err)
}
b, err := l.Stamp(sha256Empty, "local", "", "org-2")
if err != nil {
t.Fatal(err)
}
c, err := l.Stamp(sha256Empty, "shared", "", "org-1")
if err != nil {
t.Fatal(err)
}
if a[0].ReceiptID == b[0].ReceiptID || a[0].ReceiptID == c[0].ReceiptID {
t.Fatalf("expected distinct receipts")
}
}
func TestStamp_NormalizesPrefix(t *testing.T) {
path := filepath.Join(t.TempDir(), "receipts.json")
l, err := ledger.Open(path)
if err != nil {
t.Fatal(err)
}
recs, err := l.Stamp("sha256:"+strings.ToUpper(sha256Empty), "local", "", "")
if err != nil {
t.Fatal(err)
}
if recs[0].Hash != sha256Empty {
t.Fatalf("hash=%q", recs[0].Hash)
}
}
func TestStamp_RejectsBadDigestAndScope(t *testing.T) {
path := filepath.Join(t.TempDir(), "receipts.json")
l, err := ledger.Open(path)
if err != nil {
t.Fatal(err)
}
if _, err := l.Stamp("not-a-hash", "local", "", ""); err == nil {
t.Fatal("expected digest error")
}
if _, err := l.Stamp(sha256Empty, "nope", "", ""); err == nil {
t.Fatal("expected scope error")
}
}
func TestVerify_AndScopeRank(t *testing.T) {
path := filepath.Join(t.TempDir(), "receipts.json")
l, err := ledger.Open(path)
if err != nil {
t.Fatal(err)
}
recs, err := l.Stamp(sha256Empty, "local", "", "")
if err != nil {
t.Fatal(err)
}
ok := l.Verify(recs[0].ReceiptID, "local")
if !ok.Valid || ok.Receipt == nil || ok.Receipt.ReceiptID != recs[0].ReceiptID {
t.Fatalf("ok=%+v", ok)
}
weak := l.Verify(recs[0].ReceiptID, "global")
if weak.Valid {
t.Fatalf("local cannot satisfy global: %+v", weak)
}
missing := l.Verify("no-such", "")
if missing.Valid || missing.Error == "" {
t.Fatalf("missing=%+v", missing)
}
}
func TestHashReport_Filters(t *testing.T) {
path := filepath.Join(t.TempDir(), "receipts.json")
l, err := ledger.Open(path)
if err != nil {
t.Fatal(err)
}
other := strings.Repeat("ab", 32)
if _, err := l.Stamp(sha256Empty, "local", "", "org-1"); err != nil {
t.Fatal(err)
}
if _, err := l.Stamp(sha256Empty, "shared", "", "org-1"); err != nil {
t.Fatal(err)
}
if _, err := l.Stamp(sha256Empty, "local", "", "org-2"); err != nil {
t.Fatal(err)
}
if _, err := l.Stamp(other, "local", "", "org-1"); err != nil {
t.Fatal(err)
}
all, err := l.HashReport(sha256Empty, "", "")
if err != nil || len(all) != 3 {
t.Fatalf("all=%d err=%v", len(all), err)
}
org1, err := l.HashReport(sha256Empty, "org-1", "")
if err != nil || len(org1) != 2 {
t.Fatalf("org1=%d err=%v", len(org1), err)
}
shared, err := l.HashReport(sha256Empty, "", "shared")
if err != nil || len(shared) != 1 || shared[0].Scope != "shared" {
t.Fatalf("shared=%+v err=%v", shared, err)
}
}
func TestStamp_SurvivesReload(t *testing.T) {
path := filepath.Join(t.TempDir(), "subdir", "receipts.json")
first, err := ledger.Open(path)
if err != nil {
t.Fatal(err)
}
recs, err := first.Stamp(sha256Empty, "local", "", "")
if err != nil {
t.Fatal(err)
}
second, err := ledger.Open(path)
if err != nil {
t.Fatal(err)
}
report, err := second.HashReport(sha256Empty, "", "")
if err != nil || len(report) != 1 || report[0].ReceiptID != recs[0].ReceiptID {
t.Fatalf("report=%+v err=%v", report, err)
}
ok := second.Verify(recs[0].ReceiptID, "local")
if !ok.Valid {
t.Fatalf("verify after reload: %+v", ok)
}
}
func TestSave_FileMode0600Parent0700(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested")
path := filepath.Join(dir, "receipts.json")
l, err := ledger.Open(path)
if err != nil {
t.Fatal(err)
}
if _, err := l.Stamp(sha256Empty, "local", "", ""); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Fatalf("file mode=%o want 0600", fi.Mode().Perm())
}
di, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if di.Mode().Perm() != 0o700 {
t.Fatalf("dir mode=%o want 0700", di.Mode().Perm())
}
}
func TestStamp_ConcurrentIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "receipts.json")
l, err := ledger.Open(path)
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
ids := make([]string, 20)
errs := make([]error, 20)
for i := 0; i < 20; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
recs, err := l.Stamp(sha256Empty, "local", "", "org")
errs[i] = err
if err == nil && len(recs) == 1 {
ids[i] = recs[0].ReceiptID
}
}(i)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("i=%d: %v", i, err)
}
}
for i := 1; i < len(ids); i++ {
if ids[i] != ids[0] {
t.Fatalf("ids diverge: %q vs %q", ids[0], ids[i])
}
}
report, err := l.HashReport(sha256Empty, "org", "")
if err != nil || len(report) != 1 {
t.Fatalf("want 1 receipt after concurrent stamps, got %d err=%v", len(report), err)
}
}