diff --git a/handshake.go b/handshake.go index d89d9cf..07c0360 100644 --- a/handshake.go +++ b/handshake.go @@ -267,8 +267,9 @@ func (hm *Manager) removeWaiter(nodeID uint32, target chan struct{}) { // --- Trust persistence --- type trustSnapshot struct { - Trusted []trustSnapshotEntry `json:"trusted"` - Pending []pendingSnapshotEntry `json:"pending,omitempty"` + Trusted []trustSnapshotEntry `json:"trusted"` + Pending []pendingSnapshotEntry `json:"pending,omitempty"` + Revoked []revokedSnapshotEntry `json:"revoked,omitempty"` } type trustSnapshotEntry struct { @@ -286,6 +287,11 @@ type pendingSnapshotEntry struct { ReceivedAt string `json:"received_at"` } +type revokedSnapshotEntry struct { + NodeID uint32 `json:"node_id"` + Until string `json:"until"` // RFC3339 timestamp of cooldown expiry +} + func (hm *Manager) saveTrust() { if hm.storePath == "" { return @@ -309,6 +315,15 @@ func (hm *Manager) saveTrust() { ReceivedAt: p.ReceivedAt.Format(time.RFC3339), }) } + for nodeID, until := range hm.revoked { + // Only persist entries whose cooldown hasn't expired yet. + if time.Now().Before(until) { + snap.Revoked = append(snap.Revoked, revokedSnapshotEntry{ + NodeID: nodeID, + Until: until.Format(time.RFC3339), + }) + } + } // MarshalIndent on trustSnapshot is infallible: every field is a // primitive (uint32/string/bool/uint16), pre-formatted via @@ -325,7 +340,7 @@ func (hm *Manager) saveTrust() { slog.Error("write trust state", "err", err) return } - slog.Debug("trust state saved", "peers", len(hm.trusted), "pending", len(hm.pending)) + slog.Debug("trust state saved", "peers", len(hm.trusted), "pending", len(hm.pending), "revoked", len(snap.Revoked)) } func (hm *Manager) loadTrust() { @@ -363,7 +378,17 @@ func (hm *Manager) loadTrust() { ReceivedAt: received, } } - slog.Info("loaded trust state", "peers", len(hm.trusted), "pending", len(hm.pending)) + for _, e := range snap.Revoked { + until, err := time.Parse(time.RFC3339, e.Until) + if err != nil { + continue + } + // Only restore if the cooldown hasn't expired yet. + if time.Now().Before(until) { + hm.revoked[e.NodeID] = until + } + } + slog.Info("loaded trust state", "peers", len(hm.trusted), "pending", len(hm.pending), "revoked", len(hm.revoked)) } // Start binds port 444 and begins handling handshake connections. diff --git a/zz_logic_test.go b/zz_logic_test.go index 73a35f3..22ed928 100644 --- a/zz_logic_test.go +++ b/zz_logic_test.go @@ -76,6 +76,52 @@ func TestSaveLoadTrustRoundTripPreservesEntries(t *testing.T) { } } +func TestSaveLoadTrustRoundTripPreservesRevoked(t *testing.T) { + t.Parallel() + dir := t.TempDir() + idPath := filepath.Join(dir, "identity.json") + hm := newTestHM(t, idPath) + if hm.storePath == "" { + t.Fatal("storePath should be derived from IdentityPath") + } + + // Add a revoked entry with a cooldown 10 minutes in the future. + futureCooldown := time.Now().Add(10 * time.Minute).Truncate(time.Second).UTC() + hm.revoked[13] = futureCooldown + hm.saveTrust() + + // Load into a fresh manager — revoked[13] must survive the roundtrip. + hm2 := newTestHM(t, idPath) + until, ok := hm2.revoked[13] + if !ok { + t.Fatal("revoked[13] missing after load — restart resurrects revoked peers") + } + if !until.Equal(futureCooldown) { + t.Fatalf("revoked[13] = %v, want %v", until, futureCooldown) + } +} + +func TestLoadTrustDropsExpiredRevokedCooldowns(t *testing.T) { + t.Parallel() + dir := t.TempDir() + idPath := filepath.Join(dir, "identity.json") + hm := newTestHM(t, idPath) + if hm.storePath == "" { + t.Fatal("storePath should be derived from IdentityPath") + } + + // Add a revoked entry whose cooldown is already in the past. + pastCooldown := time.Now().Add(-1 * time.Minute).Truncate(time.Second).UTC() + hm.revoked[7] = pastCooldown + hm.saveTrust() + + // Load — expired cooldown should be silently dropped. + hm2 := newTestHM(t, idPath) + if _, ok := hm2.revoked[7]; ok { + t.Fatal("revoked[7] should be dropped on load — cooldown already expired") + } +} + func TestLoadTrustMissingFileIsNoop(t *testing.T) { t.Parallel() dir := t.TempDir()