diff --git a/runtime/ledger/ledger.go b/runtime/ledger/ledger.go new file mode 100644 index 0000000..4c00752 --- /dev/null +++ b/runtime/ledger/ledger.go @@ -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() } diff --git a/runtime/ledger/ledger_test.go b/runtime/ledger/ledger_test.go new file mode 100644 index 0000000..c562ab4 --- /dev/null +++ b/runtime/ledger/ledger_test.go @@ -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) } +}