feat(ledger): add file-backed append-only Event Ledger prototype (Go) with verify
This commit is contained in:
124
runtime/ledger/ledger.go
Normal file
124
runtime/ledger/ledger.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
PrevHash string `json:"prev_hash"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
type Ledger struct {
|
||||
path string
|
||||
file *os.File
|
||||
last string
|
||||
}
|
||||
|
||||
func sha256Hex(b []byte) string {
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func Open(path string) (*Ledger, error) {
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l := &Ledger{path: path, file: f}
|
||||
if err := l.loadLastHash(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *Ledger) loadLastHash() error {
|
||||
// read file line by line, keep last hash
|
||||
f, err := os.Open(l.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
dec := json.NewDecoder(f)
|
||||
var last string
|
||||
for {
|
||||
var e Entry
|
||||
if err := dec.Decode(&e); err != nil {
|
||||
break
|
||||
}
|
||||
last = e.Hash
|
||||
}
|
||||
l.last = last
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Ledger) Append(eventType string, payload interface{}) (string, error) {
|
||||
pbytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
e := Entry{
|
||||
Timestamp: time.Now().UnixNano(),
|
||||
Type: eventType,
|
||||
Payload: json.RawMessage(pbytes),
|
||||
PrevHash: l.last,
|
||||
}
|
||||
// compute hash
|
||||
hb, _ := json.Marshal(struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
PrevHash string `json:"prev_hash"`
|
||||
}{e.Timestamp, e.Type, e.Payload, e.PrevHash})
|
||||
e.Hash = sha256Hex(hb)
|
||||
enc, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := l.file.Write(append(enc, '\n')); err != nil {
|
||||
return "", err
|
||||
}
|
||||
l.last = e.Hash
|
||||
return e.Hash, nil
|
||||
}
|
||||
|
||||
func (l *Ledger) Verify() error {
|
||||
f, err := os.Open(l.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
dec := json.NewDecoder(f)
|
||||
var prev string
|
||||
for {
|
||||
var e Entry
|
||||
if err := dec.Decode(&e); err != nil {
|
||||
break
|
||||
}
|
||||
if e.PrevHash != prev {
|
||||
return fmt.Errorf("broken chain: prev %s != entry.PrevHash %s", prev, e.PrevHash)
|
||||
}
|
||||
// recompute
|
||||
hb, _ := json.Marshal(struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
PrevHash string `json:"prev_hash"`
|
||||
}{e.Timestamp, e.Type, e.Payload, e.PrevHash})
|
||||
if sha256Hex(hb) != e.Hash {
|
||||
return fmt.Errorf("hash mismatch for entry at %d", e.Timestamp)
|
||||
}
|
||||
prev = e.Hash
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Ledger) Close() error { return l.file.Close() }
|
||||
18
runtime/ledger/ledger_test.go
Normal file
18
runtime/ledger/ledger_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppendAndVerify(t *testing.T) {
|
||||
p := "test_ledger.jsonl"
|
||||
os.Remove(p)
|
||||
l, err := Open(p)
|
||||
if err != nil { t.Fatal(err) }
|
||||
defer os.Remove(p)
|
||||
defer l.Close()
|
||||
if _, err := l.Append("test_event", map[string]string{"k":"v"}); err != nil { t.Fatal(err) }
|
||||
if _, err := l.Append("test_event2", map[string]string{"a":"b"}); err != nil { t.Fatal(err) }
|
||||
if err := l.Verify(); err != nil { t.Fatal(err) }
|
||||
}
|
||||
Reference in New Issue
Block a user