Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.25.10

require (
github.com/TeoSlayer/pilotprotocol v0.0.0
github.com/pilot-protocol/common v0.1.0
github.com/pilot-protocol/common v0.2.0
)

require (
Expand All @@ -14,3 +14,5 @@ require (
)

replace github.com/TeoSlayer/pilotprotocol => ../web4

replace github.com/pilot-protocol/common => ../common
4 changes: 2 additions & 2 deletions handshake.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import (
"sync"
"time"

"github.com/TeoSlayer/pilotprotocol/pkg/coreapi"
"github.com/TeoSlayer/pilotprotocol/pkg/protocol"
"github.com/pilot-protocol/common/coreapi"
"github.com/pilot-protocol/common/protocol"
"github.com/pilot-protocol/common/crypto"
"github.com/pilot-protocol/common/fsutil"
)
Expand Down
2 changes: 1 addition & 1 deletion runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ package handshake
import (
"crypto/ed25519"

"github.com/TeoSlayer/pilotprotocol/pkg/coreapi"
"github.com/pilot-protocol/common/coreapi"
)

// Runtime is the primitives-only contract the handshake manager needs
Expand Down
2 changes: 1 addition & 1 deletion service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ package handshake
import (
"context"

"github.com/TeoSlayer/pilotprotocol/pkg/coreapi"
"github.com/pilot-protocol/common/coreapi"
)

// Service is the L11 plugin adapter for the manual trust-handshake
Expand Down
2 changes: 1 addition & 1 deletion zz_ceiling_fakes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"sync"
"testing"

"github.com/TeoSlayer/pilotprotocol/pkg/coreapi"
"github.com/pilot-protocol/common/coreapi"
"github.com/TeoSlayer/pilotprotocol/pkg/daemon"
"github.com/pilot-protocol/common/crypto"
)
Expand Down
2 changes: 1 addition & 1 deletion zz_ceiling_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"testing"
"time"

"github.com/TeoSlayer/pilotprotocol/pkg/coreapi"
"github.com/pilot-protocol/common/coreapi"
)

// Additional ceiling tests for the small uncovered branches that fell out
Expand Down
2 changes: 1 addition & 1 deletion zz_handshake_connection_parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"testing"
"time"

"github.com/TeoSlayer/pilotprotocol/pkg/coreapi"
"github.com/pilot-protocol/common/coreapi"
)

// Iter-104 coverage for handleConnection (0% baseline) + processMessage
Expand Down
2 changes: 1 addition & 1 deletion zz_handshake_manager_bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"time"

"github.com/TeoSlayer/pilotprotocol/pkg/daemon"
"github.com/TeoSlayer/pilotprotocol/pkg/protocol"
"github.com/pilot-protocol/common/protocol"
)

// Iter-114 coverage for Manager.Start — the port-444 service bootstrap
Expand Down
6 changes: 3 additions & 3 deletions zz_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import (
"testing"
"time"

"github.com/TeoSlayer/pilotprotocol/pkg/coreapi"
"github.com/pilot-protocol/common/coreapi"
"github.com/TeoSlayer/pilotprotocol/pkg/daemon"
"github.com/TeoSlayer/pilotprotocol/pkg/protocol"
registryclient "github.com/TeoSlayer/pilotprotocol/pkg/registry/client"
"github.com/pilot-protocol/common/protocol"
registryclient "github.com/pilot-protocol/common/registry/client"
"github.com/TeoSlayer/pilotprotocol/tests/regtestutil"
"github.com/pilot-protocol/common/crypto"
)
Expand Down
127 changes: 127 additions & 0 deletions zz_pending_cleanup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package handshake

// Regression for the stale pending/outgoing entries observed on
// 2026-05-26: processRelayedRequest's sameNetwork / trustedAgent /
// autoApprove branches (and symmetric paths in handleRequest) call
// markTrustedLocked without clearing hm.pending or hm.outgoing, so a
// peer who sent a direct request (recorded in pending) and then arrives
// via a relayed auto-approve path stays in pending forever — pilotctl
// surfaces it, the policy fill_trust loop could re-target it.
//
// markTrustedLocked now centralizes the cleanup. These tests pin the
// invariant: after any trust grant, the peer's pending + outgoing
// entries are gone.

import (
"testing"
"time"
)

func TestMarkTrustedClearsPending(t *testing.T) {
t.Parallel()

hm := &Manager{
trusted: make(map[uint32]*TrustRecord),
pending: make(map[uint32]*PendingHandshake),
outgoing: make(map[uint32]time.Time),
revoked: make(map[uint32]time.Time),
replaySet: make(map[[32]byte]time.Time),
trustWaiters: make(map[uint32][]chan struct{}),
}

const peer = uint32(7777)
hm.pending[peer] = &PendingHandshake{NodeID: peer, ReceivedAt: time.Now()}

hm.mu.Lock()
hm.markTrustedLocked(peer, &TrustRecord{NodeID: peer, ApprovedAt: time.Now()})
hm.mu.Unlock()

if _, ok := hm.pending[peer]; ok {
t.Fatal("markTrustedLocked left a stale entry in hm.pending — " +
"would show in pilotctl pending forever")
}
if _, ok := hm.trusted[peer]; !ok {
t.Fatal("markTrustedLocked did not record trust")
}
}

func TestMarkTrustedClearsOutgoing(t *testing.T) {
t.Parallel()

hm := &Manager{
trusted: make(map[uint32]*TrustRecord),
pending: make(map[uint32]*PendingHandshake),
outgoing: make(map[uint32]time.Time),
revoked: make(map[uint32]time.Time),
replaySet: make(map[[32]byte]time.Time),
trustWaiters: make(map[uint32][]chan struct{}),
}

const peer = uint32(8888)
hm.outgoing[peer] = time.Now()

hm.mu.Lock()
hm.markTrustedLocked(peer, &TrustRecord{NodeID: peer, ApprovedAt: time.Now()})
hm.mu.Unlock()

if _, ok := hm.outgoing[peer]; ok {
t.Fatal("markTrustedLocked left a stale entry in hm.outgoing — " +
"policy fill_trust could re-target this peer")
}
}

func TestMarkTrustedClearsBothMaps(t *testing.T) {
t.Parallel()

hm := &Manager{
trusted: make(map[uint32]*TrustRecord),
pending: make(map[uint32]*PendingHandshake),
outgoing: make(map[uint32]time.Time),
revoked: make(map[uint32]time.Time),
replaySet: make(map[[32]byte]time.Time),
trustWaiters: make(map[uint32][]chan struct{}),
}

const peer = uint32(9999)
hm.pending[peer] = &PendingHandshake{NodeID: peer, ReceivedAt: time.Now()}
hm.outgoing[peer] = time.Now()

hm.mu.Lock()
hm.markTrustedLocked(peer, &TrustRecord{NodeID: peer, ApprovedAt: time.Now()})
hm.mu.Unlock()

if _, ok := hm.pending[peer]; ok {
t.Error("pending not cleared")
}
if _, ok := hm.outgoing[peer]; ok {
t.Error("outgoing not cleared")
}
if _, ok := hm.trusted[peer]; !ok {
t.Error("trusted not recorded")
}
}

func TestMarkTrustedNoEntryIsIdempotent(t *testing.T) {
t.Parallel()
// delete() on a missing key must be a no-op so that callers which
// pre-delete (handleRequest mutual path, handleAccept) stay correct.

hm := &Manager{
trusted: make(map[uint32]*TrustRecord),
pending: make(map[uint32]*PendingHandshake),
outgoing: make(map[uint32]time.Time),
revoked: make(map[uint32]time.Time),
replaySet: make(map[[32]byte]time.Time),
trustWaiters: make(map[uint32][]chan struct{}),
}

hm.mu.Lock()
hm.markTrustedLocked(1, &TrustRecord{NodeID: 1, ApprovedAt: time.Now()})
hm.mu.Unlock()

if _, ok := hm.trusted[1]; !ok {
t.Fatal("trust grant should not depend on pre-existing pending/outgoing")
}
}
98 changes: 98 additions & 0 deletions zz_persistence_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package handshake

import (
"os"
"path/filepath"
"testing"
"time"
)

func TestSaveTrust_NoPathIsNoop(t *testing.T) {
t.Parallel()
hm := newTestHM(t, "") // empty storePath
hm.mu.Lock()
hm.trusted[1] = &TrustRecord{NodeID: 1, ApprovedAt: time.Now()}
hm.mu.Unlock()
hm.saveTrust() // must not panic
}

func TestSaveTrust_WritesFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "trust.json")
hm := newTestHM(t, path)

hm.mu.Lock()
hm.trusted[1] = &TrustRecord{NodeID: 1, ApprovedAt: time.Now(), Mutual: true}
hm.pending[2] = &PendingHandshake{NodeID: 2, ReceivedAt: time.Now()}
hm.mu.Unlock()

hm.saveTrust()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if len(body) == 0 {
t.Error("trust file empty")
}
}

func TestSaveTrust_MkdirError(t *testing.T) {
t.Parallel()
// Path under /dev/null can't have its parent dir created.
hm := newTestHM(t, "/dev/null/cannot/trust.json")
hm.saveTrust() // must not panic; logs error and returns
}

func TestLoadTrust_NoPathIsNoop(t *testing.T) {
t.Parallel()
hm := newTestHM(t, "")
hm.loadTrust() // must not panic
if len(hm.trusted) != 0 {
t.Errorf("trusted len = %d, want 0", len(hm.trusted))
}
}

func TestLoadTrust_MissingFileIsNoop(t *testing.T) {
t.Parallel()
dir := t.TempDir()
hm := newTestHM(t, filepath.Join(dir, "no-such.json"))
hm.loadTrust()
if len(hm.trusted) != 0 {
t.Errorf("trusted len = %d, want 0", len(hm.trusted))
}
}

func TestSaveLoadTrust_Roundtrip(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "trust.json")
hm := newTestHM(t, path)

approvedAt := time.Now()
hm.mu.Lock()
hm.trusted[42] = &TrustRecord{
NodeID: 42,
PublicKey: "pk",
ApprovedAt: approvedAt,
Mutual: true,
Network: 7,
}
hm.mu.Unlock()
hm.saveTrust()

// Fresh manager loads the same file.
hm2 := newTestHM(t, path)
hm2.loadTrust()
hm2.mu.RLock()
defer hm2.mu.RUnlock()
rec, ok := hm2.trusted[42]
if !ok {
t.Fatal("trust record missing after roundtrip")
}
if rec.PublicKey != "pk" || rec.Network != 7 || !rec.Mutual {
t.Errorf("loaded = %+v", rec)
}
}
Loading
Loading