Harden Go SDK head store persistence
This commit is contained in:
+3
-2
@@ -476,8 +476,9 @@ func (a *app) doctor(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
headStore := attesto.NewFileHeadStore("")
|
headStore := attesto.NewFileHeadStore("")
|
||||||
headStore.Set("__doctor__", 1, strings.Repeat("0", 64))
|
if err := headStore.SetWithError("__doctor__", 1, strings.Repeat("0", 64)); err != nil {
|
||||||
if seq, hash, ok := headStore.Get("__doctor__"); ok && seq == 1 && hash == strings.Repeat("0", 64) {
|
fail("head_store", err)
|
||||||
|
} else if seq, hash, ok := headStore.Get("__doctor__"); ok && seq == 1 && hash == strings.Repeat("0", 64) {
|
||||||
pass("head_store", nil)
|
pass("head_store", nil)
|
||||||
} else {
|
} else {
|
||||||
fail("head_store", errors.New("head store readback failed"))
|
fail("head_store", errors.New("head store readback failed"))
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ type HeadStore interface {
|
|||||||
Set(streamID string, seqNo int64, eventHash string)
|
Set(streamID string, seqNo int64, eventHash string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type errorAwareHeadStore interface {
|
||||||
|
HeadStore
|
||||||
|
SetWithError(streamID string, seqNo int64, eventHash string) error
|
||||||
|
}
|
||||||
|
|
||||||
// ForkDetectedError reports that a receipt did not extend the last accepted head
|
// ForkDetectedError reports that a receipt did not extend the last accepted head
|
||||||
// for its stream. The store is NOT advanced when this is returned.
|
// for its stream. The store is NOT advanced when this is returned.
|
||||||
type ForkDetectedError struct {
|
type ForkDetectedError struct {
|
||||||
@@ -58,6 +63,11 @@ func (s *MemoryHeadStore) Set(streamID string, seqNo int64, eventHash string) {
|
|||||||
s.heads[streamID] = [2]any{seqNo, eventHash}
|
s.heads[streamID] = [2]any{seqNo, eventHash}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MemoryHeadStore) SetWithError(streamID string, seqNo int64, eventHash string) error {
|
||||||
|
s.Set(streamID, seqNo, eventHash)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// FileHeadStore persists heads to a JSON file (default ~/.attesto/heads.json),
|
// FileHeadStore persists heads to a JSON file (default ~/.attesto/heads.json),
|
||||||
// giving fork detection across separate process invocations. Writes are atomic.
|
// giving fork detection across separate process invocations. Writes are atomic.
|
||||||
type FileHeadStore struct {
|
type FileHeadStore struct {
|
||||||
@@ -78,15 +88,26 @@ func NewFileHeadStore(path string) *FileHeadStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *FileHeadStore) load() map[string][2]json.RawMessage {
|
func (s *FileHeadStore) load() map[string][2]json.RawMessage {
|
||||||
raw, err := os.ReadFile(s.path)
|
out, err := s.loadWithError()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return map[string][2]json.RawMessage{}
|
return map[string][2]json.RawMessage{}
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileHeadStore) loadWithError() (map[string][2]json.RawMessage, error) {
|
||||||
|
raw, err := os.ReadFile(s.path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string][2]json.RawMessage{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
out := map[string][2]json.RawMessage{}
|
out := map[string][2]json.RawMessage{}
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
return map[string][2]json.RawMessage{}
|
return nil, err
|
||||||
}
|
}
|
||||||
return out
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FileHeadStore) Get(streamID string) (int64, string, bool) {
|
func (s *FileHeadStore) Get(streamID string) (int64, string, bool) {
|
||||||
@@ -105,37 +126,59 @@ func (s *FileHeadStore) Get(streamID string) (int64, string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *FileHeadStore) Set(streamID string, seqNo int64, eventHash string) {
|
func (s *FileHeadStore) Set(streamID string, seqNo int64, eventHash string) {
|
||||||
|
_ = s.SetWithError(streamID, seqNo, eventHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileHeadStore) SetWithError(streamID string, seqNo int64, eventHash string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
heads := map[string][2]any{}
|
heads := map[string][2]any{}
|
||||||
for key, entry := range s.load() {
|
stored, err := s.loadWithError()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for key, entry := range stored {
|
||||||
var n int64
|
var n int64
|
||||||
var h string
|
var h string
|
||||||
_ = json.Unmarshal(entry[0], &n)
|
if err := json.Unmarshal(entry[0], &n); err != nil {
|
||||||
_ = json.Unmarshal(entry[1], &h)
|
return fmt.Errorf("load stored head seq for %s: %w", key, err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(entry[1], &h); err != nil {
|
||||||
|
return fmt.Errorf("load stored head hash for %s: %w", key, err)
|
||||||
|
}
|
||||||
heads[key] = [2]any{n, h}
|
heads[key] = [2]any{n, h}
|
||||||
}
|
}
|
||||||
heads[streamID] = [2]any{seqNo, eventHash}
|
heads[streamID] = [2]any{seqNo, eventHash}
|
||||||
body, err := json.Marshal(heads)
|
body, err := json.Marshal(heads)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".heads-")
|
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".heads-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
tmpName := tmp.Name()
|
tmpName := tmp.Name()
|
||||||
_, writeErr := tmp.Write(body)
|
_, writeErr := tmp.Write(body)
|
||||||
closeErr := tmp.Close()
|
closeErr := tmp.Close()
|
||||||
if writeErr != nil || closeErr != nil {
|
if writeErr != nil || closeErr != nil {
|
||||||
_ = os.Remove(tmpName)
|
_ = os.Remove(tmpName)
|
||||||
return
|
if writeErr != nil {
|
||||||
|
return writeErr
|
||||||
}
|
}
|
||||||
_ = os.Chmod(tmpName, 0o600)
|
return closeErr
|
||||||
_ = os.Rename(tmpName, s.path)
|
}
|
||||||
|
if err := os.Chmod(tmpName, 0o600); err != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, s.path); err != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkAndAdvanceHead verifies a receipt extends the stored head, then advances
|
// checkAndAdvanceHead verifies a receipt extends the stored head, then advances
|
||||||
@@ -160,6 +203,9 @@ func checkAndAdvanceHead(store HeadStore, receipt EventReceipt) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if errorAware, ok := store.(errorAwareHeadStore); ok {
|
||||||
|
return errorAware.SetWithError(receipt.StreamID, receipt.SeqNo, receipt.EventHash)
|
||||||
|
}
|
||||||
store.Set(receipt.StreamID, receipt.SeqNo, receipt.EventHash)
|
store.Set(receipt.StreamID, receipt.SeqNo, receipt.EventHash)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,32 @@ func TestFileHeadStorePersistsAndIs0600(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileHeadStorePersistenceFailureReturned(t *testing.T) {
|
||||||
|
path := t.TempDir()
|
||||||
|
store := NewFileHeadStore(path)
|
||||||
|
if err := checkAndAdvanceHead(store, receipt(1, "h1", "")); err == nil {
|
||||||
|
t.Fatal("expected persistence failure when head-store path is a directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileHeadStoreCorruptFileFailsClosed(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "heads.json")
|
||||||
|
if err := os.WriteFile(path, []byte("{not-json"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store := NewFileHeadStore(path)
|
||||||
|
if err := store.SetWithError("str_demo", 1, "h1"); err == nil {
|
||||||
|
t.Fatal("expected corrupt head store to fail closed")
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(raw) != "{not-json" {
|
||||||
|
t.Fatal("corrupt head store must not be silently overwritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExactReplayOfStoredHeadIsBenign(t *testing.T) {
|
func TestExactReplayOfStoredHeadIsBenign(t *testing.T) {
|
||||||
// [P3.3 regression] A deduplicated resend returns the same receipt; the
|
// [P3.3 regression] A deduplicated resend returns the same receipt; the
|
||||||
// head tracker must treat (same seqNo, same eventHash) as a no-op.
|
// head tracker must treat (same seqNo, same eventHash) as a no-op.
|
||||||
|
|||||||
Reference in New Issue
Block a user