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:
parent
348ccd0bf2
commit
793746fcd0
2 changed files with 524 additions and 0 deletions
273
internal/ledger/file.go
Normal file
273
internal/ledger/file.go
Normal 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])
|
||||
}
|
||||
251
internal/ledger/file_test.go
Normal file
251
internal/ledger/file_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue