From 95af53fb1e15d4a0b729341cd92a424bcfbe37bf Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:38:44 +0800 Subject: [PATCH 001/125] feat(protocols): W6.4 gossipsub, the router core and its codecs The pure half of gossipsub v1.1: the RPC codec, the signing check, the score function, the caches, and the total router step. The loop task and the publish and subscribe seam stay out. An inbound publish goes to every other mesh peer, and a local publish records its id so an echo is not forwarded again. The heartbeat emits gossip after it settles the mesh and before it shifts the cache, to peers in neither the mesh nor the fanout. The default message id is the payload, because the validator uses strictNoSign. --- Tests/Gossipsub/Cache.lean | 180 ++ Tests/Gossipsub/Core.lean | 411 ++++ Tests/Gossipsub/Rpc.lean | 327 +++ Tests/Gossipsub/Score.lean | 365 +++ Tests/Gossipsub/Sign.lean | 187 ++ Tests/Main.lean | 10 + .../libp2p/protocols/Libp2p/Protocols.lean | 15 +- .../protocols/Libp2p/Protocols/Constants.lean | 27 + .../Libp2p/Protocols/Gossipsub/Cache.lean | 700 ++++++ .../Libp2p/Protocols/Gossipsub/Core.lean | 2059 +++++++++++++++++ .../Libp2p/Protocols/Gossipsub/Rpc.lean | 947 ++++++++ .../Libp2p/Protocols/Gossipsub/Score.lean | 1027 ++++++++ .../Libp2p/Protocols/Gossipsub/Sign.lean | 305 +++ .../Libp2p/Protocols/Gossipsub/Types.lean | 84 + .../protocols/Libp2p/Protocols/Mock/Core.lean | 46 + .../protocols/Libp2p/Protocols/Mock/Host.lean | 47 + .../Libp2p/Protocols/Mock/Identity.lean | 57 + .../Libp2p/Protocols/Mock/Multiaddr.lean | 21 + .../libp2p/protocols/docs/ARCHITECTURE.md | 4 + 19 files changed, 6815 insertions(+), 4 deletions(-) create mode 100644 Tests/Gossipsub/Cache.lean create mode 100644 Tests/Gossipsub/Core.lean create mode 100644 Tests/Gossipsub/Rpc.lean create mode 100644 Tests/Gossipsub/Score.lean create mode 100644 Tests/Gossipsub/Sign.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Constants.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Cache.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Core.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Rpc.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Score.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Sign.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Types.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean diff --git a/Tests/Gossipsub/Cache.lean b/Tests/Gossipsub/Cache.lean new file mode 100644 index 00000000..1febd9de --- /dev/null +++ b/Tests/Gossipsub/Cache.lean @@ -0,0 +1,180 @@ +import Tests.Support +import Libp2p.Protocols.Gossipsub.Cache + +/-! +# Gossipsub suite: Cache + +The checks of `Libp2p.Protocols.Gossipsub.Cache`. Each check is one step +of a fixed transcript: the inputs are literal times and ids, and the +expected result stands beside the call. The suite names the spec +boundaries: exactly `t + ttl`, exactly `until`, exactly the end of a +window, and exactly the capacity of the seen cache. + +The windows of the message cache checks are the spec defaults: five +history windows and three gossip windows. The seen cache uses capacity +three and a TTL of one hundred milliseconds, so each boundary is a round +number. +-/ + +set_option autoImplicit false + +namespace Tests.Gossipsub.Cache + +open Libp2p.Protocols.Gossipsub +open Libp2p.Protocols.Gossipsub.Cache + +/-- The window parameters of the transcript: the spec defaults of five +history windows and three gossip windows. -/ +def window5 : WindowParams := + { historyLength := 5, historyGossip := 3, valid := by decide } + +/-- The seen cache parameters of the transcript: three entries and a TTL +of one hundred milliseconds. -/ +def seen3 : SeenParams := + { capacity := 3, ttl := 100, valid := by decide } + +/-- A message id from one number. -/ +def mid (n : Nat) : MessageId := { bytes := [n] } + +/-- One topic of the transcript. -/ +def topicA : TopicId := { name := "a" } + +/-- A second topic of the transcript. -/ +def topicB : TopicId := { name := "b" } + +/-- A message with a one-byte payload. -/ +def msg (t : TopicId) (n : Nat) : Message := + { «from» := none, data := some [n], seqno := none, topic := t, signature := none, key := none } + +/-- A peer from one number. -/ +def peer (n : Nat) : Peer := { bytes := [n], text := s!"p{n}" } + +/-- A promise with explicit fields, for the expected lists. -/ +def promiseOf (p : Peer) (i : MessageId) (d : Millis) : IwantPromise := + { peer := p, id := i, deadline := d } + +/-- A backoff entry with explicit fields, for the expected lists. -/ +def backoffOf (p : Peer) (t : TopicId) (u : Millis) : BackoffEntry := + { peer := p, topic := t, «until» := u } + +/-- Run the checks of this module. -/ +def run (r : Runner) : IO Unit := do + r.section "gossipsub Cache" "vendor/specs/pubsub" + + -- Message cache: one message, then shifts. + let c0 : MessageCache := MessageCache.empty window5 + let c1 : MessageCache := c0.put (mid 1) (msg topicA 1) + r.check "mcache put then get returns the message" + (decide (MessageCache.get c1 (mid 1) = some (msg topicA 1))) + r.check "mcache get returns none for an absent id" + (decide (MessageCache.get c1 (mid 2) = none)) + r.check "mcache get returns the message after historyLength minus one shifts" + (decide (MessageCache.get (MessageCache.shiftN c1 4) (mid 1) = some (msg topicA 1))) + r.check "mcache get returns none after historyLength shifts" + (decide (MessageCache.get (MessageCache.shiftN c1 5) (mid 1) = none)) + + -- Message cache: four messages entered one heartbeat apart, so their + -- ages are zero to three. + let g1 : MessageCache := c0.put (mid 1) (msg topicA 1) + let g2 : MessageCache := (MessageCache.shift g1).put (mid 2) (msg topicA 2) + let g3 : MessageCache := (MessageCache.shift g2).put (mid 3) (msg topicA 3) + let g4 : MessageCache := (MessageCache.shift g3).put (mid 4) (msg topicA 4) + r.check "mcache gossipIds lists only ids newer than historyGossip windows" + (decide (MessageCache.gossipIds g4 topicA = [mid 4, mid 3, mid 2])) + let g5 : MessageCache := g4.put (mid 9) (msg topicB 9) + r.check "mcache gossipIds filters by topic" + (decide (MessageCache.gossipIds g5 topicA = [mid 4, mid 3, mid 2] ∧ + MessageCache.gossipIds g5 topicB = [mid 9])) + r.check "mcache gossipIds is empty for a message old enough" + (decide (MessageCache.gossipIds (MessageCache.shiftN c1 3) topicA = [])) + + -- Window parameters: the validity predicate of the spec. + r.check "window params reject gossip zero" (decide (¬ (0 < 0 ∧ 0 ≤ 5))) + r.check "window params reject gossip above length" (decide (¬ (0 < 6 ∧ 6 ≤ 5))) + r.check "window params accept gossip within length" (decide (0 < 3 ∧ 3 ≤ 5)) + + -- Seen cache: one id at time ten, and the two time boundaries. + let s0 : SeenCache := SeenCache.empty seen3 + r.check "seen contains after add at the same time" + (SeenCache.contains (s0.add (mid 1) 10) (mid 1) 10) + r.check "seen contains holds below t plus ttl" + (SeenCache.contains (s0.add (mid 1) 10) (mid 1) 109) + r.check "seen contains fails at exactly t plus ttl" + (!SeenCache.contains (s0.add (mid 1) 10) (mid 1) 110) + + -- Seen cache: three adds fill the cache, one more evicts the oldest. + let f1 : SeenCache := s0.add (mid 1) 10 + let f2 : SeenCache := f1.add (mid 2) 20 + let f3 : SeenCache := f2.add (mid 3) 30 + let f4 : SeenCache := f3.add (mid 4) 40 + r.check "seen add at capacity evicts the oldest entry" + (!SeenCache.contains f4 (mid 1) 40) + r.check "seen add at capacity keeps the second oldest entry" + (SeenCache.contains f4 (mid 2) 40) + r.check "seen size stays at capacity after an add at capacity" + (decide (f4.entries.length = seen3.capacity)) + let f5 : SeenCache := f3.add (mid 1) 35 + r.check "seen add refreshes an existing id" + (decide (f5.entries = [ + ({ id := mid 1, «at» := 35 } : SeenEntry), + { id := mid 3, «at» := 30 }, + { id := mid 2, «at» := 20 }])) + let f6 : SeenCache := f2.prune 115 + r.check "seen prune drops only expired entries" + (decide (f6.entries = [{ id := mid 2, «at» := 20 }])) + + -- Backoff table: a period from ten to sixty. + let b1 : BackoffTable := Backoff.add Backoff.empty (peer 1) topicA 50 10 + r.check "backoff active below until" + (Backoff.active b1 (peer 1) topicA 59) + r.check "backoff inactive at exactly until" + (!Backoff.active b1 (peer 1) topicA 60) + r.check "backoff inactive for an unknown pair" + (!Backoff.active b1 (peer 2) topicA 30 && !Backoff.active b1 (peer 1) topicB 30) + let b2 : BackoffTable := Backoff.add b1 (peer 1) topicA 50 40 + r.check "backoff a second add extends the period" + (Backoff.active b2 (peer 1) topicA 60 && Backoff.active b2 (peer 1) topicA 89 && + !Backoff.active b2 (peer 1) topicA 90) + let b3 : BackoffTable := + Backoff.add (Backoff.add Backoff.empty (peer 1) topicA 50 10) (peer 2) topicA 100 10 + let b4 : BackoffTable := Backoff.clear b3 70 + r.check "backoff clear keeps active entries" + (Backoff.active b4 (peer 2) topicA 70) + r.check "backoff clear drops expired entries" + (decide (b4 = [backoffOf (peer 2) topicA 110])) + + -- Promises: one promise, then the same pair again. + let p0 : PromiseTable := PromiseTable.promise [] (peer 1) (mid 7) 300 + r.check "promise records the pair and the deadline" + (decide (p0 = [promiseOf (peer 1) (mid 7) 300])) + let p1 : PromiseTable := PromiseTable.promise p0 (peer 1) (mid 7) 400 + r.check "promise a second time for the same pair replaces the deadline" + (decide (p1 = [promiseOf (peer 1) (mid 7) 400])) + let p2 : PromiseTable := + PromiseTable.promise + (PromiseTable.promise (PromiseTable.promise [] (peer 1) (mid 7) 300) (peer 2) (mid 7) 300) + (peer 1) (mid 8) 300 + r.check "deliver removes every promise for the id" + (decide (PromiseTable.deliver p2 (mid 7) = [promiseOf (peer 1) (mid 8) 300])) + let p3 : PromiseTable := + PromiseTable.promise (PromiseTable.promise [] (peer 1) (mid 7) 300) (peer 2) (mid 8) 400 + r.check "deliver keeps promises of other ids" + (decide (PromiseTable.deliver p3 (mid 7) = [promiseOf (peer 2) (mid 8) 400])) + + -- Broken promises at time five hundred: one expired promise of peer + -- one, and one promise that is not due. + let q0 : PromiseTable := + PromiseTable.promise (PromiseTable.promise [] (peer 1) (mid 7) 400) (peer 1) (mid 8) 900 + r.check "broken lists the peer with the expired count" + (decide (PromiseTable.broken q0 500 = [(peer 1, 1)])) + let q1 : PromiseTable := + PromiseTable.promise (PromiseTable.promise [] (peer 1) (mid 7) 400) (peer 1) (mid 8) 450 + r.check "broken counts two expired promises for one peer" + (decide (PromiseTable.broken q1 500 = [(peer 1, 2)])) + let q2 : PromiseTable := PromiseTable.promise [] (peer 1) (mid 7) 900 + r.check "broken omits peers whose deadlines have not passed" + (decide (PromiseTable.broken q2 500 = [])) + r.check "broken after deliver shows no count for the delivered id" + (decide (PromiseTable.broken (PromiseTable.deliver q1 (mid 7)) 500 = [(peer 1, 1)])) + +end Tests.Gossipsub.Cache diff --git a/Tests/Gossipsub/Core.lean b/Tests/Gossipsub/Core.lean new file mode 100644 index 00000000..7c781118 --- /dev/null +++ b/Tests/Gossipsub/Core.lean @@ -0,0 +1,411 @@ +import Tests.Support +import Libp2p.Protocols.Gossipsub.Core + +/-! +# Gossipsub suite: the router core + +Transcript checks for the gossipsub v1.1 router: the mesh fill and +prune, the backoff, the score gates, the deduplication, the IWANT +answers, the fanout, and the rejection cases. The events, the times, and +the peer orders are fixed, so every run replays. + +The spec sections are `vendor/specs/pubsub/gossipsub/gossipsub-v1.0.md` +(the router, the heartbeat, the mesh, the fanout) and +`gossipsub-v1.1.md` (the score gates, the backoff). +-/ + +set_option autoImplicit false + +namespace Tests.Gossipsub.Core + +open Libp2p.Protocols +open Libp2p.Protocols.Gossipsub +open Libp2p.Protocols.Gossipsub.Core + +/-! ## Fixtures -/ + +private def ta : TopicId := ⟨"t"⟩ +private def tb : TopicId := ⟨"u"⟩ + +private def pa : Peer := ⟨[1], "a"⟩ +private def pb : Peer := ⟨[2], "b"⟩ +private def pc : Peer := ⟨[3], "c"⟩ +private def pd : Peer := ⟨[4], "d"⟩ +private def px : Peer := ⟨[9], "x"⟩ + +private def prims : Sign.Prims := + { unmarshalKey := fun _ => none + inlineKey := fun _ => none + idOfKey := fun _ => [] + verify := fun _ _ _ => false } + +/-- The test parameters: a small mesh, and the message id is the data +bytes, so two messages of different payloads have different ids. -/ +private def baseParams : Params := + { defaults with + mesh := { d := 2, dLow := 1, dHigh := 3, dScore := 1, dLazy := 2 } + prims := prims + msgId := fun m => ⟨m.data.getD []⟩ } + +/-- Every peer is below this graylist threshold. -/ +private def grayParams : Params := + { baseParams with + thresholds := { baseParams.thresholds with graylistThreshold := 1 } } + +/-- No peer reaches this gossip threshold. -/ +private def gossipParams : Params := + { baseParams with + thresholds := { baseParams.thresholds with gossipThreshold := 1 } } + +/-- The topic parameters of the scoring checks: one invalid delivery +costs a full score unit, and the decay keeps the counters. -/ +private def scoreTopic : Score.TopicParams := + { topicWeight := 1 + timeInMeshWeight := 0 + timeInMeshQuantum := 1 + timeInMeshCap := 0 + firstMessageDeliveriesWeight := 0 + firstMessageDeliveriesDecay := scoreScale + firstMessageDeliveriesCap := 0 + meshMessageDeliveriesWeight := 0 + meshMessageDeliveriesDecay := scoreScale + meshMessageDeliveriesThreshold := 0 + meshMessageDeliveriesCap := 0 + meshMessageDeliveriesActivation := 0 + meshMessageDeliveriesWindow := 0 + meshFailurePenaltyWeight := 0 + meshFailurePenaltyDecay := scoreScale + invalidMessageDeliveriesWeight := -1000 + invalidMessageDeliveriesDecay := scoreScale } + +/-- The parameters with the topic `t` configured. -/ +private def cfgParams : Params := + { baseParams with + score := { baseParams.score with topics := fun t => if t = ta then some scoreTopic else none } } + +private def peerRec (subs : List TopicId) : PeerData := + { score := Score.PeerState.zero, proto := .meshsub, subs := subs } + +private def emptyState : State := + { peers := [] + mesh := [] + fanout := [] + mcache := Cache.MessageCache.empty baseParams.window + seen := Cache.SeenCache.empty baseParams.seen + backoff := [] + iwant := [] + topics := [] + now := 0 + lastHeartbeat := 0 } + +/-- A state with the four peers known and the topic `t` subscribed. -/ +private def known : State := + { emptyState with + peers := [(pa, peerRec [ta]), (pb, peerRec [ta]), (pc, peerRec [ta]), (pd, peerRec [ta])] + topics := [ta] } + +/-- A message that passes `strictNoSign`: the four signed fields are +absent. The payload is the message id. -/ +private def msgOf (topic : TopicId) (payload : List Nat) : Message := + { «from» := none, data := some payload, seqno := none, topic := topic, + signature := none, key := none } + +/-- A message with a `from` field, which fails `strictNoSign`. -/ +private def badMsg : Message := + { «from» := some [9], data := some [1], seqno := none, topic := ta, + signature := none, key := none } + +private def rpcOfPublish (ms : List Message) : Rpc.RPC := + { subscriptions := [], publish := ms, control := none } + +private def rpcOfControl (c : Rpc.ControlMessage) : Rpc.RPC := + { subscriptions := [], publish := [], control := some c } + +private def controlOf (grafts : List Rpc.ControlGraft) (prunes : List Rpc.ControlPrune) : Rpc.ControlMessage := + { ihave := [], iwant := [], graft := grafts, prune := prunes } + +private def pruneOf (topic : TopicId) : Rpc.ControlPrune := + { topicId := topic.name, peers := [], backoff := none } + +private def graftOf (topic : TopicId) : Rpc.ControlGraft := { topicId := topic.name } + +/-- Malformed bytes: field 1 of length 5 with one byte behind it. -/ +private def malformed : List Nat := [10, 5, 1] + +/-! ## Result accessors -/ + +private def stateOf : Except ProtocolError (State × List Action) → State + | .ok (s, _) => s + | .error _ => emptyState + +private def actionsOf : Except ProtocolError (State × List Action) → List Action + | .ok (_, a) => a + | .error _ => [] + +private def isDecodeError : Except ProtocolError (State × List Action) → Bool + | .error (.decode _) => true + | _ => false + +private def isOk : Except ProtocolError (State × List Action) → Bool + | .ok _ => true + | .error _ => false + +private def delivers (m : Message) (l : List Action) : Nat := + (l.filter (fun a => match a with + | .deliver m' => decide (m' = m) + | .sendRpc _ _ => false)).length + +private def sendsTo (q : Peer) (l : List Action) : Nat := + (l.filter (fun a => match a with + | .sendRpc q' _ => decide (q' = q) + | .deliver _ => false)).length + +private def sends (l : List Action) (test : Rpc.RPC → Bool) : Nat := + (l.filter (fun a => match a with + | .sendRpc _ r => test r + | .deliver _ => false)).length + +private def isPrune (t : TopicId) (r : Rpc.RPC) : Bool := + match r.control with + | some c => c.prune.any (fun p => decide (p.topicId = t.name)) + | none => false + +private def isGraft (t : TopicId) (r : Rpc.RPC) : Bool := + match r.control with + | some c => c.graft.any (fun g => decide (g.topicId = t.name)) + | none => false + +private def hasIhave (r : Rpc.RPC) : Bool := + match r.control with + | some c => !c.ihave.isEmpty + | none => false +private def hasPublish (r : Rpc.RPC) : Bool := !r.publish.isEmpty + +private def pruneCount (t : TopicId) (l : List Action) : Nat := sends l (isPrune t) +private def graftCount (t : TopicId) (l : List Action) : Nat := sends l (isGraft t) +private def publishCount (l : List Action) : Nat := sends l hasPublish +private def ihaveCount (l : List Action) : Nat := sends l hasIhave + +private def invalidCount (s : State) (q : Peer) (t : TopicId) : Nat := + match peerOf s q with + | some pd => (Score.countersOf pd.score t).invalidMessageDeliveries + | none => 0 + +/-- The counters of a peer with one invalid delivery. -/ +private def negCounters : Score.TopicCounters := + { Score.TopicCounters.zero with + inMesh := true, graftTime := 0, invalidMessageDeliveries := 1 } + +/-- The score state of a peer with one invalid delivery in topic `t`. -/ +private def negPeerState : Score.PeerState := + { Score.PeerState.zero with topics := [(ta, negCounters)] } + +/-- A peer with one invalid delivery in a topic whose weight is -1000. +Its score is -1. -/ +private def negPeerData : PeerData := + { score := negPeerState, proto := .meshsub, subs := [ta] } + +/-- The state with the negative peer in the mesh of `t`. -/ +private def negState : State := + { emptyState with + peers := [(pa, negPeerData)] + topics := [ta] + mesh := [(ta, [pa])] + now := 0 + lastHeartbeat := 0 } + +private def meshIs (s : State) (t : TopicId) (ps : List Peer) : Bool := decide (meshOf s t = ps) + +/-! ## Checks -/ + +/-- Run the checks of this module. -/ +def run (r : Runner) : IO Unit := do + r.section "gossipsub Core" + "vendor/specs/pubsub/gossipsub/gossipsub-v1.0.md, gossipsub-v1.1.md" + + -- A malformed RPC is the one error constructor. + r.check "core.reject.malformed-rpc" + (isDecodeError (step baseParams known (.rpc pa malformed))) + + -- The graylist gate runs before the decode: malformed bytes from a + -- graylisted peer are ignored, not rejected. + r.check "core.reject.graylist-malformed-rpc-ignored" + (match step grayParams known (.rpc pa malformed) with + | .ok (s', acts) => acts = [] && meshIs s' ta (meshOf known ta) + | _ => false) + + -- The graylist gate ignores a valid RPC too. + r.check "core.reject.graylist-valid-rpc-ignored" + (match step grayParams known (.rpc pa (Pb.encodeMessage (rpcOfControl (controlOf [graftOf ta] [])))) with + | .ok (s', acts) => acts = [] && meshIs s' ta (meshOf known ta) + | _ => false) + + -- An unknown peer is ignored. + r.check "core.reject.unknown-peer-ignored" + (match step baseParams known (.rpc px (Pb.encodeMessage (rpcOfControl (controlOf [graftOf ta] [])))) with + | .ok (s', acts) => acts = [] && meshIs s' ta (meshOf known ta) + | _ => false) + + -- A graft for a topic we do not serve draws no prune answer. + r.check "core.reject.graft-unknown-topic-ignored" + (match step baseParams known (.rpc pa (Pb.encodeMessage (rpcOfControl (controlOf [graftOf tb] [])))) with + | .ok (s', acts) => acts = [] && meshIs s' tb [] + | _ => false) + + -- A graft inside an active backoff draws a prune and adds no peer. + r.check "core.reject.graft-backoff-pruned" + (match step baseParams + { known with backoff := Cache.Backoff.add [] pa ta 60000 0, now := 1000 } + (.rpc pa (Pb.encodeMessage (rpcOfControl (controlOf [graftOf ta] [])))) with + | .ok (s', acts) => pruneCount ta acts = 1 && meshIs s' ta [] + | _ => false) + + -- A graft when the mesh holds `dHigh` peers draws a prune. + r.check "core.reject.graft-at-dHigh-pruned" + (match step baseParams { known with mesh := [(ta, [pb, pc, pd])] } + (.rpc pa (Pb.encodeMessage (rpcOfControl (controlOf [graftOf ta] [])))) with + | .ok (s', acts) => pruneCount ta acts = 1 && meshIs s' ta [pb, pc, pd] + | _ => false) + + -- An IWANT for an id the message cache does not hold draws no answer. + r.check "core.reject.iwant-uncached-no-answer" + (match step baseParams known + (.rpc pa (Pb.encodeMessage (iwantRpc [⟨[7]⟩]))) with + | .ok (_, acts) => publishCount acts = 0 + | _ => false) + + -- A message that fails `strictNoSign` draws no deliver and no + -- forward, and increments the invalid delivery counter. + r.check "core.reject.invalid-signature-no-forward" + (match step cfgParams { emptyState with peers := [(pa, peerRec [ta])], topics := [ta] } + (.rpc pa (Pb.encodeMessage (rpcOfPublish [badMsg]))) with + | .ok (s', acts) => delivers badMsg acts = 0 && publishCount acts = 0 && + invalidCount s' pa ta = 1 + | _ => false) + + -- A repeated id inside one event is delivered once. + r.check "core.reject.duplicate-delivered-once" + (match step baseParams known + (.rpc pa (Pb.encodeMessage (rpcOfPublish [msgOf ta [1], msgOf ta [1]]))) with + | .ok (_, acts) => delivers (msgOf ta [1]) acts = 1 + | _ => false) + + -- A tick before the interval ends does no maintenance. + r.check "core.reject.early-tick-no-maintenance" + (match step baseParams { known with lastHeartbeat := 0, now := 500 } + (.tick 500 [pa, pb, pc, pd]) with + | .ok (s', acts) => acts = [] && meshIs s' ta (meshOf known ta) + | _ => false) + + -- A prune from a peer outside the mesh still records the backoff pair. + r.check "core.reject.prune-from-nonmesh-records-backoff" + (match step baseParams { known with now := 1000 } + (.rpc pa (Pb.encodeMessage (rpcOfControl (controlOf [] [pruneOf ta])))) with + | .ok (s', _) => Cache.Backoff.active s'.backoff pa ta s'.now + | _ => false) + + -- The mesh fill of a subscribe: `d` peers, one graft each. + r.check "core.mesh.fill" + (let e := step baseParams known (.subscribe ta) + let s := stateOf e + let a := actionsOf e + decide ((meshOf s ta).length = 2) && graftCount ta a = 2) + + -- An overfull mesh is pruned to `d` at the heartbeat. + r.check "core.mesh.prune" + (match step baseParams + { known with mesh := [(ta, [pa, pb, pc, pd])], lastHeartbeat := 0, now := 0 } + (.tick 1000 []) with + | .ok (s', acts) => meshIs s' ta [pc, pd] && pruneCount ta acts = 2 + | _ => false) + + -- Gossip stays away from a peer below the gossip threshold. + r.check "core.gossip.threshold" + (match step gossipParams + { known with mcache := (Cache.MessageCache.empty baseParams.window).put ⟨[1]⟩ (msgOf ta [1]) } + (.tick 1000 [pa, pb, pc, pd]) with + | .ok (_, acts) => ihaveCount acts = 0 + | _ => false) + + -- Gossip goes to peers outside the mesh, and the ids are the ones the + -- cache held before the shift. `pa` and `pb` sit in the mesh, so a + -- router that advertised to mesh members would reach them. + r.check "core.gossip.outside-mesh" + (match step baseParams + { known with + mesh := [(ta, [pa, pb])] + mcache := (Cache.MessageCache.empty baseParams.window).put ⟨[1]⟩ (msgOf ta [1]) } + (.tick 1000 [pa, pb, pc, pd]) with + | .ok (_, acts) => sendsTo pa acts = 0 && sendsTo pb acts = 0 && + sendsTo pc acts = 1 && sendsTo pd acts = 1 && ihaveCount acts = 2 + | _ => false) + + -- A mesh peer below score 0 leaves the mesh at the heartbeat. + r.check "core.score.negative-pruned" + (match step cfgParams negState (.tick 1000 []) with + | .ok (s', acts) => meshIs s' ta [] && pruneCount ta acts = 1 + | _ => false) + + -- A join sends the hello RPC; a leave drops the peer from the mesh. + r.check "core.join.hello" + (match step baseParams { known with peers := [] } (.peerJoined pa .meshsub) with + | .ok (s', acts) => sendsTo pa acts = 1 && (peerOf s' pa).isSome + | _ => false) + r.check "core.leave.mesh" + (match step baseParams { known with mesh := [(ta, [pa, pb])] } (.peerLeft pa) with + | .ok (s', acts) => acts = [] && meshIs s' ta [pb] + | _ => false) + + -- An inbound message reaches every other mesh peer, and not the sender. + r.check "core.forward.mesh" + (match step baseParams { known with mesh := [(ta, [pa, pb, pc])] } + (.rpc pa (Pb.encodeMessage (rpcOfPublish [msgOf ta [1]]))) with + | .ok (_, acts) => delivers (msgOf ta [1]) acts = 1 && + sendsTo pa acts = 0 && sendsTo pb acts = 1 && sendsTo pc acts = 1 + | _ => false) + + -- A local publish records the id. A later echo of that id is not + -- delivered and is not forwarded again. The mesh is nonempty, so a + -- missing seen record would send the echo to `pa` and `pc`. + r.check "core.forward.own-echo-dropped" + (match Libp2p.Protocols.Gossipsub.Core.run baseParams + { known with mesh := [(ta, [pa, pb, pc])] } + [.publish (msgOf ta [1]), + .rpc pb (Pb.encodeMessage (rpcOfPublish [msgOf ta [1]]))] with + | .ok (_, acts) => delivers (msgOf ta [1]) acts = 0 && + sendsTo pa acts = 1 && sendsTo pb acts = 1 && sendsTo pc acts = 1 + | _ => false) + + -- Two payloads under `defaults` stay distinct. `strictNoSign` leaves + -- `from` and `seqno` empty, so the spec's default id would collapse them. + r.check "core.defaults.distinct-payloads" + (match step defaults { known with mesh := [(ta, [pa, pb])] } + (.rpc pa (Pb.encodeMessage (rpcOfPublish [msgOf ta [1], msgOf ta [2]]))) with + | .ok (_, acts) => delivers (msgOf ta [1]) acts = 1 && + delivers (msgOf ta [2]) acts = 1 && sendsTo pb acts = 2 + | _ => false) + + -- A publish without a subscription fills the fanout and forwards. + r.check "core.publish.fanout" + (match step baseParams { known with topics := [] } (.publish (msgOf ta [1])) with + | .ok (s', acts) => (fanoutOf s' ta).length = 2 && publishCount acts = 2 + | _ => false) + + -- A cached id answers an IWANT with one publish. + r.check "core.iwant.cached-answer" + (match step baseParams + { known with mcache := (Cache.MessageCache.empty baseParams.window).put ⟨[7]⟩ (msgOf ta [7]) } + (.rpc pa (Pb.encodeMessage (iwantRpc [⟨[7]⟩]))) with + | .ok (_, acts) => publishCount acts = 1 + | _ => false) + + -- The transcript driver runs two events in order and appends the + -- actions. + r.check "core.run.transcript" + (match Libp2p.Protocols.Gossipsub.Core.run baseParams known + [.subscribe ta, .publish (msgOf ta [1])] with + | .ok (s', acts) => graftCount ta acts = 2 && publishCount acts = 2 && + meshIs s' ta [pa, pb] + | _ => false) + +end Tests.Gossipsub.Core diff --git a/Tests/Gossipsub/Rpc.lean b/Tests/Gossipsub/Rpc.lean new file mode 100644 index 00000000..1173afe8 --- /dev/null +++ b/Tests/Gossipsub/Rpc.lean @@ -0,0 +1,327 @@ +import Tests.Support +import Libp2p.Protocols.Gossipsub.Rpc + +/-! +# Gossipsub suite: the RPC codec + +The wire messages of gossipsub v1.1, checked in both directions from the +field tables of the vendored spec, plus the strictness and rejection +cases and the length-delimited frame step. + +The repository carries no gossipsub vector file (`PLAN`, "Vector +stance"). Fixed message bytes close the codec family. Each expected byte +string below carries a comment that derives it from the field numbers. + +A field tag is `field number * 8 + wire type`. The wire types here are 0 +for a varint and 2 for a byte string. A byte string tag is followed by +its length and then its bytes. +-/ + +set_option autoImplicit false + +namespace Tests.Gossipsub.Rpc + +open Libp2p.Protocols +open Libp2p.Protocols.Gossipsub +open Libp2p.Protocols.Gossipsub.Rpc + +/-! ## Fixed message samples -/ + +/-- A subscription of `foo`. -/ +private def subFoo : SubOpts := { subscribe := true, topicid := "foo" } + +/-- A message on topic `t` with no optional field. -/ +private def msgT : Message := + { «from» := none, data := none, seqno := none, topic := ⟨"t"⟩, signature := none, key := none } + +/-- A message with every field of the spec. -/ +private def fullMsg : Message := + { «from» := some [1], data := some [2], seqno := some [3], topic := ⟨"t"⟩, + signature := some [4], key := some [5] } + +/-- A peer with the id bytes `[1, 2]` and no signed record. -/ +private def peer12 : PeerInfo := { peerId := some [1, 2], signedPeerRecord := none } + +/-- A prune of `g` with one peer and a backoff of 60 seconds. -/ +private def pruneG : ControlPrune := { topicId := "g", peers := [peer12], backoff := some 60 } + +/-- A control envelope with one prune. -/ +private def controlPrune : ControlMessage := + { ihave := [], iwant := [], graft := [], prune := [pruneG] } + +/-- An RPC with one subscription and one published message. -/ +private def rpcSubPublish : RPC := + { subscriptions := [subFoo], publish := [msgT], control := none } + +/-- An RPC with one control envelope. -/ +private def rpcControl : RPC := + { subscriptions := [], publish := [], control := some controlPrune } + +/-- An IHAVE of topic `t` with two message ids. -/ +private def ihaveT : ControlIHave := + { topicId := "t", messageIds := [⟨[1]⟩, ⟨[2, 3]⟩] } + +/-! ## Check helpers -/ + +/-- The model decode returns the expected value and consumes all bytes. -/ +private def decodesAs {α : Type} [Pb.ProtoMessage α] [BEq α] (bs : List Nat) (want : α) : Bool := + match Pb.decodeMessage? (α := α) bs with + | .ok (v, []) => v == want + | _ => false + +/-- The value encodes to the expected bytes. -/ +private def encodesAs {α : Type} [Pb.ProtoMessage α] [BEq α] (v : α) (bs : List Nat) : Bool := + Pb.encodeMessage v == bs + +/-- The model decode fails with the expected error. -/ +private def rejectsWith {α : Type} [Pb.ProtoMessage α] (bs : List Nat) (e : Pb.Error) : Bool := + match Pb.decodeMessage? (α := α) bs with + | .error e' => e' == e + | .ok _ => false + +/-- The model decode succeeds, whatever the value. -/ +private def accepts {α : Type} [Pb.ProtoMessage α] (bs : List Nat) : Bool := + match Pb.decodeMessage? (α := α) bs with + | .ok _ => true + | .error _ => false + +/-- The production decoder agrees with the model decoder on value, rest, +and consumed count. -/ +private def productionAgrees {α : Type} [Pb.ProtoMessage α] [BEq α] (bs : List Nat) : Bool := + match Pb.decodeMessage? (α := α) bs, Pb.decodeMessageB? (α := α) (Bytes.ofNatList bs) with + | .ok (v, rest), .ok (w, used) => v == w && rest == [] && used == bs.length + | .error e, .error e' => e == e' + | _, _ => false + +/-- The frame step returns one payload and the rest after it. -/ +private def frameIs (bytes payload rest : List Nat) : Bool := + match decodeFrame? bytes with + | .ok (some (p, r)) => p == payload && r == rest + | _ => false + +/-- The frame step needs more bytes. -/ +private def frameNeedsMore (bytes : List Nat) : Bool := + match decodeFrame? bytes with + | .ok none => true + | _ => false + +/-- The frame step fails with the expected error. -/ +private def frameFails (bytes : List Nat) (e : Pb.Error) : Bool := + match decodeFrame? bytes with + | .error e' => e' == e + | _ => false + +/-! ## Order samples -/ + +private def subA : SubOpts := { subscribe := false, topicid := "a" } +private def subB : SubOpts := { subscribe := true, topicid := "b" } + +private def msgA : Message := + { «from» := none, data := some [7], seqno := none, topic := ⟨"x"⟩, signature := none, key := none } + +private def msgB : Message := + { «from» := some [8], data := none, seqno := none, topic := ⟨"y"⟩, signature := none, key := none } + +private def peerA : PeerInfo := { peerId := some [1], signedPeerRecord := none } + +private def peerB : PeerInfo := { peerId := some [2], signedPeerRecord := some [9] } + +/-- The subscriptions of a decoded RPC, when the decode succeeds. -/ +private def rpcSubscriptions (bs : List Nat) : Option (List SubOpts) := + match Pb.decodeMessage? (α := RPC) bs with + | .ok (r, []) => some r.subscriptions + | _ => none + +/-- The published messages of a decoded RPC, when the decode succeeds. -/ +private def rpcPublish (bs : List Nat) : Option (List Message) := + match Pb.decodeMessage? (α := RPC) bs with + | .ok (r, []) => some r.publish + | _ => none + +/-- The message ids of a decoded IHAVE, when the decode succeeds. -/ +private def ihaveIds (bs : List Nat) : Option (List MessageId) := + match Pb.decodeMessage? (α := ControlIHave) bs with + | .ok (i, []) => some i.messageIds + | _ => none + +/-- The message ids of a decoded IWANT, when the decode succeeds. -/ +private def iwantIds (bs : List Nat) : Option (List MessageId) := + match Pb.decodeMessage? (α := ControlIWant) bs with + | .ok (i, []) => some i.messageIds + | _ => none + +/-- The peers of a decoded PRUNE, when the decode succeeds. -/ +private def prunePeers (bs : List Nat) : Option (List PeerInfo) := + match Pb.decodeMessage? (α := ControlPrune) bs with + | .ok (p, []) => some p.peers + | _ => none + +/-- Run the checks of this module. -/ +def run (r : Runner) : IO Unit := do + r.section "gossipsub Rpc" + "vendor/specs/pubsub (README, gossipsub-v1.0.md, gossipsub-v1.1.md)" + + -- SubOpts: field 1 varint tag is 8, value 1 is true; field 2 bytes tag + -- is 18, length 3, and "foo" is [102, 111, 111]. + r.check "spec SubOpts subscribe foo decodes" + (decodesAs (α := SubOpts) [8, 1, 18, 3, 102, 111, 111] subFoo) + r.check "spec SubOpts subscribe foo encodes byte-exact" + (encodesAs subFoo [8, 1, 18, 3, 102, 111, 111]) + + -- Message: field 4 bytes tag is 34, length 1, and "t" is 116. + r.check "spec Message topic t decodes" + (decodesAs (α := Message) [34, 1, 116] msgT) + r.check "spec Message topic t encodes byte-exact" + (encodesAs msgT [34, 1, 116]) + + -- Full Message: field 1 tag 10, field 2 tag 18, field 3 tag 26, field 4 + -- tag 34, field 5 tag 42, field 6 tag 50. Each payload has length 1. + r.check "spec full Message decodes" + (decodesAs (α := Message) + [10, 1, 1, 18, 1, 2, 26, 1, 3, 34, 1, 116, 42, 1, 4, 50, 1, 5] fullMsg) + r.check "spec full Message encodes byte-exact" + (encodesAs fullMsg [10, 1, 1, 18, 1, 2, 26, 1, 3, 34, 1, 116, 42, 1, 4, 50, 1, 5]) + + -- RPC: field 1 bytes tag is 10, the SubOpts payload has length 7; + -- field 2 bytes tag is 18, the Message payload has length 3. + r.check "spec RPC subscription and publish decodes" + (decodesAs (α := RPC) + [10, 7, 8, 1, 18, 3, 102, 111, 111, 18, 3, 34, 1, 116] rpcSubPublish) + r.check "spec RPC subscription and publish encodes byte-exact" + (encodesAs rpcSubPublish [10, 7, 8, 1, 18, 3, 102, 111, 111, 18, 3, 34, 1, 116]) + + -- ControlPrune: field 1 tag 10, field 2 bytes tag 18 with the PeerInfo + -- payload [10, 2, 1, 2], field 3 varint tag is 24, value 60. + r.check "spec ControlPrune topic peers backoff decodes" + (decodesAs (α := ControlPrune) [10, 1, 103, 18, 4, 10, 2, 1, 2, 24, 60] pruneG) + r.check "spec ControlPrune topic peers backoff encodes byte-exact" + (encodesAs pruneG [10, 1, 103, 18, 4, 10, 2, 1, 2, 24, 60]) + + -- ControlMessage: field 4 bytes tag is 34, the ControlPrune payload has + -- length 11. + r.check "spec ControlMessage with prune decodes" + (decodesAs (α := ControlMessage) + [34, 11, 10, 1, 103, 18, 4, 10, 2, 1, 2, 24, 60] controlPrune) + r.check "spec ControlMessage with prune encodes byte-exact" + (encodesAs controlPrune [34, 11, 10, 1, 103, 18, 4, 10, 2, 1, 2, 24, 60]) + + -- RPC: field 3 bytes tag is 26, the ControlMessage payload has length 13. + r.check "spec RPC with control decodes" + (decodesAs (α := RPC) + [26, 13, 34, 11, 10, 1, 103, 18, 4, 10, 2, 1, 2, 24, 60] rpcControl) + r.check "spec RPC with control encodes byte-exact" + (encodesAs rpcControl [26, 13, 34, 11, 10, 1, 103, 18, 4, 10, 2, 1, 2, 24, 60]) + + -- ControlIHave: field 1 tag 10 with "t"; field 2 bytes tag 18 twice, the + -- payloads [1] and [2, 3]. + r.check "spec ControlIHave topic and ids decodes" + (decodesAs (α := ControlIHave) [10, 1, 116, 18, 1, 1, 18, 2, 2, 3] ihaveT) + r.check "spec ControlIHave topic and ids encodes byte-exact" + (encodesAs ihaveT [10, 1, 116, 18, 1, 1, 18, 2, 2, 3]) + + -- The production decoder and the model decoder agree on the fixed bytes. + r.check "spec RPC production path agrees with the model" + (productionAgrees (α := RPC) + [26, 13, 34, 11, 10, 1, 103, 18, 4, 10, 2, 1, 2, 24, 60]) + + -- Strictness: a bool above 1, a wrong wire type on a singular field, + -- and invalid UTF-8 are badField. + r.check "SubOpts subscribe varint 2 rejected as badField" + (rejectsWith (α := SubOpts) [8, 2] .badField) + -- Field 1 with the bytes wire type: tag is 10, length 1, payload 5. + r.check "SubOpts subscribe bytes wire type rejected as badField" + (rejectsWith (α := SubOpts) [10, 1, 5] .badField) + -- Field 2 bytes with the payload [255], which has no UTF-8 reading. + r.check "SubOpts topicid invalid UTF-8 rejected as badField" + (rejectsWith (α := SubOpts) [18, 1, 255] .badField) + -- Field 2 with the varint wire type: tag is 16. + r.check "SubOpts topicid varint wire type rejected as badField" + (rejectsWith (α := SubOpts) [16, 1] .badField) + + -- The required topic. An empty buffer has no field 4. + r.check "Message without topic rejected as badField" + (rejectsWith (α := Message) [] .badField) + r.check "Message topic invalid UTF-8 rejected as badField" + (rejectsWith (α := Message) [34, 1, 255] .badField) + -- A valid topic, then field 2 with the varint wire type (tag 16). + r.check "Message data varint wire type rejected as badField" + (rejectsWith (α := Message) [34, 1, 116, 16, 1] .badField) + + -- Field 3 with the bytes wire type: tag is 26, length 1, payload 5. + r.check "ControlPrune backoff bytes wire type rejected as badField" + (rejectsWith (α := ControlPrune) [10, 1, 103, 26, 1, 5] .badField) + -- The varint of 2^63 is ten groups, one more than the nine-byte budget. + -- The varint decoder rejects it as malformed before the table runs. + r.check "ControlPrune backoff value 2^63 rejected as malformed" + (rejectsWith (α := ControlPrune) + [10, 1, 103, 24, 128, 128, 128, 128, 128, 128, 128, 128, 128, 1] + (.framing .malformed)) + + -- A control payload whose nested ControlPrune payload is the byte 5, + -- which is an invalid tag: field 0 is not a legal number. + r.check "RPC control payload that is not a ControlMessage rejected" + (rejectsWith (α := RPC) [26, 3, 34, 1, 5] .badField) + -- A publish payload with no topic fails the Message table. + r.check "RPC publish payload that is not a Message rejected" + (rejectsWith (α := RPC) [18, 0] .badField) + + -- A repeated field of another wire type is skipped. Field 1 varint tag + -- is 8, value 1. + r.check "RPC subscriptions entry with varint wire type skipped, decode succeeds" + (decodesAs (α := RPC) [8, 1] { subscriptions := [], publish := [], control := none }) + + -- Field 5 of ControlMessage (`idontwant` in v1.2) is out of scope. Bytes + -- tag is 42, length 1, payload 1. The value drops the field, and the + -- re-encode omits it. + r.check "ControlMessage idontwant field 5 ignored and dropped on re-encode" + (decodesAs (α := ControlMessage) [42, 1, 1] + { ihave := [], iwant := [], graft := [], prune := [] } && + Pb.encodeMessage ({ ihave := [], iwant := [], graft := [], prune := [] } : ControlMessage) == []) + + -- Field 99 bytes: tag 794 is [154, 6], length 1, payload 2. The value + -- drops the field, and the re-encode omits it. + r.check "RPC unknown field 99 ignored, structure drops it" + (decodesAs (α := RPC) [154, 6, 1, 2] + { subscriptions := [], publish := [], control := none } && + Pb.encodeMessage ({ subscriptions := [], publish := [], control := none } : RPC) == []) + + -- The frame step. The length prefix of 1048577 is over the cap, so the + -- decode stops before the body. + r.check "frame length over rpcCap malformed before the body" + (frameFails (Streams.Framing.encodeLength .uvarint 1048577 ++ [1, 2, 3]) + (.framing .malformed)) + -- The byte 128 is a varint continuation with no next byte. + r.check "frame prefix cut short returns none" (frameNeedsMore [128]) + -- A zero-length frame is a legal empty RPC here. Identify rejects it + -- (`Identify.Err.emptyFrame`, the spike's read rule); gossipsub has no + -- such rule, because the empty RPC is a legal value. + r.check "zero-length frame decodes to the empty RPC" + (frameIs (Streams.Framing.encodeLength .uvarint 0) [] []) + -- One frame of a fixed RPC, then the frame step reads its payload back. + r.check "frame round trip: encodeFrame? then decodeFrame? returns the payload" + (match encodeFrame? rpcControl with + | some frame => frameIs frame (Pb.encodeMessage rpcControl) [] + | none => false) + r.check "rpcCap constant is 1048576" (Constants.rpcCap == 1048576) + + -- Repeated fields keep their order. + r.check "subscriptions keep their order through the round trip" + (rpcSubscriptions (Pb.encodeMessage + ({ subscriptions := [subA, subB], publish := [], control := none } : RPC)) == + some [subA, subB]) + r.check "publish keeps their order through the round trip" + (rpcPublish (Pb.encodeMessage + ({ subscriptions := [], publish := [msgA, msgB], control := none } : RPC)) == + some [msgA, msgB]) + r.check "message ids keep their order through the round trip" + (ihaveIds (Pb.encodeMessage + ({ topicId := "o", messageIds := [⟨[1]⟩, ⟨[2]⟩] } : ControlIHave)) == + some [⟨[1]⟩, ⟨[2]⟩] && + iwantIds (Pb.encodeMessage + ({ messageIds := [⟨[3]⟩, ⟨[4]⟩] } : ControlIWant)) == some [⟨[3]⟩, ⟨[4]⟩]) + r.check "prune peers keep their order through the round trip" + (prunePeers (Pb.encodeMessage + ({ topicId := "p", peers := [peerA, peerB], backoff := none } : ControlPrune)) == + some [peerA, peerB]) + +end Tests.Gossipsub.Rpc diff --git a/Tests/Gossipsub/Score.lean b/Tests/Gossipsub/Score.lean new file mode 100644 index 00000000..6e7b2cc0 --- /dev/null +++ b/Tests/Gossipsub/Score.lean @@ -0,0 +1,365 @@ +import Tests.Support +import Libp2p.Protocols.Gossipsub.Score + +/-! +# Gossipsub suite: Score + +The checks of `Libp2p.Protocols.Gossipsub.Score`. One fixed parameter set drives a regression +walk of the score function, its strict boundaries, and the validity rejections. Each expected +number carries its hand computation. + +The fixed-point scale is 1000, so a weight of 1000 is 1.0 and a weight of -100 is -0.1. The +walk's topic `"eth"` carries these values: `topicWeight` 1000, `timeInMeshWeight` 10, +`timeInMeshQuantum` 1000 ms, `timeInMeshCap` 100, `firstMessageDeliveriesWeight` 10, +`firstMessageDeliveriesDecay` 500, `firstMessageDeliveriesCap` 10, `meshMessageDeliveriesWeight` +-100, `meshMessageDeliveriesDecay` 500, `meshMessageDeliveriesThreshold` 4, +`meshMessageDeliveriesCap` 10, `meshMessageDeliveriesActivation` 20000 ms, +`meshMessageDeliveriesWindow` 10 ms, `meshFailurePenaltyWeight` -100, +`meshFailurePenaltyDecay` 500, `invalidMessageDeliveriesWeight` -1000, and +`invalidMessageDeliveriesDecay` 500. The global table carries `appSpecificWeight` 1, +`ipColocationFactorWeight` -100, `ipColocationFactorThreshold` 2, `behaviourPenaltyWeight` +-100, `behaviourPenaltyThreshold` 0, `behaviourPenaltyDecay` 500, `decayInterval` 1000 ms, +`decayToZero` 1, and `topicScoreCap` 10000. +-/ + +set_option autoImplicit false + +namespace Tests.Gossipsub.Score + +open Libp2p.Protocols.Gossipsub +open Libp2p.Protocols.Gossipsub.Score + +/-- The topic of the walk. -/ +private def eth : TopicId := ⟨"eth"⟩ + +/-- A topic with no configured parameters. -/ +private def other : TopicId := ⟨"other"⟩ + +/-- The per-topic parameters of the walk. -/ +private def ethParams : TopicParams where + topicWeight := 1000 + timeInMeshWeight := 10 + timeInMeshQuantum := 1000 + timeInMeshCap := 100 + firstMessageDeliveriesWeight := 10 + firstMessageDeliveriesDecay := 500 + firstMessageDeliveriesCap := 10 + meshMessageDeliveriesWeight := -100 + meshMessageDeliveriesDecay := 500 + meshMessageDeliveriesThreshold := 4 + meshMessageDeliveriesCap := 10 + meshMessageDeliveriesActivation := 20000 + meshMessageDeliveriesWindow := 10 + meshFailurePenaltyWeight := -100 + meshFailurePenaltyDecay := 500 + invalidMessageDeliveriesWeight := -1000 + invalidMessageDeliveriesDecay := 500 + +/-- The global parameters of the walk. The behavior threshold is 0, so the first penalty point +already contributes one squared point. -/ +private def walkGlobal : GlobalParams where + appSpecificWeight := 1 + ipColocationFactorWeight := -100 + ipColocationFactorThreshold := 2 + behaviourPenaltyWeight := -100 + behaviourPenaltyThreshold := 0 + behaviourPenaltyDecay := 500 + decayInterval := 1000 + decayToZero := 1 + topicScoreCap := 10000 + +/-- The parameters of the walk. Only `"eth"` is configured. -/ +private def walkParams : Params where + topics := fun id => if id = eth then some ethParams else none + global := walkGlobal + +/-! ## The regression walk + +Each step applies one update to the state of the step before. +-/ + +/-- Step 2: the peer is grafted at time 0. -/ +private def s1 : PeerState := graft PeerState.zero 0 eth + +/-- Step 3: one mesh delivery and one first delivery. -/ +private def s2 : PeerState := firstDelivery walkParams (meshDelivery walkParams s1 eth) eth + +/-- Step 4: two more mesh deliveries, so the count is 3, one below the threshold 4. -/ +private def s3 : PeerState := + meshDelivery walkParams (meshDelivery walkParams s2 eth) eth + +/-- Step 5: one invalid delivery. -/ +private def s4 : PeerState := invalidDelivery walkParams s3 eth + +/-- Step 6: one behavior penalty. -/ +private def s5 : PeerState := behaviourPenalty s4 + +/-- Step 7: a prune at time 30000, with the delivery deficit of 1 square. -/ +private def s6 : PeerState := prune walkParams s5 30000 eth + +/-- Step 8: one decay interval. -/ +private def s7 : PeerState := decay walkParams s6 + +/-! ## Strictness fixtures -/ + +/-- Counters with the peer in the mesh since time 0 and no deliveries. -/ +private def meshCounters : TopicCounters := + { TopicCounters.zero with inMesh := true, graftTime := 0 } + +/-- The per-topic parameters of the topic cap checks. Only `P2` contributes, at weight 1. -/ +private def capTopic : TopicParams := + { ethParams with + timeInMeshWeight := 0 + firstMessageDeliveriesWeight := 1 + meshMessageDeliveriesWeight := 0 + meshFailurePenaltyWeight := 0 + invalidMessageDeliveriesWeight := 0 } + +/-- The parameters of the topic cap checks: a cap of 5 and the `capTopic` table. -/ +private def capParams : Params where + topics := fun id => if id = eth then some capTopic else none + global := { walkGlobal with topicScoreCap := 5 } + +/-- A state with `n` first message deliveries in `"eth"` and no other input. -/ +private def capState (n : Nat) : PeerState where + topics := [(eth, { TopicCounters.zero with firstMessageDeliveries := n })] + appScore := 0 + ipColocation := 0 + behaviourPenalty := 0 + +/-! ## Rejection fixtures + +Each fixture changes one field of the valid walk parameter set. +-/ + +private def badP2Weight : TopicParams := + { ethParams with firstMessageDeliveriesWeight := -1 } + +private def badP3Weight : TopicParams := + { ethParams with meshMessageDeliveriesWeight := 1 } + +private def badP4Weight : TopicParams := + { ethParams with invalidMessageDeliveriesWeight := 1 } + +private def badTopicWeight : TopicParams := + { ethParams with topicWeight := -1 } + +private def badQuantum : TopicParams := + { ethParams with timeInMeshQuantum := 0 } + +private def badDecay : TopicParams := + { ethParams with firstMessageDeliveriesDecay := scoreScale + 1 } + +private def badP6Weight : GlobalParams := + { walkGlobal with ipColocationFactorWeight := 1 } + +private def badP7Weight : GlobalParams := + { walkGlobal with behaviourPenaltyWeight := 1 } + +private def badIpThreshold : GlobalParams := + { walkGlobal with ipColocationFactorThreshold := 0 } + +/-! ## The compiled rejection proofs + +Each `example` proves the rejection at compile time. The runtime check beside it names the same +case in the suite output. +-/ + +example : TopicParams.Valid ethParams := + (TopicParams.validB_iff ethParams).mp (by decide) + +example : GlobalParams.Valid walkGlobal := + (GlobalParams.validB_iff walkGlobal).mp (by decide) + +/-- The walk parameter set passes `Params.Valid`. -/ +example : Params.Valid walkParams := by + refine ⟨?_, ?_⟩ + · intro id tp h + by_cases hid : id = eth + · subst id + simp [walkParams] at h + rw [← h] + exact (TopicParams.validB_iff ethParams).mp (by decide) + · simp [walkParams, hid] at h + · exact (GlobalParams.validB_iff walkGlobal).mp (by decide) + +/-- A topic parameter set that the Boolean mirror rejects makes every parameters value that +configures it invalid. -/ +private theorem paramsValid_of_topic_validB_false {tp : TopicParams} + (hbad : tp.validB = false) : + ¬ Params.Valid { topics := fun _ => some tp, global := walkGlobal } := by + intro h + have hb := (TopicParams.validB_iff tp).mpr (h.topic eth tp rfl) + rw [hbad] at hb + cases hb + +/-- A global parameter set that the Boolean mirror rejects makes every parameters value that +carries it invalid. -/ +private theorem paramsValid_of_global_validB_false {g : GlobalParams} + (hbad : g.validB = false) : + ¬ Params.Valid { topics := walkParams.topics, global := g } := by + intro h + have hb := (GlobalParams.validB_iff g).mpr h.global + rw [hbad] at hb + cases hb + +example : ¬ Params.Valid + { topics := fun _ => some badP2Weight, global := walkGlobal } := + paramsValid_of_topic_validB_false (by decide) + +example : ¬ Params.Valid + { topics := fun _ => some badP3Weight, global := walkGlobal } := + paramsValid_of_topic_validB_false (by decide) + +example : ¬ Params.Valid + { topics := fun _ => some badP4Weight, global := walkGlobal } := + paramsValid_of_topic_validB_false (by decide) + +example : ¬ Params.Valid + { topics := fun _ => some badTopicWeight, global := walkGlobal } := + paramsValid_of_topic_validB_false (by decide) + +example : ¬ Params.Valid + { topics := fun _ => some badQuantum, global := walkGlobal } := + paramsValid_of_topic_validB_false (by decide) + +example : ¬ Params.Valid + { topics := fun _ => some badDecay, global := walkGlobal } := + paramsValid_of_topic_validB_false (by decide) + +example : ¬ Params.Valid { topics := walkParams.topics, global := badP6Weight } := + paramsValid_of_global_validB_false (by decide) + +example : ¬ Params.Valid { topics := walkParams.topics, global := badP7Weight } := + paramsValid_of_global_validB_false (by decide) + +example : ¬ Params.Valid { topics := walkParams.topics, global := badIpThreshold } := + paramsValid_of_global_validB_false (by decide) + +/-- Run the checks of this module. -/ +def run (r : Runner) : IO Unit := do + r.section "gossipsub Score" "vendor/specs/pubsub" + + -- Step 1: the fresh peer. Every term is 0 and the topic list is empty. + r.check "score.walk.fresh-peer" (score walkParams PeerState.zero 0 == 0) + + -- Step 2: graft at time 0, scored at 10000. Elapsed mesh time 10000 ms gives P1 = 10000 / + -- 1000 = 10, capped at 100, so 10. P3 is 0 because the activation is 20000 ms. Topic score + -- = 10 * 10 + 10 * 0 + (-100) * 0 + (-100) * 0 + (-1000) * 0 = 100. Topic contribution = + -- 1000 * 100 tdiv 1000 = 100. Total = 100. + r.check "score.walk.graft" (score walkParams s1 10000 == 100) + + -- Step 3: one mesh delivery and one first delivery, scored at 10000. P1 = 10, P2 = 1, so the + -- topic score = 100 + 10 * 1 = 110. Contribution = 110. Total = 110. + r.check "score.walk.deliveries" (score walkParams s2 10000 == 110) + + -- Step 4: two more mesh deliveries, so the count is 3, scored at 30000. Elapsed = 30000, + -- P1 = 30, so 300. P3 applies because 20000 < 30000 and 3 < 4: deficit 1, square 1, term + -- = -100 * 1 = -100. Topic score = 300 + 10 - 100 = 210. Contribution = 210. Total = 210. + r.check "score.walk.p3" (score walkParams s3 30000 == 210) + + -- Step 5: one invalid delivery, scored at 30000. P4 = 1 * 1 = 1, term = -1000. Topic score + -- = 210 - 1000 = -790. Contribution = -790. Total = -790. + r.check "score.walk.p4" (score walkParams s4 30000 == -790) + + -- Step 6: one behavior penalty, scored at 30000. The threshold is 0, so P7 = (1 - 0)^2 = 1, + -- global term = -100 * 1 = -100. Total = -790 - 100 = -890. + r.check "score.walk.p7" (score walkParams s5 30000 == -890) + + -- Step 7: prune at 30000. The elapsed time 30000 passes the activation, and the count 3 is + -- below the threshold 4, so the failure penalty grows by (4 - 3)^2 = 1. P1 = 300, P2 = 10, + -- P3 = -100, P3b = -100 * 1 = -100, P4 = -1000: topic score = -890. Global P7 = -100. + -- Total = -990. + r.check "score.walk.prune" (score walkParams s6 30000 == -990) + + -- Step 8: one decay. Each factor is 500, so a counter c becomes c * 500 / 1000, and a result + -- below 1 becomes 0. First deliveries 1 -> 0, mesh deliveries 3 -> 1, failure penalty 1 -> 0, + -- invalid deliveries 1 -> 0, behavior penalty 1 -> 0. The mesh time and the membership stay. + r.check "score.walk.decay.first-message-deliveries" + ((countersOf s7 eth).firstMessageDeliveries == 0) + r.check "score.walk.decay.mesh-message-deliveries" + ((countersOf s7 eth).meshMessageDeliveries == 1) + r.check "score.walk.decay.mesh-failure-penalty" + ((countersOf s7 eth).meshFailurePenalty == 0) + r.check "score.walk.decay.invalid-message-deliveries" + ((countersOf s7 eth).invalidMessageDeliveries == 0) + r.check "score.walk.decay.behavior-penalty" (s7.behaviourPenalty == 0) + r.check "score.walk.decay.keeps-mesh-time" + ((countersOf s7 eth).timeInMesh == 30000 && (countersOf s7 eth).inMesh == false) + + -- Step 9: score after the decay, at 30000. P1 = 300, P2 = 0, P3 = (4 - 1)^2 = 9 with term + -- -900, P3b = 0, P4 = 0. Topic score = 300 - 900 = -600. Contribution = -600. Global P7 = 0. + -- Total = -600. + r.check "score.walk.final" (score walkParams s7 30000 == -600) + + -- The P3 activation boundary. At 20000 ms the activation is equal, so the strict comparison + -- fails and P3 = 0. At 20001 ms the deficit is (4 - 0)^2 = 16. + r.check "score.p3.activation-boundary" + (p3 ethParams 20000 meshCounters == 0 && p3 ethParams 20001 meshCounters == 16) + + -- The P3 threshold boundary. At the count 4 the strict comparison fails and P3 = 0. At 3 the + -- deficit is 1 and P3 = 1. + r.check "score.p3.threshold-boundary" + (p3 ethParams 30000 { meshCounters with meshMessageDeliveries := 4 } == 0 && + p3 ethParams 30000 { meshCounters with meshMessageDeliveries := 3 } == 1) + + -- The decay zero boundary. A counter equal to `decayToZero` with a full decay stays at 1. A + -- counter one below becomes 0. + r.check "score.decay.zero-boundary" + (decayCounter 1000 1 1 == 1 && decayCounter 1000 1 0 == 0) + + -- The P1 cap boundary. Elapsed 100000 ms gives 100 points, equal to the cap, so P1 = 100. One + -- quantum more gives 101 points, and the cap holds P1 at 100. + r.check "score.p1.cap-boundary" + (p1 ethParams 100000 meshCounters == 100 && p1 ethParams 101000 meshCounters == 100) + + -- The P6 threshold boundary. Colocation 2 equals the threshold, so P6 = 0. Colocation 3 gives + -- the surplus 1 squared. + r.check "score.p6.threshold-boundary" + (p6 walkGlobal { PeerState.zero with ipColocation := 2 } == 0 && + p6 walkGlobal { PeerState.zero with ipColocation := 3 } == 1) + + -- The P7 threshold boundary. Penalty 0 equals the threshold, so P7 = 0. Penalty 1 gives the + -- excess 1 squared. + r.check "score.p7.threshold-boundary" + (p7 walkGlobal { PeerState.zero with behaviourPenalty := 0 } == 0 && + p7 walkGlobal { PeerState.zero with behaviourPenalty := 1 } == 1) + + -- The topic cap boundary. With `P2` at weight 1 and no other term, the raw topic part equals + -- the delivery count. At 5 the raw part equals the cap 5 and stays. At 6 the raw part 6 is + -- above the cap, so the topic part is 5. + r.check "score.topic-cap-boundary" + (topicPart capParams 0 (capState 5) == 5 && topicPart capParams 0 (capState 6) == 5) + + -- The cap applies at increment time. A first delivery from 9 reaches the cap 10, and one more + -- from 10 leaves the counter at 10. + r.check "score.cap-at-increment" + ((countersOf (firstDelivery walkParams (capState 9) eth) eth).firstMessageDeliveries == 10 && + (countersOf (firstDelivery walkParams (capState 10) eth) eth).firstMessageDeliveries == 10) + + -- A delivery to a topic with no parameters leaves the score alone. The state s3 scores 210 at + -- 30000, and the first delivery to `"other"` is a no-op. + r.check "score.unconfigured-topic-ignored" + (score walkParams (firstDelivery walkParams s3 other) 30000 == + score walkParams s3 30000) + + -- The rejections. Each Boolean mirror is false, and the `example` beside the definition + -- proves the predicate rejects the same value. + r.check "score.valid.reject-negative-p2-weight" (!badP2Weight.validB) + r.check "score.valid.reject-positive-p3-weight" (!badP3Weight.validB) + r.check "score.valid.reject-positive-p4-weight" (!badP4Weight.validB) + r.check "score.valid.reject-negative-topic-weight" (!badTopicWeight.validB) + r.check "score.valid.reject-zero-quantum" (!badQuantum.validB) + r.check "score.valid.reject-decay-above-scale" (!badDecay.validB) + r.check "score.valid.reject-positive-p6-weight" (!badP6Weight.validB) + r.check "score.valid.reject-positive-p7-weight" (!badP7Weight.validB) + r.check "score.valid.reject-zero-ip-threshold" (!badIpThreshold.validB) + + -- A zero P6 weight and a zero P7 weight are valid: the predicate requires `<= 0`. The + -- walk's global table already carries a zero behavior threshold, and a zero weight is also + -- accepted. + r.check "score.valid.accept-zero-p6-p7-weight" + ({ walkGlobal with ipColocationFactorWeight := 0, behaviourPenaltyWeight := 0 } : + GlobalParams).validB + +end Tests.Gossipsub.Score diff --git a/Tests/Gossipsub/Sign.lean b/Tests/Gossipsub/Sign.lean new file mode 100644 index 00000000..df70dcf8 --- /dev/null +++ b/Tests/Gossipsub/Sign.lean @@ -0,0 +1,187 @@ +import Tests.Support +import Libp2p.Protocols.Gossipsub.Sign + +/-! +# Gossipsub suite: Sign + +The checks of `Libp2p.Protocols.Gossipsub.Sign` against +`vendor/specs/pubsub/README.md`, sections Message Signing, Signature +Policy Options, and Message Identification. + +The verify and encode functions are executable stand-ins. The +signature math belongs to the crypto package, so the stand-in verifier +answers `true` or `false` and the checks pin the policy logic. The +kernel examples at the end show that each rejection arm is reachable. +Each example closes by `rfl`. +-/ + +set_option autoImplicit false + +namespace Tests.Gossipsub.Sign + +open Libp2p.Protocols.Gossipsub +open Libp2p.Protocols.Gossipsub.Sign +open Libp2p.Protocols.Mock.Identity + +/-- An encoder that reads the `from`, `data`, `seqno`, and topic fields. +It reads neither the `signature` nor the `key` field. -/ +private def testEncode (m : Message) : List Nat := + m.from.getD [] ++ m.data.getD [] ++ m.seqno.getD [] ++ + Bytes.toNatList (Bytes.ofString m.topic.name) + +/-- An encoder that reads every field and marks presence. The signature +input check needs this encoder. An input that fails to clear the +`signature` or `key` field still changes under this encoder, so the +check detects that defect. -/ +private def tagEncode (m : Message) : List Nat := + (match m.from with | none => [0] | some b => 1 :: b) ++ + (match m.data with | none => [0] | some b => 1 :: b) ++ + (match m.seqno with | none => [0] | some b => 1 :: b) ++ + (match m.signature with | none => [0] | some b => 1 :: b) ++ + (match m.key with | none => [0] | some b => 1 :: b) ++ + Bytes.toNatList (Bytes.ofString m.topic.name) + +/-- The accepting stand-ins: a nonempty buffer parses as an ed25519 +key, the derived peer id carries the key bytes, and the verifier +accepts. -/ +private def testPrimsOk : Prims := + { unmarshalKey := fun bs => if bs.isEmpty then none else some ⟨.ed25519, bs⟩ + inlineKey := fun bs => if bs.isEmpty then none else some ⟨.ed25519, bs⟩ + idOfKey := fun k => k.data + verify := fun _ _ _ => true } + +/-- The rejecting stand-ins: the verifier rejects. -/ +private def testPrimsBad : Prims := + { testPrimsOk with verify := fun _ _ _ => false } + +/-- A message with the four signed fields set as given. -/ +private def testMsg (fr sq sig key : Option (List Nat)) : Message := + { «from» := fr, data := some [9], seqno := sq, topic := ⟨"t"⟩, + signature := sig, key := key } + +/-- The UTF-8 bytes of `libp2p-pubsub:`, from +`vendor/specs/pubsub/README.md` Message Signing and the go reference +implementation. -/ +private def prefixBytes : List Nat := + [108, 105, 98, 112, 50, 112, 45, 112, 117, 98, 115, 117, 98, 58] + +/-! ## Reachability of the rejection arms + +Each example names one arm and supplies a message that reaches it. -/ + +example : validate testPrimsOk testEncode .strictSign + (testMsg none (some [2]) (some [3]) (some [4])) = .error .missingFrom := rfl + +example : validate testPrimsOk testEncode .strictSign + (testMsg (some [1]) none (some [3]) (some [4])) = .error .missingSeqno := rfl + +example : validate testPrimsOk testEncode .strictSign + (testMsg (some [1]) (some [2]) none (some [4])) = .error .missingSignature := rfl + +example : validate testPrimsOk testEncode .strictSign + (testMsg (some [1]) (some [2]) (some [3]) (some [])) = .error .keyUnresolved := rfl + +example : validate testPrimsOk testEncode .strictSign + (testMsg (some []) (some [2]) (some [3]) none) = .error .keyUnresolved := rfl + +example : validate testPrimsOk testEncode .strictSign + (testMsg (some [1]) (some [2]) (some [3]) (some [5])) = .error .keyMismatch := rfl + +example : validate testPrimsBad testEncode .strictSign + (testMsg (some [1]) (some [2]) (some [3]) (some [1])) = .error .badSignature := rfl + +example : validate testPrimsOk testEncode .strictNoSign + (testMsg (some [1]) none none none) = .error .hasFrom := rfl + +example : validate testPrimsOk testEncode .strictNoSign + (testMsg none (some [2]) none none) = .error .hasSeqno := rfl + +example : validate testPrimsOk testEncode .strictNoSign + (testMsg none none (some [3]) none) = .error .hasSignature := rfl + +example : validate testPrimsOk testEncode .strictNoSign + (testMsg none none none (some [4])) = .error .hasKey := rfl + +/-! ## Reachability of the acceptance paths -/ + +example : validate testPrimsOk testEncode .strictSign + (testMsg (some [1]) (some [2]) (some [3]) (some [1])) = .ok () := rfl + +example : validate testPrimsOk testEncode .strictSign + (testMsg (some [1]) (some [2]) (some [3]) none) = .ok () := rfl + +example : validate testPrimsOk testEncode .strictNoSign + (testMsg none none none none) = .ok () := rfl + +/-- Run the checks of this module. -/ +def run (r : Runner) : IO Unit := do + r.section "gossipsub Sign" "vendor/specs/pubsub" + + -- strictNoSign, through the accepting stand-ins + r.check "strictNoSign accepts a message with all four fields absent" + (succeedsWith () (validate testPrimsOk testEncode .strictNoSign + (testMsg none none none none))) + r.check "strictNoSign rejects a present from" + (failsWith SignError.hasFrom (validate testPrimsOk testEncode .strictNoSign + (testMsg (some [1]) none none none))) + r.check "strictNoSign rejects a present seqno" + (failsWith SignError.hasSeqno (validate testPrimsOk testEncode .strictNoSign + (testMsg none (some [2]) none none))) + r.check "strictNoSign rejects a present signature" + (failsWith SignError.hasSignature (validate testPrimsOk testEncode .strictNoSign + (testMsg none none (some [3]) none))) + r.check "strictNoSign rejects a present key" + (failsWith SignError.hasKey (validate testPrimsOk testEncode .strictNoSign + (testMsg none none none (some [4])))) + + -- strictSign, through the accepting stand-ins + r.check "strictSign accepts a fully signed message" + (succeedsWith () (validate testPrimsOk testEncode .strictSign + (testMsg (some [1]) (some [2]) (some [3]) (some [1])))) + r.check "strictSign accepts a message with the key inlined in from" + (succeedsWith () (validate testPrimsOk testEncode .strictSign + (testMsg (some [1]) (some [2]) (some [3]) none))) + r.check "strictSign rejects a missing from" + (failsWith SignError.missingFrom (validate testPrimsOk testEncode .strictSign + (testMsg none (some [2]) (some [3]) none))) + r.check "strictSign rejects a missing seqno" + (failsWith SignError.missingSeqno (validate testPrimsOk testEncode .strictSign + (testMsg (some [1]) none (some [3]) none))) + r.check "strictSign rejects a missing signature" + (failsWith SignError.missingSignature (validate testPrimsOk testEncode .strictSign + (testMsg (some [1]) (some [2]) none none))) + r.check "strictSign rejects a key field that does not parse" + (failsWith SignError.keyUnresolved (validate testPrimsOk testEncode .strictSign + (testMsg (some [1]) (some [2]) (some [3]) (some [])))) + r.check "strictSign rejects a peer id that inlines no key" + (failsWith SignError.keyUnresolved (validate testPrimsOk testEncode .strictSign + (testMsg (some []) (some [2]) (some [3]) none))) + r.check "strictSign rejects a key that derives another peer id" + (failsWith SignError.keyMismatch (validate testPrimsOk testEncode .strictSign + (testMsg (some [1]) (some [2]) (some [3]) (some [5])))) + r.check "strictSign rejects a bad signature" + (failsWith SignError.badSignature (validate testPrimsBad testEncode .strictSign + (testMsg (some [1]) (some [2]) (some [3]) (some [1])))) + + -- message id + r.check "default msg id is from followed by seqno" + (defaultMsgId (testMsg (some [1, 2]) (some [3, 4, 5]) none none) == + ⟨[1, 2, 3, 4, 5]⟩) + r.check "default msg id is empty when the fields are absent" + (defaultMsgId (testMsg none none none none) == ⟨[]⟩) + + -- signature input + r.check "sign input starts with the libp2p-pubsub prefix" + ((signInput testEncode (testMsg (some [1]) (some [2]) (some [3]) none)).take 14 == + prefixBytes) + r.check "sign input drops the signature and key fields" + (signInput tagEncode (testMsg (some [1]) (some [2]) (some [3]) (some [4])) == + signInput tagEncode (testMsg (some [1]) (some [2]) (some [7]) (some [8])) && + signInput tagEncode (testMsg (some [1]) (some [2]) (some [3]) none) == + signInput tagEncode (testMsg (some [1]) (some [2]) (some [7]) (some [8]))) + r.check "sign input covers the data field" + (signInput tagEncode (testMsg (some [1]) (some [2]) (some [3]) none) != + signInput tagEncode + ({ testMsg (some [1]) (some [2]) (some [3]) none with data := some [10] })) + +end Tests.Gossipsub.Sign diff --git a/Tests/Main.lean b/Tests/Main.lean index 794e47f1..ea49ee88 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -1,5 +1,10 @@ import Tests.Support import Tests.Vectors +import Tests.Gossipsub.Core +import Tests.Gossipsub.Sign +import Tests.Gossipsub.Cache +import Tests.Gossipsub.Score +import Tests.Gossipsub.Rpc import Tests.Concurrency import Tests.Protobuf import Tests.Bytes @@ -31,6 +36,11 @@ pass under a single capability: `lake test -- +RTS -N1 -RTS` def main : IO UInt32 := do let r ← Tests.Runner.new Tests.Vectors.run r + Tests.Gossipsub.Core.run r + Tests.Gossipsub.Sign.run r + Tests.Gossipsub.Cache.run r + Tests.Gossipsub.Score.run r + Tests.Gossipsub.Rpc.run r Tests.Concurrency.run r Tests.Protobuf.run r Bytes.runTests r diff --git a/packages/libp2p/protocols/Libp2p/Protocols.lean b/packages/libp2p/protocols/Libp2p/Protocols.lean index 242b09ea..24df8765 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols.lean @@ -1,8 +1,15 @@ +import Libp2p.Protocols.Constants +import Libp2p.Protocols.Gossipsub.Types +import Libp2p.Protocols.Gossipsub.Rpc +import Libp2p.Protocols.Gossipsub.Score +import Libp2p.Protocols.Gossipsub.Cache +import Libp2p.Protocols.Gossipsub.Sign +import Libp2p.Protocols.Gossipsub.Core + /-! # Libp2p.Protocols -The protocols subpackage: identify and ping first, then req/resp and -gossipsub (ARCHITECTURE §4.9). Scaffold root. -The modules arrive with unit W5.7 of `docs/PLAN.md`. The package docs -live under `docs/` beside this file. +The protocols subpackage (ARCHITECTURE §4.9). This pull request carries +the gossipsub modules. Identify, ping and req/resp arrive with their own +units. The package docs live under `docs/` beside this file. -/ diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean b/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean new file mode 100644 index 00000000..a92bccea --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean @@ -0,0 +1,27 @@ +import Libp2p.Protocols.Mock.Core + +/-! +# Protocol constants + +The gossipsub protocol ids and the RPC cap. Identify and req/resp add +their own constants with their own units. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Constants + +/-- The gossipsub v1.1 protocol id, `/meshsub/1.1.0`. -/ +def meshsubId : Mock.Core.ProtocolId := ⟨"/meshsub/1.1.0"⟩ + +/-- The gossipsub v1.0 protocol id, `/meshsub/1.0.0`. A v1.1 router +speaks v1.0 to a peer that offers only this id. -/ +def meshsubV10Id : Mock.Core.ProtocolId := ⟨"/meshsub/1.0.0"⟩ + +/-- The floodsub protocol id, `/floodsub/1.0.0`. -/ +def floodsubId : Mock.Core.ProtocolId := ⟨"/floodsub/1.0.0"⟩ + +/-- The gossipsub RPC cap: 1 MiB, the go-libp2p-pubsub default. -/ +def rpcCap : Nat := 1048576 + +end Libp2p.Protocols.Constants diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Cache.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Cache.lean new file mode 100644 index 00000000..672afd7c --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Cache.lean @@ -0,0 +1,700 @@ +import Libp2p.Protocols.Gossipsub.Types + +/-! +# Gossipsub caches + +The four tables of the gossipsub router: the message cache, the seen +cache, the backoff table, and the in-flight IWANT promises. + +The tables are pure data. Time is an input (`Millis`), never a clock, so +the router core drives them from a `step` and the suite drives them with +fixed times. + +Every table is a plain `List`, newest first. An add prepends. The spec +describes the message cache as a ring of history windows; this module +stores one flat list instead and carries each entry's age, which states +the same bound (`age < historyLength`) and makes each theorem one list +induction. + +The spec sections are `vendor/specs/pubsub/gossipsub/gossipsub-v1.0.md` +("Message Cache", and the `seen` cache paragraph) and +`vendor/specs/pubsub/gossipsub/gossipsub-v1.1.md` ("Prune Backoff and +Peer Exchange", and "Spam Protection Measures"). + +The cache takes its parameters as arguments. It reads no default from +`Constants.lean`, so the router core owns the spec defaults. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub.Cache + +/-! ## Message cache + +The message cache keeps the newest `historyLength` heartbeats. A `put` +enters the current window with age zero. A `shift` ages every entry by +one heartbeat and drops the entries that reach `historyLength`. Gossip +lists the ids from the newest `historyGossip` windows of one topic. +-/ + +/-- The window counts of the message cache. `historyLength` is the spec +`mcache_len` (default 5), the number of heartbeats the cache holds. +`historyGossip` is the spec `mcache_gossip` (default 3), the number of +newest heartbeats that gossip lists. The `valid` field carries the spec +bound, so no cache can gossip zero windows or gossip more windows than it +stores. -/ +structure WindowParams where + /-- The number of heartbeats the cache keeps. -/ + historyLength : Nat + /-- The number of newest heartbeats that gossip lists. -/ + historyGossip : Nat + /-- The spec bound `0 < historyGossip` and + `historyGossip ≤ historyLength`. -/ + valid : 0 < historyGossip ∧ historyGossip ≤ historyLength + deriving Repr + +/-- One cached message. `age` counts the heartbeats since the `put`, so +age zero is the current window. The `topic` copies the required topic +field of the message, so `gossipIds` filters a topic without unpacking +the message. -/ +structure Cached where + /-- The message id, computed by the caller. -/ + id : MessageId + /-- The topic of the message. -/ + topic : TopicId + /-- The message itself. -/ + message : Message + /-- The number of heartbeats since the `put`. -/ + age : Nat + deriving DecidableEq, Repr + +/-- The message cache: the window parameters and the entries, newest +first. -/ +structure MessageCache where + /-- The window counts. -/ + params : WindowParams + /-- The cached messages, newest first. -/ + entries : List Cached + deriving Repr + +/-- An empty cache with the given parameters. -/ +def MessageCache.empty (p : WindowParams) : MessageCache := + { params := p, entries := [] } + +/-- Add a message to the current window. The id is an argument, because +the message id function lives in the signing policy and this module must +not import it. The entry takes age zero, the current window. -/ +def MessageCache.put (c : MessageCache) (id : MessageId) (m : Message) : MessageCache := + { c with + entries := { id := id, topic := m.topic, message := m, age := 0 } :: c.entries } + +/-- The message with the given id, if the cache holds it. The search runs +newest first, so a re-`put` id finds its newest message. -/ +def MessageCache.get (c : MessageCache) (id : MessageId) : Option Message := + (c.entries.find? (fun e => decide (e.id = id))).map (fun e => e.message) + +/-- Shift one heartbeat. Every entry ages by one, and an entry whose new +age reaches `historyLength` leaves the cache. -/ +def MessageCache.shift (c : MessageCache) : MessageCache := + { c with + entries := + c.entries.filterMap (fun e => + if e.age + 1 < c.params.historyLength then some { e with age := e.age + 1 } else none) } + +/-- Shift `k` heartbeats. This is the iteration form that the lifetime +theorems state, because the `^[ ]` notation is not part of the core +library. -/ +def MessageCache.shiftN (c : MessageCache) : Nat → MessageCache + | 0 => c + | k + 1 => (MessageCache.shiftN c k).shift + +/-- The ids that gossip lists for a topic: the entries of that topic whose +age is below `historyGossip`, newest first. -/ +def MessageCache.gossipIds (c : MessageCache) (topic : TopicId) : List MessageId := + (c.entries.filter (fun e => decide (e.topic = topic ∧ e.age < c.params.historyGossip))).map + (fun e => e.id) + +/-- The window bound: every entry is younger than `historyLength` +heartbeats. This is the spec bound "at most `history_length` windows" in +the flat-list form. -/ +def MessageCache.windowInvariant (c : MessageCache) : Prop := + ∀ e ∈ c.entries, e.age < c.params.historyLength + +/-- The window bound implies a positive length. -/ +theorem historyLength_pos (p : WindowParams) : 0 < p.historyLength := + Nat.lt_of_lt_of_le p.valid.1 p.valid.2 + +/-- A `put` is found by `get` at its own id. -/ +theorem put_get (c : MessageCache) (id : MessageId) (m : Message) : + MessageCache.get (MessageCache.put c id m) id = some m := by + simp [MessageCache.get, MessageCache.put] + +private theorem shiftN_zero (c : MessageCache) : MessageCache.shiftN c 0 = c := rfl + +private theorem shiftN_succ (c : MessageCache) (k : Nat) : + MessageCache.shiftN c (k + 1) = (MessageCache.shiftN c k).shift := rfl + +private theorem shift_params (c : MessageCache) : (MessageCache.shift c).params = c.params := + rfl + +private theorem shiftN_params (c : MessageCache) (k : Nat) : + (MessageCache.shiftN c k).params = c.params := by + induction k with + | zero => rw [shiftN_zero c] + | succ k ih => rw [shiftN_succ, shift_params, ih] + +/-- Every entry of a shifted cache is an input entry with age one higher, +and that higher age is still below `historyLength`. -/ +private theorem mem_shift_entries {c : MessageCache} {e' : Cached} + (h : e' ∈ (MessageCache.shift c).entries) : + ∃ e ∈ c.entries, + e' = { e with age := e.age + 1 } ∧ e.age + 1 < c.params.historyLength := by + simp only [MessageCache.shift, List.mem_filterMap] at h + obtain ⟨e, he, hf⟩ := h + split at hf + · rename_i hlt + rw [Option.some.injEq] at hf + exact ⟨e, he, hf.symm, hlt⟩ + · simp at hf + +private theorem shift_age_lt {c : MessageCache} {e : Cached} + (h : e ∈ (MessageCache.shift c).entries) : e.age < c.params.historyLength := by + obtain ⟨e0, _, hEq, hlt⟩ := mem_shift_entries h + rw [hEq] + exact hlt + +/-- After `k` shifts every entry is at least `k` heartbeats old. -/ +private theorem shiftN_age_ge (c : MessageCache) (k : Nat) : + ∀ e ∈ (MessageCache.shiftN c k).entries, k ≤ e.age := by + induction k with + | zero => + intro e _ + omega + | succ k ih => + intro e he + rw [shiftN_succ] at he + obtain ⟨e0, he0, hEq, _⟩ := mem_shift_entries he + rw [hEq] + have hge := ih e0 he0 + show k + 1 ≤ e0.age + 1 + omega + +/-- The entries of a shifted cache whose head survives: the head moves to +age one, and the tail shifts as usual. -/ +private theorem shift_entries_cons {c : MessageCache} {e : Cached} {es : List Cached} + (h : c.entries = e :: es) (hlt : e.age + 1 < c.params.historyLength) : + (MessageCache.shift c).entries + = { e with age := e.age + 1 } :: es.filterMap (fun e => + if e.age + 1 < c.params.historyLength then some { e with age := e.age + 1 } else none) := by + simp only [MessageCache.shift] + rw [h] + exact List.filterMap_cons_some (f := fun e => if e.age + 1 < c.params.historyLength + then some { e with age := e.age + 1 } else none) (a := e) (l := es) + (b := { e with age := e.age + 1 }) (by simp [hlt]) + +/-- A `put` entry stays at the head of the cache for `k` shifts, as long +as `k` is below `historyLength`. -/ +private theorem shiftN_put_cons (c : MessageCache) (id : MessageId) (m : Message) + (k : Nat) (hk : k < c.params.historyLength) : + ∃ es, (MessageCache.shiftN (MessageCache.put c id m) k).entries + = { id := id, topic := m.topic, message := m, age := k } :: es := by + induction k with + | zero => exact ⟨c.entries, rfl⟩ + | succ k ih => + have hk' : k < c.params.historyLength := by omega + obtain ⟨es, hes⟩ := ih hk' + have hparams : (MessageCache.shiftN (MessageCache.put c id m) k).params + = c.params := by + rw [shiftN_params] + rfl + have hlt : (⟨id, m.topic, m, k⟩ : Cached).age + 1 + < (MessageCache.shiftN (MessageCache.put c id m) k).params.historyLength := by + rw [hparams] + simpa using hk + refine ⟨es.filterMap (fun e => + if e.age + 1 < (MessageCache.shiftN (MessageCache.put c id m) k).params.historyLength + then some { e with age := e.age + 1 } else none), ?_⟩ + rw [shiftN_succ] + exact shift_entries_cons hes hlt + +/-- After `historyLength` shifts any cache is empty. Entries that start old +enough leave on the first shift, and the rest reach the bound by the last +one. -/ +private theorem shiftN_entries_empty (c : MessageCache) : + (MessageCache.shiftN c c.params.historyLength).entries = [] := by + rw [List.eq_nil_iff_forall_not_mem] + intro e he + have hpos : 0 < c.params.historyLength := historyLength_pos c.params + have hL : c.params.historyLength = (c.params.historyLength - 1) + 1 := by omega + have hge : c.params.historyLength ≤ e.age := by + have := shiftN_age_ge c c.params.historyLength e he + omega + have he' : e ∈ (MessageCache.shift (MessageCache.shiftN c (c.params.historyLength - 1))).entries := by + rw [← shiftN_succ, ← hL] + exact he + have hlt := shift_age_lt he' + rw [shiftN_params] at hlt + omega + +/-- A `put` entry is still found after any number of shifts below +`historyLength`. -/ +theorem put_get_below_length_shifts (c : MessageCache) (id : MessageId) (m : Message) + {k : Nat} (hk : k < c.params.historyLength) : + MessageCache.get (MessageCache.shiftN (MessageCache.put c id m) k) id = some m := by + obtain ⟨es, hes⟩ := shiftN_put_cons c id m k hk + simp [MessageCache.get, hes] + +/-- A `put` entry is gone after `historyLength` shifts. -/ +theorem put_gone_after_length_shifts (c : MessageCache) (id : MessageId) (m : Message) : + MessageCache.get (MessageCache.shiftN (MessageCache.put c id m) c.params.historyLength) id + = none := by + have h : (MessageCache.shiftN (MessageCache.put c id m) c.params.historyLength).entries = [] := by + have hp := shiftN_entries_empty (MessageCache.put c id m) + rwa [show (MessageCache.put c id m).params = c.params from rfl] at hp + simp [MessageCache.get, h] + +/-- Every id that gossip lists is an id that `get` finds. -/ +theorem gossipIds_get_finds (c : MessageCache) (topic : TopicId) : + ∀ id, id ∈ MessageCache.gossipIds c topic → ∃ m, MessageCache.get c id = some m := by + intro id hid + simp only [MessageCache.gossipIds, List.mem_map] at hid + obtain ⟨e, he, rfl⟩ := hid + rw [List.mem_filter] at he + have hsome : (MessageCache.get c e.id).isSome := by + simp only [MessageCache.get, Option.isSome_map, List.find?_isSome] + exact ⟨e, he.1, by simp⟩ + exact Option.isSome_iff_exists.mp hsome + +/-- A `put` keeps every entry inside the window bound. -/ +theorem put_preserves_windowInvariant (c : MessageCache) (id : MessageId) (m : Message) : + MessageCache.windowInvariant c → MessageCache.windowInvariant (MessageCache.put c id m) := by + intro h e he + rw [MessageCache.windowInvariant] at h + simp only [MessageCache.put, List.mem_cons] at he + rcases he with rfl | he + · exact historyLength_pos c.params + · exact h e he + +/-- A `shift` keeps every entry inside the window bound. The input +invariant is not needed, because `shift` drops the entries that would +leave the window; the theorem keeps the hypothesis of the stated target. -/ +theorem shift_preserves_windowInvariant (c : MessageCache) : + MessageCache.windowInvariant c → MessageCache.windowInvariant (MessageCache.shift c) := by + intro _ e he + exact shift_age_lt (c := c) he + +/-! ## Seen cache + +The seen cache holds the message ids observed recently. An id is seen +while `at + ttl > now`, the spec `seen_ttl` of two minutes by default. +The cache is bounded: an add at capacity drops the oldest entry. A +re-add refreshes the entry to the newest position, the "timed least +recently used" rule of the spec. +-/ + +/-- The parameters of the seen cache. `capacity` bounds the entry count. +`ttl` is the spec `seen_ttl`. `valid` carries both positivity facts: a +zero `ttl` would make `add` write an entry that is never seen, and a zero +`capacity` would make the trim drop the entry just added. -/ +structure SeenParams where + /-- The largest number of entries the cache holds. -/ + capacity : Nat + /-- The time an id counts as seen. -/ + ttl : Millis + /-- The bound `0 < capacity` and `0 < ttl`. -/ + valid : 0 < capacity ∧ 0 < ttl + deriving Repr + +/-- One seen id and its insertion time. -/ +structure SeenEntry where + /-- The message id. -/ + id : MessageId + /-- The time of the `add`. -/ + «at» : Millis + deriving DecidableEq, Repr + +/-- The seen cache: the parameters and the entries, newest first. -/ +structure SeenCache where + /-- The bounds. -/ + params : SeenParams + /-- The seen ids, newest first. -/ + entries : List SeenEntry + deriving Repr + +/-- An empty cache with the given parameters. -/ +def SeenCache.empty (p : SeenParams) : SeenCache := + { params := p, entries := [] } + +/-- Whether the cache holds the id as seen at `now`. The check reads the +current time, so an entry expires between prunes. -/ +def SeenCache.contains (c : SeenCache) (id : MessageId) (now : Millis) : Bool := + c.entries.any (fun e => decide (e.id = id ∧ e.at + c.params.ttl > now)) + +/-- Record the id as seen at `now`. The add removes the old entry of the +id, so a re-add moves the id to the newest position, and it trims the +list to `capacity`. The new entry is the head, so the trim drops the +oldest entries and never the new one. -/ +def SeenCache.add (c : SeenCache) (id : MessageId) (now : Millis) : SeenCache := + { c with + entries := + { id := id, «at» := now } :: + (c.entries.filter (fun e => decide (e.id ≠ id))).take (c.params.capacity - 1) } + +/-- Drop the entries whose `at + ttl` has passed. -/ +def SeenCache.prune (c : SeenCache) (now : Millis) : SeenCache := + { c with entries := c.entries.filter (fun e => decide (e.at + c.params.ttl > now)) } + +/-- The tail of an `add` never holds the added id, so `contains` reads the +new head alone. -/ +private theorem contains_add_self (c : SeenCache) (id : MessageId) (t now : Millis) : + SeenCache.contains (SeenCache.add c id t) id now = decide (t + c.params.ttl > now) := by + have htail : ((c.entries.filter (fun e => decide (e.id ≠ id))).take + (c.params.capacity - 1)).any + (fun e => decide (e.id = id ∧ e.at + c.params.ttl > now)) = false := by + rw [List.any_eq_false] + intro e he + have hsub : e ∈ c.entries.filter (fun e => decide (e.id ≠ id)) := + List.mem_of_mem_take he + have hne : e.id ≠ id := of_decide_eq_true (List.mem_filter.mp hsub).2 + simp [hne] + simp only [SeenCache.contains] + rw [show (SeenCache.add c id t).params = c.params from rfl] + simp only [SeenCache.add] + rw [List.any_cons, htail, Bool.or_false] + simp + +/-- An add is seen at its own time. -/ +theorem add_contains (c : SeenCache) (id : MessageId) (now : Millis) : + SeenCache.contains (SeenCache.add c id now) id now = true := by + rw [contains_add_self] + have h : now < now + c.params.ttl := Nat.lt_add_of_pos_right c.params.valid.2 + simp [h] + +/-- An id added at `t` is seen at every time below `t + ttl`. -/ +theorem add_seen_below_ttl (c : SeenCache) (id : MessageId) (t t' : Millis) + (_hle : t ≤ t') (hlt : t' < t + c.params.ttl) : + SeenCache.contains (SeenCache.add c id t) id t' = true := by + rw [contains_add_self] + have h : t + c.params.ttl > t' := hlt + simp [h] + +/-- An id added at `t` is not seen at exactly `t + ttl`. -/ +theorem add_unseen_at_ttl (c : SeenCache) (id : MessageId) (t : Millis) : + SeenCache.contains (SeenCache.add c id t) id (t + c.params.ttl) = false := by + rw [contains_add_self] + simp + +/-- An add never leaves more than `capacity` entries. -/ +theorem add_bounded (c : SeenCache) (id : MessageId) (now : Millis) : + (SeenCache.add c id now).entries.length ≤ c.params.capacity := by + have hlen : (SeenCache.add c id now).entries.length + = min (c.params.capacity - 1) + (c.entries.filter (fun e => decide (e.id ≠ id))).length + 1 := by + simp [SeenCache.add, List.length_take] + rw [hlen] + have hmin : min (c.params.capacity - 1) + (c.entries.filter (fun e => decide (e.id ≠ id))).length ≤ c.params.capacity - 1 := + Nat.min_le_left _ _ + have hcap : 0 < c.params.capacity := c.params.valid.1 + omega + +/-- A prune leaves only entries that are still live at `now`. -/ +theorem prune_removes_expired (c : SeenCache) (now : Millis) : + ∀ e ∈ (SeenCache.prune c now).entries, e.at + c.params.ttl > now := by + intro e he + simp only [SeenCache.prune, List.mem_filter] at he + exact of_decide_eq_true he.2 + +/-! ## Backoff table + +The backoff table holds the period after a prune during which the router +does not regraft a peer for a topic. The spec extends the period when a +`GRAFT` arrives inside it, and this table's `add` is the write that both +sets and extends the period. +-/ + +/-- One backoff period: the pair and the time it ends. -/ +structure BackoffEntry where + /-- The peer. -/ + peer : Peer + /-- The topic. -/ + topic : TopicId + /-- The time the period ends. -/ + «until» : Millis + deriving DecidableEq, Repr + +/-- The backoff table: one entry per pair, newest first. -/ +abbrev BackoffTable := List BackoffEntry + +/-- An empty table. -/ +def Backoff.empty : BackoffTable := [] + +/-- Set the backoff of the pair to end at `now + d`, and replace an older +period for the same pair. A prune, and a `GRAFT` inside the period, both +call this. -/ +def Backoff.add (b : BackoffTable) (peer : Peer) (topic : TopicId) + (d now : Millis) : BackoffTable := + { peer := peer, topic := topic, «until» := now + d } :: + b.filter (fun e => decide (e.peer ≠ peer ∨ e.topic ≠ topic)) + +/-- Whether the pair is in backoff at `now`. The newest entry of the pair +decides, because `add` removes older entries of the same pair. -/ +def Backoff.active (b : BackoffTable) (peer : Peer) (topic : TopicId) + (now : Millis) : Bool := + match b.find? (fun e => decide (e.peer = peer ∧ e.topic = topic)) with + | some e => decide (now < e.until) + | none => false + +/-- Drop the entries whose period has ended. -/ +def Backoff.clear (b : BackoffTable) (now : Millis) : BackoffTable := + b.filter (fun e => decide (now < e.until)) + +/-- A pair added at `t` for `d` is active at every time below `t + d`. -/ +theorem add_active_below (b : BackoffTable) (peer : Peer) (topic : TopicId) + (d t now : Millis) (_hle : t ≤ now) (hlt : now < t + d) : + Backoff.active (Backoff.add b peer topic d t) peer topic now = true := by + simp [Backoff.active, Backoff.add, hlt] + +/-- A pair added at `t` for `d` is not active at `t + d`. -/ +theorem add_inactive_at_expiry (b : BackoffTable) (peer : Peer) (topic : TopicId) + (d t : Millis) : + Backoff.active (Backoff.add b peer topic d t) peer topic (t + d) = false := by + simp [Backoff.active, Backoff.add] + +/-- A second add for the same pair replaces the first period. -/ +theorem add_overwrites (b : BackoffTable) (peer : Peer) (topic : TopicId) + (d t d' t' now : Millis) (_hle : t' ≤ now) (hlt : now < t' + d') : + Backoff.active (Backoff.add (Backoff.add b peer topic d t) peer topic d' t') peer topic now + = true := by + simp [Backoff.active, Backoff.add, hlt] + +/-- If a `find?` finds an entry, and that entry satisfies `q`, then the +search for `q` together with the original predicate finds the same entry. -/ +private theorem find?_and_of_find? {α : Type} (l : List α) (p q : α → Bool) {e : α} + (h : l.find? p = some e) (hq : q e = true) : + l.find? (fun x => decide (q x = true ∧ p x = true)) = some e := by + induction l with + | nil => simp at h + | cons a as ih => + by_cases hpa : p a = true + · rw [List.find?_cons_of_pos hpa] at h + have hae : a = e := by injection h + have hqa : q a = true := by rw [hae]; exact hq + rw [List.find?_cons_of_pos (by simp [hpa, hqa]), hae] + · rw [List.find?_cons_of_neg hpa] at h + rw [List.find?_cons_of_neg (by simp [hpa])] + exact ih h + +/-- Clearing the expired entries keeps the active ones. -/ +theorem clear_keeps_active (b : BackoffTable) (peer : Peer) (topic : TopicId) + (now : Millis) (h : Backoff.active b peer topic now = true) : + Backoff.active (Backoff.clear b now) peer topic now = true := by + simp only [Backoff.active] at h + split at h + · rename_i e hfind + have hlive : decide (now < e.until) = true := h + have hand := find?_and_of_find? b (fun e => decide (e.peer = peer ∧ e.topic = topic)) + (fun e => decide (now < e.until)) hfind hlive + have hfilter : (b.filter (fun e => decide (now < e.until))).find? + (fun e => decide (e.peer = peer ∧ e.topic = topic)) = some e := by + rw [List.find?_filter] + exact hand + simp only [Backoff.clear, Backoff.active, hfilter, hlive] + · simp at h + +/-- A clear leaves only entries whose period has not ended. -/ +theorem clear_removes_expired (b : BackoffTable) (now : Millis) : + ∀ e ∈ Backoff.clear b now, now < e.until := by + intro e he + simp only [Backoff.clear, List.mem_filter] at he + exact of_decide_eq_true he.2 + +/-! ## IWANT promises + +The router promises to answer an `IWANT` within a deadline. A delivery +of the id removes every promise for it. A promise whose deadline passed +without a delivery counts against the advertising peer, the input of the +`P₇` behavior penalty of the score part. +-/ + +/-- One in-flight promise: the peer that asked, the id it asked for, and +the time the answer is due. -/ +structure IwantPromise where + /-- The peer that asked. -/ + peer : Peer + /-- The message id it asked for. -/ + id : MessageId + /-- The time the answer is due. -/ + deadline : Millis + deriving DecidableEq, Repr + +/-- The promise table: one entry per pair, newest first. -/ +abbrev PromiseTable := List IwantPromise + +/-- Record a promise to `peer` for `id`, due at `deadline`. A second +promise for the same pair replaces the first, so the table holds one +deadline per pair. -/ +def PromiseTable.promise (ps : PromiseTable) (peer : Peer) (id : MessageId) + (deadline : Millis) : PromiseTable := + { peer := peer, id := id, deadline := deadline } :: + ps.filter (fun e => decide (e.peer ≠ peer ∨ e.id ≠ id)) + +/-- The id arrived, so every promise for it is kept. -/ +def PromiseTable.deliver (ps : PromiseTable) (id : MessageId) : PromiseTable := + ps.filter (fun e => decide (e.id ≠ id)) + +/-- The number of promises of one peer whose deadline is at or before +`now`. -/ +private def countFor (ps : PromiseTable) (peer : Peer) (now : Millis) : Nat := + (ps.filter (fun e => decide (e.peer = peer ∧ e.deadline ≤ now))).length + +/-- The peers of the promises whose deadline is at or before `now`, in +table order, with duplicates. -/ +private def expiredPeers (ps : PromiseTable) (now : Millis) : List Peer := + (ps.filter (fun e => decide (e.deadline ≤ now))).map (fun e => e.peer) + +/-- Boolean membership on a peer list, decided by `DecidableEq`. Core +`List` has no decidable membership instance, so the deduplication below +carries its own. -/ +private def memPeer (p : Peer) (l : List Peer) : Bool := + l.any (fun q => decide (q = p)) + +private theorem memPeer_eq_true {p : Peer} {l : List Peer} : + memPeer p l = true ↔ p ∈ l := by + rw [memPeer, List.any_eq_true] + constructor + · rintro ⟨q, hq, hqp⟩ + rw [← of_decide_eq_true hqp] + exact hq + · intro hp + exact ⟨p, hp, by simp⟩ + +/-- Deduplicate a peer list, keeping the first occurrence of each peer. -/ +private def dedupGo (seen : List Peer) : List Peer → List Peer + | [] => [] + | p :: ps => if memPeer p seen then dedupGo seen ps else p :: dedupGo (p :: seen) ps + +/-- Deduplicate a peer list. -/ +private def dedupPeers (ps : List Peer) : List Peer := dedupGo [] ps + +private theorem mem_dedupGo {p : Peer} {seen ps : List Peer} : + p ∈ dedupGo seen ps ↔ p ∈ ps ∧ p ∉ seen := by + induction ps generalizing seen with + | nil => simp [dedupGo] + | cons q qs ih => + simp only [dedupGo] + split + · rename_i hq + have hqmem : q ∈ seen := memPeer_eq_true.mp hq + rw [ih, List.mem_cons] + constructor + · rintro ⟨h1, h2⟩; exact ⟨Or.inr h1, h2⟩ + · rintro ⟨h1 | h1, h2⟩ + · subst h1; exact absurd hqmem h2 + · exact ⟨h1, h2⟩ + · rename_i hq + have hqmem : q ∉ seen := fun hc => hq (memPeer_eq_true.mpr hc) + rw [List.mem_cons, ih] + simp only [List.mem_cons, not_or] + constructor + · rintro (h | ⟨h1, hne, h2⟩) + · exact ⟨Or.inl h, fun hs => hqmem (h ▸ hs)⟩ + · exact ⟨Or.inr h1, h2⟩ + · rintro ⟨h1 | h1, h2⟩ + · exact Or.inl h1 + · by_cases hpq : p = q + · exact Or.inl hpq + · exact Or.inr ⟨h1, hpq, h2⟩ + +private theorem mem_dedupPeers {p : Peer} {ps : List Peer} : + p ∈ dedupPeers ps ↔ p ∈ ps := by + rw [dedupPeers, mem_dedupGo] + simp + +/-- The peers with an expired promise, one pair per peer, with the number +of that peer's expired promises. A peer appears only when it has at least +one expired promise, and it appears once. This is the input of the `P₇` +behavior penalty. -/ +def PromiseTable.broken (ps : PromiseTable) (now : Millis) : List (Peer × Nat) := + (dedupPeers (expiredPeers ps now)).map (fun p => (p, countFor ps p now)) + +/-- A promise records its pair and its deadline. -/ +theorem promise_mem (ps : PromiseTable) (peer : Peer) (id : MessageId) (d : Millis) : + ∃ e ∈ PromiseTable.promise ps peer id d, e.peer = peer ∧ e.id = id ∧ e.deadline = d := + ⟨_, List.mem_cons_self, rfl, rfl, rfl⟩ + +/-- A delivery leaves no promise that names the delivered id. -/ +theorem deliver_removes (ps : PromiseTable) (id : MessageId) : + ∀ e ∈ PromiseTable.deliver ps id, e.id ≠ id := by + intro e he + simp only [PromiseTable.deliver, List.mem_filter] at he + exact of_decide_eq_true he.2 + +/-- A delivery keeps every promise of another id. -/ +theorem deliver_keeps_others (ps : PromiseTable) (id : MessageId) : + ∀ e, e ∈ ps → e.id ≠ id → e ∈ PromiseTable.deliver ps id := by + intro e he hne + rw [PromiseTable.deliver, List.mem_filter] + exact ⟨he, by simpa using hne⟩ + +/-- `broken` names exactly the peers with an expired promise, with the +exact count. -/ +theorem broken_mem_iff (ps : PromiseTable) (now : Millis) (peer : Peer) (n : Nat) : + (peer, n) ∈ PromiseTable.broken ps now ↔ + n = (ps.filter (fun e => decide (e.peer = peer ∧ e.deadline ≤ now))).length ∧ 0 < n := by + constructor + · intro h + rw [PromiseTable.broken, List.mem_map] at h + obtain ⟨p, hp, hpn⟩ := h + rw [Prod.mk.injEq] at hpn + obtain ⟨hpe, hcnt⟩ := hpn + subst hpe + refine ⟨hcnt.symm, ?_⟩ + have hexp : p ∈ expiredPeers ps now := mem_dedupPeers.mp hp + rw [expiredPeers, List.mem_map] at hexp + obtain ⟨e, he, hep⟩ := hexp + rw [List.mem_filter] at he + have hle : e.deadline ≤ now := of_decide_eq_true he.2 + have hemem : e ∈ ps.filter (fun e => decide (e.peer = p ∧ e.deadline ≤ now)) := by + rw [List.mem_filter] + exact ⟨he.1, by simp [hep, hle]⟩ + have hpos : 0 < (ps.filter (fun e => decide (e.peer = p ∧ e.deadline ≤ now))).length := + List.length_pos_of_mem hemem + rw [← hcnt] + simpa [countFor] using hpos + · rintro ⟨hn, hpos⟩ + rw [PromiseTable.broken, List.mem_map] + have hpos' : 0 < (ps.filter (fun e => decide (e.peer = peer ∧ e.deadline ≤ now))).length := by + rw [← hn] + exact hpos + obtain ⟨e, hemem⟩ := List.exists_mem_of_length_pos hpos' + rw [List.mem_filter] at hemem + obtain ⟨he, hcond⟩ := hemem + obtain ⟨hpe, hle⟩ := of_decide_eq_true hcond + have hexp : peer ∈ expiredPeers ps now := by + rw [expiredPeers, List.mem_map] + exact ⟨e, by rw [List.mem_filter]; exact ⟨he, by simp [hle]⟩, hpe⟩ + refine ⟨peer, mem_dedupPeers.mpr hexp, ?_⟩ + rw [Prod.mk.injEq] + exact ⟨rfl, by simpa [countFor] using hn.symm⟩ + +/-- After a delivery every count that `broken` reports excludes the +delivered id: a peer's count is exactly its expired promises for other +ids. -/ +theorem broken_after_deliver_ignores_id (ps : PromiseTable) (id : MessageId) (now : Millis) : + ∀ (peer : Peer) (n : Nat), + (peer, n) ∈ PromiseTable.broken (PromiseTable.deliver ps id) now → + n = (ps.filter (fun e => + decide (e.peer = peer ∧ e.id ≠ id ∧ e.deadline ≤ now))).length := by + intro peer n hn + have h := (broken_mem_iff (PromiseTable.deliver ps id) now peer n).mp hn + obtain ⟨hcount, _⟩ := h + rw [hcount] + simp only [PromiseTable.deliver, List.filter_filter] + congr 1 + apply List.filter_congr + intro a _ + by_cases h1 : a.peer = peer <;> by_cases h2 : a.id = id <;> by_cases h3 : a.deadline ≤ now <;> + simp [h1, h2, h3] + +end Libp2p.Protocols.Gossipsub.Cache diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Core.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Core.lean new file mode 100644 index 00000000..7c5b5d98 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Core.lean @@ -0,0 +1,2059 @@ +import Libp2p.Protocols.Gossipsub.Types +import Libp2p.Protocols.Gossipsub.Rpc +import Libp2p.Protocols.Gossipsub.Score +import Libp2p.Protocols.Gossipsub.Cache +import Libp2p.Protocols.Gossipsub.Sign + +/-! +# Gossipsub router core + +The pure state machine of the gossipsub v1.1 router: the state, the +external events, the outgoing actions, the one error constructor, and +the total `step`. Time and the peer order enter as event data, so the +core reads no clock and makes no random choice. `run` drives a list of +events. + +The core owns the mesh, the fanout, the score accounting, and the +heartbeat. It calls the public surface of the score, the cache, and the +sign parts. It decodes an inbound RPC at its own boundary and maps a +decode failure to the one error constructor. + +An inbound message that passes validation and is not already seen is +delivered to the application and forwarded to every other peer of +`mesh[topic]`. The sender already holds it, so the forward list drops +the sender. A local publish records the message id in the seen cache, +so a later echo of the same id is not forwarded again. + +The heartbeat emits gossip after it settles the mesh and before it +shifts the message cache. The recipients are peers of the supplied order +at or above the gossip threshold that are in neither the mesh nor the +fanout of the topic, up to `dLazy` per topic. `gossipFactor` is stored +and never read, so the emission count is not adaptive. + +Several fields of the spec are stored and never read, and several +behaviors are absent: + +- `PubsubProto` is stored and no path reads it, so floodsub peers are + not a separate forward set. +- `ControlPrune.peers` decodes and `handlePrune` ignores it, so peer + exchange is absent. `acceptPXThreshold` is never read. +- `opportunisticGraftThreshold` is never read. +- `fanoutTtl` is never read, so a fanout entry does not expire. +- `handleIhave` records a promise and nothing calls + `PromiseTable.broken`, so an expired promise changes no score. +- No event writes `appScore`, `ipColocation`, or `behaviourPenalty`, so + P5, P6, and P7 have no producer. `PeerData` carries no address. +- `meshMessageDeliveriesWindow` is never read. A mesh peer counts + toward P3 for every accepted message. +- `leave` deletes the peer record, so a score does not survive a + disconnect. +- `planTopic` drops the oldest mesh entries. `dScore` is never read and + there is no outbound flag, so the `D_out` quota is absent. +- `validationOk` uses `strictNoSign`. There is no per-topic policy and + no Ignore outcome. `defaults.msgId` is the payload, because that + policy leaves `from` and `seqno` absent and the spec's default id + would be empty for every accepted message. +- IHAVE and IWANT caps and flood publishing are absent. + `publishThreshold` is stored and never read. +- A graft for a topic the router does not subscribe to is ignored. The + v1.0 spec answers with a prune, and the v1.1 spam measures say to + ignore it. This step follows v1.1. +- `unsubscribe` writes `pruneBackoff`. The spec's shorter unsubscribe + backoff is absent, and `subscribe` does not consult the backoff. + +The spec sections are `vendor/specs/pubsub/gossipsub/gossipsub-v1.0.md` +(the router, the message cache, the mesh, the fanout, the heartbeat) and +`gossipsub-v1.1.md` (the score gates, the backoff, the spam measures). +The root `docs/ARCHITECTURE.md` section 3.3 fixes the shape: one total +`step` that returns `Except ProtocolError (State × List Action)`, with +time as an input. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub.Core + +open Libp2p.Protocols.Gossipsub + +/-! ## Types -/ + +/-- The pubsub protocol a peer negotiated. -/ +inductive PubsubProto where + /-- The gossipsub mesh protocol. -/ + | meshsub + /-- The floodsub protocol. -/ + | floodsub + deriving DecidableEq, BEq, Repr + +/-- The per-peer record: the score state, the pubsub protocol, and the +topics the peer announced. `Peer` keeps its one meaning, the peer id of +`Types.lean`. -/ +structure PeerData where + /-- The score state of the peer. -/ + score : Score.PeerState + /-- The pubsub protocol of the peer. -/ + proto : PubsubProto + /-- The announced topics. -/ + subs : List TopicId + +/-- The mesh degree parameters: `d`, `dLow`, `dHigh`, `dScore`, `dLazy`. -/ +structure MeshParams where + /-- The target mesh degree. -/ + d : Nat + /-- The low mesh water mark. -/ + dLow : Nat + /-- The high mesh water mark. -/ + dHigh : Nat + /-- The number of score-best peers the mesh keeps. -/ + dScore : Nat + /-- The number of peers that receive gossip. -/ + dLazy : Nat + deriving DecidableEq, Repr + +/-- The router parameters. -/ +structure Params where + /-- The score parameters. -/ + score : Score.Params + /-- The score thresholds. -/ + thresholds : Thresholds + /-- The mesh degree parameters. -/ + mesh : MeshParams + /-- The heartbeat interval in milliseconds. -/ + heartbeatInterval : Millis + /-- The fanout time to live in milliseconds. -/ + fanoutTtl : Millis + /-- The prune backoff period in milliseconds. -/ + pruneBackoff : Millis + /-- The gossip factor in percent. -/ + gossipFactor : Nat + /-- The IWANT answer deadline in milliseconds. -/ + iwantDeadline : Millis + /-- The message cache window parameters. -/ + window : Cache.WindowParams + /-- The seen cache parameters. -/ + seen : Cache.SeenParams + /-- The signature primitives. -/ + prims : Sign.Prims + /-- The message id function. -/ + msgId : Sign.MsgIdFn + +/-- The parameter bound the mesh theorems consume: the target degree is +at most the high water mark. -/ +structure Params.Valid (p : Params) : Prop where + /-- The target degree is at most the high water mark. -/ + d_le_dHigh : p.mesh.d ≤ p.mesh.dHigh + +/-- One fanout record: the peers and the last publish time. -/ +structure Fanout where + /-- The fanout peers. -/ + peers : List Peer + /-- The last publish time. -/ + lastPublished : Millis + deriving Repr + +/-- The router state. -/ +structure State where + /-- The known peers and their records. -/ + peers : List (Peer × PeerData) + /-- The mesh of each topic. -/ + mesh : List (TopicId × List Peer) + /-- The fanout of each topic. -/ + fanout : List (TopicId × Fanout) + /-- The message cache. -/ + mcache : Cache.MessageCache + /-- The seen cache. -/ + seen : Cache.SeenCache + /-- The backoff table. -/ + backoff : Cache.BackoffTable + /-- The IWANT promises. -/ + iwant : Cache.PromiseTable + /-- The topics the router subscribes to. -/ + topics : List TopicId + /-- The current time. -/ + now : Millis + /-- The time of the last heartbeat. -/ + lastHeartbeat : Millis + +/-- The external events. -/ +inductive Event where + /-- A peer joined with a protocol. -/ + | peerJoined (peer : Peer) (proto : PubsubProto) + /-- A peer left. -/ + | peerLeft (peer : Peer) + /-- An RPC arrived from a peer, as bytes. -/ + | rpc (sender : Peer) (bytes : List Nat) + /-- The application publishes a message. -/ + | publish (msg : Message) + /-- The application subscribes to a topic. -/ + | subscribe (topic : TopicId) + /-- The application unsubscribes from a topic. -/ + | unsubscribe (topic : TopicId) + /-- The heartbeat fires at `now` with a peer order. -/ + | tick (now : Millis) (order : List Peer) + deriving Repr + +/-- The outgoing actions. -/ +inductive Action where + /-- Send an RPC to a peer. -/ + | sendRpc (to : Peer) (rpc : Rpc.RPC) + /-- Deliver a message to the application. -/ + | deliver (msg : Message) + deriving DecidableEq, BEq, Repr + +/-- The one error constructor: an RPC that does not decode. -/ +inductive ProtocolError where + /-- The RPC bytes do not decode. -/ + | decode (e : Pb.Error) + deriving DecidableEq, BEq, Repr + +/-! ## Boolean membership -/ + +/-- Boolean membership, decided by `DecidableEq`. The core uses it in +the guards of its definitions, because a `Prop` membership has no +decidable instance in the core library. -/ +def memB {α : Type} [DecidableEq α] (a : α) (l : List α) : Bool := + l.any (fun b => decide (b = a)) + +/-- The Boolean membership holds exactly for the `Prop` membership. -/ +theorem memB_eq_true {α : Type} [DecidableEq α] {a : α} {l : List α} : + memB a l = true ↔ a ∈ l := by + rw [memB, List.any_eq_true] + constructor + · rintro ⟨b, hb, hab⟩ + rw [← of_decide_eq_true hab] + exact hb + · intro h + exact ⟨a, h, by simp⟩ + +/-! ## Association list accessors -/ + +/-- The first list of the topic. -/ +def lookupTopic : List (TopicId × List Peer) → TopicId → List Peer + | [], _ => [] + | (t', ps) :: l, t => if t = t' then ps else lookupTopic l t + +/-- Replace the first entry of the topic, or append the topic. -/ +def upsertMesh : List (TopicId × List Peer) → TopicId → List Peer → + List (TopicId × List Peer) + | [], t, ps => [(t, ps)] + | (t', ps') :: l, t, ps => if t = t' then (t, ps) :: l else (t', ps') :: upsertMesh l t ps + +/-- The mesh of the topic. -/ +def meshOf (s : State) (topic : TopicId) : List Peer := lookupTopic s.mesh topic + +/-- Write the mesh of the topic. -/ +def setTopicMesh (s : State) (topic : TopicId) (ps : List Peer) : State := + { s with mesh := upsertMesh s.mesh topic ps } + +/-- The first fanout record of the topic. -/ +def lookupFanout : List (TopicId × Fanout) → TopicId → Option Fanout + | [], _ => none + | (t', f) :: l, t => if t = t' then some f else lookupFanout l t + +/-- The fanout peers of the topic. -/ +def fanoutOf (s : State) (topic : TopicId) : List Peer := + match lookupFanout s.fanout topic with + | some f => f.peers + | none => [] + +/-- Write the fanout record of the topic. -/ +def setTopicFanout (s : State) (topic : TopicId) (f : Fanout) : State := + { s with fanout := (topic, f) :: s.fanout.filter (fun e => decide (e.1 ≠ topic)) } + +/-- The first record of the peer. -/ +def lookupPeer : List (Peer × PeerData) → Peer → Option PeerData + | [], _ => none + | (q', pd) :: l, q => if q = q' then some pd else lookupPeer l q + +/-- Replace the first record of the peer, or append the peer. -/ +def upsertPeer : List (Peer × PeerData) → Peer → PeerData → List (Peer × PeerData) + | [], q, pd => [(q, pd)] + | (q', pd') :: l, q, pd => if q = q' then (q, pd) :: l else (q', pd') :: upsertPeer l q pd + +/-- The record of the peer. -/ +def peerOf (s : State) (q : Peer) : Option PeerData := lookupPeer s.peers q + +/-- Write the record of the peer. -/ +def setPeer (s : State) (q : Peer) (pd : PeerData) : State := + { s with peers := upsertPeer s.peers q pd } + +/-- Update every peer record through `f`. The list order stays. -/ +def mapPeers (s : State) (f : Peer → PeerData → PeerData) : State := + { s with peers := s.peers.map (fun e => (e.1, f e.1 e.2)) } + +/-- Update the score state of one peer and leave every other field. -/ +def updateScoreOf (s : State) (q : Peer) (f : Score.PeerState → Score.PeerState) : State := + mapPeers s (fun r pd => if r = q then { pd with score := f pd.score } else pd) + +/-- The score of a peer at the state time. A missing peer scores 0. -/ +def scoreOf (p : Params) (s : State) (q : Peer) : Score := + match peerOf s q with + | some pd => Score.score p.score pd.score s.now + | none => 0 + +/-- The next heartbeat is due: the interval has passed since the last one. -/ +def heartbeatDue (p : Params) (s : State) (now : Millis) : Prop := + s.lastHeartbeat + p.heartbeatInterval ≤ now + +/-- The heartbeat condition is decidable. -/ +instance heartbeatDueDecidable (p : Params) (s : State) (now : Millis) : + Decidable (heartbeatDue p s now) := + decidable_of_iff (s.lastHeartbeat + p.heartbeatInterval ≤ now) Iff.rfl + +/-- The RPC grafts the topic. -/ +def rpcGrafts (r : Rpc.RPC) (t : TopicId) : Prop := + match r.control with + | some c => ∃ g ∈ c.graft, g.topicId = t.name + | none => False + +/-- The RPC prunes the topic. -/ +def rpcPrunes (r : Rpc.RPC) (t : TopicId) : Prop := + match r.control with + | some c => ∃ p ∈ c.prune, p.topicId = t.name + | none => False + +/-- The RPC carries nonempty ihave gossip. -/ +def rpcHasIhave (r : Rpc.RPC) : Prop := + match r.control with + | some c => c.ihave ≠ [] + | none => False + +/-- The messages the action list delivers to the application. -/ +def delivers : List Action → List Message + | [] => [] + | .deliver m :: as => m :: delivers as + | .sendRpc _ _ :: as => delivers as + +/-! ## Outgoing RPC constructors -/ + +/-- An RPC that carries one control message. -/ +def controlRpc (c : Rpc.ControlMessage) : Rpc.RPC := + { subscriptions := [], publish := [], control := some c } + +/-- The hello RPC: one subscription record per topic. -/ +def helloRpc (topics : List TopicId) : Rpc.RPC := + { subscriptions := topics.map (fun t => ({ subscribe := true, topicid := t.name } : Rpc.SubOpts)), + publish := [], control := none } + +/-- A graft of the topic. -/ +def graftRpc (t : TopicId) : Rpc.RPC := + controlRpc { ihave := [], iwant := [], graft := [{ topicId := t.name }], prune := [] } + +/-- A prune of the topic with the backoff period in seconds. -/ +def pruneRpc (t : TopicId) (backoff : Millis) : Rpc.RPC := + controlRpc + { ihave := [], iwant := [], graft := [], + prune := [{ topicId := t.name, peers := [], backoff := some (backoff / 1000) }] } + +/-- An IHAVE of the topic with the message ids. -/ +def ihaveRpc (t : TopicId) (ids : List MessageId) : Rpc.RPC := + controlRpc + { ihave := [{ topicId := t.name, messageIds := ids }], iwant := [], graft := [], prune := [] } + +/-- An IWANT with the message ids. -/ +def iwantRpc (ids : List MessageId) : Rpc.RPC := + controlRpc { ihave := [], iwant := [{ messageIds := ids }], graft := [], prune := [] } + +/-- An RPC that forwards one published message. -/ +def publishRpc (m : Message) : Rpc.RPC := + { subscriptions := [], publish := [m], control := none } + +/-- The mesh peers that receive a forwarded inbound message. The sender +already holds the message, so the list drops the sender. -/ +def inboundTargets (s : State) (sender : Peer) (topic : TopicId) : List Peer := + (meshOf s topic).filter (fun q => decide (q ≠ sender)) + +/-- One publish RPC per accepted message, to each mesh peer other than +the sender. The targets are read from the pre-state mesh. -/ +def forwardPubs (s : State) (sender : Peer) (ms : List Message) : List Action := + ms.flatMap fun m => + (inboundTargets s sender m.topic).map fun q => Action.sendRpc q (publishRpc m) + +/-! ## The decode boundary -/ + +/-- The one decode entry point of the core: wire bytes to an RPC value. +The protobuf decoder returns the empty suffix, so the core drops it. -/ +def decodeRpc (bytes : List Nat) : Except ProtocolError Rpc.RPC := + match Pb.decodeMessage? (α := Rpc.RPC) bytes with + | .ok (r, _) => .ok r + | .error e => .error (.decode e) + +/-- A decode that the model decoder reports as successful decodes to the +same value. -/ +theorem decodeRpc_of_decodeMessage (bytes : List Nat) (r : Rpc.RPC) + (h : Pb.decodeMessage? (α := Rpc.RPC) bytes = .ok (r, [])) : + decodeRpc bytes = .ok r := by + simp [decodeRpc, h] + +/-- A `strictNoSign` validation as a `Bool`. -/ +def validationOk (p : Params) (m : Message) : Bool := + match Sign.validate p.prims (fun x => Pb.encodeMessage x) .strictNoSign m with + | .ok _ => true + | .error _ => false + +/-! ## Message acceptance -/ + +/-- One run of the acceptance loop: the accepted messages, the ids the +loop saw, and the seen cache after the run. -/ +structure Accept where + /-- The accepted messages, in arrival order. -/ + accepted : List Message + /-- The ids the loop passed. -/ + done : List MessageId + /-- The seen cache after the run. -/ + cache : Cache.SeenCache + +/-- Accept a list of messages. The gate reads the pre-state cache `c0`, +so a message that the pre-state cache holds as seen is never accepted. +The local `done` list rejects a repeated id inside the one event. -/ +def accept (c0 : Cache.SeenCache) (now : Millis) (idf : Sign.MsgIdFn) + (done : List MessageId) (c : Cache.SeenCache) : List Message → Accept + | [] => { accepted := [], done := done, cache := c } + | m :: ms => + if c0.contains (idf m) now || memB (idf m) done then + accept c0 now idf done c ms + else + let r := accept c0 now idf (idf m :: done) (c.add (idf m) now) ms + { accepted := m :: r.accepted, done := r.done, cache := r.cache } + +/-! ## The heartbeat plan -/ + +/-- One per-topic heartbeat plan: the kept mesh, the pruned peers, and +the added peers. -/ +structure TopicPlan where + /-- The topic. -/ + topic : TopicId + /-- The mesh after the heartbeat. -/ + kept : List Peer + /-- The peers that leave the mesh. -/ + pruned : List Peer + /-- The peers that enter the mesh. -/ + added : List Peer + deriving Repr + +/-- The peers that announce the topic. -/ +def announced (s : State) (topic : TopicId) : List Peer := + (s.peers.filter (fun e => memB topic e.2.subs)).map Prod.fst + +/-- One topic plan. An overfull mesh drops its oldest entries down to +`d`, an underfull mesh takes candidates from the order up to `d`, and a +candidate is outside the mesh, outside backoff, known, and at or above +score 0. The kept list is cut at `dHigh`, so the mesh bound holds by +construction. -/ +def planTopic (p : Params) (s : State) (now : Millis) (order : List Peer) + (topic : TopicId) : TopicPlan := + let ps := meshOf s topic + let excess := if ps.length > p.mesh.dHigh then ps.length - p.mesh.d else 0 + let over := ps.take excess + let kept0 := ps.drop excess + let need := if kept0.length < p.mesh.dLow then p.mesh.d - kept0.length else 0 + let cands := order.filter (fun q => + !memB q ps && !(Cache.Backoff.active s.backoff q topic now) && + (peerOf s q).isSome && decide (0 ≤ scoreOf p s q)) + let added := cands.take need + { topic := topic, kept := (kept0 ++ added).take p.mesh.dHigh, pruned := over, added := added } + +/-- The plan of every topic in the mesh. -/ +def planList (p : Params) (s : State) (now : Millis) (order : List Peer) : List TopicPlan := + s.mesh.map (fun e => planTopic p s now order e.1) + +/-- Apply the score updates of the plans: a pruned peer takes the prune +update, an added peer takes the graft update. -/ +def applyPlanScores (p : Params) (s : State) (plans : List TopicPlan) : State := + mapPeers s (fun q pd => + { pd with + score := plans.foldl (fun sc pl => + if memB q pl.pruned then Score.prune p.score sc s.now pl.topic + else if memB q pl.added then Score.graft sc s.now pl.topic + else sc) pd.score }) + +/-- The actions of the plans: a prune to each pruned peer, then a graft +to each added peer. -/ +def planActions (p : Params) (pl : TopicPlan) : List Action := + pl.pruned.map (fun q => Action.sendRpc q (pruneRpc pl.topic p.pruneBackoff)) ++ + pl.added.map (fun q => Action.sendRpc q (graftRpc pl.topic)) + +/-- Write the planned meshes and drop the peers below score 0 from every +mesh. A dropped peer takes the prune score update. -/ +def settleMesh (p : Params) (s : State) (plans : List TopicPlan) : State := + { s with + mesh := plans.map (fun pl => + (pl.topic, pl.kept.filter (fun q => decide (0 ≤ scoreOf p s q)))), + peers := s.peers.map (fun e => + (e.1, if scoreOf p s e.1 < 0 then + { e.2 with + score := s.topics.foldl (fun sc t => Score.prune p.score sc s.now t) e.2.score } + else e.2)) } + +/-- The gossip actions: each topic of the mesh and the fanout sends its +gossip ids to the peers of the order at or above the gossip threshold, +up to `dLazy` targets, skipping the peers that already sit in that +topic's mesh or fanout. The ids come from the cache as it stands, so a +heartbeat emits gossip before it shifts the cache. -/ +def gossipActions (p : Params) (s : State) (order : List Peer) : List Action := + let eligible := + order.filter (fun q => decide (p.thresholds.gossipThreshold ≤ scoreOf p s q)) + let topics := s.mesh.map Prod.fst ++ s.fanout.map Prod.fst + topics.flatMap fun t => + let ids := Cache.MessageCache.gossipIds s.mcache t + if ids = [] then [] + else + let targets := + (eligible.filter (fun q => + !(memB q (meshOf s t)) && !(memB q (fanoutOf s t)))).take p.mesh.dLazy + targets.map fun q => Action.sendRpc q (ihaveRpc t ids) + +/-- The state at the start of the heartbeat: the decayed scores, the new +clock, the cleared backoff table, the pruned seen cache, and the shifted +message cache. -/ +def freshState (p : Params) (s : State) (now : Millis) : State := + { s with + peers := s.peers.map (fun e => (e.1, { e.2 with score := Score.decay p.score e.2.score })), + now := now, + lastHeartbeat := now, + backoff := Cache.Backoff.clear s.backoff now, + seen := Cache.SeenCache.prune s.seen now, + mcache := Cache.MessageCache.shift s.mcache } + +/-- The per-topic plans of the heartbeat. -/ +def heartbeatPlans (p : Params) (s : State) (now : Millis) (order : List Peer) : + List TopicPlan := + planList p (freshState p s now) now order + +/-- The state after the plan score updates. -/ +def planScored (p : Params) (s : State) (now : Millis) (order : List Peer) : State := + applyPlanScores p (freshState p s now) (heartbeatPlans p s now order) + +/-- The state after the heartbeat. -/ +def heartbeatNext (p : Params) (s : State) (now : Millis) (order : List Peer) : State := + settleMesh p (planScored p s now order) (heartbeatPlans p s now order) + +/-- The plan actions of the heartbeat. -/ +def planActionsOf (p : Params) (s : State) (now : Millis) (order : List Peer) : + List Action := + (heartbeatPlans p s now order).flatMap (planActions p) + +/-- The negative prune actions of the heartbeat. -/ +def negativeActions (p : Params) (s : State) (now : Millis) (order : List Peer) : + List Action := + (heartbeatPlans p s now order).flatMap (fun pl => + (pl.kept.filter (fun q => decide (scoreOf p (planScored p s now order) q < 0))).map + (fun q => Action.sendRpc q (pruneRpc pl.topic p.pruneBackoff))) + +/-- The state the gossip reads: the mesh and the scores after the +heartbeat, and the message cache before the shift. The spec emits the +ids, then shifts the cache. -/ +def gossipState (p : Params) (s : State) (now : Millis) (order : List Peer) : State := + { heartbeatNext p s now order with mcache := s.mcache } + +/-- The actions of the heartbeat. Gossip is emitted after the mesh is +settled and before the cache shifts. -/ +def heartbeatActions (p : Params) (s : State) (now : Millis) (order : List Peer) : + List Action := + planActionsOf p s now order ++ negativeActions p s now order ++ + gossipActions p (gossipState p s now order) order + +/-- The heartbeat: decay, the fresh clock and tables, the per-topic plan, +the score updates, the mesh write with the negative prune, and the +actions of the plan, the negative prune, and the gossip. -/ +def heartbeat (p : Params) (s : State) (now : Millis) (order : List Peer) : + State × List Action := + (heartbeatNext p s now order, heartbeatActions p s now order) + +/-! ## The event handlers -/ + +/-- A peer joins: record it, and send the hello RPC with our topics. A +known peer keeps its record. -/ +def join (_p : Params) (s : State) (peer : Peer) (proto : PubsubProto) : + State × List Action := + let s1 := + if (peerOf s peer).isSome then s + else setPeer s peer { score := Score.PeerState.zero, proto := proto, subs := [] } + (s1, [Action.sendRpc peer (helloRpc s.topics)]) + +/-- A peer leaves: drop it from the peers, the meshes, the fanout +records, and the promise table. -/ +def leave (s : State) (peer : Peer) : State × List Action := + ({ s with + peers := s.peers.filter (fun e => decide (e.1 ≠ peer)), + mesh := s.mesh.map (fun e => (e.1, e.2.filter (fun q => decide (q ≠ peer)))), + fanout := s.fanout.map (fun e => + (e.1, { e.2 with peers := e.2.peers.filter (fun q => decide (q ≠ peer)) })), + iwant := s.iwant.filter (fun e => decide (e.peer ≠ peer)) }, + []) + +/-- One subscription option against the announced list. -/ +def applySubOpts (subs : List TopicId) (o : Rpc.SubOpts) : List TopicId := + let t : TopicId := ⟨o.topicid⟩ + if o.subscribe then (if memB t subs then subs else t :: subs) + else subs.filter (fun u => decide (u ≠ t)) + +/-- Update the announced topics of the sender. -/ +def handleSubs (s : State) (sender : Peer) (subs : List Rpc.SubOpts) : State := + match peerOf s sender with + | some pd => setPeer s sender { pd with subs := subs.foldl applySubOpts pd.subs } + | none => s + +/-- Handle the published messages of an RPC: validate, accept, cache, +score, deliver, and forward. -/ +def handlePubs (p : Params) (s : State) (sender : Peer) (ms : List Message) : + State × List Action := + let valid := ms.filter (fun m => validationOk p m) + let invalid := ms.filter (fun m => !(validationOk p m)) + let r := accept s.seen s.now p.msgId [] s.seen valid + let s1 : State := + { s with + seen := r.cache, + mcache := r.accepted.foldl (fun c m => c.put (p.msgId m) m) s.mcache } + let s2 := invalid.foldl (fun st m => + updateScoreOf st sender (fun sc => Score.invalidDelivery p.score sc m.topic)) s1 + let s3 := r.accepted.foldl (fun st m => + updateScoreOf st sender (fun sc => + let sc' := Score.firstDelivery p.score sc m.topic + if memB sender (meshOf s m.topic) then Score.meshDelivery p.score sc' m.topic else sc')) s2 + (s3, r.accepted.map (fun m => Action.deliver m) ++ + forwardPubs s sender r.accepted) + +/-- Whether the promise table holds the pair. -/ +def promised (ps : Cache.PromiseTable) (q : Peer) (id : MessageId) : Bool := + ps.any (fun e => decide (e.peer = q ∧ e.id = id)) + +/-- Handle one IHAVE: request the ids that are unseen, uncached, and not +promised, and record the promise. -/ +def handleIhave (p : Params) (s : State) (sender : Peer) (i : Rpc.ControlIHave) : + State × List Action := + let wanted := i.messageIds.filter (fun id => + !(s.seen.contains id s.now) && (Cache.MessageCache.get s.mcache id).isNone && + !(promised s.iwant sender id)) + let s1 : State := + { s with + iwant := wanted.foldl + (fun ps id => ps.promise sender id (s.now + p.iwantDeadline)) s.iwant } + (s1, if wanted = [] then [] else [Action.sendRpc sender (iwantRpc wanted)]) + +/-- Handle one IWANT: answer each id that the message cache holds under +the same id. -/ +def handleIwant (p : Params) (s : State) (sender : Peer) (w : Rpc.ControlIWant) : + State × List Action := + let answers := w.messageIds.filterMap (fun id => + match Cache.MessageCache.get s.mcache id with + | some m => if p.msgId m = id then some m else none + | none => none) + (s, answers.map (fun m => Action.sendRpc sender (publishRpc m))) + +/-- Handle one GRAFT. An unknown topic is ignored. A member is a no-op. +A peer in backoff, or a mesh at `dHigh`, gets a prune. Otherwise the +peer enters the mesh and takes the graft score update. -/ +def handleGraft (p : Params) (s : State) (sender : Peer) (g : Rpc.ControlGraft) : + State × List Action := + if memB (⟨g.topicId⟩ : TopicId) s.topics then + if memB sender (meshOf s ⟨g.topicId⟩) then (s, []) + else if Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now = true then + (s, [Action.sendRpc sender (pruneRpc ⟨g.topicId⟩ p.pruneBackoff)]) + else if p.mesh.dHigh ≤ (meshOf s ⟨g.topicId⟩).length then + (s, [Action.sendRpc sender (pruneRpc ⟨g.topicId⟩ p.pruneBackoff)]) + else + match peerOf s sender with + | some pd => + (setPeer (setTopicMesh s ⟨g.topicId⟩ (sender :: meshOf s ⟨g.topicId⟩)) sender + { pd with score := Score.graft pd.score s.now ⟨g.topicId⟩ }, []) + | none => (s, []) + else (s, []) + +/-- The state after a prune records the backoff pair. -/ +def pruneState (p : Params) (s : State) (sender : Peer) (q : Rpc.ControlPrune) : State := + { s with + backoff := Cache.Backoff.add s.backoff sender ⟨q.topicId⟩ + (match q.backoff with + | some sec => sec * 1000 + | none => p.pruneBackoff) s.now } + +/-- Handle one PRUNE: record the backoff pair, and remove the sender +from the topic mesh with the prune score update. -/ +def handlePrune (p : Params) (s : State) (sender : Peer) (q : Rpc.ControlPrune) : + State × List Action := + if memB sender (meshOf (pruneState p s sender q) ⟨q.topicId⟩) then + match peerOf (pruneState p s sender q) sender with + | some pd => + (setPeer (setTopicMesh (pruneState p s sender q) ⟨q.topicId⟩ + ((meshOf (pruneState p s sender q) ⟨q.topicId⟩).filter + (fun r => decide (r ≠ sender)))) sender + { pd with + score := Score.prune p.score pd.score (pruneState p s sender q).now ⟨q.topicId⟩ }, []) + | none => (pruneState p s sender q, []) + else (pruneState p s sender q, []) + +/-- Run one handler over a list, threading the state and appending the +actions. -/ +def mapHandlers {β : Type} (f : State → β → State × List Action) : + List β → State → State × List Action + | [], s => (s, []) + | b :: bs, s => + let r := f s b + let r' := mapHandlers f bs r.1 + (r'.1, r.2 ++ r'.2) + +/-- The state after the ihave handlers. -/ +def controlIhaves (p : Params) (s : State) (sender : Peer) (c : Rpc.ControlMessage) : + State := + (mapHandlers (fun st b => handleIhave p st sender b) c.ihave s).1 + +/-- The state after the iwant handlers. -/ +def controlIwants (p : Params) (s : State) (sender : Peer) (c : Rpc.ControlMessage) : + State := + (mapHandlers (fun st b => handleIwant p st sender b) c.iwant (controlIhaves p s sender c)).1 + +/-- The state after the graft handlers. -/ +def controlGrafts (p : Params) (s : State) (sender : Peer) (c : Rpc.ControlMessage) : + State := + (mapHandlers (fun st b => handleGraft p st sender b) c.graft (controlIwants p s sender c)).1 + +/-- The state after the prune handlers. -/ +def controlPrunes (p : Params) (s : State) (sender : Peer) (c : Rpc.ControlMessage) : + State := + (mapHandlers (fun st b => handlePrune p st sender b) c.prune (controlGrafts p s sender c)).1 + +/-- The actions of the control handlers: ihave, iwant, graft, then prune. -/ +def controlActions (p : Params) (s : State) (sender : Peer) (c : Rpc.ControlMessage) : + List Action := + (mapHandlers (fun st b => handleIhave p st sender b) c.ihave s).2 ++ + (mapHandlers (fun st b => handleIwant p st sender b) c.iwant + (controlIhaves p s sender c)).2 ++ + (mapHandlers (fun st b => handleGraft p st sender b) c.graft + (controlIwants p s sender c)).2 ++ + (mapHandlers (fun st b => handlePrune p st sender b) c.prune + (controlGrafts p s sender c)).2 + +/-- Handle the control envelope: ihave, iwant, graft, then prune. -/ +def handleControl (p : Params) (s : State) (sender : Peer) : + Option Rpc.ControlMessage → State × List Action + | none => (s, []) + | some c => (controlPrunes p s sender c, controlActions p s sender c) + +/-- Handle a decoded RPC: subscriptions, published messages, control. -/ +def handleRpc (p : Params) (s : State) (sender : Peer) (rpc : Rpc.RPC) : + State × List Action := + let s1 := handleSubs s sender rpc.subscriptions + let (s2, a2) := handlePubs p s1 sender rpc.publish + let (s3, a3) := handleControl p s2 sender rpc.control + (s3, a2 ++ a3) + +/-- The RPC branch gates: a graylisted peer is ignored, an unknown peer +is ignored, and a decode failure is the one error. -/ +def rpcStep (p : Params) (s : State) (sender : Peer) (bytes : List Nat) : + Except ProtocolError (State × List Action) := + if scoreOf p s sender < p.thresholds.graylistThreshold then .ok (s, []) + else + match peerOf s sender with + | none => .ok (s, []) + | some _ => + match decodeRpc bytes with + | .error e => .error e + | .ok rpc => .ok (handleRpc p s sender rpc) + +/-- The fanout candidates: peers that announce the topic, at or above +score 0. -/ +def fanoutCandidates (p : Params) (s : State) (topic : TopicId) : List Peer := + (announced s topic).filter (fun q => decide (0 ≤ scoreOf p s q)) + +/-- Extend the fanout of the topic to `d`, and set its last publish +time. -/ +def extendFanout (p : Params) (s : State) (topic : TopicId) : State := + match lookupFanout s.fanout topic with + | some f => + let need := if f.peers.length < p.mesh.d then p.mesh.d - f.peers.length else 0 + let cands := (fanoutCandidates p s topic).filter (fun q => !memB q f.peers) + setTopicFanout s topic { peers := f.peers ++ cands.take need, lastPublished := s.now } + | none => + setTopicFanout s topic + { peers := (fanoutCandidates p s topic).take p.mesh.d, lastPublished := s.now } + +/-- The forward targets of a publish: the mesh when we subscribe to the +topic, the fanout otherwise. -/ +def publishTargets (_p : Params) (s : State) (topic : TopicId) : List Peer := + if memB topic s.topics then meshOf s topic else fanoutOf s topic + +/-- The application publishes a message: validate, cache, record the id +as seen, extend the fanout when we do not subscribe, and forward to the +targets. The application already holds its own message, so this path +emits no deliver action. The seen record stops a later echo of the same +id from being forwarded again. -/ +def publishLocal (p : Params) (s : State) (m : Message) : State × List Action := + if validationOk p m then + let s1 : State := + { s with + mcache := s.mcache.put (p.msgId m) m + seen := s.seen.add (p.msgId m) s.now } + let s2 := if memB m.topic s1.topics then s1 else extendFanout p s1 m.topic + (s2, (publishTargets p s2 m.topic).map (fun q => Action.sendRpc q (publishRpc m))) + else (s, []) + +/-- The application subscribes: add the topic, move the fanout peers +into the mesh, fill to `d`, and graft the added peers. -/ +def subscribe (p : Params) (s : State) (topic : TopicId) : State × List Action := + let s1 : State := + if memB topic s.topics then s else { s with topics := topic :: s.topics } + let ps := meshOf s1 topic + let ps1 := ps ++ (fanoutOf s1 topic).filter (fun q => !memB q ps) + let need := if ps1.length < p.mesh.d then p.mesh.d - ps1.length else 0 + let cands := (announced s1 topic).filter (fun q => + !memB q ps1 && decide (0 ≤ scoreOf p s1 q) && + !(Cache.Backoff.active s1.backoff q topic s1.now)) + let added := cands.take need + let s2 := setTopicMesh s1 topic ((ps1 ++ added).take p.mesh.dHigh) + let s3 : State := + { s2 with fanout := s2.fanout.filter (fun e => decide (e.1 ≠ topic)) } + (s3, added.map (fun q => Action.sendRpc q (graftRpc topic))) + +/-- The application unsubscribes: drop the topic, empty its mesh and +fanout, prune the old mesh peers, and record the backoff pairs. -/ +def unsubscribe (p : Params) (s : State) (topic : TopicId) : State × List Action := + let ps := meshOf s topic + let s1 : State := { s with topics := s.topics.filter (fun t => decide (t ≠ topic)) } + let s2 := setTopicMesh s1 topic [] + let s3 : State := + { s2 with fanout := s2.fanout.filter (fun e => decide (e.1 ≠ topic)) } + let s4 := ps.foldl + (fun st q => { st with backoff := Cache.Backoff.add st.backoff q topic p.pruneBackoff s.now }) + s3 + (s4, ps.map (fun q => Action.sendRpc q (pruneRpc topic p.pruneBackoff))) + +/-! ## The transition and the driver -/ + +/-- One transition of the router. Every event is handled, and only an +RPC that does not decode returns an error. -/ +def step (p : Params) (s : State) : Event → Except ProtocolError (State × List Action) + | .peerJoined peer proto => .ok (join p s peer proto) + | .peerLeft peer => .ok (leave s peer) + | .rpc sender bytes => rpcStep p s sender bytes + | .publish msg => .ok (publishLocal p s msg) + | .subscribe topic => .ok (subscribe p s topic) + | .unsubscribe topic => .ok (unsubscribe p s topic) + | .tick now order => + if heartbeatDue p s now then .ok (heartbeat p s now order) else .ok (s, []) + +/-- The transcript driver: the events in order. The first error stops +the run. -/ +def run (p : Params) (s : State) : List Event → Except ProtocolError (State × List Action) + | [] => .ok (s, []) + | e :: es => + match step p s e with + | .error err => .error err + | .ok (s1, a1) => + match run p s1 es with + | .error err => .error err + | .ok (s2, a2) => .ok (s2, a1 ++ a2) + +/-! ## The spec defaults -/ + +/-- The spec defaults: d 6, dLow 4, dHigh 12, dScore 4, dLazy 6, +heartbeat 1000 ms, fanout ttl 60000 ms, prune backoff 60000 ms, gossip +factor 25 percent, iwant deadline 3000 ms, window 5 over 3, seen +capacity 65536 and ttl 120000 ms, and zero score weights with no +configured topic. The numbers follow `gossipsub-v1.0.md` and the v1.1 +recommendations. The seen capacity is a chosen memory bound that the +spec leaves open. `validationOk` uses `strictNoSign`, which requires +`from` and `seqno` to be absent, so the spec's default id `from ++ seqno` +would be empty for every accepted message. The default here is the +payload bytes. `Sign.defaultMsgId` stays available for a caller that +pairs it with `strictSign`. -/ +def defaults : Params where + score := + { topics := fun _ => none, + global := + { appSpecificWeight := 0 + ipColocationFactorWeight := 0 + ipColocationFactorThreshold := 1 + behaviourPenaltyWeight := 0 + behaviourPenaltyThreshold := 0 + behaviourPenaltyDecay := scoreScale + decayInterval := 1000 + decayToZero := 0 + topicScoreCap := 0 } } + thresholds := + { gossipThreshold := 0 + publishThreshold := 0 + graylistThreshold := 0 + acceptPXThreshold := 0 + opportunisticGraftThreshold := 0 } + mesh := { d := 6, dLow := 4, dHigh := 12, dScore := 4, dLazy := 6 } + heartbeatInterval := 1000 + fanoutTtl := 60000 + pruneBackoff := 60000 + gossipFactor := 25 + iwantDeadline := 3000 + window := { historyLength := 5, historyGossip := 3, valid := by decide } + seen := { capacity := 65536, ttl := 120000, valid := by decide } + prims := + { unmarshalKey := fun _ => none + inlineKey := fun _ => none + idOfKey := fun _ => [] + verify := fun _ _ _ => false } + msgId := fun m => ⟨m.data.getD []⟩ + +/-! ## Accessor lemmas -/ + +private theorem lookupTopic_upsertMesh_same (l : List (TopicId × List Peer)) (t : TopicId) + (ps : List Peer) : lookupTopic (upsertMesh l t ps) t = ps := by + induction l with + | nil => simp [upsertMesh, lookupTopic] + | cons e l ih => + obtain ⟨t', ps'⟩ := e + by_cases h : t = t' + · simp [upsertMesh, lookupTopic, h] + · simp [upsertMesh, lookupTopic, h, ih] + +private theorem lookupTopic_upsertMesh_ne (t' t : TopicId) (h : t' ≠ t) + (l : List (TopicId × List Peer)) (ps : List Peer) : + lookupTopic (upsertMesh l t ps) t' = lookupTopic l t' := by + induction l with + | nil => simp [upsertMesh, lookupTopic, h] + | cons e l ih => + obtain ⟨u, us⟩ := e + by_cases h2 : t = u + · subst h2 + simp [upsertMesh, lookupTopic, h] + · simp [upsertMesh, lookupTopic, h2, ih] + +/-- The mesh of the written topic. -/ +theorem meshOf_setTopicMesh_same (s : State) (t : TopicId) (ps : List Peer) : + meshOf (setTopicMesh s t ps) t = ps := by + simp [meshOf, setTopicMesh, lookupTopic_upsertMesh_same] + +/-- A write of one topic leaves the mesh of another topic. -/ +theorem meshOf_setTopicMesh_ne (s : State) (t t' : TopicId) (h : t' ≠ t) + (ps : List Peer) : meshOf (setTopicMesh s t ps) t' = meshOf s t' := by + simp [meshOf, setTopicMesh, lookupTopic_upsertMesh_ne t' t h] + +private theorem lookupPeer_upsertPeer_same (l : List (Peer × PeerData)) (q : Peer) + (pd : PeerData) : lookupPeer (upsertPeer l q pd) q = some pd := by + induction l with + | nil => simp [upsertPeer, lookupPeer] + | cons e l ih => + obtain ⟨q', pd'⟩ := e + by_cases h : q = q' + · simp [upsertPeer, lookupPeer, h] + · simp [upsertPeer, lookupPeer, h, ih] + +private theorem lookupPeer_upsertPeer_ne (q' q : Peer) (h : q' ≠ q) + (l : List (Peer × PeerData)) (pd : PeerData) : + lookupPeer (upsertPeer l q pd) q' = lookupPeer l q' := by + induction l with + | nil => simp [upsertPeer, lookupPeer, h] + | cons e l ih => + obtain ⟨u, ud⟩ := e + by_cases h2 : q = u + · subst h2 + simp [upsertPeer, lookupPeer, h] + · simp [upsertPeer, lookupPeer, h2, ih] + +/-- The record of the written peer. -/ +theorem peerOf_setPeer_same (s : State) (q : Peer) (pd : PeerData) : + peerOf (setPeer s q pd) q = some pd := by + simp [peerOf, setPeer, lookupPeer_upsertPeer_same] + +/-- A write of one peer leaves the record of another peer. -/ +theorem peerOf_setPeer_ne (s : State) (q q' : Peer) (h : q' ≠ q) (pd : PeerData) : + peerOf (setPeer s q pd) q' = peerOf s q' := by + simp [peerOf, setPeer, lookupPeer_upsertPeer_ne q' q h] + +private theorem lookupPeer_map (l : List (Peer × PeerData)) + (f : Peer → PeerData → PeerData) (q : Peer) : + lookupPeer (l.map (fun e => (e.1, f e.1 e.2))) q = (lookupPeer l q).map (f q) := by + induction l with + | nil => simp [lookupPeer] + | cons e l ih => + obtain ⟨q', pd'⟩ := e + simp only [List.map_cons, lookupPeer] + by_cases h : q = q' + · subst h + simp + · simp [h, ih] + +/-- The record of a peer after a map over the whole list. -/ +theorem peerOf_mapPeers (s : State) (f : Peer → PeerData → PeerData) (q : Peer) : + peerOf (mapPeers s f) q = (peerOf s q).map (f q) := by + simp [peerOf, mapPeers, lookupPeer_map] + +/-- The score of a peer after a map over the whole peer list. -/ +theorem scoreOf_mapPeers (p : Params) (s : State) (f : Peer → PeerData → PeerData) + (q : Peer) : + scoreOf p (mapPeers s f) q = + match peerOf s q with + | some pd => Score.score p.score (f q pd).score s.now + | none => 0 := by + simp only [scoreOf, peerOf_mapPeers] + cases h : peerOf s q <;> rfl + +/-- The score reads only the peer list and the time. -/ +theorem scoreOf_congr (p : Params) {s s' : State} (hpeers : s'.peers = s.peers) + (hnow : s'.now = s.now) (q : Peer) : scoreOf p s' q = scoreOf p s q := by + simp [scoreOf, peerOf, hpeers, hnow] + +/-! ## Plan lemmas -/ + +/-- The plan of a topic keeps at most `dHigh` peers. -/ +theorem planTopic_kept_le (p : Params) (s : State) (now : Millis) (order : List Peer) + (t : TopicId) : (planTopic p s now order t).kept.length ≤ p.mesh.dHigh := by + simp only [planTopic] + rw [List.length_take] + omega + +/-- The lookup of a mesh rebuilt from a list of plans. -/ +theorem lookupTopic_map_plans (plans : List TopicPlan) (f : TopicPlan → List Peer) + (t : TopicId) : + lookupTopic (plans.map (fun pl => (pl.topic, f pl))) t = + match plans.find? (fun pl => decide (t = pl.topic)) with + | some pl => f pl + | none => [] := by + induction plans with + | nil => simp [lookupTopic] + | cons pl rest ih => + simp only [List.map_cons] + by_cases h : t = pl.topic + · simp [lookupTopic, List.find?, h] + · simp [lookupTopic, List.find?, h, ih] + + +/-- The mesh of a topic after the heartbeat mesh write. -/ +theorem meshOf_settleMesh (p : Params) (s : State) (plans : List TopicPlan) (topic : TopicId) : + meshOf (settleMesh p s plans) topic = + match plans.find? (fun pl => decide (topic = pl.topic)) with + | some pl => pl.kept.filter (fun q => decide (0 ≤ scoreOf p s q)) + | none => [] := by + simp [meshOf, settleMesh, lookupTopic_map_plans] + +/-! ## The mesh bound theorem -/ + +/-- After a heartbeat, every topic mesh holds at most `dHigh` peers. +The kept list is cut at `dHigh` by construction, and the negative prune +only removes peers. -/ +theorem heartbeatMeshBound (p : Params) (s s' : State) (acts : List Action) + (now : Millis) (order : List Peer) + (hdue : heartbeatDue p s now) + (hstep : step p s (.tick now order) = .ok (s', acts)) : + ∀ topic, (meshOf s' topic).length ≤ p.mesh.dHigh := by + intro topic + simp only [step, ite_eq_left hdue, Except.ok.injEq] at hstep + obtain ⟨rfl, rfl⟩ := hstep + rw [heartbeatNext, meshOf_settleMesh] + split + · next pl hfind => + have hmem : pl ∈ heartbeatPlans p s now order := List.mem_of_find?_eq_some hfind + have hmem' : pl ∈ (freshState p s now).mesh.map + (fun e => planTopic p (freshState p s now) now order e.1) := by + simpa [heartbeatPlans, planList] using hmem + simp only [List.mem_map] at hmem' + obtain ⟨e, _, rfl⟩ := hmem' + exact Nat.le_trans (List.length_filter_le _ _) + (planTopic_kept_le p (freshState p s now) now order e.1) + · exact Nat.zero_le _ + + +/-! ## The graylist gate theorem -/ + +/-- No RPC is processed from a peer below the graylist threshold. The +graylist premise is the only hypothesis, so the gate is what the +theorem proves: the step returns the pre-state and no actions, and it +does not consult the bytes. -/ +theorem rpcGraylistIgnored (p : Params) (s : State) (sender : Peer) + (bytes : List Nat) + (hgray : scoreOf p s sender < p.thresholds.graylistThreshold) : + step p s (.rpc sender bytes) = .ok (s, []) := by + simp [step, rpcStep, hgray] + + +/-! ## The graft handler lemmas -/ + +private theorem handleGraft_unknown (p : Params) (s : State) (sender : Peer) + (g : Rpc.ControlGraft) (h : memB (⟨g.topicId⟩ : TopicId) s.topics = false) : + handleGraft p s sender g = (s, []) := by + simp [handleGraft, h] + +private theorem handleGraft_member (p : Params) (s : State) (sender : Peer) + (g : Rpc.ControlGraft) (hsub : memB (⟨g.topicId⟩ : TopicId) s.topics = true) + (hm : memB sender (meshOf s ⟨g.topicId⟩) = true) : + handleGraft p s sender g = (s, []) := by + simp [handleGraft, hsub, hm] + +private theorem handleGraft_backoff (p : Params) (s : State) (sender : Peer) + (g : Rpc.ControlGraft) (hsub : memB (⟨g.topicId⟩ : TopicId) s.topics = true) + (hm : memB sender (meshOf s ⟨g.topicId⟩) = false) + (hb : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now = true) : + handleGraft p s sender g = + (s, [Action.sendRpc sender (pruneRpc ⟨g.topicId⟩ p.pruneBackoff)]) := by + simp [handleGraft, hsub, hm, hb] + +private theorem handleGraft_overfull (p : Params) (s : State) (sender : Peer) + (g : Rpc.ControlGraft) (hsub : memB (⟨g.topicId⟩ : TopicId) s.topics = true) + (hm : memB sender (meshOf s ⟨g.topicId⟩) = false) + (hb : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now = false) + (hd : p.mesh.dHigh ≤ (meshOf s ⟨g.topicId⟩).length) : + handleGraft p s sender g = + (s, [Action.sendRpc sender (pruneRpc ⟨g.topicId⟩ p.pruneBackoff)]) := by + simp [handleGraft, hsub, hm, hb, hd] + +private theorem handleGraft_add (p : Params) (s : State) (sender : Peer) + (g : Rpc.ControlGraft) (pd : PeerData) + (hsub : memB (⟨g.topicId⟩ : TopicId) s.topics = true) + (hm : memB sender (meshOf s ⟨g.topicId⟩) = false) + (hb : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now = false) + (hd : ¬ p.mesh.dHigh ≤ (meshOf s ⟨g.topicId⟩).length) + (hpd : peerOf s sender = some pd) : + handleGraft p s sender g = + (setPeer (setTopicMesh s ⟨g.topicId⟩ (sender :: meshOf s ⟨g.topicId⟩)) sender + { pd with score := Score.graft pd.score s.now ⟨g.topicId⟩ }, []) := by + simp [handleGraft, hsub, hm, hb, hd, hpd] + +private theorem handleGraft_add_none (p : Params) (s : State) (sender : Peer) + (g : Rpc.ControlGraft) (hsub : memB (⟨g.topicId⟩ : TopicId) s.topics = true) + (hm : memB sender (meshOf s ⟨g.topicId⟩) = false) + (hb : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now = false) + (hd : ¬ p.mesh.dHigh ≤ (meshOf s ⟨g.topicId⟩).length) + (hpd : peerOf s sender = none) : + handleGraft p s sender g = (s, []) := by + simp [handleGraft, hsub, hm, hb, hd, hpd] + +private theorem meshOf_addPeer (s : State) (sender : Peer) (pd : PeerData) (t topic : TopicId) + (h : topic ≠ t) : + meshOf (setPeer (setTopicMesh s t (sender :: meshOf s t)) sender pd) topic + = meshOf s topic := by + simp [meshOf, setPeer, setTopicMesh, lookupTopic_upsertMesh_ne topic t h] + +private theorem meshOf_addPeer_same (s : State) (sender : Peer) (pd : PeerData) + (t : TopicId) : + meshOf (setPeer (setTopicMesh s t (sender :: meshOf s t)) sender pd) t + = sender :: meshOf s t := by + simp [meshOf, setPeer, setTopicMesh, lookupTopic_upsertMesh_same] + +private theorem handleGraft_mesh_other (p : Params) (s : State) (sender : Peer) + (g : Rpc.ControlGraft) (topic : TopicId) (h : topic ≠ ⟨g.topicId⟩) : + meshOf (handleGraft p s sender g).1 topic = meshOf s topic := by + by_cases hsub : memB (⟨g.topicId⟩ : TopicId) s.topics = true + · by_cases hm : memB sender (meshOf s ⟨g.topicId⟩) = true + · rw [handleGraft_member p s sender g hsub hm] + · have hm' : memB sender (meshOf s ⟨g.topicId⟩) = false := by + cases h' : memB sender (meshOf s ⟨g.topicId⟩) <;> simp_all + by_cases hb : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now = true + · rw [handleGraft_backoff p s sender g hsub hm' hb] + · have hb' : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now = false := by + cases h' : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now <;> simp_all + by_cases hd : p.mesh.dHigh ≤ (meshOf s ⟨g.topicId⟩).length + · rw [handleGraft_overfull p s sender g hsub hm' hb' hd] + · cases hpd : peerOf s sender with + | none => rw [handleGraft_add_none p s sender g hsub hm' hb' hd hpd] + | some pd => + rw [handleGraft_add p s sender g pd hsub hm' hb' hd hpd] + exact meshOf_addPeer s sender _ ⟨g.topicId⟩ topic h + · have hsub' : memB (⟨g.topicId⟩ : TopicId) s.topics = false := by + cases h' : memB (⟨g.topicId⟩ : TopicId) s.topics <;> simp_all + rw [handleGraft_unknown p s sender g hsub'] + +private theorem handleGraft_mesh_same (p : Params) (s : State) (sender : Peer) + (g : Rpc.ControlGraft) (hnot : memB sender (meshOf s ⟨g.topicId⟩) = false) + (hover : p.mesh.dHigh ≤ (meshOf s ⟨g.topicId⟩).length) : + meshOf (handleGraft p s sender g).1 ⟨g.topicId⟩ = meshOf s ⟨g.topicId⟩ := by + by_cases hsub : memB (⟨g.topicId⟩ : TopicId) s.topics = true + · by_cases hm : memB sender (meshOf s ⟨g.topicId⟩) = true + · rw [handleGraft_member p s sender g hsub hm] + · by_cases hb : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now = true + · rw [handleGraft_backoff p s sender g hsub hnot hb] + · have hb' : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now = false := by + cases h' : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now <;> simp_all + by_cases hd : p.mesh.dHigh ≤ (meshOf s ⟨g.topicId⟩).length + · rw [handleGraft_overfull p s sender g hsub hnot hb' hd] + · exfalso + omega + · have hsub' : memB (⟨g.topicId⟩ : TopicId) s.topics = false := by + cases h' : memB (⟨g.topicId⟩ : TopicId) s.topics <;> simp_all + rw [handleGraft_unknown p s sender g hsub'] + +private theorem handleGraft_prune (p : Params) (s : State) (sender : Peer) + (g : Rpc.ControlGraft) (hsub0 : memB (⟨g.topicId⟩ : TopicId) s.topics = true) + (hnot : memB sender (meshOf s ⟨g.topicId⟩) = false) + (hover : p.mesh.dHigh ≤ (meshOf s ⟨g.topicId⟩).length) : + ∃ pr, Action.sendRpc sender pr ∈ (handleGraft p s sender g).2 ∧ + rpcPrunes pr ⟨g.topicId⟩ := by + by_cases hm : memB sender (meshOf s ⟨g.topicId⟩) = true + · exfalso + rw [hnot] at hm + simp at hm + · by_cases hb : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now = true + · rw [handleGraft_backoff p s sender g hsub0 hnot hb] + exact ⟨pruneRpc ⟨g.topicId⟩ p.pruneBackoff, by simp, + by simp [pruneRpc, controlRpc, rpcPrunes]⟩ + · have hb' : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now = false := by + cases h' : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now <;> simp_all + rw [handleGraft_overfull p s sender g hsub0 hnot hb' hover] + exact ⟨pruneRpc ⟨g.topicId⟩ p.pruneBackoff, by simp, + by simp [pruneRpc, controlRpc, rpcPrunes]⟩ + +/-! ## The prune and control handler lemmas -/ + +private theorem filter_ne_self (l : List Peer) (sender : Peer) (h : memB sender l = false) : + l.filter (fun r => decide (r ≠ sender)) = l := by + rw [List.filter_eq_self] + intro x hx + have hx' : x ≠ sender := by + intro hc + subst hc + have hmem : memB x l = true := memB_eq_true.mpr hx + rw [h] at hmem + simp at hmem + simp [hx'] + +private theorem meshOf_pruneState (p : Params) (s : State) (sender : Peer) + (q : Rpc.ControlPrune) (topic : TopicId) : + meshOf (pruneState p s sender q) topic = meshOf s topic := by + simp [pruneState, meshOf] + +private theorem meshOf_setPeer (s : State) (q : Peer) (pd : PeerData) (topic : TopicId) : + meshOf (setPeer s q pd) topic = meshOf s topic := by + simp [meshOf, setPeer] + +private theorem handlePrune_mesh_same (p : Params) (s : State) (sender : Peer) + (q : Rpc.ControlPrune) (hnot : memB sender (meshOf s ⟨q.topicId⟩) = false) : + meshOf (handlePrune p s sender q).1 ⟨q.topicId⟩ = meshOf s ⟨q.topicId⟩ := by + have hst : meshOf (pruneState p s sender q) ⟨q.topicId⟩ = meshOf s ⟨q.topicId⟩ := + meshOf_pruneState p s sender q ⟨q.topicId⟩ + by_cases hm : memB sender (meshOf (pruneState p s sender q) ⟨q.topicId⟩) = true + · cases hpd : peerOf (pruneState p s sender q) sender with + | none => + simp only [handlePrune, hm, ite_true, hpd] + exact hst + | some pd => + have hf : (meshOf (pruneState p s sender q) ⟨q.topicId⟩).filter + (fun r => decide (r ≠ sender)) + = meshOf (pruneState p s sender q) ⟨q.topicId⟩ := by + apply filter_ne_self + rw [hst] + exact hnot + simp only [handlePrune, hm, ite_true, hpd] + rw [meshOf_setPeer, meshOf_setTopicMesh_same, hf, hst] + · simp only [handlePrune, hm] + exact hst + +private theorem handlePrune_mesh_ne (p : Params) (s : State) (sender : Peer) + (q : Rpc.ControlPrune) (topic : TopicId) (h : topic ≠ ⟨q.topicId⟩) : + meshOf (handlePrune p s sender q).1 topic = meshOf s topic := by + have hst : meshOf (pruneState p s sender q) topic = meshOf s topic := + meshOf_pruneState p s sender q topic + by_cases hm : memB sender (meshOf (pruneState p s sender q) ⟨q.topicId⟩) = true + · cases hpd : peerOf (pruneState p s sender q) sender with + | none => + simp only [handlePrune, hm, ite_true, hpd] + exact hst + | some pd => + simp only [handlePrune, hm, ite_true, hpd] + rw [meshOf_setPeer, + meshOf_setTopicMesh_ne (pruneState p s sender q) ⟨q.topicId⟩ topic h, hst] + · simp only [handlePrune, hm] + exact hst + +private theorem handlePrunes_mesh (p : Params) (s : State) (sender : Peer) + (qs : List Rpc.ControlPrune) (topic : TopicId) + (hnot : memB sender (meshOf s topic) = false) : + meshOf (mapHandlers (fun st b => handlePrune p st sender b) qs s).1 topic + = meshOf s topic := by + induction qs generalizing s with + | nil => simp [mapHandlers] + | cons q qs ih => + simp only [mapHandlers] + by_cases h : topic = ⟨q.topicId⟩ + · subst h + rw [ih (s := (handlePrune p s sender q).1) + (by rwa [handlePrune_mesh_same p s sender q hnot]), + handlePrune_mesh_same p s sender q hnot] + · rw [ih (s := (handlePrune p s sender q).1) + (by rwa [handlePrune_mesh_ne p s sender q topic h]), + handlePrune_mesh_ne p s sender q topic h] + +private theorem handleIhave_mesh (p : Params) (s : State) (sender : Peer) + (i : Rpc.ControlIHave) (topic : TopicId) : + meshOf (handleIhave p s sender i).1 topic = meshOf s topic := by + simp [handleIhave, meshOf] + +private theorem handleIwant_mesh (p : Params) (s : State) (sender : Peer) + (w : Rpc.ControlIWant) (topic : TopicId) : + meshOf (handleIwant p s sender w).1 topic = meshOf s topic := by + simp [handleIwant] + +private theorem handleIhaves_mesh (p : Params) (s : State) (sender : Peer) + (is : List Rpc.ControlIHave) (topic : TopicId) : + meshOf (mapHandlers (fun st b => handleIhave p st sender b) is s).1 topic + = meshOf s topic := by + induction is generalizing s with + | nil => simp [mapHandlers] + | cons i is ih => + simp only [mapHandlers] + rw [ih (s := (handleIhave p s sender i).1), handleIhave_mesh] + +private theorem handleIwants_mesh (p : Params) (s : State) (sender : Peer) + (ws : List Rpc.ControlIWant) (topic : TopicId) : + meshOf (mapHandlers (fun st b => handleIwant p st sender b) ws s).1 topic + = meshOf s topic := by + induction ws generalizing s with + | nil => simp [mapHandlers] + | cons w ws ih => + simp only [mapHandlers] + rw [ih (s := (handleIwant p s sender w).1), handleIwant_mesh] + +private theorem topics_mapHandlers {β : Type} (f : State → β → State × List Action) + (hf : ∀ (s : State) (b : β), (f s b).1.topics = s.topics) : + ∀ (bs : List β) (s : State), (mapHandlers f bs s).1.topics = s.topics + | [], s => rfl + | b :: bs, s => by + simp only [mapHandlers] + rw [topics_mapHandlers f hf bs (f s b).1, hf s b] + +private theorem handleIhave_topics (p : Params) (s : State) (sender : Peer) + (i : Rpc.ControlIHave) : (handleIhave p s sender i).1.topics = s.topics := by + simp [handleIhave] + +private theorem handleIwant_topics (p : Params) (s : State) (sender : Peer) + (w : Rpc.ControlIWant) : (handleIwant p s sender w).1.topics = s.topics := by + simp [handleIwant] + +private theorem handleGraft_topics (p : Params) (s : State) (sender : Peer) + (g : Rpc.ControlGraft) : (handleGraft p s sender g).1.topics = s.topics := by + by_cases hsub : memB (⟨g.topicId⟩ : TopicId) s.topics = true + · by_cases hm : memB sender (meshOf s ⟨g.topicId⟩) = true + · simp [handleGraft, hsub, hm] + · by_cases hb : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now = true + · simp [handleGraft, hsub, hm, hb] + · by_cases hd : p.mesh.dHigh ≤ (meshOf s ⟨g.topicId⟩).length + · simp [handleGraft, hsub, hm, hb, hd] + · cases hpd : peerOf s sender <;> simp [handleGraft, hsub, hm, hb, hd, hpd, setPeer, setTopicMesh] + · simp [handleGraft, hsub] + +private theorem pruneState_topics (p : Params) (s : State) (sender : Peer) + (q : Rpc.ControlPrune) : (pruneState p s sender q).topics = s.topics := by + simp [pruneState] + +private theorem handlePrune_topics (p : Params) (s : State) (sender : Peer) + (q : Rpc.ControlPrune) : (handlePrune p s sender q).1.topics = s.topics := by + by_cases hm : memB sender (meshOf (pruneState p s sender q) ⟨q.topicId⟩) = true + · cases hpd : peerOf (pruneState p s sender q) sender with + | none => + simp only [handlePrune, hm, ite_true, hpd] + exact pruneState_topics p s sender q + | some pd => + simp only [handlePrune, hm, ite_true, hpd] + simp [setPeer, setTopicMesh, pruneState] + · simp only [handlePrune, hm] + exact pruneState_topics p s sender q + +private theorem controlIwants_topics (p : Params) (s : State) (sender : Peer) + (c : Rpc.ControlMessage) : (controlIwants p s sender c).topics = s.topics := by + simp only [controlIwants, controlIhaves] + rw [topics_mapHandlers (fun st b => handleIwant p st sender b) + (fun st b => handleIwant_topics p st sender b), + topics_mapHandlers (fun st b => handleIhave p st sender b) + (fun st b => handleIhave_topics p st sender b)] + +private theorem controlGrafts_topics (p : Params) (s : State) (sender : Peer) + (c : Rpc.ControlMessage) : (controlGrafts p s sender c).topics = s.topics := by + simp only [controlGrafts] + rw [topics_mapHandlers (fun st b => handleGraft p st sender b) + (fun st b => handleGraft_topics p st sender b), + controlIwants_topics] + +private theorem handleGrafts_mesh (p : Params) (s : State) (sender : Peer) + (gs : List Rpc.ControlGraft) (topic : TopicId) + (hsub : memB topic s.topics = true) + (hnot : memB sender (meshOf s topic) = false) + (hover : p.mesh.dHigh ≤ (meshOf s topic).length) : + meshOf (mapHandlers (fun st b => handleGraft p st sender b) gs s).1 topic + = meshOf s topic := by + induction gs generalizing s with + | nil => simp [mapHandlers] + | cons g gs ih => + simp only [mapHandlers] + have htop : (handleGraft p s sender g).1.topics = s.topics := + handleGraft_topics p s sender g + have hsub' : memB topic (handleGraft p s sender g).1.topics = true := by + rw [htop]; exact hsub + by_cases h : topic = ⟨g.topicId⟩ + · subst h + have hm : meshOf (handleGraft p s sender g).1 ⟨g.topicId⟩ = meshOf s ⟨g.topicId⟩ := + handleGraft_mesh_same p s sender g hnot hover + rw [ih (s := (handleGraft p s sender g).1) hsub' (by rw [hm]; exact hnot) + (by rw [hm]; exact hover), hm] + · have hm : meshOf (handleGraft p s sender g).1 topic = meshOf s topic := + handleGraft_mesh_other p s sender g topic h + rw [ih (s := (handleGraft p s sender g).1) hsub' (by rw [hm]; exact hnot) + (by rw [hm]; exact hover), hm] + +private theorem handleGrafts_prune (p : Params) (s : State) (sender : Peer) + (gs : List Rpc.ControlGraft) (topic : TopicId) + (hsub : memB topic s.topics = true) + (hnot : memB sender (meshOf s topic) = false) + (hover : p.mesh.dHigh ≤ (meshOf s topic).length) + (hg : ∃ g ∈ gs, g.topicId = topic.name) : + ∃ pr, Action.sendRpc sender pr ∈ + (mapHandlers (fun st b => handleGraft p st sender b) gs s).2 ∧ + rpcPrunes pr topic := by + induction gs generalizing s with + | nil => simp at hg + | cons g gs ih => + simp only [mapHandlers] + by_cases hsame : g.topicId = topic.name + · have ht : (⟨g.topicId⟩ : TopicId) = topic := by rw [hsame] + have hsub' : memB (⟨g.topicId⟩ : TopicId) s.topics = true := by + rw [ht]; exact hsub + have hnot' : memB sender (meshOf s ⟨g.topicId⟩) = false := by + rw [ht]; exact hnot + have hover' : p.mesh.dHigh ≤ (meshOf s ⟨g.topicId⟩).length := by + rw [ht]; exact hover + obtain ⟨pr, hpr, hrpc⟩ := handleGraft_prune p s sender g hsub' hnot' hover' + refine ⟨pr, List.mem_append_left _ hpr, ?_⟩ + rwa [ht] at hrpc + · have hg' : ∃ g' ∈ gs, g'.topicId = topic.name := by + obtain ⟨g', hg'mem, hg'eq⟩ := hg + rcases List.mem_cons.mp hg'mem with h | h + · rw [h] at hg'eq + exact absurd hg'eq hsame + · exact ⟨g', h, hg'eq⟩ + have htop : (handleGraft p s sender g).1.topics = s.topics := + handleGraft_topics p s sender g + have hmesh : meshOf (handleGraft p s sender g).1 topic = meshOf s topic := by + by_cases h : topic = ⟨g.topicId⟩ + · subst h + exact handleGraft_mesh_same p s sender g hnot hover + · exact handleGraft_mesh_other p s sender g topic h + obtain ⟨pr, hpr, hrpc⟩ := ih (s := (handleGraft p s sender g).1) + (by rw [htop]; exact hsub) (by rw [hmesh]; exact hnot) + (by rw [hmesh]; exact hover) hg' + exact ⟨pr, List.mem_append_right _ hpr, hrpc⟩ + +private theorem handleControl_mesh (p : Params) (s : State) (sender : Peer) + (c : Option Rpc.ControlMessage) (topic : TopicId) + (hsub : memB topic s.topics = true) + (hnot : memB sender (meshOf s topic) = false) + (hover : p.mesh.dHigh ≤ (meshOf s topic).length) : + meshOf (handleControl p s sender c).1 topic = meshOf s topic := by + cases c with + | none => rfl + | some c => + have h1 : meshOf (controlIhaves p s sender c) topic = meshOf s topic := by + simp only [controlIhaves] + exact handleIhaves_mesh p s sender c.ihave topic + have h2 : meshOf (controlIwants p s sender c) topic = meshOf s topic := by + simp only [controlIwants] + rw [handleIwants_mesh, h1] + have h3 : meshOf (controlGrafts p s sender c) topic = meshOf s topic := by + simp only [controlGrafts] + rw [handleGrafts_mesh p (controlIwants p s sender c) sender c.graft topic + (by rw [controlIwants_topics]; exact hsub) + (by rw [h2]; exact hnot) (by rw [h2]; exact hover), h2] + have h4 : meshOf (controlPrunes p s sender c) topic = meshOf s topic := by + simp only [controlPrunes] + rw [handlePrunes_mesh p (controlGrafts p s sender c) sender c.prune topic + (by rw [h3]; exact hnot), h3] + simp only [handleControl] + exact h4 + +private theorem handleControl_prune (p : Params) (s : State) (sender : Peer) + (c : Rpc.ControlMessage) (topic : TopicId) + (hsub : memB topic s.topics = true) + (hnot : memB sender (meshOf s topic) = false) + (hover : p.mesh.dHigh ≤ (meshOf s topic).length) + (hg : ∃ g ∈ c.graft, g.topicId = topic.name) : + ∃ pr, Action.sendRpc sender pr ∈ controlActions p s sender c ∧ rpcPrunes pr topic := by + have h2 : meshOf (controlIwants p s sender c) topic = meshOf s topic := by + simp only [controlIwants, controlIhaves] + rw [handleIwants_mesh, handleIhaves_mesh] + have htop : (controlIwants p s sender c).topics = s.topics := + controlIwants_topics p s sender c + obtain ⟨pr, hpr, hrpc⟩ := + handleGrafts_prune p (controlIwants p s sender c) sender c.graft topic + (by rw [htop]; exact hsub) (by rw [h2]; exact hnot) (by rw [h2]; exact hover) hg + refine ⟨pr, ?_, hrpc⟩ + simp only [controlActions] + exact List.mem_append_left _ (List.mem_append_right _ hpr) + +/-! ## The RPC pipeline lemmas -/ + +private theorem updateScoreOf_mesh (s : State) (q : Peer) + (f : Score.PeerState → Score.PeerState) (topic : TopicId) : + meshOf (updateScoreOf s q f) topic = meshOf s topic := by + simp [updateScoreOf, mapPeers, meshOf] + +private theorem updateScoreOf_topics (s : State) (q : Peer) + (f : Score.PeerState → Score.PeerState) : + (updateScoreOf s q f).topics = s.topics := by + simp [updateScoreOf, mapPeers] + +private theorem foldl_updateScoreOf_mesh (ms : List Message) (s : State) (sender : Peer) + (f : Message → Score.PeerState → Score.PeerState) (topic : TopicId) : + meshOf (ms.foldl (fun st m => updateScoreOf st sender (f m)) s) topic + = meshOf s topic := by + induction ms generalizing s with + | nil => rfl + | cons m ms ih => + rw [List.foldl_cons, ih, updateScoreOf_mesh] + +private theorem foldl_updateScoreOf_topics (ms : List Message) (s : State) (sender : Peer) + (f : Message → Score.PeerState → Score.PeerState) : + (ms.foldl (fun st m => updateScoreOf st sender (f m)) s).topics = s.topics := by + induction ms generalizing s with + | nil => rfl + | cons m ms ih => + rw [List.foldl_cons, ih, updateScoreOf_topics] + +private theorem handleSubs_mesh (s : State) (sender : Peer) (subs : List Rpc.SubOpts) + (topic : TopicId) : meshOf (handleSubs s sender subs) topic = meshOf s topic := by + cases h : peerOf s sender <;> simp [handleSubs, h, setPeer, meshOf] + +private theorem handleSubs_topics (s : State) (sender : Peer) (subs : List Rpc.SubOpts) : + (handleSubs s sender subs).topics = s.topics := by + cases h : peerOf s sender <;> simp [handleSubs, h, setPeer] + +private theorem handlePubs_mesh (p : Params) (s : State) (sender : Peer) + (ms : List Message) (topic : TopicId) : + meshOf (handlePubs p s sender ms).1 topic = meshOf s topic := by + simp only [handlePubs] + rw [foldl_updateScoreOf_mesh, foldl_updateScoreOf_mesh] + simp [meshOf] + +private theorem handlePubs_topics (p : Params) (s : State) (sender : Peer) + (ms : List Message) : (handlePubs p s sender ms).1.topics = s.topics := by + simp only [handlePubs] + rw [foldl_updateScoreOf_topics, foldl_updateScoreOf_topics] + +/-- The record of a peer after the heartbeat mesh write. -/ +theorem peerOf_settleMesh (p : Params) (s : State) (plans : List TopicPlan) (q : Peer) : + peerOf (settleMesh p s plans) q = + (peerOf s q).map (fun pd => + if scoreOf p s q < 0 then + { pd with score := s.topics.foldl (fun sc t => Score.prune p.score sc s.now t) pd.score } + else pd) := by + simp only [peerOf, settleMesh] + induction s.peers with + | nil => simp [lookupPeer] + | cons e l ih => + obtain ⟨a, b⟩ := e + simp only [List.map_cons, lookupPeer] + by_cases h : q = a + · subst h + simp + · simp [h, ih] + +/-- The score of a peer after the heartbeat mesh write. -/ +theorem scoreOf_settleMesh (p : Params) (s : State) (plans : List TopicPlan) (q : Peer) : + scoreOf p (settleMesh p s plans) q = + match peerOf s q with + | some pd => + Score.score p.score + (if scoreOf p s q < 0 then + s.topics.foldl (fun sc t => Score.prune p.score sc s.now t) pd.score + else pd.score) s.now + | none => 0 := by + unfold scoreOf + rw [peerOf_settleMesh] + cases h : peerOf s q with + | none => simp + | some pd => + by_cases hlt : Score.score p.score pd.score s.now < 0 <;> + simp [scoreOf, settleMesh, h, hlt] + +/-- The mesh of a topic after a heartbeat. -/ +theorem meshOf_heartbeatNext (p : Params) (s : State) (now : Millis) + (order : List Peer) (topic : TopicId) : + meshOf (heartbeatNext p s now order) topic = + match (heartbeatPlans p s now order).find? (fun pl => decide (topic = pl.topic)) with + | some pl => + pl.kept.filter (fun q => decide (0 ≤ scoreOf p (planScored p s now order) q)) + | none => [] := by + simp [heartbeatNext, settleMesh, meshOf, lookupTopic_map_plans] + +/-! ## The score gate theorems -/ + +private theorem rpcHasIhave_pruneRpc (t : TopicId) (b : Millis) : + ¬ rpcHasIhave (pruneRpc t b) := by + simp [rpcHasIhave, pruneRpc, controlRpc] + +private theorem rpcHasIhave_graftRpc (t : TopicId) : ¬ rpcHasIhave (graftRpc t) := by + simp [rpcHasIhave, graftRpc, controlRpc] + +/-- A gossip action targets a peer of the order at or above the gossip +threshold. -/ +theorem mem_gossipActions (p : Params) (s : State) (order : List Peer) + (to : Peer) (rpc : Rpc.RPC) + (h : Action.sendRpc to rpc ∈ gossipActions p s order) : + p.thresholds.gossipThreshold ≤ scoreOf p s to := by + simp only [gossipActions, List.mem_flatMap] at h + obtain ⟨t, _, ht⟩ := h + split at ht + · simp at ht + · next ids hne => + simp only [List.mem_map] at ht + obtain ⟨q, hq, hqr⟩ := ht + cases hqr + have htake := List.mem_of_mem_take hq + rw [List.mem_filter] at htake + obtain ⟨hbase, _⟩ := htake + rw [List.mem_filter] at hbase + obtain ⟨_, hscore⟩ := hbase + exact of_decide_eq_true hscore + +/-- The gossip state keeps the settled scores and replaces the cache, so +the score of a peer is the score after the heartbeat. -/ +theorem scoreOf_gossipState (p : Params) (s : State) (now : Millis) (order : List Peer) + (q : Peer) : + scoreOf p (gossipState p s now order) q = scoreOf p (heartbeatNext p s now order) q := by + simp [scoreOf, gossipState, peerOf, heartbeatNext] +theorem tickPruneNegative (p : Params) (s s' : State) (acts : List Action) + (now : Millis) (order : List Peer) + (hdue : heartbeatDue p s now) + (hstep : step p s (.tick now order) = .ok (s', acts)) : + ∀ topic peer, peer ∈ meshOf s' topic → 0 ≤ scoreOf p s' peer := by + simp only [step, ite_eq_left hdue, Except.ok.injEq] at hstep + obtain ⟨rfl, rfl⟩ := hstep + intro topic peer hmem + rw [meshOf_heartbeatNext] at hmem + split at hmem + · next pl hfind => + simp only [List.mem_filter] at hmem + have hge : 0 ≤ scoreOf p (planScored p s now order) peer := of_decide_eq_true hmem.2 + have hscore : scoreOf p (heartbeatNext p s now order) peer + = scoreOf p (planScored p s now order) peer := by + simp only [heartbeatNext] + rw [scoreOf_settleMesh] + cases hpd : peerOf (planScored p s now order) peer with + | none => simp [scoreOf, hpd] + | some pd => + have hge' : 0 ≤ Score.score p.score pd.score (planScored p s now order).now := by + simpa [scoreOf, hpd] using hge + have hlt : ¬ Score.score p.score pd.score (planScored p s now order).now < 0 := + Int.not_lt_of_ge hge' + simp [scoreOf, hpd, hlt] + rw [hscore] + exact hge + · simp at hmem + +/-- Every gossip action targets a peer at or above the gossip threshold +in the state after the heartbeat. The prune and graft actions fail the +ihave test. -/ +theorem tickGossipGated (p : Params) (s s' : State) (acts : List Action) + (now : Millis) (order : List Peer) + (hdue : heartbeatDue p s now) + (hstep : step p s (.tick now order) = .ok (s', acts)) : + ∀ to rpc, Action.sendRpc to rpc ∈ acts → rpcHasIhave rpc → + p.thresholds.gossipThreshold ≤ scoreOf p s' to := by + simp only [step, ite_eq_left hdue, Except.ok.injEq] at hstep + obtain ⟨rfl, rfl⟩ := hstep + intro to rpc hmem hih + simp only [heartbeatActions, List.mem_append] at hmem + rcases hmem with hmem | hmem + · rcases hmem with hmem | hmem + · simp only [planActionsOf, List.mem_flatMap] at hmem + obtain ⟨pl, _, hpl⟩ := hmem + simp only [planActions, List.mem_append, List.mem_map] at hpl + rcases hpl with ⟨q, _, hq⟩ | ⟨q, _, hq⟩ + · cases hq + exact absurd hih (rpcHasIhave_pruneRpc _ _) + · cases hq + exact absurd hih (rpcHasIhave_graftRpc _) + · simp only [negativeActions, List.mem_flatMap, List.mem_map] at hmem + obtain ⟨pl, _, q, _, hq⟩ := hmem + cases hq + exact absurd hih (rpcHasIhave_pruneRpc _ _) + · rw [← scoreOf_gossipState] + exact mem_gossipActions p (gossipState p s now order) order to rpc hmem + +/-! ## The deduplication theorem -/ + +/-- Every accepted message was absent from the pre-state seen cache. -/ +theorem accept_gate (c0 : Cache.SeenCache) (now : Millis) (idf : Sign.MsgIdFn) + (done : List MessageId) (c : Cache.SeenCache) (ms : List Message) : + ∀ m ∈ (accept c0 now idf done c ms).accepted, c0.contains (idf m) now = false := by + induction ms generalizing done c with + | nil => simp [accept] + | cons m ms ih => + simp only [accept] + split + · exact ih done c + · next hskip => + intro m' hm' + rw [List.mem_cons] at hm' + rcases hm' with hhead | hm' + · rw [hhead] + cases hc : c0.contains (idf m) now with + | false => rfl + | true => exact absurd (by rw [hc]; rfl) hskip + · exact ih (idf m :: done) (c.add (idf m) now) m' hm' + +private theorem forwardPubs_not_deliver (s : State) (sender : Peer) (ms : List Message) + (m : Message) : Action.deliver m ∉ forwardPubs s sender ms := by + intro h + simp only [forwardPubs, List.mem_flatMap, List.mem_map] at h + obtain ⟨_, _, q, _, hq⟩ := h + cases hq + +/-- An inbound forward never goes back to the sender. -/ +theorem forwardPubs_excludes_sender (s : State) (sender : Peer) (ms : List Message) + (rpc : Rpc.RPC) : Action.sendRpc sender rpc ∉ forwardPubs s sender ms := by + intro h + simp only [forwardPubs, List.mem_flatMap, List.mem_map] at h + rcases h with ⟨_, _, _, hqmem, heq⟩ + injection heq with hpeer _ + simp only [inboundTargets, List.mem_filter] at hqmem + have hne : _ ≠ sender := of_decide_eq_true hqmem.2 + exact hne hpeer + +private theorem handleIhave_not_deliver (p : Params) (s : State) (sender : Peer) + (i : Rpc.ControlIHave) (m : Message) : + Action.deliver m ∉ (handleIhave p s sender i).2 := by + simp only [handleIhave] + split <;> simp + +private theorem handleIwant_not_deliver (p : Params) (s : State) (sender : Peer) + (w : Rpc.ControlIWant) (m : Message) : + Action.deliver m ∉ (handleIwant p s sender w).2 := by + simp only [handleIwant] + simp [List.mem_map] + +private theorem handlePrune_not_deliver (p : Params) (s : State) (sender : Peer) + (q : Rpc.ControlPrune) (m : Message) : + Action.deliver m ∉ (handlePrune p s sender q).2 := by + simp only [handlePrune] + split + · split <;> simp + · simp + +private theorem handleGraft_not_deliver (p : Params) (s : State) (sender : Peer) + (g : Rpc.ControlGraft) (m : Message) : + Action.deliver m ∉ (handleGraft p s sender g).2 := by + by_cases hsub : memB (⟨g.topicId⟩ : TopicId) s.topics = true + · by_cases hm : memB sender (meshOf s ⟨g.topicId⟩) = true + · rw [handleGraft_member p s sender g hsub hm] + simp + · have hm' : memB sender (meshOf s ⟨g.topicId⟩) = false := by + cases h' : memB sender (meshOf s ⟨g.topicId⟩) <;> simp_all + by_cases hb : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now = true + · rw [handleGraft_backoff p s sender g hsub hm' hb] + simp + · have hb' : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now = false := by + cases h' : Cache.Backoff.active s.backoff sender ⟨g.topicId⟩ s.now <;> simp_all + by_cases hd : p.mesh.dHigh ≤ (meshOf s ⟨g.topicId⟩).length + · rw [handleGraft_overfull p s sender g hsub hm' hb' hd] + simp + · cases hpd : peerOf s sender with + | none => + rw [handleGraft_add_none p s sender g hsub hm' hb' hd hpd] + simp + | some pd => + rw [handleGraft_add p s sender g pd hsub hm' hb' hd hpd] + simp + · have hsub' : memB (⟨g.topicId⟩ : TopicId) s.topics = false := by + cases h' : memB (⟨g.topicId⟩ : TopicId) s.topics <;> simp_all + rw [handleGraft_unknown p s sender g hsub'] + simp + +private theorem mapHandlers_not_deliver {β : Type} (f : State → β → State × List Action) + (hf : ∀ (s : State) (b : β) (m : Message), Action.deliver m ∉ (f s b).2) : + ∀ (bs : List β) (s : State) (m : Message), Action.deliver m ∉ (mapHandlers f bs s).2 := by + intro bs + induction bs with + | nil => + intro s m + simp [mapHandlers] + | cons b bs ih => + intro s m + simp only [mapHandlers, List.mem_append, not_or] + exact ⟨hf s b m, ih (f s b).1 m⟩ + +private theorem controlActions_not_deliver (p : Params) (s : State) (sender : Peer) + (c : Rpc.ControlMessage) (m : Message) : + Action.deliver m ∉ controlActions p s sender c := by + intro h + simp only [controlActions, List.mem_append] at h + rcases h with h | h + · rcases h with h | h + · rcases h with h | h + · exact mapHandlers_not_deliver _ (fun s b => handleIhave_not_deliver p s sender b) _ _ _ h + · exact mapHandlers_not_deliver _ (fun s b => handleIwant_not_deliver p s sender b) _ _ _ h + · exact mapHandlers_not_deliver _ (fun s b => handleGraft_not_deliver p s sender b) _ _ _ h + · exact mapHandlers_not_deliver _ (fun s b => handlePrune_not_deliver p s sender b) _ _ _ h + +/-- A message reaches the application only when the pre-state seen cache +does not hold its id. -/ +private theorem handleSubs_seen (s : State) (sender : Peer) (subs : List Rpc.SubOpts) : + (handleSubs s sender subs).seen = s.seen := by + cases h : peerOf s sender <;> simp [handleSubs, h, setPeer] + +private theorem handleSubs_now (s : State) (sender : Peer) (subs : List Rpc.SubOpts) : + (handleSubs s sender subs).now = s.now := by + cases h : peerOf s sender <;> simp [handleSubs, h, setPeer] + +private theorem join_not_deliver (p : Params) (s : State) (peer : Peer) + (proto : PubsubProto) (m : Message) : Action.deliver m ∉ (join p s peer proto).2 := by + simp [join] + +private theorem leave_not_deliver (s : State) (peer : Peer) (m : Message) : + Action.deliver m ∉ (leave s peer).2 := by + simp [leave] + +private theorem publishLocal_not_deliver (p : Params) (s : State) (msg : Message) + (m : Message) : Action.deliver m ∉ (publishLocal p s msg).2 := by + unfold publishLocal + by_cases h : validationOk p msg + · simp [h, List.mem_map] + · simp [h] + +private theorem subscribe_not_deliver (p : Params) (s : State) (topic : TopicId) + (m : Message) : Action.deliver m ∉ (subscribe p s topic).2 := by + simp only [subscribe] + intro h + rw [List.mem_map] at h + obtain ⟨q, -, hq⟩ := h + cases hq + +private theorem unsubscribe_not_deliver (p : Params) (s : State) (topic : TopicId) + (m : Message) : Action.deliver m ∉ (unsubscribe p s topic).2 := by + simp only [unsubscribe] + intro h + rw [List.mem_map] at h + obtain ⟨q, -, hq⟩ := h + cases hq + +private theorem planActions_not_deliver (p : Params) (pl : TopicPlan) (m : Message) : + Action.deliver m ∉ planActions p pl := by + simp [planActions, List.mem_map] + +private theorem planActionsOf_not_deliver (p : Params) (s : State) (now : Millis) + (order : List Peer) (m : Message) : + Action.deliver m ∉ planActionsOf p s now order := by + intro h + simp only [planActionsOf, List.mem_flatMap] at h + obtain ⟨pl, -, hp⟩ := h + exact planActions_not_deliver p pl m hp + +private theorem negativeActions_not_deliver (p : Params) (s : State) (now : Millis) + (order : List Peer) (m : Message) : + Action.deliver m ∉ negativeActions p s now order := by + intro h + simp only [negativeActions, List.mem_flatMap, List.mem_map] at h + obtain ⟨pl, -, q, -, hq⟩ := h + cases hq + +private theorem gossipActions_not_deliver (p : Params) (s : State) (order : List Peer) + (m : Message) : Action.deliver m ∉ gossipActions p s order := by + intro h + simp only [gossipActions, List.mem_flatMap] at h + obtain ⟨t, -, ht⟩ := h + split at ht + · simp at ht + · simp only [List.mem_map] at ht + obtain ⟨q, -, hq⟩ := ht + cases hq + +private theorem heartbeatActions_not_deliver (p : Params) (s : State) (now : Millis) + (order : List Peer) (m : Message) : + Action.deliver m ∉ heartbeatActions p s now order := by + intro h + simp only [heartbeatActions, List.mem_append] at h + rcases h with h | h + · rcases h with h | h + · exact planActionsOf_not_deliver p s now order m h + · exact negativeActions_not_deliver p s now order m h + · exact gossipActions_not_deliver p (gossipState p s now order) order m h + +/-- A message reaches the application only when the pre-state seen cache +does not hold its id. -/ +theorem stepDeliverUnseen (p : Params) (s s' : State) (acts : List Action) + (e : Event) (m : Message) + (hstep : step p s e = .ok (s', acts)) (hdel : Action.deliver m ∈ acts) : + Cache.SeenCache.contains s.seen (p.msgId m) s.now = false := by + cases e with + | peerJoined peer proto => + have h1 : join p s peer proto = (s', acts) := by + simpa only [step, Except.ok.injEq] using hstep + have hacts : acts = (join p s peer proto).2 := by rw [h1] + rw [hacts] at hdel + exact absurd hdel (join_not_deliver p s peer proto m) + | peerLeft peer => + have h1 : leave s peer = (s', acts) := by + simpa only [step, Except.ok.injEq] using hstep + have hacts : acts = (leave s peer).2 := by rw [h1] + rw [hacts] at hdel + exact absurd hdel (leave_not_deliver s peer m) + | publish msg => + have h1 : publishLocal p s msg = (s', acts) := by + simpa only [step, Except.ok.injEq] using hstep + have hacts : acts = (publishLocal p s msg).2 := by rw [h1] + rw [hacts] at hdel + exact absurd hdel (publishLocal_not_deliver p s msg m) + | subscribe topic => + have h1 : subscribe p s topic = (s', acts) := by + simpa only [step, Except.ok.injEq] using hstep + have hacts : acts = (subscribe p s topic).2 := by rw [h1] + rw [hacts] at hdel + exact absurd hdel (subscribe_not_deliver p s topic m) + | unsubscribe topic => + have h1 : unsubscribe p s topic = (s', acts) := by + simpa only [step, Except.ok.injEq] using hstep + have hacts : acts = (unsubscribe p s topic).2 := by rw [h1] + rw [hacts] at hdel + exact absurd hdel (unsubscribe_not_deliver p s topic m) + | tick now order => + by_cases hdue : heartbeatDue p s now + · have h1 : heartbeat p s now order = (s', acts) := by + simpa only [step, ite_eq_left hdue, Except.ok.injEq] using hstep + have hacts : acts = (heartbeat p s now order).2 := by rw [h1] + simp only [heartbeat] at hacts + rw [hacts] at hdel + exact absurd hdel (heartbeatActions_not_deliver p s now order m) + · have h1 : (s, ([] : List Action)) = (s', acts) := by + simpa only [step, ite_eq_right hdue, Except.ok.injEq] using hstep + have hacts : acts = [] := by + have hs := congrArg Prod.snd h1 + simpa using hs + rw [hacts] at hdel + simp at hdel + | rpc sender bytes => + simp only [step, rpcStep] at hstep + split at hstep + · have h1 : (s, ([] : List Action)) = (s', acts) := by + simpa only [Except.ok.injEq] using hstep + have hacts : acts = [] := by + have hs := congrArg Prod.snd h1 + simpa using hs + rw [hacts] at hdel + simp at hdel + · split at hstep + · have h1 : (s, ([] : List Action)) = (s', acts) := by + simpa only [Except.ok.injEq] using hstep + have hacts : acts = [] := by + have hs := congrArg Prod.snd h1 + simpa using hs + rw [hacts] at hdel + simp at hdel + · split at hstep + · cases hstep + · next rpc hdec => + have h1 : handleRpc p s sender rpc = (s', acts) := by + simpa only [Except.ok.injEq] using hstep + have hacts : acts = (handleRpc p s sender rpc).2 := by rw [h1] + rw [hacts] at hdel + simp only [handleRpc, List.mem_append] at hdel + rcases hdel with hdel | hdel + · simp only [handlePubs, List.mem_append] at hdel + rcases hdel with hdl | hdl + · simp only [List.mem_map] at hdl + obtain ⟨m', hm', heq⟩ := hdl + have hseen : (handleSubs s sender rpc.subscriptions).seen = s.seen := + handleSubs_seen s sender rpc.subscriptions + have hnow : (handleSubs s sender rpc.subscriptions).now = s.now := + handleSubs_now s sender rpc.subscriptions + cases heq + have hgate := accept_gate (handleSubs s sender rpc.subscriptions).seen + (handleSubs s sender rpc.subscriptions).now p.msgId [] + (handleSubs s sender rpc.subscriptions).seen + (List.filter (fun m => validationOk p m) rpc.publish) m hm' + rwa [hseen, hnow] at hgate + · exact absurd hdl + (forwardPubs_not_deliver (handleSubs s sender rpc.subscriptions) sender + ((accept (handleSubs s sender rpc.subscriptions).seen + (handleSubs s sender rpc.subscriptions).now p.msgId [] + (handleSubs s sender rpc.subscriptions).seen + (List.filter (fun m => validationOk p m) rpc.publish)).accepted) m) + · cases hctl : rpc.control with + | none => + rw [hctl] at hdel + simp [handleControl] at hdel + | some c => + rw [hctl] at hdel + simp only [handleControl] at hdel + exact absurd hdel (controlActions_not_deliver p _ sender c m) + +/-! ## The backoff theorem -/ + +private theorem rpcGrafts_graftRpc (t t' : TopicId) (h : rpcGrafts (graftRpc t) t') : t' = t := by + simp only [rpcGrafts, graftRpc, controlRpc, List.mem_cons, List.mem_nil_iff, or_false] at h + obtain ⟨g, hg, hgt⟩ := h + subst hg + obtain ⟨tn⟩ := t + obtain ⟨t'n⟩ := t' + simpa using hgt.symm + +private theorem rpcGrafts_pruneRpc (t t' : TopicId) (b : Millis) : + ¬ rpcGrafts (pruneRpc t b) t' := by + simp [rpcGrafts, pruneRpc, controlRpc] + +private theorem rpcGrafts_ihaveRpc (t t' : TopicId) (ids : List MessageId) : + ¬ rpcGrafts (ihaveRpc t ids) t' := by + simp [rpcGrafts, ihaveRpc, controlRpc] + +private theorem freshState_backoff (p : Params) (s : State) (now : Millis) : + (freshState p s now).backoff = Cache.Backoff.clear s.backoff now := by + simp [freshState] + +private theorem planTopic_added_backoff (p : Params) (s : State) (now : Millis) + (order : List Peer) (topic : TopicId) {q : Peer} + (h : q ∈ (planTopic p s now order topic).added) : + Cache.Backoff.active s.backoff q topic now = false := by + simp only [planTopic] at h + have hc : q ∈ order.filter (fun q => + !memB q (meshOf s topic) && !(Cache.Backoff.active s.backoff q topic now) && + (peerOf s q).isSome && decide (0 ≤ scoreOf p s q)) := List.mem_of_mem_take h + cases hb : Cache.Backoff.active s.backoff q topic now with + | false => rfl + | true => + exfalso + simp only [List.mem_filter] at hc + rw [hb] at hc + simp at hc + +/-- The heartbeat never grafts a peer whose backoff is active. -/ +theorem tickNoBackoffGraft (p : Params) (s s' : State) (acts : List Action) + (now : Millis) (order : List Peer) + (hdue : heartbeatDue p s now) + (hstep : step p s (.tick now order) = .ok (s', acts)) : + ∀ to rpc topic, Action.sendRpc to rpc ∈ acts → rpcGrafts rpc topic → + Cache.Backoff.active s.backoff to topic now = false := by + simp only [step, ite_eq_left hdue, Except.ok.injEq] at hstep + obtain ⟨rfl, rfl⟩ := hstep + intro to rpc topic hmem hgraft + simp only [heartbeatActions, List.mem_append] at hmem + rcases hmem with hmem | hmem + · rcases hmem with hmem | hmem + · simp only [planActionsOf, List.mem_flatMap] at hmem + obtain ⟨pl, hpl, hmem⟩ := hmem + simp only [planActions, List.mem_append, List.mem_map] at hmem + rcases hmem with ⟨q, hq, hqeq⟩ | ⟨q, hq, hqeq⟩ + · cases hqeq + exact absurd hgraft (rpcGrafts_pruneRpc _ _ _) + · cases hqeq + have htopic : topic = pl.topic := rpcGrafts_graftRpc pl.topic topic hgraft + subst htopic + have hpl' : pl ∈ (freshState p s now).mesh.map + (fun e => planTopic p (freshState p s now) now order e.1) := by + simpa [heartbeatPlans, planList] using hpl + simp only [List.mem_map] at hpl' + obtain ⟨e, -, rfl⟩ := hpl' + have hb := planTopic_added_backoff p (freshState p s now) now order e.1 hq + change Cache.Backoff.active s.backoff to e.1 now = false + cases hcase : Cache.Backoff.active s.backoff to e.1 now with + | false => rfl + | true => + exfalso + have hclear := Cache.clear_keeps_active s.backoff to e.1 now hcase + rw [← freshState_backoff p s now] at hclear + rw [hb] at hclear + simp at hclear + · simp only [negativeActions, List.mem_flatMap, List.mem_map] at hmem + obtain ⟨pl, -, q, -, hqeq⟩ := hmem + cases hqeq + exact absurd hgraft (rpcGrafts_pruneRpc _ _ _) + · simp only [gossipActions, List.mem_flatMap] at hmem + obtain ⟨t, -, ht⟩ := hmem + split at ht + · simp at ht + · simp only [List.mem_map] at ht + obtain ⟨q, -, hqeq⟩ := ht + cases hqeq + exact absurd hgraft (rpcGrafts_ihaveRpc _ _ _) + +end Libp2p.Protocols.Gossipsub.Core diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Rpc.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Rpc.lean new file mode 100644 index 00000000..f2294dfb --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Rpc.lean @@ -0,0 +1,947 @@ +import Proto +import Bytes +import Streams +import Libp2p.Protocols.Constants +import Libp2p.Protocols.Gossipsub.Types + +/-! +# The gossipsub RPC + +The wire messages of gossipsub v1.1, their `ProtoMessage` instances, and +the length-delimited frame step. + +The field numbers come from the vendored specs: `vendor/specs/pubsub/README.md` +for `RPC`, `SubOpts`, and `Message`, `gossipsub-v1.0.md` for the control +envelope and its four messages, and `gossipsub-v1.1.md` for the prune +peers and the backoff. + +Proto has no nested-message value. A nested message goes on the wire as a +`.bytes` entry that holds its encoding, and the decoder decodes that +encoding as the submessage. + +The table is strict on singular fields: a present field with the wrong +wire type, a present string with invalid UTF-8, and a bool varint other +than 0 or 1 are `Pb.Error.badField`. The table is permissive on repeated +fields: an entry of another wire type is skipped. Unknown fields stay in +the wire view and drop out of the value on decode. + +The validity premise of every table law is recursive. A child whose own +entries are invalid has an empty encoding, so a parent entry that holds +that empty encoding is valid. The parent law therefore states the +validity of its children as well. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub + +/-! ## Shared readers + +These readers are the house pattern of `Identify`, plus a strict bool +reader and an optional varint reader. Each one gates on `Pb.hasField`, +so a present field of another wire type is `badField` rather than a +miss. -/ + +/-- A present singular payload must be length-delimited. A miss means +absent. -/ +private def bytesField? (num : Nat) (fs : Pb.Fields) : Except Pb.Error (Option (List Nat)) := + if Pb.hasField num fs then + match Pb.getBytes? num fs with + | some bs => .ok (some bs) + | none => .error .badField + else .ok none + +/-- A present singular string must be length-delimited and valid UTF-8. +A miss means absent. -/ +private def strField? (num : Nat) (fs : Pb.Fields) : Except Pb.Error (Option String) := + if Pb.hasField num fs then + match (Pb.getBytes? num fs).bind (fun bs => Bytes.toString? (Bytes.ofNatList bs)) with + | some s => .ok (some s) + | none => .error .badField + else .ok none + +/-- A strict bool. A miss and varint 0 are `false`, varint 1 is `true`, +and every other present value is `badField`. -/ +private def boolField? (num : Nat) (fs : Pb.Fields) : Except Pb.Error Bool := + if Pb.hasField num fs then + match Pb.getVarint? num fs with + | some 0 => .ok false + | some 1 => .ok true + | _ => .error .badField + else .ok false + +/-- An optional varint. A miss is `none`, and a present value of another +wire type is `badField`. -/ +private def varintField? (num : Nat) (fs : Pb.Fields) : Except Pb.Error (Option Nat) := + if Pb.hasField num fs then + match Pb.getVarint? num fs with + | some n => .ok (some n) + | none => .error .badField + else .ok none + +/-- A required string. A miss is `badField`. -/ +private def requiredStr? (num : Nat) (fs : Pb.Fields) : Except Pb.Error String := do + match ← strField? num fs with + | some s => .ok s + | none => .error .badField + +/-- A singular nested field. A present field of another wire type is +`badField`, and a malformed nested payload keeps its wire error. -/ +private def subField? {α : Type} (num : Nat) (fs : Pb.Fields) + (ofFields? : Pb.Fields → Except Pb.Error α) : + Except Pb.Error (Option α) := + if Pb.hasField num fs then + match Pb.getSub? num fs with + | .error e => .error e + | .ok none => .error .badField + | .ok (some nested) => do + let a ← ofFields? nested + .ok (some a) + else .ok none + +/-- Decode a list of encoded children in wire order. A child that fails +keeps its wire error. -/ +private def decodeAll? {α : Type} [Pb.ProtoMessage α] : + List (List Nat) → Except Pb.Error (List α) + | [] => .ok [] + | bs :: rest => do + let p ← Pb.decodeMessage? (α := α) bs + let as ← decodeAll? rest + .ok (p.1 :: as) + +/-- The length-delimited payloads of one field number, in wire order. An +entry of another wire type is skipped. -/ +private def allBytesOf (num : Nat) : List Pb.Entry → List (List Nat) + | [] => [] + | (n, v) :: es => + (if n = num then + match v with + | .bytes bs => [bs] + | _ => [] + else []) ++ allBytesOf num es + +/-- The repeated nested reader. It takes every length-delimited payload +of the field number, in wire order, and decodes each one. -/ +private def subAll? {α : Type} [Pb.ProtoMessage α] (num : Nat) (fs : Pb.Fields) : + Except Pb.Error (List α) := + decodeAll? (allBytesOf num fs.entries) + +/-- The wire view of a value, checked. Fail closed: a list whose entries +do not fit the wire model gives the empty field table. -/ +private def fieldsOf? (es : List Pb.Entry) : Pb.Fields := + if h : ∀ e ∈ es, e.Valid then ⟨es, h⟩ else Pb.Fields.empty + +/-! ## List lemmas -/ + +/-- A miss lookup through a segment that maps payloads to another field +number. -/ +private theorem lookupMissMap {α : Type} (k n : Nat) (h : k ≠ n) (g : α → Pb.Value) + (l : List α) : + List.lookup k (l.map fun x => (n, g x)).reverse = none := by + induction l with + | nil => rfl + | cons x l ih => + have hb : (k == n) = false := by simpa using h + simp [List.lookup_append, ih, List.lookup, hb] + +/-- The payload reader splits over an append. -/ +private theorem allBytesOfAppend (num : Nat) (a b : List Pb.Entry) : + allBytesOf num (a ++ b) = allBytesOf num a ++ allBytesOf num b := by + induction a with + | nil => simp [allBytesOf] + | cons e a ih => + obtain ⟨n, v⟩ := e + simp [allBytesOf, ih, List.append_assoc] + +/-- A segment of payload entries of one field number keeps exactly those +payloads, in order. -/ +private theorem allBytesOfMapBytes {α : Type} (num k : Nat) (g : α → List Nat) + (l : List α) : + allBytesOf num (l.map fun x => (k, Pb.Value.bytes (g x))) = + if k = num then l.map g else [] := by + induction l with + | nil => simp [allBytesOf] + | cons x l ih => + simp only [List.map_cons, allBytesOf, ih] + split <;> simp_all + +/-- A segment of entries of another field number is empty. -/ +private theorem allBytesOfMapOther {α : Type} (num k : Nat) (h : k ≠ num) (g : α → Pb.Value) + (l : List α) : + allBytesOf num (l.map fun x => (k, g x)) = [] := by + induction l with + | nil => simp [allBytesOf] + | cons x l ih => simp [allBytesOf, h, ih] + +/-- One entry of another field number is empty. -/ +private theorem allBytesOfSingleOther {num k : Nat} (h : k ≠ num) (v : Pb.Value) : + allBytesOf num [(k, v)] = [] := by + simp [allBytesOf, h] + +/-- The child round trips compose over a list of encoded children. -/ +private theorem decodeAll?Encode {α : Type} [Pb.ProtoMessage α] (l : List α) + (h : ∀ a ∈ l, Pb.decodeMessage? (α := α) (Pb.encodeMessage a) = .ok (a, [])) : + decodeAll? (l.map (fun a => Pb.encodeMessage a)) = .ok l := by + induction l with + | nil => rfl + | cons a l ih => + have ha := h a (by simp) + have hl : ∀ b ∈ l, Pb.decodeMessage? (α := α) (Pb.encodeMessage b) = .ok (b, []) := + fun b hb => h b (by simp [hb]) + simp [decodeAll?, ha, ih hl, Bind.bind, Except.bind] + +/-- A `MessageId` is its payload, so a list of ids survives the wrapper. -/ +private theorem mapMessageIdMk (l : List MessageId) : + (l.map (fun m => m.bytes)).map (fun bs => (⟨bs⟩ : MessageId)) = l := by + induction l with + | nil => rfl + | cons m l ih => cases m; simp [ih] + +/-- The same reading with the two maps composed. -/ +private theorem mapMessageIdMkComp (l : List MessageId) : + l.map (fun m => (⟨m.bytes⟩ : MessageId)) = l := by + induction l with + | nil => rfl + | cons m l ih => cases m; simp [ih] + +/-- The repeated payload reader for message ids. -/ +private def messageIdsOf (num : Nat) (fs : Pb.Fields) : List MessageId := + (allBytesOf num fs.entries).map (fun bs => ⟨bs⟩) + +/-! ## The `Message` codec + +`Message` is the shared type of `Gossipsub.Types`. Its codec lives here, +in the parent namespace, so the type stays free of codec code. -/ + +namespace Message + +/-- The wire view: one entry per present field, in the ascending order +1, 2, 3, 4, 5, 6. The topic is required and always present. -/ +def entries (m : Message) : List Pb.Entry := + m.«from».toList.map (fun b => (1, Pb.Value.bytes b)) + ++ m.data.toList.map (fun b => (2, Pb.Value.bytes b)) + ++ m.seqno.toList.map (fun b => (3, Pb.Value.bytes b)) + ++ [(4, Pb.Value.bytes (Bytes.toNatList (Bytes.ofString m.topic.name)))] + ++ m.signature.toList.map (fun b => (5, Pb.Value.bytes b)) + ++ m.key.toList.map (fun b => (6, Pb.Value.bytes b)) + +/-- The wire validity of the entries. -/ +def entriesValid (m : Message) : Prop := ∀ e ∈ m.entries, e.Valid + +instance (m : Message) : Decidable (entriesValid m) := by + unfold entriesValid + infer_instance + +/-- The validity of the message. It is the validity of its entries. -/ +abbrev Valid (m : Message) : Prop := m.entriesValid + +/-- Fail closed. A message whose entries do not fit the wire model emits +no entry; every theorem about the encode takes the validity as a +premise. -/ +def toFields (m : Message) : Pb.Fields := + if h : m.entriesValid then ⟨m.entries, h⟩ else Pb.Fields.empty + +/-- The field table: strict singular fields, permissive repeated fields. +The required topic reads first, so a missing topic fails before the +other fields run. -/ +def ofFields? (fs : Pb.Fields) : Except Pb.Error Message := do + let topic ← requiredStr? 4 fs + let frm ← bytesField? 1 fs + let data ← bytesField? 2 fs + let seqno ← bytesField? 3 fs + let signature ← bytesField? 5 fs + let key ← bytesField? 6 fs + .ok { «from» := frm, data, seqno, topic := ⟨topic⟩, signature, key } + +instance : Pb.ProtoMessage Message where + toFields := toFields + ofFields? := ofFields? + +/-- The table law: a valid message round-trips through its own table. -/ +theorem ofFieldsToFields (m : Message) (h : Valid m) : + Pb.ProtoMessage.ofFields? (α := Message) (Pb.ProtoMessage.toFields m) = .ok m := by + change ofFields? (toFields m) = .ok m + have he : m.entriesValid := h + rw [toFields, dite_eq_left he] + obtain ⟨frm, data, seqno, topic, signature, key⟩ := m + obtain ⟨name⟩ := topic + cases frm <;> cases data <;> cases seqno <;> cases signature <;> cases key <;> + simp [ofFields?, requiredStr?, bytesField?, strField?, entries, Pb.hasField, Pb.getBytes?, + Pb.lookupLast, List.lookup_cons, Bind.bind, Except.bind, Bytes.ofNatListToNatList, + Bytes.toString?OfString] + +/-- An encode decodes back to the message. -/ +theorem decodeEncode (m : Message) (h : Valid m) : + Pb.decodeMessage? (Pb.encodeMessage m) = .ok (m, []) := by + simp only [Pb.decodeMessage?, Pb.encodeMessage, Pb.decodeEncode, ofFieldsToFields m h, + Bind.bind, Except.bind] + +/-- A `Message` without field 4 fails its table. -/ +theorem ofFields?MissingTopic (fs : Pb.Fields) (h : ¬ Pb.hasField 4 fs) : + ofFields? fs = .error .badField := by + have hb : Pb.hasField 4 fs = false := by + cases hm : Pb.hasField 4 fs <;> simp_all + simp [ofFields?, requiredStr?, strField?, hb, Bind.bind, Except.bind] + +end Message + +/-! ## The control messages and the RPC envelope -/ + +namespace Rpc + +/-- One subscription option. Field 1 is the bool, field 2 the topic. -/ +structure SubOpts where + /-- True subscribes, false unsubscribes. -/ + subscribe : Bool -- field 1, bool + /-- The topic name. -/ + topicid : String -- field 2, string + deriving DecidableEq, BEq, Repr + +namespace SubOpts + +/-- The wire view: field 1 as a varint, field 2 as the topic bytes. -/ +def entries (s : SubOpts) : List Pb.Entry := + [(1, .varint (if s.subscribe then 1 else 0)), + (2, .bytes (Bytes.toNatList (Bytes.ofString s.topicid)))] + +def entriesValid (s : SubOpts) : Prop := ∀ e ∈ s.entries, e.Valid + +instance (s : SubOpts) : Decidable (entriesValid s) := by + unfold entriesValid + infer_instance + +/-- The validity of the value. -/ +abbrev Valid (s : SubOpts) : Prop := s.entriesValid + +def toFields (s : SubOpts) : Pb.Fields := + fieldsOf? s.entries + +/-- The field table. A miss reads as false or the empty string. -/ +def ofFields? (fs : Pb.Fields) : Except Pb.Error SubOpts := do + let subscribe ← boolField? 1 fs + let topicid ← strField? 2 fs + .ok { subscribe, topicid := topicid.getD "" } + +instance : Pb.ProtoMessage SubOpts where + toFields := toFields + ofFields? := ofFields? + +/-- The table law. -/ +theorem ofFieldsToFields (s : SubOpts) (h : Valid s) : + Pb.ProtoMessage.ofFields? (α := SubOpts) (Pb.ProtoMessage.toFields s) = .ok s := by + change ofFields? (toFields s) = .ok s + have he : ∀ e ∈ s.entries, e.Valid := h + rw [toFields, fieldsOf?, dite_eq_left he] + obtain ⟨sub, tid⟩ := s + cases sub <;> simp [ofFields?, boolField?, strField?, entries, Pb.hasField, Pb.getBytes?, + Pb.getVarint?, Pb.lookupLast, List.lookup_cons, Bind.bind, Except.bind, + Bytes.ofNatListToNatList, Bytes.toString?OfString] + +/-- An encode decodes back to the value. -/ +theorem decodeEncode (s : SubOpts) (h : Valid s) : + Pb.decodeMessage? (Pb.encodeMessage s) = .ok (s, []) := by + simp only [Pb.decodeMessage?, Pb.encodeMessage, Pb.decodeEncode, ofFieldsToFields s h, + Bind.bind, Except.bind] + +end SubOpts + +/-- A peer record of the v1.1 peer exchange. -/ +structure PeerInfo where + /-- The peer id bytes. -/ + peerId : Option (List Nat) -- field 1, bytes + /-- The signed peer record bytes. -/ + signedPeerRecord : Option (List Nat) -- field 2, bytes + deriving DecidableEq, BEq, Repr + +namespace PeerInfo + +def entries (p : PeerInfo) : List Pb.Entry := + p.peerId.toList.map (fun b => (1, Pb.Value.bytes b)) + ++ p.signedPeerRecord.toList.map (fun b => (2, Pb.Value.bytes b)) + +def entriesValid (p : PeerInfo) : Prop := ∀ e ∈ p.entries, e.Valid + +instance (p : PeerInfo) : Decidable (entriesValid p) := by + unfold entriesValid + infer_instance + +/-- The validity of the value. -/ +abbrev Valid (p : PeerInfo) : Prop := p.entriesValid + +def toFields (p : PeerInfo) : Pb.Fields := + fieldsOf? p.entries + +/-- The field table. A miss reads as an absent payload. -/ +def ofFields? (fs : Pb.Fields) : Except Pb.Error PeerInfo := do + let peerId ← bytesField? 1 fs + let signedPeerRecord ← bytesField? 2 fs + .ok { peerId, signedPeerRecord } + +instance : Pb.ProtoMessage PeerInfo where + toFields := toFields + ofFields? := ofFields? + +/-- The table law. -/ +theorem ofFieldsToFields (p : PeerInfo) (h : Valid p) : + Pb.ProtoMessage.ofFields? (α := PeerInfo) (Pb.ProtoMessage.toFields p) = .ok p := by + change ofFields? (toFields p) = .ok p + have he : ∀ e ∈ p.entries, e.Valid := h + rw [toFields, fieldsOf?, dite_eq_left he] + obtain ⟨pid, spr⟩ := p + cases pid <;> cases spr <;> + simp [ofFields?, bytesField?, entries, Pb.hasField, Pb.getBytes?, Pb.lookupLast, + List.lookup_cons, Bind.bind, Except.bind] + +/-- An encode decodes back to the value. -/ +theorem decodeEncode (p : PeerInfo) (h : Valid p) : + Pb.decodeMessage? (Pb.encodeMessage p) = .ok (p, []) := by + simp only [Pb.decodeMessage?, Pb.encodeMessage, Pb.decodeEncode, ofFieldsToFields p h, + Bind.bind, Except.bind] + +end PeerInfo + +/-- An IHAVE control message. -/ +structure ControlIHave where + /-- The topic of the gossip. -/ + topicId : String -- field 1, string + /-- The ids of the messages on offer. -/ + messageIds : List MessageId -- field 2, repeated bytes + deriving DecidableEq, BEq, Repr + +namespace ControlIHave + +def entries (i : ControlIHave) : List Pb.Entry := + [(1, .bytes (Bytes.toNatList (Bytes.ofString i.topicId)))] + ++ i.messageIds.map (fun m => (2, Pb.Value.bytes m.bytes)) + +def entriesValid (i : ControlIHave) : Prop := ∀ e ∈ i.entries, e.Valid + +instance (i : ControlIHave) : Decidable (entriesValid i) := by + unfold entriesValid + infer_instance + +/-- The validity of the value. -/ +abbrev Valid (i : ControlIHave) : Prop := i.entriesValid + +def toFields (i : ControlIHave) : Pb.Fields := + fieldsOf? i.entries + +/-- The field table. -/ +def ofFields? (fs : Pb.Fields) : Except Pb.Error ControlIHave := do + let topicId ← strField? 1 fs + .ok { topicId := topicId.getD "", messageIds := messageIdsOf 2 fs } + +instance : Pb.ProtoMessage ControlIHave where + toFields := toFields + ofFields? := ofFields? + +/-- The table law. -/ +theorem ofFieldsToFields (i : ControlIHave) (h : Valid i) : + Pb.ProtoMessage.ofFields? (α := ControlIHave) (Pb.ProtoMessage.toFields i) = .ok i := by + change ofFields? (toFields i) = .ok i + have he : ∀ e ∈ i.entries, e.Valid := h + rw [toFields, fieldsOf?, dite_eq_left he] + obtain ⟨tid, ids⟩ := i + have hb : allBytesOf 2 ((1, Pb.Value.bytes (Bytes.toNatList (Bytes.ofString tid))) :: + ids.map (fun m => (2, Pb.Value.bytes m.bytes))) = ids.map (fun m => m.bytes) := by + simp [allBytesOf, allBytesOfMapBytes] + simp [ofFields?, strField?, entries, messageIdsOf, hb, mapMessageIdMkComp, Function.comp_def, + Pb.hasField, Pb.getBytes?, Pb.lookupLast, List.lookup_append, Bind.bind, Except.bind, + Bytes.ofNatListToNatList, Bytes.toString?OfString, lookupMissMap] + +/-- An encode decodes back to the value. -/ +theorem decodeEncode (i : ControlIHave) (h : Valid i) : + Pb.decodeMessage? (Pb.encodeMessage i) = .ok (i, []) := by + simp only [Pb.decodeMessage?, Pb.encodeMessage, Pb.decodeEncode, ofFieldsToFields i h, + Bind.bind, Except.bind] + +end ControlIHave + +/-- An IWANT control message. -/ +structure ControlIWant where + /-- The ids of the wanted messages. -/ + messageIds : List MessageId -- field 1, repeated bytes + deriving DecidableEq, BEq, Repr + +namespace ControlIWant + +def entries (i : ControlIWant) : List Pb.Entry := + i.messageIds.map (fun m => (1, Pb.Value.bytes m.bytes)) + +def entriesValid (i : ControlIWant) : Prop := ∀ e ∈ i.entries, e.Valid + +instance (i : ControlIWant) : Decidable (entriesValid i) := by + unfold entriesValid + infer_instance + +/-- The validity of the value. -/ +abbrev Valid (i : ControlIWant) : Prop := i.entriesValid + +def toFields (i : ControlIWant) : Pb.Fields := + fieldsOf? i.entries + +/-- The field table. -/ +def ofFields? (fs : Pb.Fields) : Except Pb.Error ControlIWant := + .ok { messageIds := messageIdsOf 1 fs } + +instance : Pb.ProtoMessage ControlIWant where + toFields := toFields + ofFields? := ofFields? + +/-- The table law. -/ +theorem ofFieldsToFields (i : ControlIWant) (h : Valid i) : + Pb.ProtoMessage.ofFields? (α := ControlIWant) (Pb.ProtoMessage.toFields i) = .ok i := by + change ofFields? (toFields i) = .ok i + have he : ∀ e ∈ i.entries, e.Valid := h + rw [toFields, fieldsOf?, dite_eq_left he] + obtain ⟨ids⟩ := i + simp [ofFields?, entries, messageIdsOf, allBytesOfMapBytes, mapMessageIdMkComp, Function.comp_def] + +/-- An encode decodes back to the value. -/ +theorem decodeEncode (i : ControlIWant) (h : Valid i) : + Pb.decodeMessage? (Pb.encodeMessage i) = .ok (i, []) := by + simp only [Pb.decodeMessage?, Pb.encodeMessage, Pb.decodeEncode, ofFieldsToFields i h, + Bind.bind, Except.bind] + +end ControlIWant + +/-- A GRAFT control message. -/ +structure ControlGraft where + /-- The topic to graft. -/ + topicId : String -- field 1, string + deriving DecidableEq, BEq, Repr + +namespace ControlGraft + +def entries (g : ControlGraft) : List Pb.Entry := + [(1, .bytes (Bytes.toNatList (Bytes.ofString g.topicId)))] + +def entriesValid (g : ControlGraft) : Prop := ∀ e ∈ g.entries, e.Valid + +instance (g : ControlGraft) : Decidable (entriesValid g) := by + unfold entriesValid + infer_instance + +/-- The validity of the value. -/ +abbrev Valid (g : ControlGraft) : Prop := g.entriesValid + +def toFields (g : ControlGraft) : Pb.Fields := + fieldsOf? g.entries + +/-- The field table. -/ +def ofFields? (fs : Pb.Fields) : Except Pb.Error ControlGraft := do + let topicId ← strField? 1 fs + .ok { topicId := topicId.getD "" } + +instance : Pb.ProtoMessage ControlGraft where + toFields := toFields + ofFields? := ofFields? + +/-- The table law. -/ +theorem ofFieldsToFields (g : ControlGraft) (h : Valid g) : + Pb.ProtoMessage.ofFields? (α := ControlGraft) (Pb.ProtoMessage.toFields g) = .ok g := by + change ofFields? (toFields g) = .ok g + have he : ∀ e ∈ g.entries, e.Valid := h + rw [toFields, fieldsOf?, dite_eq_left he] + obtain ⟨tid⟩ := g + simp [ofFields?, strField?, entries, Pb.hasField, Pb.getBytes?, Pb.lookupLast, + Bind.bind, Except.bind, Bytes.ofNatListToNatList, Bytes.toString?OfString] + +/-- An encode decodes back to the value. -/ +theorem decodeEncode (g : ControlGraft) (h : Valid g) : + Pb.decodeMessage? (Pb.encodeMessage g) = .ok (g, []) := by + simp only [Pb.decodeMessage?, Pb.encodeMessage, Pb.decodeEncode, ofFieldsToFields g h, + Bind.bind, Except.bind] + +end ControlGraft + +/-- A PRUNE control message of gossipsub v1.1. -/ +structure ControlPrune where + /-- The topic to prune. -/ + topicId : String -- field 1, string + /-- The peers of the peer exchange. -/ + peers : List PeerInfo -- field 2, repeated nested + /-- The backoff in seconds. -/ + backoff : Option Nat -- field 3, uint64 varint + deriving DecidableEq, BEq, Repr + +namespace ControlPrune + +def entries (p : ControlPrune) : List Pb.Entry := + [(1, .bytes (Bytes.toNatList (Bytes.ofString p.topicId)))] + ++ p.peers.map (fun q => (2, Pb.Value.bytes (Pb.encodeMessage q))) + ++ p.backoff.toList.map (fun n => (3, Pb.Value.varint n)) + +def entriesValid (p : ControlPrune) : Prop := ∀ e ∈ p.entries, e.Valid + +instance (p : ControlPrune) : Decidable (entriesValid p) := by + unfold entriesValid + infer_instance + +/-- The validity of the value: its entries, and every peer. -/ +abbrev Valid (p : ControlPrune) : Prop := + p.entriesValid ∧ ∀ q ∈ p.peers, q.Valid + +def toFields (p : ControlPrune) : Pb.Fields := + fieldsOf? p.entries + +/-- The field table. The peers decode as nested messages. -/ +def ofFields? (fs : Pb.Fields) : Except Pb.Error ControlPrune := do + let topicId ← strField? 1 fs + let peers ← subAll? (α := PeerInfo) 2 fs + let backoff ← varintField? 3 fs + .ok { topicId := topicId.getD "", peers, backoff } + +instance : Pb.ProtoMessage ControlPrune where + toFields := toFields + ofFields? := ofFields? + +/-- The table law. -/ +theorem ofFieldsToFields (p : ControlPrune) (h : Valid p) : + Pb.ProtoMessage.ofFields? (α := ControlPrune) (Pb.ProtoMessage.toFields p) = .ok p := by + change ofFields? (toFields p) = .ok p + obtain ⟨he, hp⟩ := h + have he' : ∀ e ∈ p.entries, e.Valid := he + rw [toFields, fieldsOf?, dite_eq_left he'] + obtain ⟨tid, peers, backoff⟩ := p + have hdec := decodeAll?Encode peers (fun q hq => PeerInfo.decodeEncode q (hp q hq)) + cases backoff with + | none => + have hb : allBytesOf 2 ((1, Pb.Value.bytes (Bytes.toNatList (Bytes.ofString tid))) :: + peers.map (fun q => (2, Pb.Value.bytes (Pb.encodeMessage q)))) = + peers.map (fun q => Pb.encodeMessage q) := by + simp [allBytesOf, allBytesOfMapBytes] + simp [ofFields?, strField?, varintField?, subAll?, hdec, hb, entries, Pb.hasField, + Pb.getBytes?, Pb.lookupLast, List.lookup_append, lookupMissMap, Bind.bind, + Except.bind, Bytes.ofNatListToNatList, Bytes.toString?OfString] + | some n => + have hb : allBytesOf 2 ((1, Pb.Value.bytes (Bytes.toNatList (Bytes.ofString tid))) :: + (peers.map (fun q => (2, Pb.Value.bytes (Pb.encodeMessage q))) ++ + [(3, Pb.Value.varint n)])) = + peers.map (fun q => Pb.encodeMessage q) := by + simp [allBytesOf, allBytesOfAppend, allBytesOfMapBytes] + simp [ofFields?, strField?, varintField?, subAll?, hdec, hb, entries, Pb.hasField, + Pb.getBytes?, Pb.getVarint?, Pb.lookupLast, List.lookup_append, List.lookup_cons, + List.reverse_append, lookupMissMap, Bind.bind, Except.bind, + Bytes.ofNatListToNatList, Bytes.toString?OfString] + +/-- An encode decodes back to the value. -/ +theorem decodeEncode (p : ControlPrune) (h : Valid p) : + Pb.decodeMessage? (Pb.encodeMessage p) = .ok (p, []) := by + simp only [Pb.decodeMessage?, Pb.encodeMessage, Pb.decodeEncode, ofFieldsToFields p h, + Bind.bind, Except.bind] + +end ControlPrune + +/-- The control envelope. Field 5 (`idontwant`, v1.2) is out of scope. -/ +structure ControlMessage where + /-- The IHAVE messages. -/ + ihave : List ControlIHave -- field 1, repeated nested + /-- The IWANT messages. -/ + iwant : List ControlIWant -- field 2, repeated nested + /-- The GRAFT messages. -/ + graft : List ControlGraft -- field 3, repeated nested + /-- The PRUNE messages. -/ + prune : List ControlPrune -- field 4, repeated nested + deriving DecidableEq, BEq, Repr + +namespace ControlMessage + +def entries (c : ControlMessage) : List Pb.Entry := + c.ihave.map (fun x => (1, Pb.Value.bytes (Pb.encodeMessage x))) + ++ c.iwant.map (fun x => (2, Pb.Value.bytes (Pb.encodeMessage x))) + ++ c.graft.map (fun x => (3, Pb.Value.bytes (Pb.encodeMessage x))) + ++ c.prune.map (fun x => (4, Pb.Value.bytes (Pb.encodeMessage x))) + +def entriesValid (c : ControlMessage) : Prop := ∀ e ∈ c.entries, e.Valid + +instance (c : ControlMessage) : Decidable (entriesValid c) := by + unfold entriesValid + infer_instance + +/-- The validity of the value: its entries and every child. -/ +abbrev Valid (c : ControlMessage) : Prop := + c.entriesValid ∧ + (∀ x ∈ c.ihave, x.Valid) ∧ + (∀ x ∈ c.iwant, x.Valid) ∧ + (∀ x ∈ c.graft, x.Valid) ∧ + (∀ x ∈ c.prune, x.Valid) + +def toFields (c : ControlMessage) : Pb.Fields := + fieldsOf? c.entries + +/-- The field table. Each of the four lists decodes as nested messages. -/ +def ofFields? (fs : Pb.Fields) : Except Pb.Error ControlMessage := do + let ihave ← subAll? (α := ControlIHave) 1 fs + let iwant ← subAll? (α := ControlIWant) 2 fs + let graft ← subAll? (α := ControlGraft) 3 fs + let prune ← subAll? (α := ControlPrune) 4 fs + .ok { ihave, iwant, graft, prune } + +instance : Pb.ProtoMessage ControlMessage where + toFields := toFields + ofFields? := ofFields? + +/-- The table law. -/ +theorem ofFieldsToFields (c : ControlMessage) (h : Valid c) : + Pb.ProtoMessage.ofFields? (α := ControlMessage) (Pb.ProtoMessage.toFields c) = .ok c := by + change ofFields? (toFields c) = .ok c + obtain ⟨he, hi, hw, hg, hr⟩ := h + have he' : ∀ e ∈ c.entries, e.Valid := he + rw [toFields, fieldsOf?, dite_eq_left he'] + obtain ⟨ih, iw, gr, pr⟩ := c + have h1 := decodeAll?Encode ih (fun x hx => ControlIHave.decodeEncode x (hi x hx)) + have h2 := decodeAll?Encode iw (fun x hx => ControlIWant.decodeEncode x (hw x hx)) + have h3 := decodeAll?Encode gr (fun x hx => ControlGraft.decodeEncode x (hg x hx)) + have h4 := decodeAll?Encode pr (fun x hx => ControlPrune.decodeEncode x (hr x hx)) + simp [ofFields?, subAll?, h1, h2, h3, h4, entries, allBytesOfAppend, + allBytesOfMapBytes, Bind.bind, Except.bind] + +/-- An encode decodes back to the value. -/ +theorem decodeEncode (c : ControlMessage) (h : Valid c) : + Pb.decodeMessage? (Pb.encodeMessage c) = .ok (c, []) := by + simp only [Pb.decodeMessage?, Pb.encodeMessage, Pb.decodeEncode, ofFieldsToFields c h, + Bind.bind, Except.bind] + +end ControlMessage + +/-- The control value of an optional control field. The `Option` shape +keeps the predicate decidable over a bounded value. -/ +def controlValid : Option ControlMessage → Prop + | none => True + | some c => c.Valid + +instance (o : Option ControlMessage) : Decidable (controlValid o) := by + cases o with + | none => exact isTrue trivial + | some c => + rw [show controlValid (some c) = c.Valid from rfl] + infer_instance + +/-- The RPC envelope. -/ +structure RPC where + /-- The subscriptions. -/ + subscriptions : List SubOpts -- field 1, repeated nested + /-- The published messages. -/ + publish : List Message -- field 2, repeated nested + /-- The control envelope. -/ + control : Option ControlMessage -- field 3, optional nested + deriving DecidableEq, BEq, Repr + +namespace RPC + +def entries (r : RPC) : List Pb.Entry := + r.subscriptions.map (fun s => (1, Pb.Value.bytes (Pb.encodeMessage s))) + ++ r.publish.map (fun m => (2, Pb.Value.bytes (Pb.encodeMessage m))) + ++ r.control.toList.map (fun c => (3, Pb.Value.bytes (Pb.encodeMessage c))) + +def entriesValid (r : RPC) : Prop := ∀ e ∈ r.entries, e.Valid + +instance (r : RPC) : Decidable (entriesValid r) := by + unfold entriesValid + infer_instance + +/-- The validity of the value: its entries, every child, and the control +value. -/ +abbrev Valid (r : RPC) : Prop := + r.entriesValid ∧ + (∀ s ∈ r.subscriptions, s.Valid) ∧ + (∀ m ∈ r.publish, m.Valid) ∧ + controlValid r.control + +instance (r : RPC) : Decidable (Valid r) := by + unfold Valid + cases r.control with + | none => + rw [show controlValid none = True from rfl] + infer_instance + | some c => + rw [show controlValid (some c) = c.Valid from rfl] + infer_instance + +def toFields (r : RPC) : Pb.Fields := + fieldsOf? r.entries + +/-- The field table. The subscriptions and the messages decode as nested +messages, and the control field as one nested message. -/ +def ofFields? (fs : Pb.Fields) : Except Pb.Error RPC := do + let subscriptions ← subAll? (α := SubOpts) 1 fs + let publish ← subAll? (α := Message) 2 fs + let control ← subField? 3 fs ControlMessage.ofFields? + .ok { subscriptions, publish, control } + +instance : Pb.ProtoMessage RPC where + toFields := toFields + ofFields? := ofFields? + +/-- The table law. -/ +theorem ofFieldsToFields (r : RPC) (h : Valid r) : + Pb.ProtoMessage.ofFields? (α := RPC) (Pb.ProtoMessage.toFields r) = .ok r := by + change ofFields? (toFields r) = .ok r + obtain ⟨he, hsub, hpub, hctl⟩ := h + have he' : ∀ e ∈ r.entries, e.Valid := he + rw [toFields, fieldsOf?, dite_eq_left he'] + obtain ⟨subs, pubs, ctl⟩ := r + have h1 := decodeAll?Encode subs (fun s hmem => SubOpts.decodeEncode s (hsub s hmem)) + have h2 := decodeAll?Encode pubs (fun m hmem => Message.decodeEncode m (hpub m hmem)) + cases ctl with + | none => + simp [ofFields?, subAll?, subField?, h1, h2, entries, allBytesOfAppend, + allBytesOfMapBytes, Pb.hasField, Bind.bind, Except.bind] + | some c => + have hcv : c.Valid := hctl + have h4 : ControlMessage.ofFields? c.toFields = .ok c := + ControlMessage.ofFieldsToFields c hcv + have he₂ : ∀ e : Pb.Entry, + e ∈ (List.map (fun s => (1, Pb.Value.bytes (Pb.encodeMessage s))) subs ++ + (List.map (fun m => (2, Pb.Value.bytes (Pb.encodeMessage m))) pubs ++ + [(3, Pb.Value.bytes (Pb.encodeMessage c))])) → e.Valid := by + intro e hmem + exact he' e (by simpa [entries] using hmem) + have hsub3 : Pb.getSub? 3 ⟨List.map (fun s => (1, Pb.Value.bytes (Pb.encodeMessage s))) subs ++ + (List.map (fun m => (2, Pb.Value.bytes (Pb.encodeMessage m))) pubs ++ + [(3, Pb.Value.bytes (Pb.encodeMessage c))]), he₂⟩ = + .ok (some (ControlMessage.toFields c)) := by + simp [Pb.getSub?, Pb.getBytes?, Pb.lookupLast, Pb.encodeMessage, Pb.decodeEncode, + Bind.bind, Except.bind] + rfl + simp [ofFields?, subAll?, subField?, h1, h2, h4, hsub3, entries, allBytesOfAppend, + allBytesOfMapBytes, allBytesOfSingleOther, Pb.hasField, Bind.bind, Except.bind] + +/-- An encode decodes back to the value. -/ +theorem decodeEncode (r : RPC) (h : Valid r) : + Pb.decodeMessage? (Pb.encodeMessage r) = .ok (r, []) := by + simp only [Pb.decodeMessage?, Pb.encodeMessage, Pb.decodeEncode, ofFieldsToFields r h, + Bind.bind, Except.bind] + +/-- Equal encodings imply equal messages. Without the premise the +statement is false: two different invalid messages both encode to the +empty list. -/ +theorem encodeInjective (a b : RPC) (ha : Valid a) (hb : Valid b) + (h : Pb.encodeMessage a = Pb.encodeMessage b) : a = b := by + have hf : toFields a = toFields b := Pb.encodeInjective h + have h3 : Pb.ProtoMessage.ofFields? (α := RPC) (Pb.ProtoMessage.toFields a) = + Pb.ProtoMessage.ofFields? (α := RPC) (Pb.ProtoMessage.toFields b) := + congrArg (fun fs => Pb.ProtoMessage.ofFields? (α := RPC) fs) hf + rw [ofFieldsToFields a ha, ofFieldsToFields b hb] at h3 + exact Except.ok.inj h3 + +end RPC + +/-- The repeated fields keep their order through the round trip. -/ +theorem roundTripLists (r : RPC) (h : RPC.Valid r) : + ∃ r' : RPC, Pb.decodeMessage? (Pb.encodeMessage r) = .ok (r', []) ∧ + r'.subscriptions = r.subscriptions ∧ r'.publish = r.publish := + ⟨r, RPC.decodeEncode r h, rfl, rfl⟩ + +/-- The four control lists keep their order through the round trip. -/ +theorem roundTripControlLists (c : ControlMessage) (h : ControlMessage.Valid c) : + ∃ c' : ControlMessage, Pb.decodeMessage? (Pb.encodeMessage c) = .ok (c', []) ∧ + c'.ihave = c.ihave ∧ c'.iwant = c.iwant ∧ c'.graft = c.graft ∧ c'.prune = c.prune := + ⟨c, ControlMessage.decodeEncode c h, rfl, rfl, rfl, rfl⟩ + +/-- The message ids of an IHAVE keep their order through the round +trip. -/ +theorem roundTripMessageIdsIHave (i : ControlIHave) (h : ControlIHave.Valid i) : + ∃ i' : ControlIHave, Pb.decodeMessage? (Pb.encodeMessage i) = .ok (i', []) ∧ + i'.messageIds = i.messageIds := + ⟨i, ControlIHave.decodeEncode i h, rfl⟩ + +/-- The message ids of an IWANT keep their order through the round +trip. -/ +theorem roundTripMessageIdsIWant (i : ControlIWant) (h : ControlIWant.Valid i) : + ∃ i' : ControlIWant, Pb.decodeMessage? (Pb.encodeMessage i) = .ok (i', []) ∧ + i'.messageIds = i.messageIds := + ⟨i, ControlIWant.decodeEncode i h, rfl⟩ + +/-- The peers of a PRUNE keep their order through the round trip. -/ +theorem roundTripPeers (p : ControlPrune) (h : ControlPrune.Valid p) : + ∃ p' : ControlPrune, Pb.decodeMessage? (Pb.encodeMessage p) = .ok (p', []) ∧ + p'.peers = p.peers := + ⟨p, ControlPrune.decodeEncode p h, rfl⟩ + + +/-! ## The length-delimited frame step + +One RPC frame is a uvarint length and the message bytes, under +`Constants.rpcCap`. The decoder checks the cap before it takes the body, +as `Streams.Framing.Frame.decode?` does. A zero-length frame is a legal +empty RPC; unlike `Identify`, this protocol has no empty-frame rule. -/ + +/-- One length-delimited RPC frame under the cap. `none` when the +payload is over the cap. -/ +def encodeFrame? (r : RPC) : Option (List Nat) := + let payload := Pb.encodeMessage r + if payload.length ≤ Constants.rpcCap then + some (Streams.Framing.encodeLength .uvarint payload.length ++ payload) + else none + +/-- One frame from the front of the buffer. + +* `.ok (some (payload, rest))`: one whole frame, and the bytes after it. +* `.ok none`: the buffer ends inside the prefix or inside the payload. +* `.error (.framing .malformed)`: a bad prefix, or a length over the cap. + +The cap check runs before the payload is taken. -/ +def decodeFrame? (bytes : List Nat) : + Except Pb.Error (Option (List Nat × List Nat)) := + match Streams.Framing.Frame.decode? .uvarint Constants.rpcCap bytes with + | .error .malformed => .error (.framing .malformed) + | .error .truncated => .ok none + | .ok (frame, rest) => .ok (some (frame.payload, rest)) + +/-- The frame step reads a whole frame back from its encoding, with any +rest after it. -/ +theorem decodeFrame?Encode (f : Streams.Framing.Frame .uvarint Constants.rpcCap) + (rest : List Nat) : + decodeFrame? (Streams.Framing.Frame.encode f ++ rest) = + .ok (some (f.payload, rest)) := by + unfold decodeFrame? + rw [Streams.Framing.Frame.decode?EncodeAppend f rest] + +/-- A prefix with a length over the cap is malformed before the body. -/ +theorem decodeFrame?OverCap (len : Nat) (rest : List Nat) + (hlen : len ≤ Streams.Framing.Prefix.maxLen .uvarint) + (hcap : Constants.rpcCap < len) : + decodeFrame? (Streams.Framing.encodeLength .uvarint len ++ rest) = + .error (.framing .malformed) := by + unfold decodeFrame? + rw [Streams.Framing.Frame.decode?OverCap (p := .uvarint) + (cap := Constants.rpcCap) + (l := Streams.Framing.encodeLength .uvarint len ++ rest) + (rest := rest) (len := len) + (Streams.Framing.decodeLength?EncodeLength .uvarint len rest hlen) hcap] + +/-- A prefix cut short needs more bytes. -/ +theorem decodeFrame?Truncated (bytes : List Nat) + (h : Streams.Framing.Frame.decode? .uvarint Constants.rpcCap bytes = + .error .truncated) : + decodeFrame? bytes = .ok none := by + unfold decodeFrame? + rw [h] + +/-- The rpc cap fits the uvarint prefix. -/ +theorem rpcCapLeMaxLen : + Constants.rpcCap ≤ Streams.Framing.Prefix.maxLen .uvarint := by + simp only [Constants.rpcCap, Streams.Framing.Prefix.maxLen, + Streams.Mock.Varint.maxBytes] + decide + +/-- The frame steps are inverse for a valid RPC under the cap. -/ +theorem decodeFrame?EncodeRpc (r : RPC) (rest : List Nat) + (_h : ∀ e ∈ r.entries, e.Valid) + (hcap : (Pb.encodeMessage r).length ≤ Constants.rpcCap) : + decodeFrame? (Streams.Framing.encodeLength .uvarint (Pb.encodeMessage r).length + ++ Pb.encodeMessage r ++ rest) = .ok (some (Pb.encodeMessage r, rest)) := by + have hbytes : ∀ b ∈ Pb.encodeMessage r, b < 256 := + Pb.encodeFieldsBytes (Pb.ProtoMessage.toFields r) + have hmax : (Pb.encodeMessage r).length ≤ + Streams.Framing.Prefix.maxLen .uvarint := + Nat.le_trans hcap rpcCapLeMaxLen + let f : Streams.Framing.Frame .uvarint Constants.rpcCap := + ⟨Pb.encodeMessage r, hbytes, ⟨hcap, hmax⟩⟩ + simpa [Streams.Framing.Frame.encode] using decodeFrame?Encode f rest + +end Rpc + +end Libp2p.Protocols.Gossipsub diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Score.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Score.lean new file mode 100644 index 00000000..4ec108c5 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Score.lean @@ -0,0 +1,1027 @@ +import Libp2p.Protocols.Gossipsub.Types + +/-! +# Gossipsub peer scoring + +The peer score function of gossipsub v1.1 over its counters, with decay. The function is pure. +The caller passes the time as `Millis`, and this module reads no clock. + +## The score of the spec + +The spec mixes four per-topic terms with three global terms +(`vendor/specs/pubsub/gossipsub/gossipsub-v1.1.md`, section "Peer Scoring"): + +`Score = TopicCap(sum over topics t * (w1*P1 + w2*P2 + w3*P3 + w3b*P3b + w4*P4)) + + w5*P5 + w6*P6 + w7*P7` + +`P1` is the time in mesh, `P2` the first message deliveries, `P3` the mesh message delivery rate, +`P3b` the mesh message delivery failures, `P4` the invalid message deliveries, `P5` the +application score, `P6` the IP colocation factor, and `P7` the behavior penalty. The topic cap +applies to the sum of the topic terms. A topic with no configured parameters contributes nothing. + +## Fixed point + +The spec computes with float64. This module computes with integers, in `scoreScale` units per 1.0 +(`Gossipsub/Types.lean`). One convention applies to every product: + +- Every weight is an `Int` in `1/scoreScale` units. A weight of 3 means 0.003. +- The `P1` to `P4`, `P6`, and `P7` values are `Nat` counts. +- A weight times a count is an exact `Int` product. +- The one rounded product is the topic contribution: + `(topicWeight * topicScore) tdiv scoreScale`. `Int.tdiv` truncates toward zero. This is the + single rounding site. +- The decay divides with `Nat` division, `counter * decay / scoreScale`, which floors. + +Lean 4.34 defines `/` on `Int` as Euclidean division. For a negative numerator, `/ scoreScale` +rounds down by one unit at the boundary, and `Int.tdiv scoreScale` truncates toward zero. This +module writes `Int.tdiv` to keep the truncation toward zero that the fixed-point convention +states. + +## Parameters and validity + +`Params.Valid` carries the weight signs and the decay bounds that the score theorems consume, +plus three constraints that no theorem consumes. The predicate adds `topicWeight >= 0` and +`timeInMeshQuantum > 0` to the weight and decay list, because a negative topic weight inverts +every term of its topic. It also carries `ipColocationFactorThreshold >= 1` and +`appSpecificWeight >= 0` from the spec's global table. The `TopicParams.Valid` docstring lists +the constraints and names the spec constraint that the predicate does not encode. The predicate +is a sufficient condition for the theorems, and it is not the complete parameter table of the +spec. + +## The behavior penalty threshold + +The vendored spec describes `P7` as the square of a counter with no threshold. The global +parameter table names only `BehaviourPenaltyWeight` and `BehaviourPenaltyDecay`. This module +carries `behaviourPenaltyThreshold`, as the go implementation does, and `P7` is the square of the +excess of the counter over the threshold. A threshold of 0 reproduces the spec's square of the +counter. + +## Application score + +`PeerState.appScore` is an integer count of application points. The spec leaves the unit of `P5` +open. An integer count keeps the product `appSpecificWeight * appScore` exact. + +## Decay + +`decayCounter` zeroes a counter that falls below `decayToZero` and keeps a counter that equals it. +The comparison is strict, as the spec's pseudocode writes it. + +## Time + +`elapsed` computes the mesh time lazily from `timeInMesh`, `graftTime`, and `now`. `Nat` +subtraction saturates at zero. The caller passes `now >= graftTime`; no theorem consumes that +precondition. + +## The Boolean validity mirror + +`TopicParams.validB` and `GlobalParams.validB` repeat the sign lists of the predicates as +`Bool` values. The test runner takes `Bool`, and `Params.Valid` has a `∀` over `TopicId`, so no +`Decidable` instance exists. The reflection theorems tie each `Bool` mirror to its predicate. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub.Score + +/-! ## Parameters -/ + +/-- The per-topic score parameters of gossipsub v1.1. The field order follows the topic +parameter table of the spec. `meshMessageDeliveriesWindow` and `meshMessageDeliveriesActivation` +feed the core's delivery accounting; `topicScore` reads only the activation. -/ +structure TopicParams where + /-- The weight of this topic in the total score. -/ + topicWeight : Int + /-- The weight of `P1`, the time in mesh. -/ + timeInMeshWeight : Int + /-- The mesh time that earns one `P1` point. -/ + timeInMeshQuantum : Millis + /-- The maximum of `P1`. -/ + timeInMeshCap : Nat + /-- The weight of `P2`, the first message deliveries. -/ + firstMessageDeliveriesWeight : Int + /-- The decay factor of the `P2` counter. -/ + firstMessageDeliveriesDecay : Nat + /-- The maximum of the `P2` counter. The cap applies at increment time. -/ + firstMessageDeliveriesCap : Nat + /-- The weight of `P3`, the mesh message delivery rate. -/ + meshMessageDeliveriesWeight : Int + /-- The decay factor of the `P3` counter. -/ + meshMessageDeliveriesDecay : Nat + /-- The delivery count below which `P3` penalizes the peer. -/ + meshMessageDeliveriesThreshold : Nat + /-- The maximum of the `P3` counter. The cap applies at increment time. -/ + meshMessageDeliveriesCap : Nat + /-- The mesh time a peer needs before `P3` applies. -/ + meshMessageDeliveriesActivation : Millis + /-- The window after a first delivery that counts as a near-first delivery. -/ + meshMessageDeliveriesWindow : Millis + /-- The weight of `P3b`, the mesh failure penalty. -/ + meshFailurePenaltyWeight : Int + /-- The decay factor of the `P3b` counter. -/ + meshFailurePenaltyDecay : Nat + /-- The weight of `P4`, the invalid message deliveries. -/ + invalidMessageDeliveriesWeight : Int + /-- The decay factor of the `P4` counter. -/ + invalidMessageDeliveriesDecay : Nat + +/-- The global score parameters of gossipsub v1.1. The field order follows the global parameter +table of the spec. `decayInterval` feeds the core's heartbeat; `score` does not read it. + +The vendored spec gives `P7` as the square of a counter with no threshold +(`gossipsub-v1.1.md`, "The Score Function"). The global parameter table names only +`BehaviourPenaltyWeight` and `BehaviourPenaltyDecay`. This module carries +`behaviourPenaltyThreshold`, as the go implementation does. -/ +structure GlobalParams where + /-- The weight of `P5`, the application score. -/ + appSpecificWeight : Int + /-- The weight of `P6`, the IP colocation factor. -/ + ipColocationFactorWeight : Int + /-- The colocation count at or below which `P6` is zero. -/ + ipColocationFactorThreshold : Nat + /-- The weight of `P7`, the behavior penalty. -/ + behaviourPenaltyWeight : Int + /-- The behavior penalty count at or below which `P7` is zero. -/ + behaviourPenaltyThreshold : Nat + /-- The decay factor of the behavior penalty counter. -/ + behaviourPenaltyDecay : Nat + /-- The decay period of the router's heartbeat. -/ + decayInterval : Millis + /-- The counter limit below which a decayed counter becomes zero. -/ + decayToZero : Nat + /-- The cap of the summed topic part. A cap of 0 or less means no cap. -/ + topicScoreCap : Int + +/-- The score parameters: the per-topic table and the global table. -/ +structure Params where + /-- The parameters of each configured topic; `none` means the topic is not configured. -/ + topics : TopicId → Option TopicParams + /-- The global parameters. -/ + global : GlobalParams + +/-! ## Validity -/ + +/-- The validity predicate of a topic's parameters. It carries the weight signs that the score +theorems consume, the decay bounds that the decay theorems consume, and one constraint that no +theorem consumes: + +- `topicWeight_nonneg`: a negative topic weight inverts every term of the topic. The invalid + delivery monotonicity theorem consumes it. +- `timeInMeshQuantum_pos`: the quantum divides the elapsed mesh time. No theorem consumes this + constraint, and it is part of the contract the core relies on. +- The four decay bounds keep each factor at or below `scoreScale`, the unit of a full decay. + Each decay theorem consumes the bound of its own counter. + +The spec's `MeshMessageDeliveriesCap >= MeshMessageDeliveriesThreshold` constraint is not +encoded. -/ +structure TopicParams.Valid (tp : TopicParams) : Prop where + /-- The topic weight is at or above 0. -/ + topicWeight_nonneg : 0 ≤ tp.topicWeight + /-- The `P1` weight is at or above 0. -/ + timeInMeshWeight_nonneg : 0 ≤ tp.timeInMeshWeight + /-- The `P2` weight is at or above 0. -/ + firstMessageDeliveriesWeight_nonneg : 0 ≤ tp.firstMessageDeliveriesWeight + /-- The `P3` weight is at or below 0. -/ + meshMessageDeliveriesWeight_nonpos : tp.meshMessageDeliveriesWeight ≤ 0 + /-- The `P3b` weight is at or below 0. -/ + meshFailurePenaltyWeight_nonpos : tp.meshFailurePenaltyWeight ≤ 0 + /-- The `P4` weight is at or below 0. -/ + invalidMessageDeliveriesWeight_nonpos : tp.invalidMessageDeliveriesWeight ≤ 0 + /-- The time quantum is at or above 1 millisecond. -/ + timeInMeshQuantum_pos : 0 < tp.timeInMeshQuantum + /-- The `P2` decay factor is at most a full decay. -/ + firstMessageDeliveriesDecay_le : tp.firstMessageDeliveriesDecay ≤ scoreScale + /-- The `P3` decay factor is at most a full decay. -/ + meshMessageDeliveriesDecay_le : tp.meshMessageDeliveriesDecay ≤ scoreScale + /-- The `P3b` decay factor is at most a full decay. -/ + meshFailurePenaltyDecay_le : tp.meshFailurePenaltyDecay ≤ scoreScale + /-- The `P4` decay factor is at most a full decay. -/ + invalidMessageDeliveriesDecay_le : tp.invalidMessageDeliveriesDecay ≤ scoreScale + +/-- The validity predicate of the global parameters. It carries the weight signs that the score +theorems consume, the colocation threshold of the spec, and the behavior decay bound. + +`appSpecificWeight_nonneg` and `ipColocationFactorThreshold_pos` are spec constraints, and no +theorem consumes them. -/ +structure GlobalParams.Valid (g : GlobalParams) : Prop where + /-- The `P5` weight is at or above 0. -/ + appSpecificWeight_nonneg : 0 ≤ g.appSpecificWeight + /-- The `P6` weight is at or below 0. -/ + ipColocationFactorWeight_nonpos : g.ipColocationFactorWeight ≤ 0 + /-- The colocation threshold is at least 1. -/ + ipColocationFactorThreshold_pos : 1 ≤ g.ipColocationFactorThreshold + /-- The `P7` weight is at or below 0. -/ + behaviourPenaltyWeight_nonpos : g.behaviourPenaltyWeight ≤ 0 + /-- The behavior penalty decay factor is at most a full decay. -/ + behaviourPenaltyDecay_le : g.behaviourPenaltyDecay ≤ scoreScale + +/-- The validity predicate of the parameters: every configured topic passes +`TopicParams.Valid`, and the global parameters pass `GlobalParams.Valid`. An unconfigured topic +passes vacuously. -/ +structure Params.Valid (p : Params) : Prop where + /-- Every configured topic is valid. -/ + topic : ∀ (id : TopicId) (tp : TopicParams), p.topics id = some tp → TopicParams.Valid tp + /-- The global parameters are valid. -/ + global : GlobalParams.Valid p.global + +/-- The `Bool` mirror of `TopicParams.Valid`. The test runner takes `Bool`, and a `Prop` +structure has no `Decidable` instance. `TopicParams.validB_iff` ties the mirror to the +predicate. -/ +def TopicParams.validB (tp : TopicParams) : Bool := + decide (0 ≤ tp.topicWeight) && + decide (0 ≤ tp.timeInMeshWeight) && + decide (0 ≤ tp.firstMessageDeliveriesWeight) && + decide (tp.meshMessageDeliveriesWeight ≤ 0) && + decide (tp.meshFailurePenaltyWeight ≤ 0) && + decide (tp.invalidMessageDeliveriesWeight ≤ 0) && + decide (0 < tp.timeInMeshQuantum) && + decide (tp.firstMessageDeliveriesDecay ≤ scoreScale) && + decide (tp.meshMessageDeliveriesDecay ≤ scoreScale) && + decide (tp.meshFailurePenaltyDecay ≤ scoreScale) && + decide (tp.invalidMessageDeliveriesDecay ≤ scoreScale) + +/-- The `Bool` mirror of `GlobalParams.Valid`. -/ +def GlobalParams.validB (g : GlobalParams) : Bool := + decide (0 ≤ g.appSpecificWeight) && + decide (g.ipColocationFactorWeight ≤ 0) && + decide (1 ≤ g.ipColocationFactorThreshold) && + decide (g.behaviourPenaltyWeight ≤ 0) && + decide (g.behaviourPenaltyDecay ≤ scoreScale) + +/-- The `Bool` mirror accepts exactly the valid topic parameters. -/ +theorem TopicParams.validB_iff (tp : TopicParams) : + tp.validB = true ↔ TopicParams.Valid tp := by + constructor + · intro h + simp [TopicParams.validB, Bool.and_eq_true, decide_eq_true_eq] at h + constructor <;> simp_all + · intro h + cases h + simp [TopicParams.validB, *] + +/-- The `Bool` mirror accepts exactly the valid global parameters. -/ +theorem GlobalParams.validB_iff (g : GlobalParams) : + g.validB = true ↔ GlobalParams.Valid g := by + constructor + · intro h + simp [GlobalParams.validB, Bool.and_eq_true, decide_eq_true_eq] at h + constructor <;> simp_all + · intro h + cases h + simp [GlobalParams.validB, *] + +/-! ## Peer state -/ + +/-- The counters of one peer in one topic. `timeInMesh` holds the accrued mesh time, and +`graftTime` holds the start of the current mesh period. `elapsed` sums the two when `inMesh` is +true. -/ +structure TopicCounters where + /-- The mesh time accrued in earlier mesh periods. -/ + timeInMesh : Millis + /-- The time of the last graft. -/ + graftTime : Millis + /-- Whether the peer is in the mesh of the topic. -/ + inMesh : Bool + /-- The first message deliveries, capped at increment time. -/ + firstMessageDeliveries : Nat + /-- The mesh message deliveries, capped at increment time. -/ + meshMessageDeliveries : Nat + /-- The mesh message delivery failures. -/ + meshFailurePenalty : Nat + /-- The invalid message deliveries. -/ + invalidMessageDeliveries : Nat + +/-- The all-zero counters of a peer that has no history in a topic. -/ +def TopicCounters.zero : TopicCounters where + timeInMesh := 0 + graftTime := 0 + inMesh := false + firstMessageDeliveries := 0 + meshMessageDeliveries := 0 + meshFailurePenalty := 0 + invalidMessageDeliveries := 0 + +/-- The score state of one peer: the per-topic counters and the three global inputs. -/ +structure PeerState where + /-- The counters of each topic the peer has a history in. -/ + topics : List (TopicId × TopicCounters) + /-- The application score `P5`, an integer count of application points. -/ + appScore : Int + /-- The IP colocation count `P6`. -/ + ipColocation : Nat + /-- The behavior penalty counter `P7`. -/ + behaviourPenalty : Nat + +/-- The state of a fresh peer: no topic history, no application score, no colocation, and no +behavior penalty. -/ +def PeerState.zero : PeerState where + topics := [] + appScore := 0 + ipColocation := 0 + behaviourPenalty := 0 + +/-! ## Scoring -/ + +/-- The mesh time of a peer in a topic. The peer accrues time from `graftTime` to `now` while +`inMesh` is true, and the stored `timeInMesh` holds the earlier periods. The `Nat` subtraction +saturates at zero. The caller passes `now >= graftTime`. -/ +def elapsed (now : Millis) (t : TopicCounters) : Millis := + t.timeInMesh + (if t.inMesh then now - t.graftTime else 0) + +/-- `P1`, the time in mesh: the elapsed time in quanta, capped. -/ +def p1 (tp : TopicParams) (now : Millis) (t : TopicCounters) : Nat := + min (elapsed now t / tp.timeInMeshQuantum) tp.timeInMeshCap + +/-- `P3`, the mesh message delivery rate: the square of the delivery deficit below the +threshold, when the activation window has passed. Both comparisons are strict, so equality with +either bound gives 0. -/ +def p3 (tp : TopicParams) (now : Millis) (t : TopicCounters) : Nat := + if tp.meshMessageDeliveriesActivation < elapsed now t ∧ + t.meshMessageDeliveries < tp.meshMessageDeliveriesThreshold then + (tp.meshMessageDeliveriesThreshold - t.meshMessageDeliveries) * + (tp.meshMessageDeliveriesThreshold - t.meshMessageDeliveries) + else 0 + +/-- `P4`, the invalid message deliveries: the square of the counter, uncapped. -/ +def p4 (t : TopicCounters) : Nat := + t.invalidMessageDeliveries * t.invalidMessageDeliveries + +/-- `P6`, the IP colocation factor: the square of the surplus over the threshold. -/ +def p6 (g : GlobalParams) (s : PeerState) : Nat := + if g.ipColocationFactorThreshold < s.ipColocation then + (s.ipColocation - g.ipColocationFactorThreshold) * + (s.ipColocation - g.ipColocationFactorThreshold) + else 0 + +/-- `P7`, the behavior penalty: the square of the excess over the threshold. -/ +def p7 (g : GlobalParams) (s : PeerState) : Nat := + if g.behaviourPenaltyThreshold < s.behaviourPenalty then + (s.behaviourPenalty - g.behaviourPenaltyThreshold) * + (s.behaviourPenalty - g.behaviourPenaltyThreshold) + else 0 + +/-- The per-topic score: the weighted sum of the five terms, in `1/scoreScale` weight units. +The `P2` and `P3b` caps apply at increment time, so this function reads the counters as they +stand. -/ +def topicScore (tp : TopicParams) (now : Millis) (t : TopicCounters) : Int := + tp.timeInMeshWeight * (p1 tp now t : Int) + + tp.firstMessageDeliveriesWeight * (t.firstMessageDeliveries : Int) + + tp.meshMessageDeliveriesWeight * (p3 tp now t : Int) + + tp.meshFailurePenaltyWeight * (t.meshFailurePenalty : Int) + + tp.invalidMessageDeliveriesWeight * (p4 t : Int) + +/-- The contribution of one topic entry. An unconfigured topic contributes 0. A configured +topic contributes `(topicWeight * topicScore) tdiv scoreScale`; this is the one rounding site of +the module. -/ +private def topicContribution (p : Params) (now : Millis) + (entry : TopicId × TopicCounters) : Int := + match p.topics entry.1 with + | some tp => (tp.topicWeight * topicScore tp now entry.2).tdiv scoreScale + | none => 0 + +/-- The sum of the topic contributions, before the cap. -/ +private def rawTopicPart (p : Params) (now : Millis) (s : PeerState) : Int := + (s.topics.map (topicContribution p now)).sum + +/-- The topic part of the score: the sum of the configured topic contributions, capped at +`topicScoreCap` when the cap is positive. A cap of 0 or less means no cap. -/ +def topicPart (p : Params) (now : Millis) (s : PeerState) : Int := + let raw := rawTopicPart p now s + if 0 < p.global.topicScoreCap ∧ p.global.topicScoreCap < raw then + p.global.topicScoreCap + else raw + +/-- The peer score of gossipsub v1.1: the capped topic part, the weighted application score, and +the weighted `P6` and `P7` terms. The last three products are exact. -/ +def score (p : Params) (s : PeerState) (now : Millis) : Score := + topicPart p now s + + p.global.appSpecificWeight * s.appScore + + p.global.ipColocationFactorWeight * (p6 p.global s : Int) + + p.global.behaviourPenaltyWeight * (p7 p.global s : Int) + +/-! ## Decay -/ + +/-- One decay step of a counter: multiply by the decay factor, divide by `scoreScale`, and +zero the result when it falls below `decayToZero`. The comparison is strict. -/ +def decayCounter (d : Nat) (decayToZero : Nat) (c : Nat) : Nat := + if c * d / scoreScale < decayToZero then 0 else c * d / scoreScale + +/-- One decay step of the topic counters. The four decaying counters move; `timeInMesh`, +`graftTime`, and `inMesh` stay. -/ +def decayTopic (tp : TopicParams) (decayToZero : Nat) (t : TopicCounters) : TopicCounters := + { t with + firstMessageDeliveries := + decayCounter tp.firstMessageDeliveriesDecay decayToZero t.firstMessageDeliveries + meshMessageDeliveries := + decayCounter tp.meshMessageDeliveriesDecay decayToZero t.meshMessageDeliveries + meshFailurePenalty := + decayCounter tp.meshFailurePenaltyDecay decayToZero t.meshFailurePenalty + invalidMessageDeliveries := + decayCounter tp.invalidMessageDeliveriesDecay decayToZero t.invalidMessageDeliveries } + +/-- One decay step of a topic entry. An entry whose topic has no configured parameters stays +unchanged. Such an entry contributes 0 to the score. -/ +private def decayEntry (p : Params) (decayToZero : Nat) + (entry : TopicId × TopicCounters) : TopicId × TopicCounters := + match p.topics entry.1 with + | some tp => (entry.1, decayTopic tp decayToZero entry.2) + | none => entry + +/-- One decay interval of a peer state. The four topic counters and the behavior penalty +decay. The application score and the colocation count stay; the application owns their +lifecycle. -/ +def decay (p : Params) (s : PeerState) : PeerState := + { s with + topics := s.topics.map (decayEntry p p.global.decayToZero) + behaviourPenalty := + decayCounter p.global.behaviourPenaltyDecay p.global.decayToZero s.behaviourPenalty } + +/-! ## The counter table -/ + +/-- The first entry of `l` with key `id`. -/ +private def lookupCounters : + List (TopicId × TopicCounters) → TopicId → Option TopicCounters + | [], _ => none + | (k, v) :: rest, id => if k = id then some v else lookupCounters rest id + +/-- The counters of `id`, or the all-zero counters when the state has no entry for the topic. -/ +def countersOf (s : PeerState) (id : TopicId) : TopicCounters := + match lookupCounters s.topics id with + | some t => t + | none => TopicCounters.zero + +/-- Replace the first entry with key `id`, or append a new entry when the key is absent. The +other entries keep their order. -/ +private def upsert : + List (TopicId × TopicCounters) → TopicId → TopicCounters → + List (TopicId × TopicCounters) + | [], id, t => [(id, t)] + | (k, v) :: rest, id, t => + if k = id then (k, t) :: rest else (k, v) :: upsert rest id t + +/-- Replace the counters of `id` in the state. -/ +private def setCounters (s : PeerState) (id : TopicId) (t : TopicCounters) : PeerState := + { s with topics := upsert s.topics id t } + +/-! ## Counter updates -/ + +/-- The graft update: when the peer is not in the mesh, set `inMesh` and record the graft time. +A peer that is already in the mesh is unchanged. -/ +def graft (s : PeerState) (now : Millis) (id : TopicId) : PeerState := + let t := countersOf s id + if t.inMesh then s + else setCounters s id { t with inMesh := true, graftTime := now } + +/-- The counter part of a prune: fold the elapsed mesh time into `timeInMesh`, leave the mesh, +and add the square of the delivery deficit to the failure penalty when the activation window +has passed and the delivery count is below the threshold. -/ +private def pruneCounters (p : Params) (now : Millis) (id : TopicId) + (t : TopicCounters) : TopicCounters := + let e := elapsed now t + let t1 := { t with timeInMesh := e, inMesh := false } + match p.topics id with + | some tp => + if tp.meshMessageDeliveriesActivation < e ∧ + t.meshMessageDeliveries < tp.meshMessageDeliveriesThreshold then + { t1 with meshFailurePenalty := t.meshFailurePenalty + + (tp.meshMessageDeliveriesThreshold - t.meshMessageDeliveries) * + (tp.meshMessageDeliveriesThreshold - t.meshMessageDeliveries) } + else t1 + | none => t1 + +/-- The prune update: a peer in the mesh folds its mesh time and adds the failure penalty, per +spec. A peer that is not in the mesh is unchanged. -/ +def prune (p : Params) (s : PeerState) (now : Millis) (id : TopicId) : PeerState := + let t := countersOf s id + if t.inMesh then setCounters s id (pruneCounters p now id t) else s + +/-- The first message delivery update: increment the counter and cap it. An unconfigured topic +is unchanged. -/ +def firstDelivery (p : Params) (s : PeerState) (id : TopicId) : PeerState := + match p.topics id with + | some tp => + let t := countersOf s id + setCounters s id { t with + firstMessageDeliveries := + min (t.firstMessageDeliveries + 1) tp.firstMessageDeliveriesCap } + | none => s + +/-- The mesh message delivery update: increment the counter and cap it. An unconfigured topic +is unchanged. -/ +def meshDelivery (p : Params) (s : PeerState) (id : TopicId) : PeerState := + match p.topics id with + | some tp => + let t := countersOf s id + setCounters s id { t with + meshMessageDeliveries := + min (t.meshMessageDeliveries + 1) tp.meshMessageDeliveriesCap } + | none => s + +/-- The counter part of an invalid delivery: increment the uncapped `P4` counter. -/ +private def invalidStep (t : TopicCounters) : TopicCounters := + { t with invalidMessageDeliveries := t.invalidMessageDeliveries + 1 } + +/-- The invalid message delivery update: increment the uncapped `P4` counter. An unconfigured +topic is unchanged. -/ +def invalidDelivery (p : Params) (s : PeerState) (id : TopicId) : PeerState := + match p.topics id with + | some _ => setCounters s id (invalidStep (countersOf s id)) + | none => s + +/-- The behavior penalty update: increment the `P7` counter. It is uncapped, and the decay +reduces it later. -/ +def behaviourPenalty (s : PeerState) : PeerState := + { s with behaviourPenalty := s.behaviourPenalty + 1 } + +/-! ## Table lemmas -/ + +/-- Reading the table after a write of the same key returns the written counters. -/ +private theorem lookupCounters_upsert_same (l : List (TopicId × TopicCounters)) + (id : TopicId) (t : TopicCounters) : + lookupCounters (upsert l id t) id = some t := by + induction l with + | nil => simp [upsert, lookupCounters] + | cons head rest ih => + obtain ⟨k, v⟩ := head + by_cases hk : k = id + · simp [upsert, lookupCounters, hk] + · simp [upsert, lookupCounters, hk, ih] + +/-- Reading the table of a mapped list maps the read entry, when the map keeps the keys. -/ +private theorem lookupCounters_map + (f : TopicId × TopicCounters → TopicId × TopicCounters) + (hf : ∀ e, (f e).1 = e.1) : + ∀ (l : List (TopicId × TopicCounters)) (id : TopicId), + lookupCounters (l.map f) id = + (lookupCounters l id).map (fun t => (f (id, t)).2) := by + intro l + induction l with + | nil => intro id; rfl + | cons head rest ih => + intro id + obtain ⟨k, v⟩ := head + by_cases hk : k = id + · simp [lookupCounters, hf, hk] + · simp [lookupCounters, hf, hk, ih] + +/-- The decay map keeps the key of every entry. -/ +private theorem decayEntry_key (p : Params) (z : Nat) (e : TopicId × TopicCounters) : + (decayEntry p z e).1 = e.1 := by + obtain ⟨k, v⟩ := e + unfold decayEntry + split <;> rfl + +/-- Reading the counters of a decayed state. -/ +private theorem countersOf_decay (p : Params) (s : PeerState) (id : TopicId) : + countersOf (decay p s) id = + match lookupCounters s.topics id with + | some t => (decayEntry p p.global.decayToZero (id, t)).2 + | none => TopicCounters.zero := by + unfold countersOf decay + rw [lookupCounters_map (decayEntry p p.global.decayToZero) (decayEntry_key p _) + s.topics id] + cases lookupCounters s.topics id <;> rfl + +/-- Replacing the first matching entry does not increase a sum of contributions, when the +replacement contributes at most the old entry. -/ +private theorem sum_map_upsert_le_of_found (f : TopicId × TopicCounters → Int) + (l : List (TopicId × TopicCounters)) (id : TopicId) + (u t : TopicCounters) (hfound : lookupCounters l id = some t) + (hle : f (id, u) ≤ f (id, t)) : + ((upsert l id u).map f).sum ≤ (l.map f).sum := by + induction l with + | nil => simp [lookupCounters] at hfound + | cons head rest ih => + obtain ⟨k, v⟩ := head + by_cases hk : k = id + · have hvt : v = t := by + rw [hk] at hfound + simpa [lookupCounters] using hfound + subst v + rw [hk] + simp [upsert, List.map_cons, List.sum_cons] + omega + · have hfound' : lookupCounters rest id = some t := by + simpa [lookupCounters, hk] using hfound + have hrec := ih hfound' + simp [upsert, hk, List.map_cons, List.sum_cons] + omega + +/-- Appending an entry does not increase a sum of contributions, when the new entry +contributes at most 0. -/ +private theorem sum_map_upsert_le_of_absent (f : TopicId × TopicCounters → Int) + (l : List (TopicId × TopicCounters)) (id : TopicId) (u : TopicCounters) + (habsent : lookupCounters l id = none) (hle : f (id, u) ≤ 0) : + ((upsert l id u).map f).sum ≤ (l.map f).sum := by + induction l with + | nil => simpa [upsert] using hle + | cons head rest ih => + obtain ⟨k, v⟩ := head + by_cases hk : k = id + · simp [lookupCounters, hk] at habsent + · have habsent' : lookupCounters rest id = none := by + simpa [lookupCounters, hk] using habsent + have hrec := ih habsent' + simp [upsert, hk, List.map_cons, List.sum_cons] + omega + +/-! ## Sign facts -/ + +/-- The `P1` term is at or above 0. -/ +theorem p1Term_nonneg {tp : TopicParams} (h : TopicParams.Valid tp) + (now : Millis) (t : TopicCounters) : + 0 ≤ tp.timeInMeshWeight * (p1 tp now t : Int) := + Int.mul_nonneg h.timeInMeshWeight_nonneg (Int.natCast_nonneg _) + +/-- The `P2` term is at or above 0. -/ +theorem p2Term_nonneg {tp : TopicParams} (h : TopicParams.Valid tp) + (t : TopicCounters) : + 0 ≤ tp.firstMessageDeliveriesWeight * (t.firstMessageDeliveries : Int) := + Int.mul_nonneg h.firstMessageDeliveriesWeight_nonneg (Int.natCast_nonneg _) + +/-- The `P3` term is at or below 0. -/ +theorem p3Term_nonpos {tp : TopicParams} (h : TopicParams.Valid tp) + (now : Millis) (t : TopicCounters) : + tp.meshMessageDeliveriesWeight * (p3 tp now t : Int) ≤ 0 := + Int.mul_nonpos_of_nonpos_of_nonneg h.meshMessageDeliveriesWeight_nonpos + (Int.natCast_nonneg _) + +/-- The `P3b` term is at or below 0. -/ +theorem p3bTerm_nonpos {tp : TopicParams} (h : TopicParams.Valid tp) + (t : TopicCounters) : + tp.meshFailurePenaltyWeight * (t.meshFailurePenalty : Int) ≤ 0 := + Int.mul_nonpos_of_nonpos_of_nonneg h.meshFailurePenaltyWeight_nonpos + (Int.natCast_nonneg _) + +/-- The `P4` term is at or below 0. -/ +theorem p4Term_nonpos {tp : TopicParams} (h : TopicParams.Valid tp) + (t : TopicCounters) : + tp.invalidMessageDeliveriesWeight * (p4 t : Int) ≤ 0 := + Int.mul_nonpos_of_nonpos_of_nonneg h.invalidMessageDeliveriesWeight_nonpos + (Int.natCast_nonneg _) + +/-- The `P6` term is at or below 0. -/ +theorem p6Term_nonpos {g : GlobalParams} (h : GlobalParams.Valid g) (s : PeerState) : + g.ipColocationFactorWeight * (p6 g s : Int) ≤ 0 := + Int.mul_nonpos_of_nonpos_of_nonneg h.ipColocationFactorWeight_nonpos + (Int.natCast_nonneg _) + +/-- The `P7` term is at or below 0. -/ +theorem p7Term_nonpos {g : GlobalParams} (h : GlobalParams.Valid g) (s : PeerState) : + g.behaviourPenaltyWeight * (p7 g s : Int) ≤ 0 := + Int.mul_nonpos_of_nonpos_of_nonneg h.behaviourPenaltyWeight_nonpos + (Int.natCast_nonneg _) + +/-! ## The topic cap -/ + +/-- The topic part never exceeds a positive topic score cap. The statement needs no +`Params.Valid`: the cap holds for any parameters. -/ +theorem topicPart_le_cap (p : Params) (now : Millis) (s : PeerState) + (hcap : 0 < p.global.topicScoreCap) : + topicPart p now s ≤ p.global.topicScoreCap := by + dsimp only [topicPart] + split <;> omega + +/-! ## Decay theorems -/ + +/-- A decay step never increases a counter, when the decay factor is at most `scoreScale`. -/ +theorem decayCounter_le (d : Nat) (z : Nat) (c : Nat) (hd : d ≤ scoreScale) : + decayCounter d z c ≤ c := by + have hdiv : c * d / scoreScale ≤ c := by + exact Nat.div_le_of_le_mul (by + rw [Nat.mul_comm scoreScale c] + exact Nat.mul_le_mul_left c hd) + unfold decayCounter + split <;> omega + +/-- A counter below `decayToZero` becomes 0 after a decay step, when the decay factor is at most +`scoreScale`. -/ +theorem decayCounter_of_lt (d : Nat) (z : Nat) (c : Nat) (hd : d ≤ scoreScale) + (hc : c < z) : + decayCounter d z c = 0 := by + have hdiv : c * d / scoreScale ≤ c := by + exact Nat.div_le_of_le_mul (by + rw [Nat.mul_comm scoreScale c] + exact Nat.mul_le_mul_left c hd) + unfold decayCounter + split + · rfl + · omega + +/-- The behavior penalty never increases under a decay step. -/ +theorem decay_behaviourPenalty_le {p : Params} (h : Params.Valid p) (s : PeerState) : + (decay p s).behaviourPenalty ≤ s.behaviourPenalty := by + simpa [decay] using + decayCounter_le p.global.behaviourPenaltyDecay p.global.decayToZero + s.behaviourPenalty h.global.behaviourPenaltyDecay_le + +/-- A decay step leaves the mesh time, the graft time, and the mesh membership unchanged. -/ +theorem decay_preserves_mesh {p : Params} (s : PeerState) (id : TopicId) : + (countersOf (decay p s) id).timeInMesh = (countersOf s id).timeInMesh ∧ + (countersOf (decay p s) id).graftTime = (countersOf s id).graftTime ∧ + (countersOf (decay p s) id).inMesh = (countersOf s id).inMesh := by + rw [countersOf_decay] + cases hlook : lookupCounters s.topics id with + | none => simp [countersOf, hlook, TopicCounters.zero] + | some t => + simp only [countersOf, hlook] + cases hcfg : p.topics id with + | none => simp [decayEntry, hcfg] + | some tp => simp [decayEntry, decayTopic, hcfg] + +/-- The `P2` counter never increases under a decay step. -/ +theorem decay_firstMessageDeliveries_le {p : Params} (h : Params.Valid p) + (s : PeerState) (id : TopicId) : + (countersOf (decay p s) id).firstMessageDeliveries ≤ + (countersOf s id).firstMessageDeliveries := by + rw [countersOf_decay] + cases hlook : lookupCounters s.topics id with + | none => simp [countersOf, hlook, TopicCounters.zero] + | some t => + simp only [countersOf, hlook] + cases hcfg : p.topics id with + | none => simp [decayEntry, hcfg] + | some tp => + simp only [decayEntry, hcfg] + exact decayCounter_le tp.firstMessageDeliveriesDecay p.global.decayToZero + t.firstMessageDeliveries (h.topic id tp hcfg).firstMessageDeliveriesDecay_le + +/-- The `P3` counter never increases under a decay step. -/ +theorem decay_meshMessageDeliveries_le {p : Params} (h : Params.Valid p) + (s : PeerState) (id : TopicId) : + (countersOf (decay p s) id).meshMessageDeliveries ≤ + (countersOf s id).meshMessageDeliveries := by + rw [countersOf_decay] + cases hlook : lookupCounters s.topics id with + | none => simp [countersOf, hlook, TopicCounters.zero] + | some t => + simp only [countersOf, hlook] + cases hcfg : p.topics id with + | none => simp [decayEntry, hcfg] + | some tp => + simp only [decayEntry, hcfg] + exact decayCounter_le tp.meshMessageDeliveriesDecay p.global.decayToZero + t.meshMessageDeliveries (h.topic id tp hcfg).meshMessageDeliveriesDecay_le + +/-- The `P3b` counter never increases under a decay step. -/ +theorem decay_meshFailurePenalty_le {p : Params} (h : Params.Valid p) + (s : PeerState) (id : TopicId) : + (countersOf (decay p s) id).meshFailurePenalty ≤ + (countersOf s id).meshFailurePenalty := by + rw [countersOf_decay] + cases hlook : lookupCounters s.topics id with + | none => simp [countersOf, hlook, TopicCounters.zero] + | some t => + simp only [countersOf, hlook] + cases hcfg : p.topics id with + | none => simp [decayEntry, hcfg] + | some tp => + simp only [decayEntry, hcfg] + exact decayCounter_le tp.meshFailurePenaltyDecay p.global.decayToZero + t.meshFailurePenalty (h.topic id tp hcfg).meshFailurePenaltyDecay_le + +/-- The `P4` counter never increases under a decay step. -/ +theorem decay_invalidMessageDeliveries_le {p : Params} (h : Params.Valid p) + (s : PeerState) (id : TopicId) : + (countersOf (decay p s) id).invalidMessageDeliveries ≤ + (countersOf s id).invalidMessageDeliveries := by + rw [countersOf_decay] + cases hlook : lookupCounters s.topics id with + | none => simp [countersOf, hlook, TopicCounters.zero] + | some t => + simp only [countersOf, hlook] + cases hcfg : p.topics id with + | none => simp [decayEntry, hcfg] + | some tp => + simp only [decayEntry, hcfg] + exact decayCounter_le tp.invalidMessageDeliveriesDecay p.global.decayToZero + t.invalidMessageDeliveries (h.topic id tp hcfg).invalidMessageDeliveriesDecay_le + +/-- The first message delivery counter stays at or below its cap. The topic must be +configured. -/ +theorem firstDelivery_le_cap {p : Params} {s : PeerState} {id : TopicId} {tp : TopicParams} + (hcfg : p.topics id = some tp) : + (countersOf (firstDelivery p s id) id).firstMessageDeliveries ≤ + tp.firstMessageDeliveriesCap := by + have h : min ((countersOf s id).firstMessageDeliveries + 1) + tp.firstMessageDeliveriesCap ≤ tp.firstMessageDeliveriesCap := + Nat.min_le_right _ _ + simpa [firstDelivery, hcfg, setCounters, countersOf, lookupCounters_upsert_same] using h + +/-- The mesh message delivery counter stays at or below its cap. The topic must be +configured. -/ +theorem meshDelivery_le_cap {p : Params} {s : PeerState} {id : TopicId} {tp : TopicParams} + (hcfg : p.topics id = some tp) : + (countersOf (meshDelivery p s id) id).meshMessageDeliveries ≤ + tp.meshMessageDeliveriesCap := by + have h : min ((countersOf s id).meshMessageDeliveries + 1) + tp.meshMessageDeliveriesCap ≤ tp.meshMessageDeliveriesCap := + Nat.min_le_right _ _ + simpa [meshDelivery, hcfg, setCounters, countersOf, lookupCounters_upsert_same] using h + +/-! ## Invalid delivery monotonicity -/ + +/-- One invalid delivery leaves `P1` unchanged. -/ +private theorem p1_invalidStep (tp : TopicParams) (now : Millis) (t : TopicCounters) : + p1 tp now (invalidStep t) = p1 tp now t := rfl + +/-- One invalid delivery leaves `P3` unchanged. -/ +private theorem p3_invalidStep (tp : TopicParams) (now : Millis) (t : TopicCounters) : + p3 tp now (invalidStep t) = p3 tp now t := rfl + +/-- The `P4` square does not fall when the counter grows. -/ +private theorem p4_invalidStep_mono (t : TopicCounters) : + p4 t ≤ p4 (invalidStep t) := + Nat.mul_le_mul (Nat.le_succ _) (Nat.le_succ _) + +/-- One invalid delivery does not raise the topic score, when the `P4` weight is at or below +0. -/ +private theorem topicScore_invalidStep_le (tp : TopicParams) (now : Millis) + (t : TopicCounters) (hw : tp.invalidMessageDeliveriesWeight ≤ 0) : + topicScore tp now (invalidStep t) ≤ topicScore tp now t := by + have h1 := p1_invalidStep tp now t + have h3 := p3_invalidStep tp now t + have hp4 : (p4 t : Int) ≤ (p4 (invalidStep t) : Int) := + Int.ofNat_le.mpr (p4_invalidStep_mono t) + have hprod := Int.mul_le_mul_of_nonpos_left hw hp4 + unfold topicScore + have h2 : (invalidStep t).firstMessageDeliveries = t.firstMessageDeliveries := rfl + have h4 : (invalidStep t).meshFailurePenalty = t.meshFailurePenalty := rfl + rw [h1, h3, h2, h4] + omega + +/-- One invalid delivery does not raise a topic contribution, when the topic weight is at or +above 0 and the `P4` weight is at or below 0. -/ +private theorem topicContribution_invalidStep_le (p : Params) (now : Millis) + (id : TopicId) {tp : TopicParams} (htp : p.topics id = some tp) + (hW : 0 ≤ tp.topicWeight) (hw : tp.invalidMessageDeliveriesWeight ≤ 0) + (t : TopicCounters) : + topicContribution p now (id, invalidStep t) ≤ topicContribution p now (id, t) := by + have hts := topicScore_invalidStep_le tp now t hw + have hmul : tp.topicWeight * topicScore tp now (invalidStep t) ≤ + tp.topicWeight * topicScore tp now t := + Int.mul_le_mul_of_nonneg_left hts hW + have hdiv := Int.tdiv_le_tdiv + (by decide : (0 : Int) < (scoreScale : Int)) hmul + simpa [topicContribution, htp] using hdiv + +/-- The contribution of a fresh counters value after one invalid delivery is at or below 0, +when the topic weight is at or above 0 and the `P4` weight is at or below 0. -/ +private theorem topicContribution_invalidStep_zero_nonpos (p : Params) (now : Millis) + (id : TopicId) {tp : TopicParams} (htp : p.topics id = some tp) + (hW : 0 ≤ tp.topicWeight) (hw : tp.invalidMessageDeliveriesWeight ≤ 0) : + topicContribution p now (id, invalidStep TopicCounters.zero) ≤ 0 := by + have hts : topicScore tp now (invalidStep TopicCounters.zero) = + tp.invalidMessageDeliveriesWeight := by + simp [topicScore, p1, p3, p4, elapsed, invalidStep, TopicCounters.zero] + have hmul : tp.topicWeight * tp.invalidMessageDeliveriesWeight ≤ 0 := + Int.mul_nonpos_of_nonneg_of_nonpos hW hw + have hdiv : (tp.topicWeight * tp.invalidMessageDeliveriesWeight).tdiv + (scoreScale : Int) ≤ 0 := by + have h0 : (0 : Int).tdiv (scoreScale : Int) = 0 := by decide + have := Int.tdiv_le_tdiv (by decide : (0 : Int) < (scoreScale : Int)) hmul + omega + simpa [topicContribution, htp, hts] using hdiv + +/-- The topic cap preserves the order of the raw topic part. -/ +private theorem topicPart_mono {p : Params} {now : Millis} {s s' : PeerState} + (h : rawTopicPart p now s' ≤ rawTopicPart p now s) : + topicPart p now s' ≤ topicPart p now s := by + dsimp only [topicPart] + split <;> split <;> omega + +/-- The score preserves the order of the topic part, when the three global inputs are equal. -/ +private theorem score_le_of_topicPart_le {p : Params} {now : Millis} {s s' : PeerState} + (h : topicPart p now s' ≤ topicPart p now s) + (happ : s'.appScore = s.appScore) + (hip : s'.ipColocation = s.ipColocation) + (hbp : s'.behaviourPenalty = s.behaviourPenalty) : + score p s' now ≤ score p s now := by + have happ' : p.global.appSpecificWeight * s'.appScore = + p.global.appSpecificWeight * s.appScore := by + rw [happ] + have hp6 : p6 p.global s' = p6 p.global s := by + unfold p6 + rw [hip] + have hp7 : p7 p.global s' = p7 p.global s := by + unfold p7 + rw [hbp] + unfold score + rw [happ', hp6, hp7] + exact Int.add_le_add_right (Int.add_le_add_right (Int.add_le_add_right h _) _) _ + +/-- One more invalid delivery never raises the score of a peer. The proof consumes the topic +weight sign and the `P4` weight sign of the target topic. The other topics do not move. -/ +theorem invalidDelivery_score_mono {p : Params} (h : Params.Valid p) + (now : Millis) (s : PeerState) (id : TopicId) : + score p (invalidDelivery p s id) now ≤ score p s now := by + cases hcfg : p.topics id with + | none => simp [invalidDelivery, hcfg] + | some tp => + simp only [invalidDelivery, hcfg] + have htp := h.topic id tp hcfg + have hraw : + rawTopicPart p now + (setCounters s id (invalidStep (countersOf s id))) ≤ + rawTopicPart p now s := by + cases hlook : lookupCounters s.topics id with + | none => + have hu : countersOf s id = TopicCounters.zero := by + simp [countersOf, hlook] + rw [hu] + exact sum_map_upsert_le_of_absent (topicContribution p now) s.topics id + (invalidStep TopicCounters.zero) hlook + (topicContribution_invalidStep_zero_nonpos p now id hcfg + htp.topicWeight_nonneg htp.invalidMessageDeliveriesWeight_nonpos) + | some t => + have hu : countersOf s id = t := by + simp [countersOf, hlook] + rw [hu] + exact sum_map_upsert_le_of_found (topicContribution p now) s.topics id + (invalidStep t) t hlook + (topicContribution_invalidStep_le p now id hcfg + htp.topicWeight_nonneg htp.invalidMessageDeliveriesWeight_nonpos t) + have hpart := topicPart_mono hraw + have happ : (setCounters s id (invalidStep (countersOf s id))).appScore = + s.appScore := rfl + have hip : (setCounters s id (invalidStep (countersOf s id))).ipColocation = + s.ipColocation := rfl + have hbp : (setCounters s id (invalidStep (countersOf s id))).behaviourPenalty = + s.behaviourPenalty := rfl + exact score_le_of_topicPart_le hpart happ hip hbp + +/-- An invalid delivery to a topic with no configured parameters leaves the score +unchanged. -/ +theorem invalidDelivery_unconfigured_score {p : Params} {s : PeerState} {id : TopicId} + (hcfg : p.topics id = none) (now : Millis) : + score p (invalidDelivery p s id) now = score p s now := by + simp [invalidDelivery, hcfg] + +/-! ## The fresh peer -/ + +/-- The raw topic part of a fresh peer is 0. -/ +private theorem rawTopicPart_zero (p : Params) (now : Millis) : + rawTopicPart p now PeerState.zero = 0 := rfl + +/-- The topic part of a fresh peer is 0, for any parameters. -/ +private theorem topicPart_zero (p : Params) (now : Millis) : + topicPart p now PeerState.zero = 0 := by + have hraw := rawTopicPart_zero p now + dsimp only [topicPart] + rw [hraw] + split <;> omega + +/-- A fresh peer scores 0. The statement needs no `Params.Valid`: it holds for any +parameters. -/ +theorem freshPeer_scores_zero (p : Params) (now : Millis) : + score p PeerState.zero now = 0 := by + have hpart := topicPart_zero p now + unfold score + rw [hpart] + simp [p6, p7, PeerState.zero] + +/-! ## Prune -/ + +/-- A prune with an active delivery deficit adds the square of the deficit to the failure +penalty. The peer is in the mesh, the topic is configured, the activation window has passed, +and the delivery count is below the threshold. -/ +theorem prune_adds_deficit {p : Params} {s : PeerState} {now : Millis} {id : TopicId} + {tp : TopicParams} {t : TopicCounters} + (hcfg : p.topics id = some tp) (ht : countersOf s id = t) (hin : t.inMesh) + (hact : tp.meshMessageDeliveriesActivation < elapsed now t) + (hdef : t.meshMessageDeliveries < tp.meshMessageDeliveriesThreshold) : + (countersOf (prune p s now id) id).meshFailurePenalty = + t.meshFailurePenalty + + (tp.meshMessageDeliveriesThreshold - t.meshMessageDeliveries) * + (tp.meshMessageDeliveriesThreshold - t.meshMessageDeliveries) := by + have hprune : prune p s now id = setCounters s id (pruneCounters p now id t) := by + simp [prune, ht, hin] + rw [hprune] + simp [setCounters, countersOf, lookupCounters_upsert_same, pruneCounters, hcfg, hact, hdef] + +/-- A prune with no active delivery deficit leaves the failure penalty unchanged. -/ +theorem prune_without_deficit_keeps_penalty {p : Params} {s : PeerState} {now : Millis} + {id : TopicId} {tp : TopicParams} {t : TopicCounters} + (hcfg : p.topics id = some tp) (ht : countersOf s id = t) (hin : t.inMesh) + (hno : ¬ (tp.meshMessageDeliveriesActivation < elapsed now t ∧ + t.meshMessageDeliveries < tp.meshMessageDeliveriesThreshold)) : + (countersOf (prune p s now id) id).meshFailurePenalty = t.meshFailurePenalty := by + have hprune : prune p s now id = setCounters s id (pruneCounters p now id t) := by + simp [prune, ht, hin] + rw [hprune] + simp [setCounters, countersOf, lookupCounters_upsert_same, pruneCounters, hcfg, hno] + +/-- A prune removes a peer from the mesh. The statement needs no parameter hypothesis. -/ +theorem prune_leaves_mesh {p : Params} {s : PeerState} {now : Millis} {id : TopicId} + {t : TopicCounters} (ht : countersOf s id = t) (hin : t.inMesh) : + ¬ (countersOf (prune p s now id) id).inMesh := by + have hprune : prune p s now id = setCounters s id (pruneCounters p now id t) := by + simp [prune, ht, hin] + rw [hprune] + simp [setCounters, countersOf, lookupCounters_upsert_same] + cases hcfg : p.topics id with + | none => simp [pruneCounters, hcfg] + | some tp => + simp [pruneCounters, hcfg] + split <;> simp + +end Libp2p.Protocols.Gossipsub.Score diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Sign.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Sign.lean new file mode 100644 index 00000000..601246db --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Sign.lean @@ -0,0 +1,305 @@ +import Libp2p.Protocols.Gossipsub.Types +import Libp2p.Protocols.Mock.Identity +import Bytes + +/-! +# Gossipsub signing policy + +This module holds the signing policy check, the bytes a signature +covers, and the default message id of gossipsub v1.1. The vendored +spec is `vendor/specs/pubsub/README.md`, sections Message Signing, +Signature Policy Options, and Message Identification. + +## Policy decision + +The router uses `strictNoSign` by default. The project targets the +Ethereum consensus layer, and that layer requires the StrictNoSign +policy for gossipsub v1.1 messages: +. +The libp2p pubsub spec defines the policy options: +. +Under `strictNoSign` the four signed fields are absent, so the default +message id is empty. The consensus layer supplies a content-addressed +id function through `MsgIdFn`. The hash itself is outside this module. + +The two lax options stay out. The spec marks them legacy, and the +project does not target them. + +## The signature input + +The bytes that a signature covers are the UTF-8 bytes of the string +`libp2p-pubsub:` followed by the encoded message with the `signature` +and `key` fields absent. The vendored spec excludes the signature +field (Message Signing). The go reference implementation also clears +the key field before it signs, so this module clears both fields. +The encoder is a parameter. The rpc module supplies the protobuf +encoder at integration. + +## Check order + +Under `strictSign` the check order is: `from`, `seqno`, `signature`, +key resolution, key match, verify. The key match applies when the +`key` field is present. An absent field produces its own error. Under +`strictNoSign` the check order is: `from`, `seqno`, `signature`, +`key`. The first present field decides the error. Both orders are +fixed here, so the error of a message with several faults is well +defined. + +The key resolution takes a key from the `key` field when that field is +present, and from the peer id otherwise. A `key` field that does not +parse never falls back to the peer id. This rule matches the go +reference. + +## The message id + +The default id is the concatenation of `from` and `seqno`. An absent +field adds no bytes. The spec calls this strategy origin stamping +(Message Identification). `MsgIdFn` is the type of a caller-supplied +id function. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub.Sign + +open Libp2p.Protocols.Mock.Identity + +/-- The two deterministic signature policies of the spec. The spec +names no numeric codes for the policies, so this is a plain inductive +with no code pair. -/ +inductive SignPolicy where + /-- Require `from`, `seqno`, and `signature`, resolve the key, and + verify the signature. -/ + | strictSign + /-- Require the four signed fields to be absent. -/ + | strictNoSign + deriving DecidableEq, BEq, Repr + +/-- The rejection reasons of `validate`. Each arm names one reason. +The first six arms belong to `strictSign`, the last four to +`strictNoSign`. -/ +inductive SignError where + /-- `strictSign`: the `from` field is absent. -/ + | missingFrom + /-- `strictSign`: the `seqno` field is absent. -/ + | missingSeqno + /-- `strictSign`: the `signature` field is absent. -/ + | missingSignature + /-- `strictSign`: the `key` field does not parse, or the `from` + field inlines no key. -/ + | keyUnresolved + /-- `strictSign`: the key of the `key` field derives another peer + id than `from`. -/ + | keyMismatch + /-- `strictSign`: the verifier rejects the signature. -/ + | badSignature + /-- `strictNoSign`: the `from` field is present. -/ + | hasFrom + /-- `strictNoSign`: the `seqno` field is present. -/ + | hasSeqno + /-- `strictNoSign`: the `signature` field is present. -/ + | hasSignature + /-- `strictNoSign`: the `key` field is present. -/ + | hasKey + deriving DecidableEq, BEq, Repr + +/-- The signing prefix of the spec and of the go reference +implementation. -/ +def signPrefix : String := "libp2p-pubsub:" + +/-- The prefix as UTF-8 bytes. -/ +def signPrefixBytes : List Nat := Bytes.toNatList (Bytes.ofString signPrefix) + +/-- The functions that the router wires to the real packages at +integration. + +`unmarshalKey` parses the `key` field. `inlineKey` extracts a key that +the peer id inlines. `idOfKey` derives the peer id bytes of a key. +`verify` checks a signature over the signed bytes. All four functions +are total, and the caller supplies them. -/ +structure Prims where + /-- Parse the `key` field. -/ + unmarshalKey : List Nat → Option PubKey + /-- Extract the key that a peer id inlines. -/ + inlineKey : List Nat → Option PubKey + /-- Derive the peer id bytes of a key. -/ + idOfKey : PubKey → List Nat + /-- Check a signature over the signed bytes. -/ + verify : PubKey → List Nat → List Nat → Bool + +/-- The bytes a signature covers: the prefix, then the encoded message +with the `signature` and `key` fields absent. -/ +def signInput (encode : Message → List Nat) (m : Message) : List Nat := + signPrefixBytes ++ encode { m with signature := none, key := none } + +/-- Check a message against a signature policy. + +Under `strictNoSign`, the first present field of `from`, `seqno`, +`signature`, and `key` produces its error arm. + +Under `strictSign`, the checks run in this order: `from`, `seqno`, +`signature`, key resolution, key match, verify. A `key` field that is +present must parse, and the derived peer id must equal `from`. An +absent `key` field resolves through the peer id. -/ +def validate (p : Prims) (encode : Message → List Nat) (policy : SignPolicy) + (m : Message) : Except SignError Unit := + match policy, m with + | .strictNoSign, m => + if m.from.isSome then .error .hasFrom + else if m.seqno.isSome then .error .hasSeqno + else if m.signature.isSome then .error .hasSignature + else if m.key.isSome then .error .hasKey + else .ok () + | .strictSign, m => + match m.from with + | none => .error .missingFrom + | some fr => + match m.seqno with + | none => .error .missingSeqno + | some _ => + match m.signature with + | none => .error .missingSignature + | some sig => + match m.key with + | some kb => + match p.unmarshalKey kb with + | none => .error .keyUnresolved + | some k => + if p.idOfKey k = fr then + if p.verify k (signInput encode m) sig then .ok () + else .error .badSignature + else .error .keyMismatch + | none => + match p.inlineKey fr with + | none => .error .keyUnresolved + | some k => + if p.verify k (signInput encode m) sig then .ok () + else .error .badSignature + +/-- A caller-supplied message id function. The consensus layer supplies +a content-addressed function. -/ +abbrev MsgIdFn := Message → MessageId + +/-- The default message id: `from` followed by `seqno`. An absent field +adds no bytes. -/ +def defaultMsgId (m : Message) : MessageId := + ⟨m.from.getD [] ++ m.seqno.getD []⟩ + +/-! ## Theorems -/ + +/-- The UTF-8 bytes of the prefix are the expected 14 bytes. -/ +theorem signPrefixBytes_eq : + signPrefixBytes = + [108, 105, 98, 112, 50, 112, 45, 112, 117, 98, 115, 117, 98, 58] := rfl + +/-- `strictNoSign` accepts a message exactly when the four signed fields +are absent. -/ +theorem validate_strictNoSign_iff (p : Prims) (encode : Message → List Nat) + (m : Message) : + validate p encode .strictNoSign m = .ok () ↔ + m.from = none ∧ m.seqno = none ∧ m.signature = none ∧ m.key = none := by + rcases m with ⟨fr, d, sq, t, sig, key⟩ + cases fr <;> cases sq <;> cases sig <;> cases key <;> simp [validate] + +/-- `strictSign` acceptance implies that `from`, `seqno`, and +`signature` are present, and that the verifier accepted the signature +over the signed bytes. -/ +theorem validate_strictSign_sound (p : Prims) (encode : Message → List Nat) + (m : Message) : + validate p encode .strictSign m = .ok () → + m.from.isSome ∧ m.seqno.isSome ∧ + ∃ k sig, m.signature = some sig ∧ + p.verify k (signInput encode m) sig = true := by + intro h + rcases m with ⟨fr, d, sq, t, sig, key⟩ + cases fr with + | none => simp [validate] at h + | some fr => + cases sq with + | none => simp [validate] at h + | some sq => + cases sig with + | none => simp [validate] at h + | some sig => + cases key with + | none => + cases hik : p.inlineKey fr with + | none => simp [validate, hik] at h + | some k => + simp [validate, hik] at h + exact ⟨rfl, rfl, k, sig, rfl, h⟩ + | some kb => + cases huk : p.unmarshalKey kb with + | none => simp [validate, huk] at h + | some k => + by_cases hm : p.idOfKey k = fr + · simp [validate, huk, hm] at h + exact ⟨rfl, rfl, k, sig, rfl, h⟩ + · simp [validate, huk, hm] at h + +/-- Under `strictSign`, an accepted message with a `key` field has a +key that parses and derives the peer id in `from`. This theorem pins +the key match check, which the soundness theorem does not name. -/ +theorem validate_strictSign_keyField_binding (p : Prims) + (encode : Message → List Nat) (m : Message) : + validate p encode .strictSign m = .ok () → m.key.isSome → + ∃ kb k, m.key = some kb ∧ p.unmarshalKey kb = some k ∧ + m.from = some (p.idOfKey k) := by + intro h hsome + rcases m with ⟨fr, d, sq, t, sig, key⟩ + cases fr with + | none => simp [validate] at h + | some fr => + cases sq with + | none => simp [validate] at h + | some sq => + cases sig with + | none => simp [validate] at h + | some sig => + cases key with + | none => simp at hsome + | some kb => + cases huk : p.unmarshalKey kb with + | none => simp [validate, huk] at h + | some k => + by_cases hm : p.idOfKey k = fr + · simp [validate, huk, hm] at h + exact ⟨kb, k, rfl, huk, by simp [hm]⟩ + · simp [validate, huk, hm] at h + +/-- The bytes of the signature input do not depend on the `signature` +and `key` fields. -/ +theorem signInput_signature_key_irrelevant (encode : Message → List Nat) + (m : Message) (s s' k k' : Option (List Nat)) : + signInput encode { m with signature := s, key := k } = + signInput encode { m with signature := s', key := k' } := by + simp [signInput] + +/-- The signature input starts with the prefix bytes. -/ +theorem signInput_prefix (encode : Message → List Nat) (m : Message) : + (signInput encode m).take signPrefixBytes.length = signPrefixBytes := by + simp [signInput] + +/-- The default message id ignores the payload and the topic. -/ +theorem defaultMsgId_ignores_payload (m : Message) (d' : Option (List Nat)) + (t' : TopicId) : + defaultMsgId { m with data := d', topic := t' } = defaultMsgId m := rfl + +/-- An accepted `strictNoSign` message has the empty default message +id. -/ +theorem strictNoSign_ok_defaultMsgId_empty (p : Prims) + (encode : Message → List Nat) (m : Message) : + validate p encode .strictNoSign m = .ok () → defaultMsgId m = ⟨[]⟩ := by + intro h + have h' := (validate_strictNoSign_iff p encode m).mp h + simp [defaultMsgId, h'.1, h'.2.1] + +/-- The ten rejection arms are pairwise distinct. -/ +theorem signError_arms_nodup : + [SignError.missingFrom, SignError.missingSeqno, SignError.missingSignature, + SignError.keyUnresolved, SignError.keyMismatch, SignError.badSignature, + SignError.hasFrom, SignError.hasSeqno, SignError.hasSignature, + SignError.hasKey].Nodup := by + decide + +end Libp2p.Protocols.Gossipsub.Sign diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Types.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Types.lean new file mode 100644 index 00000000..47a4b6cc --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Types.lean @@ -0,0 +1,84 @@ +import Libp2p.Protocols.Mock.Identity + +/-! +# Gossipsub shared types + +The types that every gossipsub module reads: the topic and message ids, +the published message, the score value and its thresholds, and the time +unit. The RPC codec, the scoring function, the caches, the signing +policy, and the router core all build on these, so they live in one +module that imports nothing else of gossipsub. + +A score is an `Int` in fixed point: `scoreScale` units make 1.0 of the +pubsub spec. The spec states scores as float64. Fixed point keeps the +arithmetic exact, so `omega` can close the score theorems. + +Time enters the router as data. `Millis` is the time of an `Event.tick` +in milliseconds. No gossipsub module reads a clock. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub + +open Libp2p.Protocols.Mock.Identity + +/-- A topic, by its name. -/ +structure TopicId where + name : String + deriving DecidableEq, BEq, Hashable, Repr + +/-- A message id: the bytes the message id function returns. -/ +structure MessageId where + bytes : List Nat + deriving DecidableEq, BEq, Hashable, Repr + +/-- A published message, with the fields of the pubsub spec. Each +optional field of the protobuf is an `Option`, so an absent field and +an empty field stay apart. -/ +structure Message where + /-- The author's peer id bytes. -/ + «from» : Option (List Nat) + /-- The payload. -/ + data : Option (List Nat) + /-- The sequence number bytes. -/ + seqno : Option (List Nat) + /-- The topic, a required field. -/ + topic : TopicId + /-- The signature over the message. -/ + signature : Option (List Nat) + /-- The author's public key, when the peer id does not inline it. -/ + key : Option (List Nat) + deriving DecidableEq, BEq, Repr + +/-- A peer score, in fixed point: `scoreScale` units per 1.0. -/ +abbrev Score := Int + +/-- The fixed-point scale of `Score`. -/ +def scoreScale : Nat := 1000 + +/-- The score thresholds of gossipsub v1.1. The router compares a peer's +score with them. The spec leaves the values to the application. -/ +structure Thresholds where + /-- Below this score, the router emits no gossip to the peer and + ignores its gossip. -/ + gossipThreshold : Score + /-- Below this score, the router does not publish to the peer. -/ + publishThreshold : Score + /-- Below this score, the router ignores every RPC from the peer. -/ + graylistThreshold : Score + /-- At or above this score, the router accepts peer exchange from + the peer's prune. -/ + acceptPXThreshold : Score + /-- Below this median mesh score, the router grafts opportunistically. -/ + opportunisticGraftThreshold : Score + deriving DecidableEq, Repr + +/-- A point in time, in milliseconds. -/ +abbrev Millis := Nat + +/-- The peer id of the identity mock. The real identity package +replaces it at integration. -/ +abbrev Peer := PeerId + +end Libp2p.Protocols.Gossipsub diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean new file mode 100644 index 00000000..8b3f0f9b --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean @@ -0,0 +1,46 @@ +import Std +import Conc +import Streams + +/-! +# Core seam mock + +`Libp2p.Core` (issue #15, PR #37) is not merged. This mock declares +the frozen seam shapes from `packages/libp2p/core/docs/ARCHITECTURE.md`: +`ProtocolId`, the coarse `Error`, and `StreamHandler`. The real core +package replaces this module at integration. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Mock.Core + +/-- A peer-defined protocol id, an open set of strings. The code +compares `ProtocolId` values only. -/ +structure ProtocolId where + toString : String + deriving DecidableEq, Hashable + +/-- The coarse error of the core seam, with the rendered inner error. -/ +inductive Error where + | protocol (rendered : String) + | authentication (rendered : String) + | timeout (rendered : String) + | closed (rendered : String) + deriving DecidableEq, BEq, Repr + +/-- The handler seam: the id it registers under, and the callback a +served stream reaches. -/ +structure StreamHandler where + id : ProtocolId + handle : Streams.Stream → ExceptT Error Conc.Async Unit + +/-- One map from a stream error to the coarse error, shared by every +handler of this package. `closed` stays closed, `reset` is a protocol +fault, and `timeout` stays timeout. -/ +def mapStreamError : Streams.Error → Error + | .closed => .closed "the stream closed" + | .reset => .protocol "the peer reset the stream" + | .timeout => .timeout "the stream deadline expired" + +end Libp2p.Protocols.Mock.Core diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean new file mode 100644 index 00000000..295aa6f2 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean @@ -0,0 +1,47 @@ +import Std +import Conc +import Libp2p.Protocols.Mock.Identity + +/-! +# Host peerstore mock + +`Libp2p.Host` (issue #19) is not merged. This mock declares the +peerstore surface that the identify push handler updates, from +`packages/libp2p/host/docs/ARCHITECTURE.md`: `addAddrs`, deduplicated +at add, and `addrsOf`. The host doc freezes the names and the dedup +rule, and it leaves the signatures open. This mock fixes minimal +signatures. The real host package replaces this module at integration. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Mock.Host + +open Libp2p.Protocols.Mock.Identity + +/-- The peerstore seam the push handler updates: addresses by peer, +deduplicated at add. -/ +structure Peerstore where + /-- Store the addresses of one peer, deduplicated at add. -/ + addAddrs : PeerId → List (List Nat) → IO Unit + /-- The stored addresses of one peer. -/ + addrsOf : PeerId → IO (List (List Nat)) + +/-- The executable in-memory double. Each call makes a fresh table: +bind the action once, and pass the resulting value to both the handler +and `addrsOf`. A second add of the same addresses grows nothing. The +table changes through `OnceRef.update`, one atomic step per add +(ARCHITECTURE §4.1, rule 3). -/ +opaque inMemory : IO Peerstore := do + let table ← Conc.OnceRef.new ([] : List (PeerId × List (List Nat))) + let addAddrs : PeerId → List (List Nat) → IO Unit := fun p addrs => + table.update fun cur => + let old := (cur.find? (fun e => e.1 == p)).map (·.2) |>.getD [] + let merged := (old ++ addrs).eraseDups + (p, merged) :: cur.filter (fun e => !(e.1 == p)) + let addrsOf : PeerId → IO (List (List Nat)) := fun p => do + let cur ← table.get + return (cur.find? (fun e => e.1 == p)).map (·.2) |>.getD [] + return { addAddrs, addrsOf } + +end Libp2p.Protocols.Mock.Host diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean new file mode 100644 index 00000000..4f1f40ff --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean @@ -0,0 +1,57 @@ +import Std + +/-! +# Identity mock + +`Libp2p.Identity` (issue #11) is not merged. This mock declares the key +and peer id facts of `packages/libp2p/identity/docs/ARCHITECTURE.md`. +That doc freezes `KeyType` and what a key and a peer id hold, and it +leaves the signatures open. This mock fixes minimal signatures. +`PubKey` and `PeerId` drop their proof fields. The real identity package +replaces this module at integration. + +The stand-in bodies are executable, so the suite can drive both outcomes +of `unmarshalPubKey?`. The theorems of `Identify` stay true for any +replacement, because they are conditional on these opaque results. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Mock.Identity + +/-- The closed set of key types: rsa (0), ed25519 (1), secp256k1 (2), +ecdsa (3). -/ +inductive KeyType where + | rsa | ed25519 | secp256k1 | ecdsa + deriving DecidableEq, BEq, Repr + +/-- A public key: its type and its data bytes. -/ +structure PubKey where + keyType : KeyType + data : List Nat + deriving DecidableEq, Repr + +/-- A peer id: the derived bytes and the text form. -/ +structure PeerId where + bytes : List Nat + text : String + deriving DecidableEq, BEq, Repr + +/-- Executable stand-in: a nonempty buffer parses as an ed25519 key, +an empty buffer does not parse. -/ +opaque unmarshalPubKey? (bs : List Nat) : Option PubKey := + if bs.isEmpty then none else some ⟨.ed25519, bs⟩ + +/-- Executable stand-in: the derived id carries the key bytes. -/ +opaque peerIdOfPubKey (k : PubKey) : PeerId := + ⟨k.data, ""⟩ + +/-- Executable stand-in: the raw key data. The mock `unmarshalPubKey?` +parses any nonempty buffer as `⟨.ed25519, buffer⟩`, and `peerIdOfPubKey` +reads only the buffer, so the round trip holds for a nonempty ed25519 +stand-in. The real identity package writes the serialized PublicKey +protobuf (peer-ids spec); that body replaces this one at integration. -/ +opaque marshalPubKey (k : PubKey) : List Nat := + k.data + +end Libp2p.Protocols.Mock.Identity diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean new file mode 100644 index 00000000..089d3947 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean @@ -0,0 +1,21 @@ +import Std + +/-! +# Multiaddr mock + +`Multiformats` (issue #8, PR #36, merged) owns the real +`Multiaddr.ofByteArray?`. This mock declares the minimal validity +predicate that the identify validate needs. The real check replaces +it at integration. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Mock.Multiaddr + +/-- Executable stand-in: the empty address is invalid, every other +buffer is valid. -/ +opaque validBytes (bs : List Nat) : Bool := + !bs.isEmpty + +end Libp2p.Protocols.Mock.Multiaddr diff --git a/packages/libp2p/protocols/docs/ARCHITECTURE.md b/packages/libp2p/protocols/docs/ARCHITECTURE.md index 31fb0f09..80f9723a 100644 --- a/packages/libp2p/protocols/docs/ARCHITECTURE.md +++ b/packages/libp2p/protocols/docs/ARCHITECTURE.md @@ -22,7 +22,11 @@ group is §7, row 10). The spike has no req/resp or gossipsub code. | `Libp2p/Protocols/ReqResp/Frame.lean` | the `/eth2/.../ssz_sn` framing, a uvarint length over a snappy-compressed chunk, as a `Codec` with a `LawfulCodec` instance (§3.2); the snappy call goes through the `codec/snappy` binding (root PLAN W6.2); the codec feeds the core | | `Libp2p/Protocols/ReqResp/Core.lean` | the response-chunk state machine of §3.3: `State`, `Event`, `Action`, the error inductive, and the total `step`; the snappy decompression context carries across chunks, and the chunk count and the error/EOF distinction live in the state | | `Libp2p/Protocols/ReqResp/Exchange.lean` | the client and handler seam over the loop: the client function, the handler value the registry stores, and the deadlines | +| `Libp2p/Protocols/Gossipsub/Types.lean` | the shared gossipsub types: topic and message ids, the published message, the fixed-point score and its thresholds, and the time unit | | `Libp2p/Protocols/Gossipsub/Rpc.lean` | the RPC message with its `ProtoMessage` instance (§3.2) | +| `Libp2p/Protocols/Gossipsub/Score.lean` | the peer score function of v1.1 over its counters, with decay; pure, so the core calls it on its state | +| `Libp2p/Protocols/Gossipsub/Cache.lean` | the message cache, the seen cache, the backoff table, and the IWANT promises; pure data with the core's time as input | +| `Libp2p/Protocols/Gossipsub/Sign.lean` | the signature policy check, the signature input, and the default message id; generic over the verify function | | `Libp2p/Protocols/Gossipsub/Core.lean` | `State`, `Event`, `Action`, `ProtocolError`, and the total `step` that holds mesh, scoring, and heartbeat (§3.3) | | `Libp2p/Protocols/Gossipsub/Node.lean` | the loop task over the core, and the publish and subscribe surface at the seam level | From b72b032923e39fcf8d7c90138143513b46163b65 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:18:59 +0800 Subject: [PATCH 002/125] fix(protocols): close the rpc cap bound without naming the mock varint rpcCapLeMaxLen unfolded Streams.Mock.Varint.maxBytes. PR #58 deletes that module. decide closes the same goal against the current framing and against the framing after that deletion, so the proof names neither. --- packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Rpc.lean | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Rpc.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Rpc.lean index f2294dfb..3c2e4e6f 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Rpc.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Rpc.lean @@ -923,8 +923,6 @@ theorem decodeFrame?Truncated (bytes : List Nat) /-- The rpc cap fits the uvarint prefix. -/ theorem rpcCapLeMaxLen : Constants.rpcCap ≤ Streams.Framing.Prefix.maxLen .uvarint := by - simp only [Constants.rpcCap, Streams.Framing.Prefix.maxLen, - Streams.Mock.Varint.maxBytes] decide /-- The frame steps are inverse for a valid RPC under the cap. -/ From 6fc2c1890b298e1e24655b77180ccc31ad384b2e Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:17:38 +0800 Subject: [PATCH 003/125] feat(protocols): W5.7 1a, identify and ping pure core --- Tests/Main.lean | 2 + Tests/Protocols.lean | 162 ++++++++ lakefile.toml | 1 + .../libp2p/protocols/Libp2p/Protocols.lean | 10 +- .../protocols/Libp2p/Protocols/Constants.lean | 30 ++ .../protocols/Libp2p/Protocols/Identify.lean | 355 ++++++++++++++++++ .../protocols/Libp2p/Protocols/Mock/Core.lean | 22 ++ .../Libp2p/Protocols/Mock/Identity.lean | 48 +++ .../Libp2p/Protocols/Mock/Multiaddr.lean | 21 ++ .../protocols/Libp2p/Protocols/Ping.lean | 63 ++++ 10 files changed, 711 insertions(+), 3 deletions(-) create mode 100644 Tests/Protocols.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Constants.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Identify.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Ping.lean diff --git a/Tests/Main.lean b/Tests/Main.lean index 794e47f1..6a8c257e 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -16,6 +16,7 @@ import Tests.BackendSoak import Tests.Hashes import Tests.Facade import Tests.CryptoCross +import Tests.Protocols /-! # Test entry point @@ -47,4 +48,5 @@ def main : IO UInt32 := do Tests.Hashes.run r Tests.Facade.run r Tests.CryptoCross.run r + Tests.Protocols.run r r.summary diff --git a/Tests/Protocols.lean b/Tests/Protocols.lean new file mode 100644 index 00000000..9797aecc --- /dev/null +++ b/Tests/Protocols.lean @@ -0,0 +1,162 @@ +import Tests.Support +import Proto +import Bytes +import Varint +import Libp2p.Protocols +import Libp2p.Protocols.Mock.Identity +import Libp2p.Protocols.Mock.Multiaddr + +/-! +# Identify and ping suite (unit W5.7, sub-unit 1a) + +Mirrors `vendor/specs/ping/ping.md` (32-byte echo, protocol id +`/ipfs/ping/1.0.0`) and the captured go-libp2p Identify protobuf +(`identify-go.json`). The suite replays the vector and drives the +pure `Identify.validate` and `Ping.checkEcho` checks. The mock key +and multiaddr stand-ins run their executable bodies here, so every +mock outcome of the validate appears in a check. +-/ + +set_option autoImplicit false + +namespace Tests.Protocols + +open Libp2p.Protocols +open Libp2p.Protocols.Mock.Identity +open Libp2p.Protocols.Mock.Multiaddr + +/-- The model decode over the byte list. -/ +private def decodeModel (bs : List Nat) : Except Pb.Error (Identify × List Nat) := + Pb.decodeMessage? (α := Identify) bs + +/-- The production decode over a byte array. -/ +private def decodeByteArray (bs : ByteArray) : Except Pb.Error (Identify × Nat) := + Pb.decodeMessageB? (α := Identify) bs + +/-- The all-absent identify message. -/ +private def emptyIdentify : Identify := ⟨none, [], [], none, none, none, none⟩ + +/-- The encoding of a message, through the shared Proto encoder. -/ +private def msgPayload (m : Identify) : List Nat := + Pb.encodeMessage m + +def run (r : Runner) : IO Unit := do + r.section "protocols" + "libp2p/specs identify/README.md + ping/ping.md + captured go Identify protobuf" + + let j ← loadJson "identify-go.json" + let respHex ← jStr "identify-go.json" j "responseHex" + let pkHex ← jStr "identify-go.json" j "publicKeyHex" + let wantProtocols ← jStrs "identify-go.json" j "protocols" + let wantAgent ← jStr "identify-go.json" j "agentVersion" + let jsonVersion ← jStr "identify-go.json" j "protocolVersion" + + match Bytes.ofHex? respHex with + | none => + r.check "identify vector length prefix is 330" false + r.check "identify vector decodes (model)" false + r.check "identify vector decodes (bytearray)" false + r.check "identify vector model equals bytearray" false + | some respBytes => + match Varint.decode? (Bytes.toNatList respBytes) with + | .error _ => + r.check "identify vector length prefix is 330" false + r.check "identify vector decodes (model)" false + r.check "identify vector decodes (bytearray)" false + r.check "identify vector model equals bytearray" false + | .ok (len, payload) => + -- the captured response carries a uvarint length prefix; + -- the prefix must equal 330 and the remaining length + r.check "identify vector length prefix is 330" + (len.val == 330 && len.val == payload.length) + match decodeModel payload with + | .error _ => + r.check "identify vector decodes (model)" false + r.check "identify vector decodes (bytearray)" false + r.check "identify vector model equals bytearray" false + | .ok (m, _) => + r.check "identify vector decodes (model)" true + let mB := decodeByteArray (Bytes.ofNatList payload) + r.check "identify vector decodes (bytearray)" + (match mB with + | .ok (_, used) => used == payload.length + | .error _ => false) + r.check "identify vector model equals bytearray" + (match mB with + | .ok (m2, _) => m2 == m + | .error _ => false) + r.check "identify vector re-encodes byte-exact" + (Pb.encodeMessage m == payload) + r.check "identify vector encodes deterministically" + (Pb.encodeMessage m == Pb.encodeMessage m) + match Bytes.ofHex? pkHex with + | none => + r.check "identify vector publicKey equals JSON publicKeyHex" false + | some pkBytes => + -- the wire carries the PublicKey protobuf: field 1 is + -- the ed25519 key type, field 2 holds the 32 key bytes + r.check "identify vector publicKey equals JSON publicKeyHex" + (m.publicKey == some ([8, 1, 18, 32] ++ Bytes.toNatList pkBytes)) + r.check "identify vector protocols equal JSON protocols in order" + (m.protocols == wantProtocols) + r.check "identify vector agentVersion equals JSON agentVersion" + (m.agentVersion == some wantAgent) + -- the binary form of /ip4/127.0.0.1/tcp/35371; the real + -- Multiaddr text codec is unmerged + r.check "identify vector listenAddrs has one entry, the binary form of JSON listenAddrs[0]" + (m.listenAddrs == [[4, 127, 0, 0, 1, 6, 138, 43]]) + -- the wire carries the empty string, the JSON says + -- /ipfs/1.0.0; the wire is the truth + r.check "identify vector protocolVersion wire delta recorded" + (m.protocolVersion == some "" && jsonVersion == "/ipfs/1.0.0") + r.skip "identify vector peer id derivation skipped" + "the peer id derivation is an opaque mock; identity #11 replaces it" + + -- validate: rejection and acceptance, through the mock bodies + r.check "identify over-cap payload rejected before decode" + (failsWith Identify.Error.overCap + (Identify.validate (List.replicate 65537 0) none)) + r.check "identify empty payload has no key" + (failsWith Identify.Error.missingKey (Identify.validate [] none)) + let noKey := { emptyIdentify with agentVersion := some "leanp2p" } + r.check "identify missing publicKey rejected" + (failsWith Identify.Error.missingKey (Identify.validate (msgPayload noKey) none)) + let badKeyMsg := { emptyIdentify with publicKey := some [] } + r.check "identify undecodable publicKey rejected" + (failsWith Identify.Error.badKey (Identify.validate (msgPayload badKeyMsg) none)) + let otherId : PeerId := ⟨[], "other"⟩ + let mismatchMsg := { emptyIdentify with publicKey := some [1, 2] } + r.check "identify key mismatch rejected" + (failsWith Identify.Error.keyMismatch + (Identify.validate (msgPayload mismatchMsg) (some otherId))) + let badListen := { emptyIdentify with publicKey := some [1, 2], listenAddrs := [[]] } + r.check "identify invalid listen address rejected" + (failsWith Identify.Error.badAddress (Identify.validate (msgPayload badListen) none)) + let badObs := { emptyIdentify with publicKey := some [1, 2], observedAddr := some [] } + r.check "identify invalid observed address rejected" + (failsWith Identify.Error.badAddress (Identify.validate (msgPayload badObs) none)) + -- a continuation byte with no terminator: truncated framing + r.check "identify malformed protobuf rejected" + (failsWith (Identify.Error.badMessage (.framing .truncated)) + (Identify.validate [0x80] none)) + let good := { emptyIdentify with publicKey := some [1, 2] } + let pid : PeerId := peerIdOfPubKey ⟨.ed25519, [1, 2]⟩ + r.check "identify accepted with matching derivation" + (succeedsWith (good, pid) (Identify.validate (msgPayload good) (some pid))) + r.check "identify accepted with no expected id" + (succeedsWith (good, pid) (Identify.validate (msgPayload good) none)) + + -- ping: the spec constants and the echo check + r.check "ping protocol id constant" (Ping.protocolId.toString == "/ipfs/ping/1.0.0") + r.check "ping payload size 32" (Ping.payloadSize == 32) + let sent := List.replicate 32 7 + r.check "ping echo exact accepted" (succeedsWith () (Ping.checkEcho sent sent)) + r.check "ping echo short rejected" + (failsWith (Ping.Error.wrongSize 31) (Ping.checkEcho sent (List.replicate 31 7))) + r.check "ping echo long rejected" + (failsWith (Ping.Error.wrongSize 33) (Ping.checkEcho sent (List.replicate 33 7))) + let altered := 0 :: List.replicate 31 7 + r.check "ping echo altered rejected" + (failsWith .mismatch (Ping.checkEcho sent altered)) + +end Tests.Protocols diff --git a/lakefile.toml b/lakefile.toml index f7924309..f0ae2ae5 100644 --- a/lakefile.toml +++ b/lakefile.toml @@ -174,6 +174,7 @@ roots = ["Libp2p.Host"] name = "Libp2pProtocols" srcDir = "packages/libp2p/protocols" roots = ["Libp2p.Protocols"] +globs = ["Libp2p.Protocols.*"] [[lean_lib]] name = "Cli" diff --git a/packages/libp2p/protocols/Libp2p/Protocols.lean b/packages/libp2p/protocols/Libp2p/Protocols.lean index 242b09ea..b817c01b 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols.lean @@ -1,8 +1,12 @@ +import Libp2p.Protocols.Constants +import Libp2p.Protocols.Identify +import Libp2p.Protocols.Ping + /-! # Libp2p.Protocols The protocols subpackage: identify and ping first, then req/resp and -gossipsub (ARCHITECTURE §4.9). Scaffold root. -The modules arrive with unit W5.7 of `docs/PLAN.md`. The package docs -live under `docs/` beside this file. +gossipsub (ARCHITECTURE §4.9). Sub-unit 1a of W5.7 lands the pure +identify and ping code. The IO handlers and clients are sub-unit 1b. +The package docs live under `docs/` beside this file. -/ diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean b/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean new file mode 100644 index 00000000..97b2a952 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean @@ -0,0 +1,30 @@ +import Libp2p.Protocols.Mock.Core + +/-! +# Protocol constants + +One home per literal (DRY). The module holds the protocol ids of +identify, identify push, and ping, the identify message cap, and the +ping payload size. Gossipsub and req/resp ids wait for their units. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Constants + +/-- The identify protocol id, `/ipfs/id/1.0.0`. -/ +def identifyId : Mock.Core.ProtocolId := ⟨"/ipfs/id/1.0.0"⟩ + +/-- The identify push protocol id, `/ipfs/id/push/1.0.0`. -/ +def identifyPushId : Mock.Core.ProtocolId := ⟨"/ipfs/id/push/1.0.0"⟩ + +/-- The ping protocol id, `/ipfs/ping/1.0.0`. -/ +def pingId : Mock.Core.ProtocolId := ⟨"/ipfs/ping/1.0.0"⟩ + +/-- The identify message cap, inherited from the spike: 65536 bytes. -/ +def identifyCap : Nat := 65536 + +/-- The ping payload size of the spec: 32 bytes. -/ +def pingSize : Nat := 32 + +end Libp2p.Protocols.Constants diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean new file mode 100644 index 00000000..5a7f20b7 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean @@ -0,0 +1,355 @@ +import Proto +import Bytes +import Libp2p.Protocols.Mock.Identity +import Libp2p.Protocols.Mock.Multiaddr +import Libp2p.Protocols.Constants + +/-! +# Identify message and validate + +The pure half of `/ipfs/id/1.0.0` (sub-unit 1a): the `Identify` +message with its `ProtoMessage` instance, the strict field table, the +validate, and the table theorems. The client and the handler values +are sub-unit 1b. + +The wire shape comes from go-libp2p v0.50.0 `identify.pb.go`: fields +1, 2, 3, 4, 5, 6, and 8. Field 8 is the signed peer record. The emit +order is ascending field number, the order of the captured wire. + +The table is strict on singular fields: a present field with the +wrong wire type, or a present string field with invalid UTF-8, is +`Pb.Error.badField`, gated by `Pb.hasField`. Repeated fields stay +permissive: entries of another wire type are skipped, and invalid +UTF-8 entries are dropped, as the Proto accessors already do. + +The strings read back through the real `Bytes.toString?`, never +through `Pb.getStr?`. The latter runs the opaque mock of +`Pb.Mock.Bytes`, which no theorem can relate to `Bytes.ofString`. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols + +open Mock + +/-- One identify answer. The field numbers are the ones of the +go-libp2p v0.50.0 identify protobuf. -/ +structure Identify where + publicKey : Option (List Nat) -- field 1, the serialized PublicKey protobuf + listenAddrs : List (List Nat) -- field 2, binary multiaddrs, repeated + protocols : List String -- field 3, repeated + observedAddr : Option (List Nat) -- field 4 + protocolVersion : Option String -- field 5 + agentVersion : Option String -- field 6 + signedPeerRecord : Option (List Nat) -- field 8, opaque bytes + deriving Repr, BEq, DecidableEq + +namespace Identify + +open Mock.Identity Mock.Multiaddr + +/-- The identify protocol id. -/ +def protocolId : Mock.Core.ProtocolId := Constants.identifyId + +/-- The identify push protocol id. -/ +def pushId : Mock.Core.ProtocolId := Constants.identifyPushId + +/-- The wire image: one entry per present field, in the ascending +order 1, 2, 3, 4, 5, 6, 8. Strings go to bytes with the real +`Bytes` package. -/ +def entries (m : Identify) : List Pb.Entry := + m.publicKey.toList.map (fun b => (1, Pb.Value.bytes b)) + ++ m.listenAddrs.map (fun b => (2, Pb.Value.bytes b)) + ++ m.protocols.map (fun s => (3, Pb.Value.bytes (Bytes.toNatList (Bytes.ofString s)))) + ++ m.observedAddr.toList.map (fun b => (4, Pb.Value.bytes b)) + ++ m.protocolVersion.toList.map (fun s => (5, Pb.Value.bytes (Bytes.toNatList (Bytes.ofString s)))) + ++ m.agentVersion.toList.map (fun s => (6, Pb.Value.bytes (Bytes.toNatList (Bytes.ofString s)))) + ++ m.signedPeerRecord.toList.map (fun b => (8, Pb.Value.bytes b)) + +/-- Fail closed. A message whose entries do not fit the wire model +emits no entry; every theorem about the encode takes the validity as +a premise. A payload of `List Nat` and a `String` carry no bound, so +the gate cannot be dropped. -/ +def toFields (m : Identify) : Pb.Fields := + if h : ∀ e ∈ m.entries, e.Valid then ⟨m.entries, h⟩ else Pb.Fields.empty + +/-- A present singular payload must be length-delimited. A miss means +absent. -/ +private def bytesField? (num : Nat) (fs : Pb.Fields) : Except Pb.Error (Option (List Nat)) := + if Pb.hasField num fs then + match Pb.getBytes? num fs with + | some bs => .ok (some bs) + | none => .error .badField + else .ok none + +/-- A present singular string must be length-delimited and valid +UTF-8. A miss means absent. -/ +private def strField? (num : Nat) (fs : Pb.Fields) : Except Pb.Error (Option String) := + if Pb.hasField num fs then + match (Pb.getBytes? num fs).bind (fun bs => Bytes.toString? (Bytes.ofNatList bs)) with + | some s => .ok (some s) + | none => .error .badField + else .ok none + +/-- The field table: strict singular fields, permissive repeated +fields. Unknown fields stay untouched in the wire view. -/ +def ofFields? (fs : Pb.Fields) : Except Pb.Error Identify := do + let publicKey ← bytesField? 1 fs + let observedAddr ← bytesField? 4 fs + let protocolVersion ← strField? 5 fs + let agentVersion ← strField? 6 fs + let signedPeerRecord ← bytesField? 8 fs + .ok { publicKey, + listenAddrs := Pb.getAllBytes 2 fs, + protocols := (Pb.getAllBytes 3 fs).filterMap + (fun bs => Bytes.toString? (Bytes.ofNatList bs)), + observedAddr, protocolVersion, agentVersion, signedPeerRecord } + +instance : Pb.ProtoMessage Identify where + toFields := toFields + ofFields? := ofFields? + +/-! ## The table theorems + +I1, the table law, carries the wire-validity premise. The two +round-trip theorems compose it with the Proto laws. -/ + +/-- A miss lookup through a segment that maps payloads to another +field number. -/ +private theorem lookupMissMap {α : Type} (k n : Nat) (h : k ≠ n) (g : α → Pb.Value) + (l : List α) : + List.lookup k (l.map fun x => (n, g x)).reverse = none := by + induction l with + | nil => rfl + | cons x l ih => + have hb : (k == n) = false := by simpa using h + simp [List.lookup_append, ih, List.lookup, hb] + +/-- A filterMap of constant `none` drops every entry. -/ +private theorem filterMapNone {α β : Type} (l : List α) : + l.filterMap (fun _ => (none : Option β)) = [] := + List.filterMap_eq_nil_iff.mpr fun _ _ => rfl + +/-- I1: the table law. Every valid message round-trips through its +own table. -/ +theorem ofFieldsToFields (m : Identify) (h : ∀ e ∈ m.entries, e.Valid) : + Pb.ProtoMessage.ofFields? (α := Identify) (Pb.ProtoMessage.toFields m) = .ok m := by + change ofFields? (toFields m) = .ok m + rw [toFields, dite_eq_left h] + obtain ⟨pk, la, ps, oa, pv, av, spr⟩ := m + simp only [ofFields?, bytesField?, strField?, Pb.hasField, Pb.getBytes?, Pb.getAllBytes, + Pb.lookupLast] + cases pk <;> cases oa <;> cases pv <;> cases av <;> cases spr <;> + simp [entries, List.lookup_append, List.reverse_append, List.filterMap_append, + List.any_append, Bind.bind, Except.bind, Bytes.ofNatListToNatList, + Bytes.toString?OfString, Function.comp_def, List.lookup_cons, filterMapNone, + lookupMissMap] + +/-- I2: an encode decodes back to the message. -/ +theorem decodeEncode (m : Identify) (h : ∀ e ∈ m.entries, e.Valid) : + Pb.decodeMessage? (Pb.encodeMessage m) = .ok (m, []) := by + simp only [Pb.decodeMessage?, Pb.encodeMessage, Pb.decodeEncode, ofFieldsToFields m h, + Bind.bind, Except.bind] + +/-- I3: equal encodings imply equal messages. Without the premise +the statement is false: a message with an out-of-range byte and the +empty message both encode to `[]`. -/ +theorem encodeInjective (a b : Identify) (ha : ∀ e ∈ a.entries, e.Valid) + (hb : ∀ e ∈ b.entries, e.Valid) + (h : Pb.encodeMessage a = Pb.encodeMessage b) : a = b := by + have hf : toFields a = toFields b := Pb.encodeInjective h + have h3 : Pb.ProtoMessage.ofFields? (α := Identify) (Pb.ProtoMessage.toFields a) = + Pb.ProtoMessage.ofFields? (α := Identify) (Pb.ProtoMessage.toFields b) := + congrArg (fun fs => Pb.ProtoMessage.ofFields? (α := Identify) fs) hf + rw [ofFieldsToFields a ha, ofFieldsToFields b hb] at h3 + exact Except.ok.inj h3 + +/-- I4: the repeated fields survive the round trip in order. -/ +theorem roundTripRepeated (m : Identify) (h : ∀ e ∈ m.entries, e.Valid) : + ∃ m' : Identify, Pb.decodeMessage? (Pb.encodeMessage m) = .ok (m', []) ∧ + m'.protocols = m.protocols ∧ m'.listenAddrs = m.listenAddrs := + ⟨m, decodeEncode m h, rfl, rfl⟩ + +/-! ## Validate + +The checks run in this order: cap, decode, key presence, key parse, +expected id, addresses. The cap check runs before any decode. -/ + +/-- The validate failures. The zero-length rule of the read path is +sub-unit 1b, so the pure validate sees an empty payload as an empty +message, which then fails with `missingKey`. -/ +inductive Error where + | overCap + | badMessage (e : Pb.Error) + | missingKey + | badKey + | keyMismatch + | badAddress + deriving DecidableEq, BEq, Repr + +/-- The address checks: every listening address and the observed +address, when present, must be valid bytes. -/ +private def checkAddrs (m : Identify) (k : Mock.Identity.PubKey) : + Except Error (Identify × Mock.Identity.PeerId) := + if ∀ a ∈ m.listenAddrs, validBytes a then + if ∀ a ∈ m.observedAddr.toList, validBytes a then + .ok (m, peerIdOfPubKey k) + else .error .badAddress + else .error .badAddress + +/-- Validate one received response: the derived peer id must match +the expected id, and the size must stay under the cap. -/ +def validate (payload : List Nat) (expected : Option Mock.Identity.PeerId) : + Except Error (Identify × Mock.Identity.PeerId) := + if Constants.identifyCap < payload.length then .error .overCap + else match (Pb.decodeMessage? payload : Except Pb.Error (Identify × List Nat)) with + | .error e => .error (.badMessage e) + | .ok (m, _) => + match m.publicKey with + | none => .error .missingKey + | some kb => + match unmarshalPubKey? kb with + | none => .error .badKey + | some k => + match expected with + | none => checkAddrs m k + | some e => + if peerIdOfPubKey k = e then checkAddrs m k + else .error .keyMismatch + +/-- The address check accepts exactly the messages whose addresses +are all valid. -/ +private theorem checkAddrsSound (m : Identify) (k : Mock.Identity.PubKey) + (m' : Identify) (pid : Mock.Identity.PeerId) + (h : checkAddrs m k = .ok (m', pid)) : + (∀ a ∈ m.listenAddrs, validBytes a) ∧ + (∀ a ∈ m.observedAddr.toList, validBytes a) ∧ + m' = m ∧ pid = peerIdOfPubKey k := by + unfold checkAddrs at h + by_cases ha : ∀ a ∈ m.listenAddrs, validBytes a + · by_cases ho : ∀ a ∈ m.observedAddr.toList, validBytes a + · rw [ite_eq_left ha, ite_eq_left ho] at h + simp only [Except.ok.injEq, Prod.mk.injEq] at h + obtain ⟨rfl, rfl⟩ := h + exact ⟨ha, ho, rfl, rfl⟩ + · rw [ite_eq_left ha, ite_eq_right ho] at h + cases h + · rw [ite_eq_right ha] at h + cases h + +/-- I5: an over-cap payload fails before any decode. -/ +theorem validateOverCap (payload : List Nat) (expected : Option Mock.Identity.PeerId) + (h : Constants.identifyCap < payload.length) : + validate payload expected = .error .overCap := by + simp [validate, h] + +/-- I6: the empty payload is the empty message, and it has no key. -/ +theorem decodeEmpty : + Pb.decodeMessage? (α := Identify) [] = .ok (⟨none, [], [], none, none, none, none⟩, []) := + rfl + +theorem validateEmptyNoKey : validate [] none = .error .missingKey := rfl + +/-- I7: acceptance, conditional on the opaque mock results. -/ +theorem validateAccepts (payload : List Nat) (m : Identify) (kb : List Nat) + (k : Mock.Identity.PubKey) (expected : Option Mock.Identity.PeerId) + (hsize : payload.length ≤ Constants.identifyCap) + (hdec : Pb.decodeMessage? (α := Identify) payload = .ok (m, [])) + (hpub : m.publicKey = some kb) + (hun : Mock.Identity.unmarshalPubKey? kb = some k) + (hderive : ∀ e, expected = some e → Mock.Identity.peerIdOfPubKey k = e) + (haddrs : ∀ a ∈ m.listenAddrs, validBytes a) + (hobs : ∀ a ∈ m.observedAddr.toList, validBytes a) : + validate payload expected = .ok (m, peerIdOfPubKey k) := by + unfold validate + rw [ite_eq_right (by omega)] + simp only [hdec, hpub, hun] + split + · simp only [checkAddrs] + rw [ite_eq_left haddrs, ite_eq_left hobs] + · rename_i e + have hk : Mock.Identity.peerIdOfPubKey k = e := hderive e rfl + rw [ite_eq_left hk] + simp only [checkAddrs] + rw [ite_eq_left haddrs, ite_eq_left hobs] + +/-- I8: a derived id that differs from the expected id is rejected. -/ +theorem validateKeyMismatch (payload : List Nat) (m : Identify) (kb : List Nat) + (k : Mock.Identity.PubKey) (e : Mock.Identity.PeerId) + (hsize : payload.length ≤ Constants.identifyCap) + (hdec : Pb.decodeMessage? (α := Identify) payload = .ok (m, [])) + (hpub : m.publicKey = some kb) + (hun : Mock.Identity.unmarshalPubKey? kb = some k) + (hne : Mock.Identity.peerIdOfPubKey k ≠ e) : + validate payload (some e) = .error .keyMismatch := by + unfold validate + rw [ite_eq_right (by omega)] + simp only [hdec, hpub, hun] + split + · rename_i hh + exact absurd hh hne + · rfl + +/-- I9: soundness. A passing validate carries the cap bound, the +key facts, the derivation match, and the address validity. With I7 +and I8 this pins the acceptance set exactly. -/ +theorem validateSound (payload : List Nat) (expected : Option Mock.Identity.PeerId) + (m : Identify) (pid : Mock.Identity.PeerId) + (h : validate payload expected = .ok (m, pid)) : + payload.length ≤ Constants.identifyCap ∧ + ∃ kb k, m.publicKey = some kb ∧ Mock.Identity.unmarshalPubKey? kb = some k ∧ + pid = Mock.Identity.peerIdOfPubKey k ∧ + (∀ e, expected = some e → Mock.Identity.peerIdOfPubKey k = e) ∧ + (∀ a ∈ m.listenAddrs, validBytes a) ∧ + (∀ a ∈ m.observedAddr.toList, validBytes a) := by + unfold validate at h + by_cases hcap : Constants.identifyCap < payload.length + · rw [ite_eq_left hcap] at h + cases h + · rw [ite_eq_right hcap] at h + cases hd : Pb.decodeMessage? (α := Identify) payload with + | error e => + simp only [hd] at h + cases h + | ok pair => + obtain ⟨m', _⟩ := pair + simp only [hd] at h + cases hm : m'.publicKey with + | none => + simp only [hm] at h + cases h + | some kb => + simp only [hm] at h + cases hu : Mock.Identity.unmarshalPubKey? kb with + | none => + simp only [hu] at h + cases h + | some k => + simp only [hu] at h + cases he : expected with + | none => + simp only [he] at h + have hs := checkAddrsSound m' k m pid h + rcases hs with ⟨ha, ho, rfl, rfl⟩ + refine ⟨Nat.le_of_not_lt hcap, ?_⟩ + refine ⟨kb, k, hm, hu, rfl, ?_, ha, ho⟩ + intro e' he' + cases he' + | some e => + simp only [he] at h + by_cases hkey : Mock.Identity.peerIdOfPubKey k = e + · rw [ite_eq_left hkey] at h + have hs := checkAddrsSound m' k m pid h + rcases hs with ⟨ha, ho, rfl, rfl⟩ + refine ⟨Nat.le_of_not_lt hcap, ?_⟩ + refine ⟨kb, k, hm, hu, rfl, ?_, ha, ho⟩ + intro e' he' + cases he' + exact hkey + · rw [ite_eq_right hkey] at h + cases h + +end Identify + +end Libp2p.Protocols diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean new file mode 100644 index 00000000..aeece266 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean @@ -0,0 +1,22 @@ +import Std + +/-! +# ProtocolId mock + +`Libp2p.Core` (issue #15, PR #37 draft) is not merged. This mock +declares the frozen `ProtocolId` shape from +`packages/libp2p/core/docs/ARCHITECTURE.md`. The real core package +replaces this module at integration. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Mock.Core + +/-- A peer-defined protocol id, an open set of strings. The code +compares `ProtocolId` values only. -/ +structure ProtocolId where + toString : String + deriving DecidableEq, Hashable + +end Libp2p.Protocols.Mock.Core diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean new file mode 100644 index 00000000..ee955278 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean @@ -0,0 +1,48 @@ +import Std + +/-! +# Identity mock + +`Libp2p.Identity` (issue #11) is not merged. This mock declares the +frozen key and peer id shapes from +`packages/libp2p/identity/docs/ARCHITECTURE.md`. The real identity +package replaces this module at integration. + +`PubKey` drops the validity proof field of the real shape. The +stand-in bodies are executable, so the suite can drive both outcomes +of `unmarshalPubKey?`. The theorems of `Identify` stay true for any +replacement, because they are conditional on these opaque results. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Mock.Identity + +/-- The closed set of key types: rsa (0), ed25519 (1), secp256k1 (2), +ecdsa (3). -/ +inductive KeyType where + | rsa | ed25519 | secp256k1 | ecdsa + deriving DecidableEq, BEq, Repr + +/-- A public key: its type and its data bytes. -/ +structure PubKey where + keyType : KeyType + data : List Nat + deriving DecidableEq, Repr + +/-- A peer id: the derived bytes and the text form. -/ +structure PeerId where + bytes : List Nat + text : String + deriving DecidableEq, BEq, Repr + +/-- Executable stand-in: a nonempty buffer parses as an ed25519 key, +an empty buffer does not parse. -/ +opaque unmarshalPubKey? (bs : List Nat) : Option PubKey := + if bs.isEmpty then none else some ⟨.ed25519, bs⟩ + +/-- Executable stand-in: the derived id carries the key bytes. -/ +opaque peerIdOfPubKey (k : PubKey) : PeerId := + ⟨k.data, ""⟩ + +end Libp2p.Protocols.Mock.Identity diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean new file mode 100644 index 00000000..4f93d1bb --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean @@ -0,0 +1,21 @@ +import Std + +/-! +# Multiaddr mock + +`Multiformats` (issue #8, PR #36, unmerged) owns the real +`Multiaddr.ofByteArray?`. This mock declares the minimal validity +predicate that the identify validate needs. The real check replaces +it at integration. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Mock.Multiaddr + +/-- Executable stand-in: the empty address is invalid, every other +buffer is valid. -/ +opaque validBytes (bs : List Nat) : Bool := + !bs.isEmpty + +end Libp2p.Protocols.Mock.Multiaddr diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean b/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean new file mode 100644 index 00000000..c7fbd12b --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean @@ -0,0 +1,63 @@ +import Libp2p.Protocols.Constants + +/-! +# Ping + +The pure half of `/ipfs/ping/1.0.0`: the protocol id, the payload +size, and the echo check. The IO handler and the initiator are +sub-unit 1b. + +Per `vendor/specs/ping/ping.md`: the initiator sends 32 random +bytes, and the responder echoes them. A payload of another size, or +an echo that differs from the sent payload, is a protocol error. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Ping + +/-- The ping protocol id. -/ +def protocolId : Mock.Core.ProtocolId := Constants.pingId + +/-- The ping payload size of the spec. -/ +def payloadSize : Nat := Constants.pingSize + +/-- The failures of the pure echo check. The IO arm (a partial +payload at end of stream) joins it in sub-unit 1b. -/ +inductive Error where + | wrongSize (got : Nat) + | mismatch + deriving DecidableEq, BEq, Repr + +/-- Check one echo: 32 bytes, equal to the sent payload. -/ +def checkEcho (sent echo : List Nat) : Except Error Unit := + if echo.length ≠ payloadSize then .error (.wrongSize echo.length) + else if echo ≠ sent then .error .mismatch + else .ok () + +/-- P1: an exact echo of a 32-byte payload passes. -/ +theorem checkEchoAccepts (sent : List Nat) (h : sent.length = payloadSize) : + checkEcho sent sent = .ok () := by + simp [checkEcho, h] + +/-- P2: another size fails before the comparison. -/ +theorem checkEchoWrongSize (sent echo : List Nat) (h : echo.length ≠ payloadSize) : + checkEcho sent echo = .error (.wrongSize echo.length) := by + simp [checkEcho, h] + +/-- P3: a same-size echo that differs fails. -/ +theorem checkEchoMismatch (sent echo : List Nat) (hlen : echo.length = payloadSize) + (hne : echo ≠ sent) : checkEcho sent echo = .error .mismatch := by + simp [checkEcho, hlen, hne] + +/-- P4: a passing echo is the sent payload, of the spec size. -/ +theorem checkEchoSound (sent echo : List Nat) (h : checkEcho sent echo = .ok ()) : + echo = sent ∧ sent.length = payloadSize := by + by_cases hlen : echo.length = payloadSize + · by_cases hne : echo ≠ sent + · simp [checkEcho, hlen, hne] at h + · have heq : echo = sent := by simpa using hne + exact ⟨heq, heq ▸ hlen⟩ + · simp [checkEcho, hlen] at h + +end Libp2p.Protocols.Ping From b73ec3c3fd912981830bf164ebd537d320c98803 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:08:18 +0800 Subject: [PATCH 004/125] feat(protocols): W5.7 1b, identify and ping handlers and clients --- Tests/Protocols.lean | 332 +++++++++++++++++- .../protocols/Libp2p/Protocols/Constants.lean | 3 + .../protocols/Libp2p/Protocols/Identify.lean | 261 +++++++++++++- .../protocols/Libp2p/Protocols/Mock/Core.lean | 34 +- .../protocols/Libp2p/Protocols/Mock/Host.lean | 44 +++ .../Libp2p/Protocols/Mock/Identity.lean | 8 + .../protocols/Libp2p/Protocols/Ping.lean | 131 ++++++- 7 files changed, 799 insertions(+), 14 deletions(-) create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean diff --git a/Tests/Protocols.lean b/Tests/Protocols.lean index 9797aecc..00fa37ae 100644 --- a/Tests/Protocols.lean +++ b/Tests/Protocols.lean @@ -2,12 +2,16 @@ import Tests.Support import Proto import Bytes import Varint +import Conc +import Streams import Libp2p.Protocols import Libp2p.Protocols.Mock.Identity import Libp2p.Protocols.Mock.Multiaddr +import Libp2p.Protocols.Mock.Core +import Libp2p.Protocols.Mock.Host /-! -# Identify and ping suite (unit W5.7, sub-unit 1a) +# Identify and ping suite (unit W5.7, sub-units 1a and 1b) Mirrors `vendor/specs/ping/ping.md` (32-byte echo, protocol id `/ipfs/ping/1.0.0`) and the captured go-libp2p Identify protobuf @@ -15,6 +19,12 @@ Mirrors `vendor/specs/ping/ping.md` (32-byte echo, protocol id pure `Identify.validate` and `Ping.checkEcho` checks. The mock key and multiaddr stand-ins run their executable bodies here, so every mock outcome of the validate appears in a check. + +Sub-unit 1b adds the exchange cases: the identify client against the +responder handler, the push handler against the in-memory peerstore +double, the client deadline on the test clock, and the ping echo in +both directions. The exchanges run over a private pair of `Conc.Pipe` +values spliced into two `Streams.Stream` ends. -/ set_option autoImplicit false @@ -40,6 +50,318 @@ private def emptyIdentify : Identify := ⟨none, [], [], none, none, none, none private def msgPayload (m : Identify) : List Nat := Pb.encodeMessage m +/-! ## The private stream pair (sub-unit 1b) + +Two `Conc.Pipe` values spliced like the spike's `mkSplit` +(`Tests/PingId.lean`): end `a` reads `ba` and writes `ab`, end `b` +reads `ab` and writes `ba`. The adapter's `close` is a no-op, so one +pair serves the swapped-role exchange; the cases that need an end +close the pipe directly. `reset` closes the pipe the end reads, so a +reset reaches the parked read. -/ + +/-- The pair: the two stream ends and the two pipes, for the cases +that close a pipe directly. -/ +private structure Pair where + a : Streams.Stream + b : Streams.Stream + ab : Conc.Pipe + ba : Conc.Pipe + +/-- One stream end over the two pipes. -/ +private def mkEnd (readP writeP : Conc.Pipe) : Streams.Stream := { + read := do + match ← readP.read with + | none => return .ok none + | some chunk => return .ok (some chunk) + write := fun bytes => + (Except.mapError (fun e => match e with + | Conc.Error.closed => Streams.Error.closed + | Conc.Error.deadlineExpired => Streams.Error.timeout)) <$> writeP.write bytes + close := pure () + reset := readP.close } + +/-- The pair: `a` reads `ba` and writes `ab`; `b` reads `ab` and +writes `ba`. -/ +private def mkPair : BaseIO Pair := do + let ab ← Conc.Pipe.new 4 + let ba ← Conc.Pipe.new 4 + return { a := mkEnd ba ab, b := mkEnd ab ba, ab, ba } + +/-- True when the answer is `.ok (some _)`. -/ +private def isOkSome {ε α : Type} (x : Except ε (Option α)) : Bool := + match x with + | .ok (some _) => true + | _ => false + +/-- True when the answer is `.ok none`. -/ +private def isOkNone {ε α : Type} (x : Except ε (Option α)) : Bool := + match x with + | .ok none => true + | _ => false + +/-- True when the handler answer is a protocol error. -/ +private def isProtocolError (x : Except Libp2p.Protocols.Mock.Core.Error Unit) : Bool := + match x with + | .error (.protocol _) => true + | _ => false + +/-- Run the handler on `s`, and fail the task when the handler +answers an error. -/ +private def runHandler (h : Libp2p.Protocols.Mock.Core.StreamHandler) + (s : Streams.Stream) : Conc.Async Unit := do + let res : Except Libp2p.Protocols.Mock.Core.Error Unit ← h.handle s + match res with + | .error e => throw (IO.userError s!"the handler answered {repr e}") + | .ok () => pure () + +/-- The 1b exchange cases, on the test clock and the private pair. -/ +private def asyncChecks (r : Runner) : Conc.Async Unit := do + -- identify: the responder round trip + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let key : PubKey := ⟨.ed25519, [1, 2]⟩ + let pid : PeerId := peerIdOfPubKey key + let addr := [4, 127, 0, 0, 1, 6, 138, 43] + let agent := "leanp2p" + let protocols := ["/ipfs/id/1.0.0"] + let ts ← Conc.TaskSet.new + ts.spawn do + runHandler (Identify.respondHandler key [addr] protocols none agent none) pair.b + let res ← Identify.client pair.a tc.clock 1000 (some pid) + ts.joinAll + r.check "identify e2e responder round trip accepted" + (match res with + | .ok (some (m, got)) => + got == pid && m.agentVersion == some agent && m.protocols == protocols + | _ => false) + tc.close + + -- identify: the same pair runs the exchange twice, roles swapped + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let key : PubKey := ⟨.ed25519, [3, 4]⟩ + let pid : PeerId := peerIdOfPubKey key + let agent := "leanp2p" + let ts ← Conc.TaskSet.new + ts.spawn do + runHandler (Identify.respondHandler key [] [] none agent none) pair.b + let first ← Identify.client pair.a tc.clock 1000 (some pid) + ts.joinAll + ts.spawn do + runHandler (Identify.respondHandler key [] [] none agent none) pair.a + let second ← Identify.client pair.b tc.clock 1000 (some pid) + ts.joinAll + r.check "identify e2e round trip both directions" + (isOkSome first && isOkSome second) + tc.close + + -- identify: the push handler updates the peerstore + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let store ← Mock.Host.inMemory + let key : PubKey := ⟨.ed25519, [1, 2]⟩ + let pid : PeerId := peerIdOfPubKey key + let addr := [4, 127, 0, 0, 1, 6, 138, 43] + let msg := Identify.respond key [addr] [] none "" none + match Streams.Framing.encodeB? .uvarint Constants.identifyCap + (Pb.encodeMessageB msg) with + | none => r.check "identify e2e push updates the peerstore and reads back" false + | some frame => + let ts ← Conc.TaskSet.new + ts.spawn do + runHandler (Identify.pushHandler store pid tc.clock 1000) pair.b + discard <| pair.a.write frame + ts.joinAll + let stored ← store.addrsOf pid + r.check "identify e2e push updates the peerstore and reads back" + (stored == [addr]) + tc.close + + -- identify: a second push of the same addresses grows nothing + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let store ← Mock.Host.inMemory + let key : PubKey := ⟨.ed25519, [1, 2]⟩ + let pid : PeerId := peerIdOfPubKey key + let addr := [4, 127, 0, 0, 1, 6, 138, 43] + let msg := Identify.respond key [addr] [] none "" none + match Streams.Framing.encodeB? .uvarint Constants.identifyCap + (Pb.encodeMessageB msg) with + | none => r.check "identify e2e push redial adds no duplicate address" false + | some frame => + let ts ← Conc.TaskSet.new + ts.spawn do + runHandler (Identify.pushHandler store pid tc.clock 1000) pair.b + discard <| pair.a.write frame + ts.joinAll + let ts2 ← Conc.TaskSet.new + ts2.spawn do + runHandler (Identify.pushHandler store pid tc.clock 1000) pair.b + discard <| pair.a.write frame + ts2.joinAll + let stored ← store.addrsOf pid + r.check "identify e2e push redial adds no duplicate address" + (stored == [addr]) + tc.close + + -- identify: the client rejects an over-cap frame before the body + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let overCapPrefix := Bytes.ofNatList (Streams.Framing.encodeLength .uvarint 65537) + discard <| pair.b.write overCapPrefix + pair.ba.close + let res ← Identify.client pair.a tc.clock 1000 none + r.check "identify client rejects an over-cap frame before the body" + (failsWith (Identify.Error.badMessage (.framing .malformed)) res) + tc.close + + -- identify: the client rejects a zero-length frame + do + let pair ← mkPair + let tc ← Conc.TestClock.new + discard <| pair.b.write (Bytes.ofNatList (Streams.Framing.encodeLength .uvarint 0)) + let res ← Identify.client pair.a tc.clock 1000 none + r.check "identify client rejects a zero-length frame" + (failsWith Identify.Error.emptyFrame res) + tc.close + + -- identify: the client answers absent on a clean close in the header + do + let pair ← mkPair + let tc ← Conc.TestClock.new + pair.ba.close + let res ← Identify.client pair.a tc.clock 1000 none + r.check "identify client returns absent on a clean close in the header" + (isOkNone res) + tc.close + + -- identify: the client hits the deadline on a silent peer + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let answer : Conc.OnceRef (Option (Except Identify.Error (Option (Identify × PeerId)))) ← + Conc.OnceRef.new none + let ts ← Conc.TaskSet.new + ts.spawn do + answer.set (some (← Identify.client pair.a tc.clock 50 none)) + let armed ← tc.nextTimer + tc.advance 50 + ts.joinAll + let res ← answer.get + r.check "identify client hits the deadline on a silent peer" + (armed && res.any (failsWith Identify.Error.timeout)) + tc.close + + -- ping: the echo round trip measures the rtt + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let payload := Bytes.ofNatList (List.replicate 32 7) + let ts ← Conc.TaskSet.new + ts.spawn do + runHandler Ping.pingHandler pair.b + let res ← Ping.ping pair.a tc.clock payload + pair.ab.close + ts.joinAll + r.check "ping e2e echo round trip measures the rtt" (isOkSome res) + tc.close + + -- ping: an altered echo is rejected + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let payload := Bytes.ofNatList (List.replicate 32 7) + let altered := Bytes.ofNatList (0 :: List.replicate 31 7) + let ts ← Conc.TaskSet.new + ts.spawn do + discard <| pair.b.write altered + discard <| pair.b.read + let res ← Ping.ping pair.a tc.clock payload + ts.joinAll + r.check "ping e2e altered echo rejected" (failsWith Ping.Error.mismatch res) + tc.close + + -- ping: a partial echo at the end of the stream is rejected + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let payload := Bytes.ofNatList (List.replicate 32 7) + let ts ← Conc.TaskSet.new + ts.spawn do + discard <| pair.b.write (Bytes.ofNatList (List.replicate 31 7)) + pair.ba.close + let res ← Ping.ping pair.a tc.clock payload + ts.joinAll + r.check "ping e2e partial echo at EOF rejected" (failsWith Ping.Error.truncated res) + tc.close + + -- ping: an echo split across chunks is accepted + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let payload := Bytes.ofNatList (List.replicate 32 7) + let half := List.replicate 16 7 + let ts ← Conc.TaskSet.new + ts.spawn do + discard <| pair.b.write (Bytes.ofNatList half) + discard <| pair.b.write (Bytes.ofNatList half) + discard <| pair.b.read + let res ← Ping.ping pair.a tc.clock payload + ts.joinAll + r.check "ping e2e echo split across chunks accepted" (isOkSome res) + tc.close + + -- ping: the handler echoes a payload written in two chunks + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let payload := List.replicate 32 7 + let half := List.replicate 16 7 + let ts ← Conc.TaskSet.new + ts.spawn do + runHandler Ping.pingHandler pair.b + discard <| pair.a.write (Bytes.ofNatList half) + discard <| pair.a.write (Bytes.ofNatList half) + let echo ← pair.a.read + let ok := match echo with + | .ok (some chunk) => succeedsWith () (Ping.checkEcho payload (Bytes.toNatList chunk)) + | _ => false + pair.ab.close + ts.joinAll + r.check "ping e2e handler echoes a split write" ok + tc.close + + -- ping: a partial payload at the end of the stream is a protocol error + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let answer : Conc.OnceRef (Option (Except Libp2p.Protocols.Mock.Core.Error Unit)) ← + Conc.OnceRef.new none + let ts ← Conc.TaskSet.new + ts.spawn do + answer.set (some (← Ping.pingHandler.handle pair.b)) + discard <| pair.a.write (Bytes.ofNatList (List.replicate 20 7)) + pair.ab.close + ts.joinAll + let res ← answer.get + r.check "ping e2e handler partial payload at EOF is a protocol error" + (res.any isProtocolError) + tc.close + + -- ping: a wrong outgoing size is rejected before the write + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let res ← Ping.ping pair.a tc.clock (Bytes.ofNatList (List.replicate 31 7)) + r.check "ping e2e wrong outgoing size rejected" + (failsWith (Ping.Error.wrongSize 31) res) + tc.close + def run (r : Runner) : IO Unit := do r.section "protocols" "libp2p/specs identify/README.md + ping/ping.md + captured go Identify protobuf" @@ -105,6 +427,12 @@ def run (r : Runner) : IO Unit := do -- Multiaddr text codec is unmerged r.check "identify vector listenAddrs has one entry, the binary form of JSON listenAddrs[0]" (m.listenAddrs == [[4, 127, 0, 0, 1, 6, 138, 43]]) + -- the full captured bytes, prefix and payload, through the + -- pure client core: the wire decision path of sub-unit 1b + r.check "identify vector through the client core" + (match Identify.clientCore (Bytes.toNatList respBytes) none with + | .ok (some (m2, _)) => m2 == m + | _ => false) -- the wire carries the empty string, the JSON says -- /ipfs/1.0.0; the wire is the truth r.check "identify vector protocolVersion wire delta recorded" @@ -159,4 +487,6 @@ def run (r : Runner) : IO Unit := do r.check "ping echo altered rejected" (failsWith .mismatch (Ping.checkEcho sent altered)) + Conc.Async.run (asyncChecks r) + end Tests.Protocols diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean b/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean index 97b2a952..770f9524 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean @@ -27,4 +27,7 @@ def identifyCap : Nat := 65536 /-- The ping payload size of the spec: 32 bytes. -/ def pingSize : Nat := 32 +/-- The protocol version the responder writes, the spike's constant. -/ +def identifyProtocolVersion : String := "/ipfs/1.0.0" + end Libp2p.Protocols.Constants diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean index 5a7f20b7..ebe2e661 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean @@ -1,7 +1,10 @@ import Proto import Bytes +import Conc +import Streams import Libp2p.Protocols.Mock.Identity import Libp2p.Protocols.Mock.Multiaddr +import Libp2p.Protocols.Mock.Host import Libp2p.Protocols.Constants /-! @@ -9,8 +12,8 @@ import Libp2p.Protocols.Constants The pure half of `/ipfs/id/1.0.0` (sub-unit 1a): the `Identify` message with its `ProtoMessage` instance, the strict field table, the -validate, and the table theorems. The client and the handler values -are sub-unit 1b. +validate, and the table theorems. Sub-unit 1b adds the IO half: the +responder, the client, the push handler, and the theorems C1 to C3. The wire shape comes from go-libp2p v0.50.0 `identify.pb.go`: fields 1, 2, 3, 4, 5, 6, and 8. Field 8 is the signed peer record. The emit @@ -25,6 +28,12 @@ UTF-8 entries are dropped, as the Proto accessors already do. The strings read back through the real `Bytes.toString?`, never through `Pb.getStr?`. The latter runs the opaque mock of `Pb.Mock.Bytes`, which no theorem can relate to `Bytes.ofString`. + +The private read loop `readFrameFrom` is a `partial def`: a silent +peer never ends the loop, and the deadline of `client` or `push` is +what ends it. The unit reads one frame per stream and drops leftover +bytes after it; go merges chunked identify messages, a recorded delta. +The read rejects a zero-length frame, the spike's rule. -/ set_option autoImplicit false @@ -176,9 +185,10 @@ theorem roundTripRepeated (m : Identify) (h : ∀ e ∈ m.entries, e.Valid) : The checks run in this order: cap, decode, key presence, key parse, expected id, addresses. The cap check runs before any decode. -/ -/-- The validate failures. The zero-length rule of the read path is -sub-unit 1b, so the pure validate sees an empty payload as an empty -message, which then fails with `missingKey`. -/ +/-- The validate failures of 1a, plus the read-path arms of 1b. The +zero-length rule lives on the read path, so the pure validate sees an +empty payload as an empty message, which then fails with +`missingKey`. -/ inductive Error where | overCap | badMessage (e : Pb.Error) @@ -186,6 +196,12 @@ inductive Error where | badKey | keyMismatch | badAddress + /-- A zero-length frame on the read path, the spike's rule. -/ + | emptyFrame + /-- The read deadline expired. -/ + | timeout + /-- The stream answered a fault. -/ + | stream (e : Streams.Error) deriving DecidableEq, BEq, Repr /-- The address checks: every listening address and the observed @@ -350,6 +366,241 @@ theorem validateSound (payload : List Nat) (expected : Option Mock.Identity.Peer · rw [ite_eq_right hkey] at h cases h +/-! ## The responder (sub-unit 1b) + +The responder builds one message from the host data, writes one +length-delimited frame, and half-closes. `respond?` fails closed when +the entries do not fit the wire model: such a message would encode to +an empty one and write a zero-length frame. -/ + +/-- Build one identify response from the host data. -/ +def respond (key : Mock.Identity.PubKey) (addrs : List (List Nat)) + (protocols : List String) (observed : Option (List Nat)) (agent : String) + (spr : Option (List Nat) := none) : Identify := + { protocolVersion := some Constants.identifyProtocolVersion, + agentVersion := some agent, + publicKey := some (marshalPubKey key), + listenAddrs := addrs, + observedAddr := observed, + protocols := protocols, + signedPeerRecord := spr } + +/-- Build the response, and fail closed when its entries do not fit the +wire model. -/ +def respond? (key : Mock.Identity.PubKey) (addrs : List (List Nat)) + (protocols : List String) (observed : Option (List Nat)) (agent : String) + (spr : Option (List Nat) := none) : Except Error Identify := + let m := respond key addrs protocols observed agent spr + if ∀ e ∈ m.entries, e.Valid then .ok m else .error .badAddress + +/-! ## The client and the push handler (sub-unit 1b) + +One pure frame step serves both the model core and the IO reader, so +the theorems C1 to C3 pin exactly the decoder that the IO path runs. -/ + +/-- The pure frame step over the accumulated bytes. + +* `.ok (some (payload, rest))`: one whole frame with a nonempty + payload; `rest` holds the bytes after it. +* `.ok none`: the buffer ends inside a frame; more bytes can complete + it. +* `.error .emptyFrame`: a zero-length frame, the spike's zero rule. +* `.error (.badMessage (.framing .malformed))`: a bad prefix, a length + over `Constants.identifyCap`, or a payload byte that is not a byte. + `Frame.decode?` checks the cap before it takes the body. + +The function is total and non-recursive. `clientCore` and `readFrame` +both call it, so the model theorems and the IO path share every wire +decision. -/ +private def decodeFrame? (bytes : List Nat) : + Except Error (Option (List Nat × List Nat)) := + match Streams.Framing.Frame.decode? .uvarint Constants.identifyCap bytes with + | .error .malformed => .error (.badMessage (.framing .malformed)) + | .error .truncated => .ok none + | .ok (frame, rest) => + if frame.payload.isEmpty then .error .emptyFrame + else .ok (some (frame.payload, rest)) + +/-- The client's pure core: decode one frame under the cap, reject the +empty frame, and validate exactly the payload bytes that the frame +names. A call of the pure core supplies all its bytes, so a buffer +that ends inside a frame is the `.truncated` framing error, not +`.ok none`. -/ +def clientCore (bytes : List Nat) (expected : Option Mock.Identity.PeerId) : + Except Error (Option (Identify × Mock.Identity.PeerId)) := + match decodeFrame? bytes with + | .error e => .error e + | .ok none => .error (.badMessage (.framing .truncated)) + | .ok (some (payload, _)) => some <$> validate payload expected + +/-- One length-delimited frame from the stream. The `partial` reason is +in the module doc: a silent peer never ends the loop, and `client` or +`push` ends it through the deadline. -/ +private partial def readFrameFrom (s : Streams.Stream) (buf : List Nat) : + Conc.Async (Except Error (Option (List Nat))) := do + match decodeFrame? buf with + | .error e => return .error e + | .ok (some (payload, _)) => return .ok (some payload) + | .ok none => + match ← s.read with + | .error e => return .error (.stream e) + | .ok none => + if buf.isEmpty then return .ok none + else return .error (.badMessage (.framing .truncated)) + | .ok (some chunk) => readFrameFrom s (buf ++ Bytes.toNatList chunk) + +/-- One frame from the stream, over the private loop. -/ +private def readFrame (s : Streams.Stream) : + Conc.Async (Except Error (Option (List Nat))) := + readFrameFrom s [] + +/-- `withinDeadline` needs a `BaseIO` expiry. The stream's `close` is +the write half-close, so it does not wake a parked `read`. `reset` +stops both directions, so it reaches the park. This spelling runs the +reset as a task, the one `Async` to `BaseIO` route the Wrap module +offers. -/ +private def resetQuietly (s : Streams.Stream) : BaseIO Unit := + discard (s.reset.asTask.toBaseIO) + +/-- Read one response under the cap and the deadline, then validate +it. `.ok none` is the clean end between frames. -/ +def client (s : Streams.Stream) (clock : Conc.Clock) (timeoutMs : Nat) + (expected : Option Mock.Identity.PeerId) : + Conc.Async (Except Error (Option (Identify × Mock.Identity.PeerId))) := do + match ← Conc.withinDeadline clock timeoutMs (resetQuietly s) (readFrame s) with + | .error .deadlineExpired => return .error .timeout + | .error .closed => return .error (.stream .closed) + | .ok (.error e) => return .error e + | .ok (.ok none) => return .ok none + | .ok (.ok (some payload)) => return (some <$> validate payload expected) + +/-- One map from this protocol's error into the coarse core error, +used once, at the seam value. -/ +def mapIdentifyError : Error → Mock.Core.Error + | .stream e => Mock.Core.mapStreamError e + | .timeout => .timeout "identify: the deadline expired" + | .emptyFrame => .protocol "identify: a zero-length frame" + | e => .protocol (toString (repr e)) + +/-- Handle one pushed message: validate it, then update the peerstore. +An empty read is a no-op, as in go. -/ +def push (store : Mock.Host.Peerstore) (expected : Mock.Identity.PeerId) + (clock : Conc.Clock) (timeoutMs : Nat) (s : Streams.Stream) : + Conc.Async (Except Error Unit) := do + match ← Conc.withinDeadline clock timeoutMs (resetQuietly s) (readFrame s) with + | .error .deadlineExpired => return .error .timeout + | .error .closed => return .error (.stream .closed) + | .ok (.error e) => return .error e + | .ok (.ok none) => return .ok () + | .ok (.ok (some payload)) => + match validate payload (some expected) with + | .error e => return .error e + | .ok (m, pid) => + store.addAddrs pid m.listenAddrs + return .ok () + +/-- The push handler value. -/ +def pushHandler (store : Mock.Host.Peerstore) (expected : Mock.Identity.PeerId) + (clock : Conc.Clock) (timeoutMs : Nat) : Mock.Core.StreamHandler := + { id := pushId, + handle := fun s => ExceptT.mk ((Except.mapError mapIdentifyError) <$> + push store expected clock timeoutMs s) } + +/-- The responder body. Write one length-delimited message and +half-close. -/ +def respondTo (key : Mock.Identity.PubKey) (addrs : List (List Nat)) + (protocols : List String) (observed : Option (List Nat)) (agent : String) + (spr : Option (List Nat) := none) (s : Streams.Stream) : + Conc.Async (Except Mock.Core.Error Unit) := do + match respond? key addrs protocols observed agent spr with + | .error e => return .error (mapIdentifyError e) + | .ok msg => + match Streams.Framing.encodeB? .uvarint Constants.identifyCap + (Pb.encodeMessageB msg) with + | none => return .error (.protocol "the identify response exceeds the cap") + | some frame => + match ← s.write frame with + | .error e => return .error (Mock.Core.mapStreamError e) + | .ok () => + discard s.close + return .ok () + +/-- The responder handler value. -/ +def respondHandler (key : Mock.Identity.PubKey) (addrs : List (List Nat)) + (protocols : List String) (observed : Option (List Nat)) (agent : String) + (spr : Option (List Nat) := none) : Mock.Core.StreamHandler := + { id := protocolId, + handle := fun s => respondTo key addrs protocols observed agent spr s } + +/-! ## The client-core theorems (sub-unit 1b) + +C1 pins the frame that the core validates. C2 pins the cap check +before the body. C3 pins the zero-length rule. Each consumes a proved +law of `Streams.Framing`; none touches a mock result. -/ + +/-- The frame step reads a whole frame back from its encoding, with +any rest after it. -/ +private theorem decodeFrame?Encode + (f : Streams.Framing.Frame .uvarint Constants.identifyCap) + (rest : List Nat) (h : 0 < f.payload.length) : + decodeFrame? (Streams.Framing.Frame.encode f ++ rest) = + .ok (some (f.payload, rest)) := by + unfold decodeFrame? + rw [Streams.Framing.Frame.decode?EncodeAppend f rest] + have hne : f.payload ≠ [] := by intro hnil; rw [hnil] at h; simp at h + simp [List.isEmpty_iff, hne] + +/-- C1: the client validates exactly the payload bytes that the frame +it read names. The brief theorem 3, first half. -/ +theorem clientCoreFrame (f : Streams.Framing.Frame .uvarint Constants.identifyCap) + (rest : List Nat) (expected : Option Mock.Identity.PeerId) + (h : 0 < f.payload.length) : + clientCore (Streams.Framing.Frame.encode f ++ rest) expected = + some <$> validate f.payload expected := by + unfold clientCore + rw [decodeFrame?Encode f rest h] + +/-- The frame step rejects a length over the cap before it reads the +body. -/ +private theorem decodeFrame?OverCap (len : Nat) (rest : List Nat) + (hlen : len ≤ Streams.Framing.Prefix.maxLen .uvarint) + (hcap : Constants.identifyCap < len) : + decodeFrame? (Streams.Framing.encodeLength .uvarint len ++ rest) = + .error (.badMessage (.framing .malformed)) := by + unfold decodeFrame? + rw [Streams.Framing.Frame.decode?OverCap (p := .uvarint) + (cap := Constants.identifyCap) + (l := Streams.Framing.encodeLength .uvarint len ++ rest) + (rest := rest) (len := len) + (Streams.Framing.decodeLength?EncodeLength .uvarint len rest hlen) hcap] + +/-- C2: a length over the cap is rejected before the body is read. +The statement has no premise about the bytes after the prefix. -/ +theorem clientCoreOverCap (len : Nat) (rest : List Nat) + (expected : Option Mock.Identity.PeerId) + (hlen : len ≤ Streams.Framing.Prefix.maxLen .uvarint) + (hcap : Constants.identifyCap < len) : + clientCore (Streams.Framing.encodeLength .uvarint len ++ rest) expected = + .error (.badMessage (.framing .malformed)) := by + unfold clientCore + rw [decodeFrame?OverCap len rest hlen hcap] + +/-- The zero-length rule on the frame step. -/ +private theorem decodeFrame?EncodeLengthZero : + decodeFrame? (Streams.Framing.encodeLength .uvarint 0) = .error .emptyFrame := by + unfold decodeFrame? Streams.Framing.Frame.decode? + have hd := Streams.Framing.decodeLength?EncodeLength .uvarint 0 [] (by simp) + simp only [List.append_nil] at hd + rw [hd] + simp [Streams.Framing.take?] + +/-- C3: the zero-length rule on the model, the spike's rule. -/ +theorem clientCoreEmptyFrame (expected : Option Mock.Identity.PeerId) : + clientCore (Streams.Framing.encodeLength .uvarint 0) expected = + .error .emptyFrame := by + unfold clientCore + rw [decodeFrame?EncodeLengthZero] + end Identify end Libp2p.Protocols diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean index aeece266..6546ba32 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean @@ -1,12 +1,16 @@ import Std +import Conc +import Streams /-! -# ProtocolId mock +# Core seam mock `Libp2p.Core` (issue #15, PR #37 draft) is not merged. This mock -declares the frozen `ProtocolId` shape from -`packages/libp2p/core/docs/ARCHITECTURE.md`. The real core package -replaces this module at integration. +declares the frozen seam shapes from +`packages/libp2p/core/docs/ARCHITECTURE.md`: the `ProtocolId` of +sub-unit 1a, and the coarse `Error` and the `StreamHandler` of +sub-unit 1b. The real core package replaces this module at +integration. -/ set_option autoImplicit false @@ -19,4 +23,26 @@ structure ProtocolId where toString : String deriving DecidableEq, Hashable +/-- The coarse error of the core seam, with the rendered inner error. -/ +inductive Error where + | protocol (rendered : String) + | authentication (rendered : String) + | timeout (rendered : String) + | closed (rendered : String) + deriving DecidableEq, BEq, Repr + +/-- The handler seam: the id it registers under, and the callback a +served stream reaches. -/ +structure StreamHandler where + id : ProtocolId + handle : Streams.Stream → ExceptT Error Conc.Async Unit + +/-- One map from a stream answer to the coarse error, shared by every +handler of this package. `closed` stays closed, `reset` is a protocol +fault, and `timeout` stays timeout. -/ +def mapStreamError : Streams.Error → Error + | .closed => .closed "the stream closed" + | .reset => .protocol "the peer reset the stream" + | .timeout => .timeout "the stream deadline expired" + end Libp2p.Protocols.Mock.Core diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean new file mode 100644 index 00000000..57364e18 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean @@ -0,0 +1,44 @@ +import Std +import Libp2p.Protocols.Mock.Identity + +/-! +# Host peerstore mock + +`Libp2p.Host` (issue #19) is not merged. This mock declares the +peerstore surface that the identify push handler updates, from +`packages/libp2p/host/docs/ARCHITECTURE.md`: `addAddrs`, deduplicated +at add, and `addrsOf`. The host doc freezes the names and the dedup +rule, not the signatures. This mock fixes minimal signatures. The real +host package replaces this module at integration. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Mock.Host + +open Libp2p.Protocols.Mock.Identity + +/-- The peerstore seam the push handler updates: addresses by peer, +deduplicated at add. -/ +structure Peerstore where + /-- Store the addresses of one peer, deduplicated at add. -/ + addAddrs : PeerId → List (List Nat) → IO Unit + /-- The stored addresses of one peer. -/ + addrsOf : PeerId → IO (List (List Nat)) + +/-- The executable in-memory double. Each call makes a fresh table: +bind the action once, and pass the resulting value to both the handler +and `addrsOf`. A second add of the same addresses grows nothing. -/ +opaque inMemory : IO Peerstore := do + let table ← IO.mkRef ([] : List (PeerId × List (List Nat))) + let addAddrs : PeerId → List (List Nat) → IO Unit := fun p addrs => do + let cur ← table.get + let old := (cur.find? (fun e => e.1 == p)).map (·.2) |>.getD [] + let merged := (old ++ addrs).eraseDups + table.set ((p, merged) :: cur.filter (fun e => !(e.1 == p))) + let addrsOf : PeerId → IO (List (List Nat)) := fun p => do + let cur ← table.get + return (cur.find? (fun e => e.1 == p)).map (·.2) |>.getD [] + return { addAddrs, addrsOf } + +end Libp2p.Protocols.Mock.Host diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean index ee955278..e9ab109f 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean @@ -45,4 +45,12 @@ opaque unmarshalPubKey? (bs : List Nat) : Option PubKey := opaque peerIdOfPubKey (k : PubKey) : PeerId := ⟨k.data, ""⟩ +/-- Executable stand-in: the raw key data. The mock `unmarshalPubKey?` +parses any nonempty buffer as `⟨.ed25519, buffer⟩`, and `peerIdOfPubKey` +reads only the buffer, so the round trip holds for a nonempty ed25519 +stand-in. The real identity package writes the serialized PublicKey +protobuf (peer-ids spec); that body replaces this one at integration. -/ +opaque marshalPubKey (k : PubKey) : List Nat := + k.data + end Libp2p.Protocols.Mock.Identity diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean b/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean index c7fbd12b..a1734f8e 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean @@ -1,15 +1,25 @@ +import Bytes +import Conc +import Streams +import Libp2p.Protocols.Mock.Core import Libp2p.Protocols.Constants /-! # Ping The pure half of `/ipfs/ping/1.0.0`: the protocol id, the payload -size, and the echo check. The IO handler and the initiator are -sub-unit 1b. +size, and the echo check. Sub-unit 1b adds the IO half: the echo +handler, the initiator, and the theorems P5 and P6. Per `vendor/specs/ping/ping.md`: the initiator sends 32 random bytes, and the responder echoes them. A payload of another size, or an echo that differs from the sent payload, is a protocol error. + +The private echo loop `echoLoop` and the initiator's `awaitEcho` are +`partial def`s: the peer may stay silent, and no measure shows that a +read returns. A partial payload at the end of the stream is +`.truncated`, a recorded delta from the spike's responder, which +closes silently. -/ set_option autoImplicit false @@ -22,11 +32,17 @@ def protocolId : Mock.Core.ProtocolId := Constants.pingId /-- The ping payload size of the spec. -/ def payloadSize : Nat := Constants.pingSize -/-- The failures of the pure echo check. The IO arm (a partial -payload at end of stream) joins it in sub-unit 1b. -/ +/-- The failures of the pure echo check, plus the IO arms of 1b: +`truncated` (a partial payload at end of stream) and `stream` (a +stream fault). -/ inductive Error where | wrongSize (got : Nat) | mismatch + /-- The stream ended with a partial payload, a recorded delta from + the spike's responder. -/ + | truncated + /-- The stream answered a fault. -/ + | stream (e : Streams.Error) deriving DecidableEq, BEq, Repr /-- Check one echo: 32 bytes, equal to the sent payload. -/ @@ -60,4 +76,111 @@ theorem checkEchoSound (sent echo : List Nat) (h : checkEcho sent echo = .ok ()) exact ⟨heq, heq ▸ hlen⟩ · simp [checkEcho, hlen] at h +/-! ## The echo handler and the initiator (sub-unit 1b) -/ + +/-- Split the accumulated bytes into whole 32-byte payloads and the +tail. The echo handler writes every whole payload; the tail waits for +more bytes. -/ +def splitPayloads (acc : List Nat) : List (List Nat) × List Nat := + if payloadSize ≤ acc.length then + let (rest, tail) := splitPayloads (acc.drop payloadSize) + (acc.take payloadSize :: rest, tail) + else ([], acc) +termination_by acc.length +decreasing_by + simp only [List.length_drop] + have hp : payloadSize = 32 := rfl + omega + +/-- P6: an accumulator below the payload size holds no whole +payload. -/ +theorem splitPayloadsShort (acc : List Nat) (h : acc.length < payloadSize) : + splitPayloads acc = ([], acc) := by + rw [splitPayloads] + rw [ite_eq_right (by omega : ¬ payloadSize ≤ acc.length)] + +/-- P5: the splitter takes one whole payload from the front and keeps +the tail. -/ +theorem splitPayloadsWhole (sent tail : List Nat) + (h : sent.length = payloadSize) (ht : tail.length < payloadSize) : + splitPayloads (sent ++ tail) = ([sent], tail) := by + have hcond : payloadSize ≤ (sent ++ tail).length := by + simp [List.length_append, h] + rw [splitPayloads] + rw [ite_eq_left hcond, List.take_left' h, List.drop_left' h, + splitPayloadsShort tail ht] + +/-- Write every whole payload, in order. Structural: the list +shrinks. -/ +private def writeAll (s : Streams.Stream) : + List (List Nat) → Conc.Async (Except Error Unit) + | [] => return .ok () + | p :: ps => do + match ← s.write (Bytes.ofNatList p) with + | .error e => return .error (.stream e) + | .ok () => writeAll s ps + +/-- The echo loop. `partial`: the peer may stay silent, and no measure +shows a read returns; the module doc records the reason. -/ +private partial def echoLoop (s : Streams.Stream) (acc : List Nat) : + Conc.Async (Except Error Unit) := do + match ← s.read with + | .error e => return .error (.stream e) + | .ok none => + if acc.isEmpty then return .ok () else return .error .truncated + | .ok (some chunk) => + let (whole, tail) := splitPayloads (acc ++ Bytes.toNatList chunk) + match ← writeAll s whole with + | .error e => return .error e + | .ok () => echoLoop s tail + +/-- The echo handler: write back every whole payload, until the stream +closes. A partial payload at the close is a protocol error. -/ +def handleCore (s : Streams.Stream) : Conc.Async (Except Error Unit) := + echoLoop s [] + +/-- One map from this protocol's error into the coarse core error, +used once, at the seam value. -/ +def mapPingError : Error → Mock.Core.Error + | .stream e => Mock.Core.mapStreamError e + | e => .protocol (toString (repr e)) + +/-- The ping handler value. -/ +def pingHandler : Mock.Core.StreamHandler := + { id := protocolId, + handle := fun s => ExceptT.mk ((Except.mapError mapPingError) <$> handleCore s) } + +/-- Accumulate echo chunks until one whole payload is present. +`partial`: the peer may stay silent; the module doc records the +reason. -/ +private partial def awaitEcho (s : Streams.Stream) (acc : List Nat) : + Conc.Async (Except Error (List Nat)) := do + match splitPayloads acc with + | (p :: _, _) => return .ok p + | ([], _) => + match ← s.read with + | .error e => return .error (.stream e) + | .ok none => return .error .truncated + | .ok (some chunk) => awaitEcho s (acc ++ Bytes.toNatList chunk) + +/-- Send one 32-byte payload, await the echo, check it with the 1a +`checkEcho`, and return the round-trip time in milliseconds. `.ok +none` never occurs on the echo path; the option carries the measured +time. -/ +def ping (s : Streams.Stream) (clock : Conc.Clock) (payload : ByteArray) : + Conc.Async (Except Error (Option Nat)) := do + if payload.size ≠ payloadSize then return .error (.wrongSize payload.size) + let t0 ← clock.nowMs + match ← s.write payload with + | .error e => return .error (.stream e) + | .ok () => + match ← awaitEcho s [] with + | .error e => return .error e + | .ok echo => + match checkEcho (Bytes.toNatList payload) echo with + | .error e => return .error e + | .ok () => + let t1 ← clock.nowMs + return .ok (some (t1 - t0)) + end Libp2p.Protocols.Ping From 1e76fc6d5a58bc024f4c4b938b54cbb060ea68a8 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:20:58 +0800 Subject: [PATCH 005/125] docs(protocols): describe the code, drop the work-split labels --- Tests/Protocols.lean | 24 ++-- .../libp2p/protocols/Libp2p/Protocols.lean | 5 +- .../protocols/Libp2p/Protocols/Constants.lean | 5 +- .../protocols/Libp2p/Protocols/Identify.lean | 136 +++++++++--------- .../protocols/Libp2p/Protocols/Mock/Core.lean | 12 +- .../protocols/Libp2p/Protocols/Mock/Host.lean | 4 +- .../Libp2p/Protocols/Mock/Identity.lean | 15 +- .../protocols/Libp2p/Protocols/Ping.lean | 40 +++--- .../libp2p/protocols/docs/ARCHITECTURE.md | 28 ++-- 9 files changed, 139 insertions(+), 130 deletions(-) diff --git a/Tests/Protocols.lean b/Tests/Protocols.lean index 00fa37ae..a4fd5177 100644 --- a/Tests/Protocols.lean +++ b/Tests/Protocols.lean @@ -11,7 +11,7 @@ import Libp2p.Protocols.Mock.Core import Libp2p.Protocols.Mock.Host /-! -# Identify and ping suite (unit W5.7, sub-units 1a and 1b) +# Identify and ping suite Mirrors `vendor/specs/ping/ping.md` (32-byte echo, protocol id `/ipfs/ping/1.0.0`) and the captured go-libp2p Identify protobuf @@ -20,11 +20,12 @@ pure `Identify.validate` and `Ping.checkEcho` checks. The mock key and multiaddr stand-ins run their executable bodies here, so every mock outcome of the validate appears in a check. -Sub-unit 1b adds the exchange cases: the identify client against the -responder handler, the push handler against the in-memory peerstore -double, the client deadline on the test clock, and the ping echo in -both directions. The exchanges run over a private pair of `Conc.Pipe` -values spliced into two `Streams.Stream` ends. +The exchange cases cover the identify client against the responder +handler, the push handler against the in-memory peerstore double, the +client deadline on the test clock, and the ping echo, with the +initiator on end `a` and the handler on end `b`. The exchanges run over +a private pair of `Conc.Pipe` values, wired as two `Streams.Stream` +ends. -/ set_option autoImplicit false @@ -50,9 +51,9 @@ private def emptyIdentify : Identify := ⟨none, [], [], none, none, none, none private def msgPayload (m : Identify) : List Nat := Pb.encodeMessage m -/-! ## The private stream pair (sub-unit 1b) +/-! ## The private stream pair -Two `Conc.Pipe` values spliced like the spike's `mkSplit` +Two `Conc.Pipe` values wired like the spike's `mkSplit` (`Tests/PingId.lean`): end `a` reads `ba` and writes `ab`, end `b` reads `ab` and writes `ba`. The adapter's `close` is a no-op, so one pair serves the swapped-role exchange; the cases that need an end @@ -114,7 +115,7 @@ private def runHandler (h : Libp2p.Protocols.Mock.Core.StreamHandler) | .error e => throw (IO.userError s!"the handler answered {repr e}") | .ok () => pure () -/-- The 1b exchange cases, on the test clock and the private pair. -/ +/-- The exchange cases, on the test clock and the private pair. -/ private def asyncChecks (r : Runner) : Conc.Async Unit := do -- identify: the responder round trip do @@ -427,14 +428,13 @@ def run (r : Runner) : IO Unit := do -- Multiaddr text codec is unmerged r.check "identify vector listenAddrs has one entry, the binary form of JSON listenAddrs[0]" (m.listenAddrs == [[4, 127, 0, 0, 1, 6, 138, 43]]) - -- the full captured bytes, prefix and payload, through the - -- pure client core: the wire decision path of sub-unit 1b + -- the full captured bytes, prefix and payload, through `clientCore` r.check "identify vector through the client core" (match Identify.clientCore (Bytes.toNatList respBytes) none with | .ok (some (m2, _)) => m2 == m | _ => false) -- the wire carries the empty string, the JSON says - -- /ipfs/1.0.0; the wire is the truth + -- /ipfs/1.0.0; the suite asserts the wire value r.check "identify vector protocolVersion wire delta recorded" (m.protocolVersion == some "" && jsonVersion == "/ipfs/1.0.0") r.skip "identify vector peer id derivation skipped" diff --git a/packages/libp2p/protocols/Libp2p/Protocols.lean b/packages/libp2p/protocols/Libp2p/Protocols.lean index b817c01b..87cb602c 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols.lean @@ -6,7 +6,6 @@ import Libp2p.Protocols.Ping # Libp2p.Protocols The protocols subpackage: identify and ping first, then req/resp and -gossipsub (ARCHITECTURE §4.9). Sub-unit 1a of W5.7 lands the pure -identify and ping code. The IO handlers and clients are sub-unit 1b. -The package docs live under `docs/` beside this file. +gossipsub (ARCHITECTURE §4.9). The package docs live under `docs/` +beside this file. -/ diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean b/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean index 770f9524..8a6c6645 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean @@ -4,8 +4,9 @@ import Libp2p.Protocols.Mock.Core # Protocol constants One home per literal (DRY). The module holds the protocol ids of -identify, identify push, and ping, the identify message cap, and the -ping payload size. Gossipsub and req/resp ids wait for their units. +identify, identify push, and ping, the identify message cap, the ping +payload size, and the protocol version that the identify responder +writes. Gossipsub and req/resp ids wait for their units. -/ set_option autoImplicit false diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean index ebe2e661..e4dd2ba3 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean @@ -8,12 +8,12 @@ import Libp2p.Protocols.Mock.Host import Libp2p.Protocols.Constants /-! -# Identify message and validate +# Identify -The pure half of `/ipfs/id/1.0.0` (sub-unit 1a): the `Identify` -message with its `ProtoMessage` instance, the strict field table, the -validate, and the table theorems. Sub-unit 1b adds the IO half: the -responder, the client, the push handler, and the theorems C1 to C3. +The pure half of `/ipfs/id/1.0.0`: the `Identify` message with its +`ProtoMessage` instance, the strict field table, the validate, and the +table theorems. The IO half: the responder, the client, the push +handler, and the theorems on `clientCore`. The wire shape comes from go-libp2p v0.50.0 `identify.pb.go`: fields 1, 2, 3, 4, 5, 6, and 8. Field 8 is the signed peer record. The emit @@ -25,14 +25,14 @@ wrong wire type, or a present string field with invalid UTF-8, is permissive: entries of another wire type are skipped, and invalid UTF-8 entries are dropped, as the Proto accessors already do. -The strings read back through the real `Bytes.toString?`, never -through `Pb.getStr?`. The latter runs the opaque mock of -`Pb.Mock.Bytes`, which no theorem can relate to `Bytes.ofString`. +The strings read back through the real `Bytes.toString?`. `Pb.getStr?` +runs the opaque mock of `Pb.Mock.Bytes`, which no theorem can relate to +`Bytes.ofString`. The private read loop `readFrameFrom` is a `partial def`: a silent -peer never ends the loop, and the deadline of `client` or `push` is -what ends it. The unit reads one frame per stream and drops leftover -bytes after it; go merges chunked identify messages, a recorded delta. +peer never ends the loop, and the deadline of `client` or `push` ends +it. `client` and `push` read one frame per stream and drop the bytes +after it. Go merges chunked identify messages, a recorded delta. The read rejects a zero-length frame, the spike's rule. -/ @@ -42,7 +42,7 @@ namespace Libp2p.Protocols open Mock -/-- One identify answer. The field numbers are the ones of the +/-- One identify response. The field numbers are the ones of the go-libp2p v0.50.0 identify protobuf. -/ structure Identify where publicKey : Option (List Nat) -- field 1, the serialized PublicKey protobuf @@ -64,7 +64,7 @@ def protocolId : Mock.Core.ProtocolId := Constants.identifyId /-- The identify push protocol id. -/ def pushId : Mock.Core.ProtocolId := Constants.identifyPushId -/-- The wire image: one entry per present field, in the ascending +/-- The wire view: one entry per present field, in the ascending order 1, 2, 3, 4, 5, 6, 8. Strings go to bytes with the real `Bytes` package. -/ def entries (m : Identify) : List Pb.Entry := @@ -79,7 +79,7 @@ def entries (m : Identify) : List Pb.Entry := /-- Fail closed. A message whose entries do not fit the wire model emits no entry; every theorem about the encode takes the validity as a premise. A payload of `List Nat` and a `String` carry no bound, so -the gate cannot be dropped. -/ +`toFields` must keep this check. -/ def toFields (m : Identify) : Pb.Fields := if h : ∀ e ∈ m.entries, e.Valid then ⟨m.entries, h⟩ else Pb.Fields.empty @@ -121,8 +121,9 @@ instance : Pb.ProtoMessage Identify where /-! ## The table theorems -I1, the table law, carries the wire-validity premise. The two -round-trip theorems compose it with the Proto laws. -/ +`ofFieldsToFields`, the table law, carries the wire-validity premise. +`decodeEncode`, `encodeInjective` and `roundTripRepeated` build on it +with the Proto laws. -/ /-- A miss lookup through a segment that maps payloads to another field number. -/ @@ -140,7 +141,7 @@ private theorem filterMapNone {α β : Type} (l : List α) : l.filterMap (fun _ => (none : Option β)) = [] := List.filterMap_eq_nil_iff.mpr fun _ _ => rfl -/-- I1: the table law. Every valid message round-trips through its +/-- The table law. Every valid message round-trips through its own table. -/ theorem ofFieldsToFields (m : Identify) (h : ∀ e ∈ m.entries, e.Valid) : Pb.ProtoMessage.ofFields? (α := Identify) (Pb.ProtoMessage.toFields m) = .ok m := by @@ -155,13 +156,13 @@ theorem ofFieldsToFields (m : Identify) (h : ∀ e ∈ m.entries, e.Valid) : Bytes.toString?OfString, Function.comp_def, List.lookup_cons, filterMapNone, lookupMissMap] -/-- I2: an encode decodes back to the message. -/ +/-- An encode decodes back to the message. -/ theorem decodeEncode (m : Identify) (h : ∀ e ∈ m.entries, e.Valid) : Pb.decodeMessage? (Pb.encodeMessage m) = .ok (m, []) := by simp only [Pb.decodeMessage?, Pb.encodeMessage, Pb.decodeEncode, ofFieldsToFields m h, Bind.bind, Except.bind] -/-- I3: equal encodings imply equal messages. Without the premise +/-- Equal encodings imply equal messages. Without the premise the statement is false: a message with an out-of-range byte and the empty message both encode to `[]`. -/ theorem encodeInjective (a b : Identify) (ha : ∀ e ∈ a.entries, e.Valid) @@ -174,7 +175,7 @@ theorem encodeInjective (a b : Identify) (ha : ∀ e ∈ a.entries, e.Valid) rw [ofFieldsToFields a ha, ofFieldsToFields b hb] at h3 exact Except.ok.inj h3 -/-- I4: the repeated fields survive the round trip in order. -/ +/-- The repeated fields survive the round trip in order. -/ theorem roundTripRepeated (m : Identify) (h : ∀ e ∈ m.entries, e.Valid) : ∃ m' : Identify, Pb.decodeMessage? (Pb.encodeMessage m) = .ok (m', []) ∧ m'.protocols = m.protocols ∧ m'.listenAddrs = m.listenAddrs := @@ -185,10 +186,9 @@ theorem roundTripRepeated (m : Identify) (h : ∀ e ∈ m.entries, e.Valid) : The checks run in this order: cap, decode, key presence, key parse, expected id, addresses. The cap check runs before any decode. -/ -/-- The validate failures of 1a, plus the read-path arms of 1b. The -zero-length rule lives on the read path, so the pure validate sees an -empty payload as an empty message, which then fails with -`missingKey`. -/ +/-- The validate failures, plus the read-path arms. The zero-length +rule lives on the read path, so the pure validate sees an empty payload +as an empty message, which then fails with `missingKey`. -/ inductive Error where | overCap | badMessage (e : Pb.Error) @@ -200,7 +200,7 @@ inductive Error where | emptyFrame /-- The read deadline expired. -/ | timeout - /-- The stream answered a fault. -/ + /-- The stream returned an error. -/ | stream (e : Streams.Error) deriving DecidableEq, BEq, Repr @@ -215,7 +215,7 @@ private def checkAddrs (m : Identify) (k : Mock.Identity.PubKey) : else .error .badAddress /-- Validate one received response: the derived peer id must match -the expected id, and the size must stay under the cap. -/ +the expected id, and the size must be at most the cap. -/ def validate (payload : List Nat) (expected : Option Mock.Identity.PeerId) : Except Error (Identify × Mock.Identity.PeerId) := if Constants.identifyCap < payload.length then .error .overCap @@ -254,20 +254,21 @@ private theorem checkAddrsSound (m : Identify) (k : Mock.Identity.PubKey) · rw [ite_eq_right ha] at h cases h -/-- I5: an over-cap payload fails before any decode. -/ +/-- An over-cap payload fails before any decode. -/ theorem validateOverCap (payload : List Nat) (expected : Option Mock.Identity.PeerId) (h : Constants.identifyCap < payload.length) : validate payload expected = .error .overCap := by simp [validate, h] -/-- I6: the empty payload is the empty message, and it has no key. -/ +/-- The empty payload decodes to the empty message. -/ theorem decodeEmpty : Pb.decodeMessage? (α := Identify) [] = .ok (⟨none, [], [], none, none, none, none⟩, []) := rfl +/-- The empty message has no key, so `validate` rejects it. -/ theorem validateEmptyNoKey : validate [] none = .error .missingKey := rfl -/-- I7: acceptance, conditional on the opaque mock results. -/ +/-- Acceptance, conditional on the opaque mock results. -/ theorem validateAccepts (payload : List Nat) (m : Identify) (kb : List Nat) (k : Mock.Identity.PubKey) (expected : Option Mock.Identity.PeerId) (hsize : payload.length ≤ Constants.identifyCap) @@ -290,7 +291,7 @@ theorem validateAccepts (payload : List Nat) (m : Identify) (kb : List Nat) simp only [checkAddrs] rw [ite_eq_left haddrs, ite_eq_left hobs] -/-- I8: a derived id that differs from the expected id is rejected. -/ +/-- `validate` rejects a derived id that differs from the expected id. -/ theorem validateKeyMismatch (payload : List Nat) (m : Identify) (kb : List Nat) (k : Mock.Identity.PubKey) (e : Mock.Identity.PeerId) (hsize : payload.length ≤ Constants.identifyCap) @@ -307,9 +308,11 @@ theorem validateKeyMismatch (payload : List Nat) (m : Identify) (kb : List Nat) exact absurd hh hne · rfl -/-- I9: soundness. A passing validate carries the cap bound, the -key facts, the derivation match, and the address validity. With I7 -and I8 this pins the acceptance set exactly. -/ +/-- Soundness. A passing validate carries the cap bound, the key +facts, the derivation match, and the address validity. The statement +does not tie `m` to the decode of `payload`. The stronger conjunct +`∃ rest, Pb.decodeMessage? (α := Identify) payload = .ok (m, rest)` is +not proved, because `checkAddrs` is private. -/ theorem validateSound (payload : List Nat) (expected : Option Mock.Identity.PeerId) (m : Identify) (pid : Mock.Identity.PeerId) (h : validate payload expected = .ok (m, pid)) : @@ -366,7 +369,7 @@ theorem validateSound (payload : List Nat) (expected : Option Mock.Identity.Peer · rw [ite_eq_right hkey] at h cases h -/-! ## The responder (sub-unit 1b) +/-! ## The responder The responder builds one message from the host data, writes one length-delimited frame, and half-closes. `respond?` fails closed when @@ -393,10 +396,11 @@ def respond? (key : Mock.Identity.PubKey) (addrs : List (List Nat)) let m := respond key addrs protocols observed agent spr if ∀ e ∈ m.entries, e.Valid then .ok m else .error .badAddress -/-! ## The client and the push handler (sub-unit 1b) +/-! ## The client and the push handler -One pure frame step serves both the model core and the IO reader, so -the theorems C1 to C3 pin exactly the decoder that the IO path runs. -/ +One pure frame step, `decodeFrame?`, serves both `clientCore` and the +IO reader, so the theorems on `clientCore` pin the decoder that the IO +path runs. -/ /-- The pure frame step over the accumulated bytes. @@ -404,14 +408,13 @@ the theorems C1 to C3 pin exactly the decoder that the IO path runs. -/ payload; `rest` holds the bytes after it. * `.ok none`: the buffer ends inside a frame; more bytes can complete it. -* `.error .emptyFrame`: a zero-length frame, the spike's zero rule. +* `.error .emptyFrame`: a zero-length frame, the spike's rule. * `.error (.badMessage (.framing .malformed))`: a bad prefix, a length - over `Constants.identifyCap`, or a payload byte that is not a byte. + over `Constants.identifyCap`, or a payload value of 256 or more. `Frame.decode?` checks the cap before it takes the body. -The function is total and non-recursive. `clientCore` and `readFrame` -both call it, so the model theorems and the IO path share every wire -decision. -/ +`clientCore` and `readFrameFrom` both call it, so the theorems and the +IO path share every wire decision. -/ private def decodeFrame? (bytes : List Nat) : Except Error (Option (List Nat × List Nat)) := match Streams.Framing.Frame.decode? .uvarint Constants.identifyCap bytes with @@ -421,11 +424,12 @@ private def decodeFrame? (bytes : List Nat) : if frame.payload.isEmpty then .error .emptyFrame else .ok (some (frame.payload, rest)) -/-- The client's pure core: decode one frame under the cap, reject the -empty frame, and validate exactly the payload bytes that the frame -names. A call of the pure core supplies all its bytes, so a buffer -that ends inside a frame is the `.truncated` framing error, not -`.ok none`. -/ +/-- The pure half of the client: decode one frame under the cap, reject +the empty frame, and validate the payload bytes of that frame. The +caller supplies all the bytes at once, so a buffer that ends inside a +frame is the `.truncated` framing error. An empty buffer is also +`.truncated`, and the core never answers `.ok none`, while `client` +answers `.ok none` on a clean end. -/ def clientCore (bytes : List Nat) (expected : Option Mock.Identity.PeerId) : Except Error (Option (Identify × Mock.Identity.PeerId)) := match decodeFrame? bytes with @@ -455,15 +459,15 @@ private def readFrame (s : Streams.Stream) : readFrameFrom s [] /-- `withinDeadline` needs a `BaseIO` expiry. The stream's `close` is -the write half-close, so it does not wake a parked `read`. `reset` -stops both directions, so it reaches the park. This spelling runs the -reset as a task, the one `Async` to `BaseIO` route the Wrap module -offers. -/ +the write half-close, so it does not wake a parked `read`. `reset` stops +both directions, so it wakes the read. The body starts the reset with +`asTask`, as `Conc.Wrap.spawn` does. No owner joins this task, and the +code ignores its result. -/ private def resetQuietly (s : Streams.Stream) : BaseIO Unit := discard (s.reset.asTask.toBaseIO) /-- Read one response under the cap and the deadline, then validate -it. `.ok none` is the clean end between frames. -/ +it. `.ok none` means that the stream ended before any byte arrived. -/ def client (s : Streams.Stream) (clock : Conc.Clock) (timeoutMs : Nat) (expected : Option Mock.Identity.PeerId) : Conc.Async (Except Error (Option (Identify × Mock.Identity.PeerId))) := do @@ -474,8 +478,8 @@ def client (s : Streams.Stream) (clock : Conc.Clock) (timeoutMs : Nat) | .ok (.ok none) => return .ok none | .ok (.ok (some payload)) => return (some <$> validate payload expected) -/-- One map from this protocol's error into the coarse core error, -used once, at the seam value. -/ +/-- The one map from this protocol's error into the coarse core error. +`pushHandler` and `respondTo` call it at the seam. -/ def mapIdentifyError : Error → Mock.Core.Error | .stream e => Mock.Core.mapStreamError e | .timeout => .timeout "identify: the deadline expired" @@ -483,7 +487,9 @@ def mapIdentifyError : Error → Mock.Core.Error | e => .protocol (toString (repr e)) /-- Handle one pushed message: validate it, then update the peerstore. -An empty read is a no-op, as in go. -/ +An empty read leaves the peerstore unchanged. This is a delta from go, +whose `consumeMessage` runs on the empty message and clears the peer's +protocols. -/ def push (store : Mock.Host.Peerstore) (expected : Mock.Identity.PeerId) (clock : Conc.Clock) (timeoutMs : Nat) (s : Streams.Stream) : Conc.Async (Except Error Unit) := do @@ -532,11 +538,14 @@ def respondHandler (key : Mock.Identity.PubKey) (addrs : List (List Nat)) { id := protocolId, handle := fun s => respondTo key addrs protocols observed agent spr s } -/-! ## The client-core theorems (sub-unit 1b) +/-! ## The client-core theorems -C1 pins the frame that the core validates. C2 pins the cap check -before the body. C3 pins the zero-length rule. Each consumes a proved -law of `Streams.Framing`; none touches a mock result. -/ +`clientCoreFrame` pins the frame that `clientCore` validates. +`clientCoreOverCap` pins the cap check before the body. +`clientCoreEmptyFrame` pins the zero-length rule. Each consumes a law of +`Streams.Framing`, and that law rests on the axiom +`Streams.Mock.Varint.decodeEncodeSuffix`. No proof here reads a result +of this package's mocks. -/ /-- The frame step reads a whole frame back from its encoding, with any rest after it. -/ @@ -550,8 +559,7 @@ private theorem decodeFrame?Encode have hne : f.payload ≠ [] := by intro hnil; rw [hnil] at h; simp at h simp [List.isEmpty_iff, hne] -/-- C1: the client validates exactly the payload bytes that the frame -it read names. The brief theorem 3, first half. -/ +/-- The client validates the payload bytes of the frame that it read. -/ theorem clientCoreFrame (f : Streams.Framing.Frame .uvarint Constants.identifyCap) (rest : List Nat) (expected : Option Mock.Identity.PeerId) (h : 0 < f.payload.length) : @@ -574,8 +582,8 @@ private theorem decodeFrame?OverCap (len : Nat) (rest : List Nat) (rest := rest) (len := len) (Streams.Framing.decodeLength?EncodeLength .uvarint len rest hlen) hcap] -/-- C2: a length over the cap is rejected before the body is read. -The statement has no premise about the bytes after the prefix. -/ +/-- `clientCore` rejects a length over the cap before it reads the +body. The statement has no premise about the bytes after the prefix. -/ theorem clientCoreOverCap (len : Nat) (rest : List Nat) (expected : Option Mock.Identity.PeerId) (hlen : len ≤ Streams.Framing.Prefix.maxLen .uvarint) @@ -594,7 +602,7 @@ private theorem decodeFrame?EncodeLengthZero : rw [hd] simp [Streams.Framing.take?] -/-- C3: the zero-length rule on the model, the spike's rule. -/ +/-- The zero-length rule on `clientCore`, the spike's rule. -/ theorem clientCoreEmptyFrame (expected : Option Mock.Identity.PeerId) : clientCore (Streams.Framing.encodeLength .uvarint 0) expected = .error .emptyFrame := by diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean index 6546ba32..8b3f0f9b 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean @@ -5,12 +5,10 @@ import Streams /-! # Core seam mock -`Libp2p.Core` (issue #15, PR #37 draft) is not merged. This mock -declares the frozen seam shapes from -`packages/libp2p/core/docs/ARCHITECTURE.md`: the `ProtocolId` of -sub-unit 1a, and the coarse `Error` and the `StreamHandler` of -sub-unit 1b. The real core package replaces this module at -integration. +`Libp2p.Core` (issue #15, PR #37) is not merged. This mock declares +the frozen seam shapes from `packages/libp2p/core/docs/ARCHITECTURE.md`: +`ProtocolId`, the coarse `Error`, and `StreamHandler`. The real core +package replaces this module at integration. -/ set_option autoImplicit false @@ -37,7 +35,7 @@ structure StreamHandler where id : ProtocolId handle : Streams.Stream → ExceptT Error Conc.Async Unit -/-- One map from a stream answer to the coarse error, shared by every +/-- One map from a stream error to the coarse error, shared by every handler of this package. `closed` stays closed, `reset` is a protocol fault, and `timeout` stays timeout. -/ def mapStreamError : Streams.Error → Error diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean index 57364e18..97243fb6 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean @@ -8,8 +8,8 @@ import Libp2p.Protocols.Mock.Identity peerstore surface that the identify push handler updates, from `packages/libp2p/host/docs/ARCHITECTURE.md`: `addAddrs`, deduplicated at add, and `addrsOf`. The host doc freezes the names and the dedup -rule, not the signatures. This mock fixes minimal signatures. The real -host package replaces this module at integration. +rule, and it leaves the signatures open. This mock fixes minimal +signatures. The real host package replaces this module at integration. -/ set_option autoImplicit false diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean index e9ab109f..4f1f40ff 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean @@ -3,13 +3,14 @@ import Std /-! # Identity mock -`Libp2p.Identity` (issue #11) is not merged. This mock declares the -frozen key and peer id shapes from -`packages/libp2p/identity/docs/ARCHITECTURE.md`. The real identity -package replaces this module at integration. - -`PubKey` drops the validity proof field of the real shape. The -stand-in bodies are executable, so the suite can drive both outcomes +`Libp2p.Identity` (issue #11) is not merged. This mock declares the key +and peer id facts of `packages/libp2p/identity/docs/ARCHITECTURE.md`. +That doc freezes `KeyType` and what a key and a peer id hold, and it +leaves the signatures open. This mock fixes minimal signatures. +`PubKey` and `PeerId` drop their proof fields. The real identity package +replaces this module at integration. + +The stand-in bodies are executable, so the suite can drive both outcomes of `unmarshalPubKey?`. The theorems of `Identify` stay true for any replacement, because they are conditional on these opaque results. -/ diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean b/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean index a1734f8e..3c04daf0 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean @@ -8,8 +8,8 @@ import Libp2p.Protocols.Constants # Ping The pure half of `/ipfs/ping/1.0.0`: the protocol id, the payload -size, and the echo check. Sub-unit 1b adds the IO half: the echo -handler, the initiator, and the theorems P5 and P6. +size, and the echo check. The IO half: the echo handler and the +initiator, which share the pure `splitPayloads` and its theorems. Per `vendor/specs/ping/ping.md`: the initiator sends 32 random bytes, and the responder echoes them. A payload of another size, or @@ -32,16 +32,15 @@ def protocolId : Mock.Core.ProtocolId := Constants.pingId /-- The ping payload size of the spec. -/ def payloadSize : Nat := Constants.pingSize -/-- The failures of the pure echo check, plus the IO arms of 1b: -`truncated` (a partial payload at end of stream) and `stream` (a -stream fault). -/ +/-- The failures of the pure echo check, plus the IO arms: `truncated` +(a partial payload at end of stream) and `stream` (a stream fault). -/ inductive Error where | wrongSize (got : Nat) | mismatch /-- The stream ended with a partial payload, a recorded delta from the spike's responder. -/ | truncated - /-- The stream answered a fault. -/ + /-- The stream returned an error. -/ | stream (e : Streams.Error) deriving DecidableEq, BEq, Repr @@ -51,22 +50,22 @@ def checkEcho (sent echo : List Nat) : Except Error Unit := else if echo ≠ sent then .error .mismatch else .ok () -/-- P1: an exact echo of a 32-byte payload passes. -/ +/-- An exact echo of a 32-byte payload passes. -/ theorem checkEchoAccepts (sent : List Nat) (h : sent.length = payloadSize) : checkEcho sent sent = .ok () := by simp [checkEcho, h] -/-- P2: another size fails before the comparison. -/ +/-- Another size fails before the comparison. -/ theorem checkEchoWrongSize (sent echo : List Nat) (h : echo.length ≠ payloadSize) : checkEcho sent echo = .error (.wrongSize echo.length) := by simp [checkEcho, h] -/-- P3: a same-size echo that differs fails. -/ +/-- A same-size echo that differs fails. -/ theorem checkEchoMismatch (sent echo : List Nat) (hlen : echo.length = payloadSize) (hne : echo ≠ sent) : checkEcho sent echo = .error .mismatch := by simp [checkEcho, hlen, hne] -/-- P4: a passing echo is the sent payload, of the spec size. -/ +/-- A passing echo is the sent payload, of the spec size. -/ theorem checkEchoSound (sent echo : List Nat) (h : checkEcho sent echo = .ok ()) : echo = sent ∧ sent.length = payloadSize := by by_cases hlen : echo.length = payloadSize @@ -76,7 +75,7 @@ theorem checkEchoSound (sent echo : List Nat) (h : checkEcho sent echo = .ok ()) exact ⟨heq, heq ▸ hlen⟩ · simp [checkEcho, hlen] at h -/-! ## The echo handler and the initiator (sub-unit 1b) -/ +/-! ## The echo handler and the initiator -/ /-- Split the accumulated bytes into whole 32-byte payloads and the tail. The echo handler writes every whole payload; the tail waits for @@ -92,14 +91,14 @@ decreasing_by have hp : payloadSize = 32 := rfl omega -/-- P6: an accumulator below the payload size holds no whole +/-- An accumulator below the payload size holds no whole payload. -/ theorem splitPayloadsShort (acc : List Nat) (h : acc.length < payloadSize) : splitPayloads acc = ([], acc) := by rw [splitPayloads] rw [ite_eq_right (by omega : ¬ payloadSize ≤ acc.length)] -/-- P5: the splitter takes one whole payload from the front and keeps +/-- The splitter takes one whole payload from the front and keeps the tail. -/ theorem splitPayloadsWhole (sent tail : List Nat) (h : sent.length = payloadSize) (ht : tail.length < payloadSize) : @@ -120,8 +119,7 @@ private def writeAll (s : Streams.Stream) : | .error e => return .error (.stream e) | .ok () => writeAll s ps -/-- The echo loop. `partial`: the peer may stay silent, and no measure -shows a read returns; the module doc records the reason. -/ +/-- The echo loop. The module doc gives the reason for `partial`. -/ private partial def echoLoop (s : Streams.Stream) (acc : List Nat) : Conc.Async (Except Error Unit) := do match ← s.read with @@ -150,9 +148,8 @@ def pingHandler : Mock.Core.StreamHandler := { id := protocolId, handle := fun s => ExceptT.mk ((Except.mapError mapPingError) <$> handleCore s) } -/-- Accumulate echo chunks until one whole payload is present. -`partial`: the peer may stay silent; the module doc records the -reason. -/ +/-- Accumulate echo chunks until one whole payload is present. The +module doc gives the reason for `partial`. -/ private partial def awaitEcho (s : Streams.Stream) (acc : List Nat) : Conc.Async (Except Error (List Nat)) := do match splitPayloads acc with @@ -163,10 +160,9 @@ private partial def awaitEcho (s : Streams.Stream) (acc : List Nat) : | .ok none => return .error .truncated | .ok (some chunk) => awaitEcho s (acc ++ Bytes.toNatList chunk) -/-- Send one 32-byte payload, await the echo, check it with the 1a -`checkEcho`, and return the round-trip time in milliseconds. `.ok -none` never occurs on the echo path; the option carries the measured -time. -/ +/-- Send one 32-byte payload, await the echo, check it with +`checkEcho`, and return the round-trip time in milliseconds inside +`some`. The echo path never returns `.ok none`. -/ def ping (s : Streams.Stream) (clock : Conc.Clock) (payload : ByteArray) : Conc.Async (Except Error (Option Nat)) := do if payload.size ≠ payloadSize then return .error (.wrongSize payload.size) diff --git a/packages/libp2p/protocols/docs/ARCHITECTURE.md b/packages/libp2p/protocols/docs/ARCHITECTURE.md index 31fb0f09..82facca8 100644 --- a/packages/libp2p/protocols/docs/ARCHITECTURE.md +++ b/packages/libp2p/protocols/docs/ARCHITECTURE.md @@ -16,9 +16,10 @@ group is §7, row 10). The spike has no req/resp or gossipsub code. | Module | Holds, and why | |---|---| | `Libp2p/Protocols.lean` | the root module; one import line per public submodule | -| `Libp2p/Protocols/Constants.lean` | the protocol ids and the caps: `/ipfs/id/1.0.0`, `/ipfs/id/push/1.0.0`, `/ipfs/ping/1.0.0`, the `/eth2/.../ssz_sn` pattern of §8, gossipsub's `/meshsub/1.1.0`, and one message cap per protocol; one home per fact (§3.8, §3.4) | +| `Libp2p/Protocols/Constants.lean` | the protocol ids and the caps: `/ipfs/id/1.0.0`, `/ipfs/id/push/1.0.0`, `/ipfs/ping/1.0.0`, the `/eth2/.../ssz_sn` pattern of §8, gossipsub's `/meshsub/1.1.0`, one message cap per protocol, and the protocol version that the identify responder writes; one home per fact (§3.8, §3.4) | | `Libp2p/Protocols/Identify.lean` | the `Identify` message with its `ProtoMessage` instance (§3.2), `Identify.Error`, the validate, the client that reads and validates one response, and the handler values for identify and push | | `Libp2p/Protocols/Ping.lean` | `Ping.Error`, the echo handler, and the initiator that sends 32 bytes and awaits the echo | +| `Libp2p/Protocols/Mock/{Identity,Multiaddr,Core,Host}.lean` | the mocks of the unmerged dependencies (RULES.md, "Working ahead of the tree"); the integration deletes them | | `Libp2p/Protocols/ReqResp/Frame.lean` | the `/eth2/.../ssz_sn` framing, a uvarint length over a snappy-compressed chunk, as a `Codec` with a `LawfulCodec` instance (§3.2); the snappy call goes through the `codec/snappy` binding (root PLAN W6.2); the codec feeds the core | | `Libp2p/Protocols/ReqResp/Core.lean` | the response-chunk state machine of §3.3: `State`, `Event`, `Action`, the error inductive, and the total `step`; the snappy decompression context carries across chunks, and the chunk count and the error/EOF distinction live in the state | | `Libp2p/Protocols/ReqResp/Exchange.lean` | the client and handler seam over the loop: the client function, the handler value the registry stores, and the deadlines | @@ -48,14 +49,18 @@ it takes no module line and lives with the test support. example; the literals live only in `Constants.lean`). `Identify.validate` checks a received message: the peer id derived from the `publicKey` field matches the connected peer, and the size - stays under the cap. The client (the spike's `requestR` shape) reads + is at most the cap. The client (the spike's `requestR` shape) reads one response, validates it, and returns the metadata for the - peerstore. The two handler values serve inbound streams and register - under their ids. The responder builds the message from the host's - identity, addresses, protocol list, observed address, and agent - version, as the spike's `respond` does. The push sender sends the - message under `Identify.pushId` (root W5.7 is "identify (+ push)"), - and a push updates the peerstore through the host. + peerstore. The client reads one frame per stream and drops the bytes + after it. Go merges up to 10 frames; this is a recorded delta. The + two handler values serve inbound streams and register under their + ids. The responder builds the message from the host's identity, + addresses, protocol list, observed address, and agent version, as the + spike's `respond` does. The push sender is deferred. A received push + updates the peerstore through the host. A push must carry the public + key. The identify spec lets a peer send a partial update and says to + ignore missing fields. `push` runs `validate`, so a push with no + `publicKey` field fails with `.missingKey`. This is a recorded delta. - **Ping.** `Ping.protocolId` is the constant. The handler echoes each 32-byte payload until the stream closes (the spike's `handle`). The initiator sends 32 bytes, awaits the echo, and checks the length; @@ -72,7 +77,7 @@ it takes no module line and lives with the test support. The client sides live in this package. §2 bans protocol logic in `cli/`, and the spike's `Main.lean` and `PingNode.lean` call exactly -these: `Identify.request` (`requestR` over a bare stream), `Ping.ping`, +these: `Identify.client` (the spike's `requestR` over a bare stream), `Ping.ping`, and the echo demo handler. ## Owned contracts and how the package keeps them @@ -107,8 +112,9 @@ and the echo demo handler. hold in the same `State`, and every timer fires as an event. The yamux pair shows the shape. A test replays the core with no clock and no task. Each unbounded IO loop in the package, the gossipsub - loop, the ping echo, and the response-chunk loop, is a `partial def` - with its reason in its module doc. + loop, the ping echo, the ping initiator's wait, the identify frame + read, and the response-chunk loop, is a `partial def` with its reason + in its module doc. 6. **Signing policies are a recorded decision** (root PLAN W6.4 names the space). The vendored pubsub spec fixes two axes, signature creation and signature verification. Unit 3 records the From 4deee3c48ea8fdfe79a422a7ffcc355302801951 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:51:54 +0800 Subject: [PATCH 006/125] docs(protocols): correct the constants doc and axiom note --- .../libp2p/protocols/Libp2p/Protocols/Constants.lean | 5 ++--- .../libp2p/protocols/Libp2p/Protocols/Identify.lean | 11 +++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean b/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean index 8a6c6645..7159b0d1 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean @@ -4,9 +4,8 @@ import Libp2p.Protocols.Mock.Core # Protocol constants One home per literal (DRY). The module holds the protocol ids of -identify, identify push, and ping, the identify message cap, the ping -payload size, and the protocol version that the identify responder -writes. Gossipsub and req/resp ids wait for their units. +identify, identify push, and ping, the identify message cap, the +identify protocol version, and the ping payload size. -/ set_option autoImplicit false diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean index e4dd2ba3..565f57d9 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean @@ -310,9 +310,8 @@ theorem validateKeyMismatch (payload : List Nat) (m : Identify) (kb : List Nat) /-- Soundness. A passing validate carries the cap bound, the key facts, the derivation match, and the address validity. The statement -does not tie `m` to the decode of `payload`. The stronger conjunct -`∃ rest, Pb.decodeMessage? (α := Identify) payload = .ok (m, rest)` is -not proved, because `checkAddrs` is private. -/ +does not tie `m` to the decode of `payload`; it omits the conjunct +`∃ rest, Pb.decodeMessage? (α := Identify) payload = .ok (m, rest)`. -/ theorem validateSound (payload : List Nat) (expected : Option Mock.Identity.PeerId) (m : Identify) (pid : Mock.Identity.PeerId) (h : validate payload expected = .ok (m, pid)) : @@ -543,9 +542,9 @@ def respondHandler (key : Mock.Identity.PubKey) (addrs : List (List Nat)) `clientCoreFrame` pins the frame that `clientCore` validates. `clientCoreOverCap` pins the cap check before the body. `clientCoreEmptyFrame` pins the zero-length rule. Each consumes a law of -`Streams.Framing`, and that law rests on the axiom -`Streams.Mock.Varint.decodeEncodeSuffix`. No proof here reads a result -of this package's mocks. -/ +`Streams.Framing`, which inherits the axiom +`Streams.Mock.Varint.decodeEncodeSuffix`. None takes a result of this +package's mocks as a premise. -/ /-- The frame step reads a whole frame back from its encoding, with any rest after it. -/ From fe4af46206daf1183eab77b169b17e50dc730aec Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:57:52 +0800 Subject: [PATCH 007/125] docs(protocols): qualify the identify validation claim --- packages/libp2p/protocols/docs/ARCHITECTURE.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/libp2p/protocols/docs/ARCHITECTURE.md b/packages/libp2p/protocols/docs/ARCHITECTURE.md index 82facca8..6c96ee16 100644 --- a/packages/libp2p/protocols/docs/ARCHITECTURE.md +++ b/packages/libp2p/protocols/docs/ARCHITECTURE.md @@ -47,9 +47,10 @@ it takes no module line and lives with the test support. - **Identify.** `Identify.protocolId` and `Identify.pushId` are the `ProtocolId` constants (§3.4 names `Identify.protocolId` as the root example; the literals live only in `Constants.lean`). - `Identify.validate` checks a received message: the peer id derived - from the `publicKey` field matches the connected peer, and the size - is at most the cap. The client (the spike's `requestR` shape) reads + When the caller supplies the connected peer id, `Identify.validate` + checks that the id derived from `publicKey` matches it. The function + also checks that the message size is at most the cap. The client (the + spike's `requestR` shape) reads one response, validates it, and returns the metadata for the peerstore. The client reads one frame per stream and drops the bytes after it. Go merges up to 10 frames; this is a recorded delta. The From 0d783798f10a35072b7b23e69ff81fa1bd220df7 Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Wed, 4 Nov 2026 12:36:31 +0700 Subject: [PATCH 008/125] fix(protocols): make the host mock atomic, record two deltas --- .../protocols/Libp2p/Protocols/Mock/Host.lean | 17 ++++++++++------- packages/libp2p/protocols/docs/ARCHITECTURE.md | 18 +++++++++++++----- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean index 97243fb6..295aa6f2 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean @@ -1,4 +1,5 @@ import Std +import Conc import Libp2p.Protocols.Mock.Identity /-! @@ -28,14 +29,16 @@ structure Peerstore where /-- The executable in-memory double. Each call makes a fresh table: bind the action once, and pass the resulting value to both the handler -and `addrsOf`. A second add of the same addresses grows nothing. -/ +and `addrsOf`. A second add of the same addresses grows nothing. The +table changes through `OnceRef.update`, one atomic step per add +(ARCHITECTURE §4.1, rule 3). -/ opaque inMemory : IO Peerstore := do - let table ← IO.mkRef ([] : List (PeerId × List (List Nat))) - let addAddrs : PeerId → List (List Nat) → IO Unit := fun p addrs => do - let cur ← table.get - let old := (cur.find? (fun e => e.1 == p)).map (·.2) |>.getD [] - let merged := (old ++ addrs).eraseDups - table.set ((p, merged) :: cur.filter (fun e => !(e.1 == p))) + let table ← Conc.OnceRef.new ([] : List (PeerId × List (List Nat))) + let addAddrs : PeerId → List (List Nat) → IO Unit := fun p addrs => + table.update fun cur => + let old := (cur.find? (fun e => e.1 == p)).map (·.2) |>.getD [] + let merged := (old ++ addrs).eraseDups + (p, merged) :: cur.filter (fun e => !(e.1 == p)) let addrsOf : PeerId → IO (List (List Nat)) := fun p => do let cur ← table.get return (cur.find? (fun e => e.1 == p)).map (·.2) |>.getD [] diff --git a/packages/libp2p/protocols/docs/ARCHITECTURE.md b/packages/libp2p/protocols/docs/ARCHITECTURE.md index 6c96ee16..7f3a2afe 100644 --- a/packages/libp2p/protocols/docs/ARCHITECTURE.md +++ b/packages/libp2p/protocols/docs/ARCHITECTURE.md @@ -58,14 +58,22 @@ it takes no module line and lives with the test support. ids. The responder builds the message from the host's identity, addresses, protocol list, observed address, and agent version, as the spike's `respond` does. The push sender is deferred. A received push - updates the peerstore through the host. A push must carry the public - key. The identify spec lets a peer send a partial update and says to - ignore missing fields. `push` runs `validate`, so a push with no - `publicKey` field fails with `.missingKey`. This is a recorded delta. + updates the peerstore through the host. The push stores the + addresses. The peerstore surface of the host doc holds addresses + only, so a push's protocols list has no store yet. This is a recorded + delta from go, whose push handler updates both. A push must carry the + public key. The identify spec lets a peer send a partial update and + says to ignore missing fields. `push` runs `validate`, so a push with + no `publicKey` field fails with `.missingKey`. This is a recorded + delta. - **Ping.** `Ping.protocolId` is the constant. The handler echoes each 32-byte payload until the stream closes (the spike's `handle`). The initiator sends 32 bytes, awaits the echo, and checks the length; - the spike's `Ping.ping` is the shape. + the spike's `Ping.ping` is the shape. The initiator's echo read + carries no deadline yet. The deadline rule of §5.4 binds the + unauthenticated phase only. A silent peer parks the initiator until + the stream closes. This deferral is a recorded delta from the + spike. - **Req/resp.** The framing codec feeds the core. The client side dials a stream through the host, sends one request under the framing, and awaits the response chunks under a deadline. The handler side serves From f40c8e650c892f020a5b61e97eb3feeed14e8f42 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:52:52 +0800 Subject: [PATCH 009/125] fix(protocols): reject a ping echo longer than one payload The one-exchange initiator returned the first 32 bytes and dropped the tail, so a 33-byte echo passed. Reject an accumulator above the payload size with .wrongSize. The repeating echo handler is unchanged. --- Tests/Protocols.lean | 29 +++++++++++++++++++ .../protocols/Libp2p/Protocols/Ping.lean | 7 +++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Tests/Protocols.lean b/Tests/Protocols.lean index a4fd5177..67d245a1 100644 --- a/Tests/Protocols.lean +++ b/Tests/Protocols.lean @@ -301,6 +301,35 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do r.check "ping e2e partial echo at EOF rejected" (failsWith Ping.Error.truncated res) tc.close + -- ping: an echo longer than one payload is rejected + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let payload := Bytes.ofNatList (List.replicate 32 7) + let ts ← Conc.TaskSet.new + ts.spawn do + discard <| pair.b.write (Bytes.ofNatList (List.replicate 33 7)) + let res ← Ping.ping pair.a tc.clock payload + ts.joinAll + r.check "ping e2e 33-byte echo rejected" + (failsWith (Ping.Error.wrongSize 33) res) + tc.close + + -- ping: an echo over one payload, split across chunks, is rejected + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let payload := Bytes.ofNatList (List.replicate 32 7) + let ts ← Conc.TaskSet.new + ts.spawn do + discard <| pair.b.write (Bytes.ofNatList (List.replicate 16 7)) + discard <| pair.b.write (Bytes.ofNatList (List.replicate 17 7)) + let res ← Ping.ping pair.a tc.clock payload + ts.joinAll + r.check "ping e2e 16+17-byte echo rejected" + (failsWith (Ping.Error.wrongSize 33) res) + tc.close + -- ping: an echo split across chunks is accepted do let pair ← mkPair diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean b/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean index 3c04daf0..c066077e 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean @@ -149,11 +149,14 @@ def pingHandler : Mock.Core.StreamHandler := handle := fun s => ExceptT.mk ((Except.mapError mapPingError) <$> handleCore s) } /-- Accumulate echo chunks until one whole payload is present. The -module doc gives the reason for `partial`. -/ +initiator reads one 32-byte payload, so an accumulator above that size +is an oversized echo. The module doc gives the reason for `partial`. -/ private partial def awaitEcho (s : Streams.Stream) (acc : List Nat) : Conc.Async (Except Error (List Nat)) := do match splitPayloads acc with - | (p :: _, _) => return .ok p + | (p :: _, _) => + if acc.length = payloadSize then return .ok p + else return .error (.wrongSize acc.length) | ([], _) => match ← s.read with | .error e => return .error (.stream e) From 126e665c9cfc26ef97b34a069143bc38441724e4 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:51:43 +0800 Subject: [PATCH 010/125] feat(negotiate): W5.5 1a, the multistream core and its suite --- Tests/Main.lean | 2 + Tests/Negotiate.lean | 258 +++++++++ lakefile.toml | 1 + .../libp2p/negotiate/Libp2p/Negotiate.lean | 10 +- .../negotiate/Libp2p/Negotiate/Constants.lean | 23 + .../negotiate/Libp2p/Negotiate/Message.lean | 448 ++++++++++++++++ .../negotiate/Libp2p/Negotiate/Mock/Core.lean | 31 ++ .../Libp2p/Negotiate/Mock/Varint.lean | 82 +++ .../negotiate/Libp2p/Negotiate/Step.lean | 504 ++++++++++++++++++ 9 files changed, 1356 insertions(+), 3 deletions(-) create mode 100644 Tests/Negotiate.lean create mode 100644 packages/libp2p/negotiate/Libp2p/Negotiate/Constants.lean create mode 100644 packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean create mode 100644 packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean create mode 100644 packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean create mode 100644 packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean diff --git a/Tests/Main.lean b/Tests/Main.lean index 794e47f1..2f7a268b 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -16,6 +16,7 @@ import Tests.BackendSoak import Tests.Hashes import Tests.Facade import Tests.CryptoCross +import Tests.Negotiate /-! # Test entry point @@ -47,4 +48,5 @@ def main : IO UInt32 := do Tests.Hashes.run r Tests.Facade.run r Tests.CryptoCross.run r + Tests.Negotiate.run r r.summary diff --git a/Tests/Negotiate.lean b/Tests/Negotiate.lean new file mode 100644 index 00000000..5d013488 --- /dev/null +++ b/Tests/Negotiate.lean @@ -0,0 +1,258 @@ +import Tests.Support +import Libp2p.Negotiate +import Bytes + +/-! +# Multistream-select suite (unit 1a) + +The wire examples, the `ls` framing, and the captured go traces replay +through the pure core: events in, actions out, no network and no clock +(root §6, the unit 1a milestones of the package plan). The strictness +checks pin every rejection the plan names, and the generic codec +harness runs over `Msg` samples. +-/ + +set_option autoImplicit false + +namespace Tests.Negotiate + +open Libp2p.Negotiate +open Bytes (DecodeError) +open Lean (Json) + +/-- A test fixture text that fails the suite when it is over the cap. -/ +private def msgOf (s : String) : IO Msg := + match Msg.ofText? s with + | some m => pure m + | none => throw <| IO.userError "test fixture text over the 1 KiB cap" + +/-- Parse fixture hex through the shared lowercase reader. -/ +private def hexOf (h : String) : IO (List Nat) := + match Bytes.ofHex? h with + | some b => pure (Bytes.toNatList b) + | none => throw <| IO.userError "test fixture hex did not parse" + +/-- The writes of an action list, in order. -/ +private def writesOf (actions : List Action) : List (List Nat) := + actions.filterMap fun + | .write bs => some bs + | .select .. => none + +/-- The selects of an action list, in order. -/ +private def selectsOf (actions : List Action) : List (Mock.Core.ProtocolId × List Nat) := + actions.filterMap fun + | .select p r => some (p, r) + | .write _ => none + +/-- The responder's initial state. -/ +private def responderState (supported : List Mock.Core.ProtocolId) : State := + { role := .responder, supported := supported, proposals := [], buffer := [], phase := .headerPending } + +/-- The initiator's initial state. -/ +private def initiatorState : State := + { role := .initiator, supported := [], proposals := [], buffer := [], phase := .headerPending } + +/-- Drain the buffer of a state, collecting the actions. The fuel +bounds the walk; the buffer shrinks by one frame per step. -/ +private def drain (fuel : Nat) (s : State) : Except Error (State × List Action) := + if fuel = 0 then .error .negotiationFailed + else if s.buffer.isEmpty then .ok (s, []) + else do + let (s', actions) ← step s (.bytes []) + let (s'', rest) ← drain (fuel - 1) s' + .ok (s'', actions ++ rest) + +/-- Replay byte chunks through the core: each chunk is one read event, +then the buffer drains before the next chunk. -/ +private def replay (s : State) (chunks : List (List Nat)) : Except Error (State × List Action) := + match chunks with + | [] => .ok (s, []) + | c :: cs => do + let (s1, a1) ← step s (.bytes c) + let (s2, a2) ← drain (s1.buffer.length + 1) s1 + let (s3, a3) ← replay s2 cs + .ok (s3, a1 ++ a2 ++ a3) + +/-- One captured trace: the initiator-to-responder hexes and the +responder-to-initiator hexes, in write order. -/ +private def traceHexes (file : String) (trace : List Json) : IO (List String × List String) := do + let entries ← trace.mapM fun e => do + let d ← Tests.jStr file e "dir" + let h ← Tests.jStr file e "hex" + pure (d, h) + pure (entries.filter (·.1 = "i2r") |>.map (·.2), entries.filter (·.1 = "r2i") |>.map (·.2)) + +/-- The suite. -/ +def run (r : Runner) : IO Unit := do + r.section "multistream-select 1.0.0 (unit 1a)" "packages/libp2p/negotiate/docs/ARCHITECTURE.md" + + /- Model-level strictness. -/ + r.check "mss decode zero length malformed" + (failsWith DecodeError.malformed (decodeMsg? (Mock.Varint.encode (Mock.Varint.boundedVarint 0) ++ [10]))) + r.check "mss decode no trailing newline malformed" + (failsWith DecodeError.malformed (decodeMsg? (Mock.Varint.encode (Mock.Varint.boundedVarint 2) ++ [110, 97]))) + r.check "mss decode bad UTF-8 malformed" + (failsWith DecodeError.malformed (decodeMsg? (Mock.Varint.encode (Mock.Varint.boundedVarint 2) ++ [195, 10]))) + r.check "mss decode overlong varint malformed" + (failsWith DecodeError.malformed (decodeMsg? (List.replicate 12 255))) + r.check "mss decode short varint truncated" + (failsWith DecodeError.truncated (decodeMsg? [128])) + r.check "mss decode short payload truncated" + (failsWith DecodeError.truncated (decodeMsg? (Mock.Varint.encode (Mock.Varint.boundedVarint 3) ++ [110, 97]))) + r.check "mss decode empty input truncated" + (failsWith DecodeError.truncated (decodeMsg? [])) + r.check "mss decode cap malformed" + (failsWith DecodeError.malformed (decodeMsg? (Mock.Varint.encode (Mock.Varint.boundedVarint (maxMsg + 1)) ++ []))) + r.check "mss decode non-byte model value malformed" + (failsWith DecodeError.malformed (decodeMsg? (Mock.Varint.encode (Mock.Varint.boundedVarint 2) ++ [300, 10]))) + let mna ← msgOf "na" + r.check "mss decode suffix" + (succeedsWith (mna, [7, 8]) (decodeMsg? (encodeMsg mna ++ [7, 8]))) + let kad ← msgOf "/ipfs/kad/1.0.0" + r.check "mss decode coalesced" + (succeedsWith (header, encodeText kad.text) (decodeMsg? (encodeMsg header ++ encodeText kad.text))) + + /- The wire vectors. -/ + let wire ← Tests.loadJson "mss-wire.json" + let cases ← Tests.jArr "mss-wire.json" wire "cases" + let naHex ← Tests.jStr "mss-wire.json" cases[0]! "bytesHex" + let naBytes ← hexOf naHex + r.check "mss wire na encode" (encodeMsg mna == naBytes) + r.check "mss wire na decode" (succeedsWith (mna, []) (decodeMsg? naBytes)) + let hdrHex ← Tests.jStr "mss-wire.json" cases[1]! "bytesHex" + let hdrBytes ← hexOf hdrHex + r.check "mss wire header encode" (encodeMsg header == hdrBytes) + r.check "mss wire header decode" (succeedsWith (header, []) (decodeMsg? hdrBytes)) + + /- The `ls` vector. -/ + let lsj ← Tests.loadJson "mss-ls.json" + let lsWireHex ← Tests.jStr "mss-ls.json" lsj "lsWireHex" + let lsBytes ← hexOf lsWireHex + let mls ← msgOf "ls" + r.check "mss ls wire encode" (encodeMsg mls == lsBytes) + r.check "mss ls wire decode" (succeedsWith (mls, []) (decodeMsg? lsBytes)) + let reply ← Tests.jObj "mss-ls.json" lsj "replyExample" + let protos ← Tests.jStrs "mss-ls.json" reply "protocols" + let bodyHex ← Tests.jStr "mss-ls.json" reply "bodyHex" + let bodyBytes ← hexOf bodyHex + let ids : List Mock.Core.ProtocolId := protos.map fun s => ⟨s⟩ + r.check "mss ls body encode" (lsBody ids == bodyBytes) + r.check "mss ls reply encode" + (encodeLs ids == Mock.Varint.encode (Mock.Varint.boundedVarint bodyBytes.length) ++ bodyBytes) + r.check "mss ls reply decode" (succeedsWith (ids, []) (decodeLs? (encodeLs ids))) + + /- The go traces. -/ + let goj ← Tests.loadJson "mss-go-trace.json" + let goNaWireHex ← Tests.jStr "mss-go-trace.json" goj "naWireHex" + let goNaBytes ← hexOf goNaWireHex + let goHdrWireHex ← Tests.jStr "mss-go-trace.json" goj "headerWireHex" + let goHdrBytes ← hexOf goHdrWireHex + r.check "mss go na wire encode" (encodeMsg (Reply.toMsg .na) == goNaBytes) + r.check "mss go na wire decode" (succeedsWith (Reply.toMsg .na, []) (decodeMsg? goNaBytes)) + r.check "mss go header wire encode" (encodeMsg header == goHdrBytes) + r.check "mss go header wire decode" (succeedsWith (header, []) (decodeMsg? goHdrBytes)) + let fullTrace ← Tests.jArr "mss-go-trace.json" goj "fullTrace" + let (i2rF, r2iF) ← traceHexes "mss-go-trace.json" fullTrace + let naTrace ← Tests.jArr "mss-go-trace.json" goj "naTrace" + let (i2rN, r2iN) ← traceHexes "mss-go-trace.json" naTrace + let lazyTrace ← Tests.jArr "mss-go-trace.json" goj "lazyTrace" + let (i2rL, r2iL) ← traceHexes "mss-go-trace.json" lazyTrace + let kadId : Mock.Core.ProtocolId := ⟨"/ipfs/kad/1.0.0"⟩ + let nopeId : Mock.Core.ProtocolId := ⟨"/nope/9.9.9"⟩ + + match replay (responderState [kadId]) (← i2rF.mapM hexOf) with + | .error _ => r.check "mss go full trace" false + | .ok (sf, af) => + r.check "mss go full trace" + (writesOf af == (← r2iF.mapM hexOf) ∧ selectsOf af == [(kadId, [])] ∧ sf.phase = .selected) + + match replay (responderState []) (← i2rN.mapM hexOf) with + | .error _ => r.check "mss go na trace" false + | .ok (sn, an) => + r.check "mss go na trace" + (writesOf an == (← r2iN.mapM hexOf) ∧ selectsOf an == [] ∧ sn.phase = .negotiating) + + match replay (responderState [kadId]) (← i2rL.mapM hexOf) with + | .error _ => r.check "mss go lazy trace" false + | .ok (sl, al) => + r.check "mss go lazy trace" + (writesOf al == (← r2iL.mapM hexOf) ∧ selectsOf al == [(kadId, [])] ∧ sl.phase = .selected) + + let lazyI2r ← hexOf i2rL[0]! + match step initiatorState (.propose kadId) with + | .error _ => r.check "mss go lazy coalesced write" false + | .ok (s1, a1) => + match a1 with + | [.write w] => + r.check "mss go lazy coalesced write" + (w == lazyI2r ∧ s1.phase = .negotiating ∧ s1.proposals == [kadId]) + | _ => r.check "mss go lazy coalesced write" false + + match step initiatorState (.propose kadId) with + | .error _ => r.check "mss go initiator lazy select" false + | .ok (s1, _) => + match replay s1 (← r2iL.mapM hexOf) with + | .error _ => r.check "mss go initiator lazy select" false + | .ok (s2, a2) => + r.check "mss go initiator lazy select" + (s2.phase = .selected ∧ selectsOf a2 == [(kadId, [])] ∧ writesOf a2 == []) + + /- Step-level checks. -/ + let sNeg : State := { role := .responder, supported := [], proposals := [], buffer := [], phase := .negotiating } + match step sNeg (.bytes (encodeText nopeId.toString)) with + | .error _ => r.check "mss step responder unknown id writes na and stays negotiating" false + | .ok (s', actions) => + r.check "mss step responder unknown id writes na and stays negotiating" + (s'.phase = .negotiating ∧ actions == [.write (encodeMsg (Reply.toMsg .na))]) + + let sup : List Mock.Core.ProtocolId := [⟨"/ipfs/kad/1.0.0"⟩, ⟨"/ipfs/id/1.0.0"⟩] + let sSup : State := { role := .responder, supported := sup, proposals := [], buffer := [], phase := .negotiating } + match step sSup (.bytes (encodeText "ls")) with + | .error _ => r.check "mss step responder ls writes ls reply and stays negotiating" false + | .ok (s', actions) => + r.check "mss step responder ls writes ls reply and stays negotiating" + (s'.phase = .negotiating ∧ actions == [.write (encodeLs sup)]) + + let sInit : State := { role := .initiator, supported := [], proposals := [kadId, nopeId], buffer := [], phase := .awaitingAnswer } + match step sInit (.bytes (encodeText "na")) with + | .error _ => r.check "mss step initiator na advances proposal list" false + | .ok (s', actions) => + r.check "mss step initiator na advances proposal list" + (s'.proposals == [nopeId] ∧ actions == [.write (encodeText nopeId.toString)]) + + let sOne : State := { role := .initiator, supported := [], proposals := [kadId], buffer := [], phase := .awaitingAnswer } + r.check "mss step initiator proposals exhausted errors" + (failsWith Error.negotiationFailed (step sOne (.bytes (encodeText "na")))) + + r.check "mss step hostile frame errors" + (failsWith (Error.decode .malformed) + (step sNeg (.bytes (Mock.Varint.encode (Mock.Varint.boundedVarint (maxMsg + 1)) ++ [])))) + + let sBuf : State := { role := .responder, supported := [], proposals := [], buffer := [], phase := .negotiating } + match step sBuf (.bytes [128]) with + | .error _ => r.check "mss step truncated waits" false + | .ok (s', actions) => + r.check "mss step truncated waits" (actions == [] ∧ s'.phase = .negotiating ∧ s'.buffer == [128]) + + let sKad : State := { role := .responder, supported := [kadId], proposals := [], buffer := [], phase := .negotiating } + match step sKad (.bytes (encodeText kadId.toString ++ [99])) with + | .error _ => r.check "mss step select leftover conservation" false + | .ok (s', actions) => + r.check "mss step select leftover conservation" + (s'.phase = .selected ∧ actions == [.write (encodeText kadId.toString), .select kadId [99]]) + + match step sNeg (.bytes (List.replicate 8 255)), step sNeg (.bytes (List.replicate 9 255)) with + | .ok (s1, a1), .error e2 => + r.check "mss step hostile buffer bounded" + (a1 == [] ∧ e2 == Error.decode .malformed ∧ s1.buffer == List.replicate 8 255) + | _, _ => r.check "mss step hostile buffer bounded" false + + /- The generic harness. -/ + let ipfsId ← msgOf "/ipfs/id/1.0.0" + let samples : List Msg := [Reply.toMsg .na, Reply.toMsg .ls, header, kad, ipfsId] + r.check "mss generic round-trip harness" (Bytes.checkRoundTrips samples == true) + + r.skip "mss generic model pin" + "needs the ByteArray adapter (stretch S1, plan section 7); lands with unit 1b" + +end Tests.Negotiate diff --git a/lakefile.toml b/lakefile.toml index f7924309..f05f0a2c 100644 --- a/lakefile.toml +++ b/lakefile.toml @@ -164,6 +164,7 @@ roots = ["Libp2p.Mux"] name = "Libp2pNegotiate" srcDir = "packages/libp2p/negotiate" roots = ["Libp2p.Negotiate"] +globs = ["Libp2p.Negotiate.*"] [[lean_lib]] name = "Libp2pHost" diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate.lean b/packages/libp2p/negotiate/Libp2p/Negotiate.lean index e5b516f2..a45e4440 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate.lean @@ -1,7 +1,11 @@ +import Libp2p.Negotiate.Constants +import Libp2p.Negotiate.Message +import Libp2p.Negotiate.Step + /-! # Libp2p.Negotiate -The negotiate subpackage: multistream-select and the upgrader (ARCHITECTURE §4.9). Scaffold root. -The modules arrive with unit W5.5 of `docs/PLAN.md`. The package docs -live under `docs/` beside this file. +The negotiate subpackage: multistream-select 1.0.0 and the upgrader +(ARCHITECTURE §4.9). One import line per public submodule. The root +does not import the Mock modules. The upgrader lands with unit 1b. -/ diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Constants.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Constants.lean new file mode 100644 index 00000000..0ae29546 --- /dev/null +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Constants.lean @@ -0,0 +1,23 @@ +import Libp2p.Negotiate.Mock.Core + +/-! +# The constants of the negotiation + +One home per fact (root §3.8). The multistream protocol id and the +1 KiB message cap live only here. At W5.2 integration the `Mock.Core` +import swaps to `Libp2p.Core`, and the literal stays. +-/ + +set_option autoImplicit false + +namespace Libp2p.Negotiate + +/-- The multistream 1.0.0 protocol id. The only string literal of the +package (root §3.4). -/ +def protocolId : Mock.Core.ProtocolId := ⟨"/multistream/1.0.0"⟩ + +/-- The 1 KiB multistream cap (root §5.1, contract 1 of the package +docs). -/ +def maxMsg : Nat := 1024 + +end Libp2p.Negotiate diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean new file mode 100644 index 00000000..be9b298b --- /dev/null +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean @@ -0,0 +1,448 @@ +import Libp2p.Negotiate.Constants +import Libp2p.Negotiate.Mock.Varint +import Bytes + +/-! +# The multistream message + +`Msg` is one multistream message: a uvarint length prefix, then the +UTF-8 bytes of the text plus a trailing newline. The length counts the +newline (ARCHITECTURE §3.2, package docs "Public surface"). + +The decoder splits one frame at the index the prefix computes, then +runs the computed inverse of the payload map: it reads the body as +UTF-8, re-encodes it, and compares. One comparison rejects a missing +trailing newline, a model value at or above 256, and bad UTF-8. This +keeps the `LawfulCodec` inverse to one `encodeDecode` step. + +The 1 KiB cap is checked in `splitFrame`, before any payload +inspection (contract 1). The `ls` reply framing walks its entries as +ordinary `Msg` frames on a fuel counter, so the entry reader reuses +the proved frame decoder. +-/ + +set_option autoImplicit false + +namespace Libp2p.Negotiate + +open Bytes (DecodeError) + +/-! ## The payload map and its inverse -/ + +/-- The model bytes of a text, newline included. The one total map +between texts and payloads. -/ +def payloadOf (s : String) : List Nat := + Bytes.toNatList (Bytes.ofString (s ++ "\n")) + +/-- The total text encoder: a uvarint length prefix, then the payload. -/ +def encodeText (s : String) : List Nat := + Mock.Varint.encode (Mock.Varint.boundedVarint (payloadOf s).length) ++ payloadOf s + +/-- A multistream message. `text` excludes the trailing newline. +`fits` is the proof-carrying cap fact of root §3.7: the message fits +the 1 KiB cap, so the encoder writes the exact length, no modulo. -/ +structure Msg where + text : String + fits : (Bytes.ofString (text ++ "\n")).size ≤ maxMsg + deriving Repr, DecidableEq + +/-- The model payload length equals the byte size of the text with its +newline. -/ +theorem payloadOfLength (s : String) : + (payloadOf s).length = (Bytes.ofString (s ++ "\n")).size := by + simp only [payloadOf, Bytes.toNatList, List.length_map, Array.length_toList] + rfl + +/-- The payload of a text is its UTF-8 bytes plus the newline byte. -/ +theorem payloadOfEq (s : String) : + payloadOf s = Bytes.toNatList (Bytes.ofString s) ++ [10] := by + simp [payloadOf, Bytes.toNatList, Bytes.ofString, String.toByteArray_append] + exact ⟨10, by decide, rfl⟩ + +/-- Every payload ends in a newline, so it is never empty. -/ +theorem payloadOfNe (s : String) : payloadOf s ≠ [] := by + rw [payloadOfEq]; simp + +/-- The checked constructor. The tests and `header` build messages +through it. -/ +def Msg.ofText? (s : String) : Option Msg := + if h : (Bytes.ofString (s ++ "\n")).size ≤ maxMsg then some ⟨s, h⟩ else none + +/-- The frame the codec writes for a message. -/ +def encodeMsg (m : Msg) : List Nat := encodeText m.text + +/-- The multistream header message. The literal lives in `Constants`. -/ +def header : Msg := ⟨protocolId.toString, by decide⟩ + +/-! ## The frame split -/ + +/-- Split one frame at the index the length prefix computes. The cap +is checked first, before any payload inspection (contract 1). A zero +declared length is malformed. A payload cut short is truncated, so +the caller keeps waiting. -/ +def splitFrame (bs : List Nat) : Except DecodeError (List Nat × List Nat) := + match Mock.Varint.decode? bs with + | .error e => .error e + | .ok (v, rest) => + if maxMsg < v.val then .error .malformed + else if v.val = 0 then .error .malformed + else if rest.length < v.val then .error .truncated + else .ok (rest.take v.val, rest.drop v.val) + +/-- The computed inverse of `payloadOf`, checked by re-encoding. -/ +def textOf? (payload : List Nat) : Option String := + match Bytes.toString? (Bytes.ofNatList payload.dropLast) with + | none => none + | some t => if payloadOf t = payload then some t else none + +/-- A text read back from a payload re-encodes to that payload. -/ +theorem textOf?Spec {payload : List Nat} {t : String} (h : textOf? payload = some t) : + payloadOf t = payload := by + unfold textOf? at h + split at h + · simp at h + · split at h + · simp_all + · simp at h + +/-- The inverse reads its own image back. -/ +theorem textOf?PayloadOf (s : String) : textOf? (payloadOf s) = some s := by + unfold textOf? + have hd : (payloadOf s).dropLast = Bytes.toNatList (Bytes.ofString s) := by + rw [payloadOfEq]; simp + rw [hd, Bytes.ofNatListToNatList, Bytes.toString?OfString] + simp + +/-- A payload whose last byte is not the newline reads back as no +text. -/ +theorem textOf?None {payload : List Nat} (hlast : payload.getLast? ≠ some 10) : + textOf? payload = none := by + unfold textOf? + cases hto : Bytes.toString? (Bytes.ofNatList payload.dropLast) with + | none => rfl + | some t => + by_cases heq : payloadOf t = payload + · have hpl : (payloadOf t).getLast? = some 10 := by rw [payloadOfEq]; simp + rw [heq] at hpl + exact (hlast hpl).elim + · simp [heq] + +/-- Decode one message and the bytes left after it. -/ +def decodeMsg? (bs : List Nat) : Except DecodeError (Msg × List Nat) := + match splitFrame bs with + | .error e => .error e + | .ok (payload, rest) => + match textOf? payload with + | none => .error .malformed + | some t => + if hf : (Bytes.ofString (t ++ "\n")).size ≤ maxMsg then .ok (⟨t, hf⟩, rest) + else .error .malformed + +/-! ## The frame lemmas -/ + +/-- One shared lemma: a framed payload splits back into that payload +and the remainder. -/ +theorem splitFrameEncode (s : String) (h : (payloadOf s).length ≤ maxMsg) (rest : List Nat) : + splitFrame (encodeText s ++ rest) = .ok (payloadOf s, rest) := by + have hlim : (payloadOf s).length < Mock.Varint.limit := by + unfold Mock.Varint.limit maxMsg at *; omega + have hb : (Mock.Varint.boundedVarint (payloadOf s).length).val = (payloadOf s).length := by + simp [Mock.Varint.boundedVarint, Nat.mod_eq_of_lt hlim] + have hpos : 0 < (payloadOf s).length := List.length_pos_iff.mpr (payloadOfNe s) + unfold splitFrame encodeText + rw [List.append_assoc, Mock.Varint.decodeEncodeSuffix] + simp only [hb] + have h1 : ¬ maxMsg < (payloadOf s).length := by omega + have h2 : ¬ (payloadOf s).length = 0 := by omega + simp [h1, h2] + +/-- The suffix round trip: a message decodes back from its frame plus +any remainder. The lazy dialer's coalesced read needs this form. The +frame is `encodeText m.text`, definitionally `encodeMsg m`. -/ +theorem decodeMsgSuffix (m : Msg) (rest : List Nat) : + decodeMsg? (encodeText m.text ++ rest) = .ok (m, rest) := by + have h : (payloadOf m.text).length ≤ maxMsg := by rw [payloadOfLength]; exact m.fits + unfold decodeMsg? + rw [splitFrameEncode m.text h] + simp [textOf?PayloadOf, m.fits] + +/-- The inverse direction: every accepted input is the encoding of +the decoded value. No UTF-8 reasoning; `textOf?` already re-encoded. -/ +theorem decodeMsgEncodeDecode (bs : List Nat) (m : Msg) (rest : List Nat) + (h : decodeMsg? bs = .ok (m, rest)) : encodeMsg m ++ rest = bs := by + show encodeText m.text ++ rest = bs + unfold decodeMsg? splitFrame at h + split at h + · simp at h + · rename_i payload rest0 hsplit + split at hsplit + · simp at hsplit + · rename_i v r0 hdec + have hbs := Mock.Varint.encodeDecode bs v r0 hdec + split at hsplit + · simp at hsplit + split at hsplit + · simp at hsplit + split at hsplit + · simp at hsplit + rename_i hcap _ hlen + simp only [Except.ok.injEq, Prod.mk.injEq] at hsplit + obtain ⟨rfl, rfl⟩ := hsplit + split at h + · simp at h + · rename_i t ht + have hp := textOf?Spec ht + split at h + · simp only [Except.ok.injEq, Prod.mk.injEq] at h + obtain ⟨rfl, rfl⟩ := h + have hl : (payloadOf t).length = v.val := by + rw [hp]; simp; omega + have hv : Mock.Varint.boundedVarint (payloadOf t).length = v := by + cases v; simp_all [Mock.Varint.boundedVarint, Nat.mod_eq_of_lt] + rw [encodeText, hv, hp, List.append_assoc, List.take_append_drop] + exact hbs.symm + · simp at h + +/-- The codec writes the frame and reads one message back. -/ +instance : Bytes.Codec Msg where + encode := encodeMsg + decode? := decodeMsg? + +theorem decodeMsgEncode (m : Msg) : decodeMsg? (encodeMsg m) = .ok (m, []) := by + simpa [encodeMsg] using decodeMsgSuffix m [] + +/-- The round trip law, the frozen demand of §3.2. -/ +instance : Bytes.LawfulCodec Msg where + decodeEncode := decodeMsgEncode + encodeDecode := by + intro bs a rest h + exact decodeMsgEncodeDecode bs a rest h + +/-- The cap fires before the payload is read, for every payload that +follows the prefix (contract 1). -/ +theorem decodeMsgCap {v : Mock.Varint.UVarint} {payload : List Nat} + (h : maxMsg < v.val) : + decodeMsg? (Mock.Varint.encode v ++ payload) = .error .malformed := by + simp [decodeMsg?, splitFrame, Mock.Varint.decodeEncodeSuffix, h] + +/-- A payload cut short is incomplete: more bytes may complete it. -/ +theorem decodeMsgTruncated {v : Mock.Varint.UVarint} {payload : List Nat} + (hv0 : 0 < v.val) (hcap : v.val ≤ maxMsg) (hshort : payload.length < v.val) : + decodeMsg? (Mock.Varint.encode v ++ payload) = .error .truncated := by + unfold decodeMsg? splitFrame + rw [Mock.Varint.decodeEncodeSuffix] + have h1 : ¬ maxMsg < v.val := by omega + have h2 : ¬ v.val = 0 := by omega + simp only [h1, h2, hshort, ite_true, ite_false] + +/-- A zero declared length is malformed. -/ +theorem decodeMsgZeroLength {v : Mock.Varint.UVarint} {payload : List Nat} + (h : v.val = 0) : + decodeMsg? (Mock.Varint.encode v ++ payload) = .error .malformed := by + simp [decodeMsg?, splitFrame, Mock.Varint.decodeEncodeSuffix, h, maxMsg] + +/-- A body whose last byte is not the newline is malformed. -/ +theorem decodeMsgNoNewline {v : Mock.Varint.UVarint} {payload : List Nat} + (hv0 : 0 < v.val) (hcap : v.val ≤ maxMsg) (hlen : payload.length = v.val) + (hlast : payload.getLast? ≠ some 10) : + decodeMsg? (Mock.Varint.encode v ++ payload) = .error .malformed := by + have hnone : textOf? (List.take v.val payload) = none := by + rw [← hlen, List.take_length] + exact textOf?None hlast + unfold decodeMsg? splitFrame + rw [Mock.Varint.decodeEncodeSuffix] + have h1 : ¬ maxMsg < v.val := by omega + have h2 : ¬ v.val = 0 := by omega + have h3 : ¬ payload.length < v.val := by omega + simp only [h1, h2, h3, ite_false] + rw [hnone] + +/-- The framing budget: a message frame spans its payload plus at +most nine prefix bytes. -/ +theorem encodeMsgLength (m : Msg) : + (encodeMsg m).length ≤ maxMsg + Mock.Varint.maxBytes := by + have h1 := Mock.Varint.encodeLengthBound (Mock.Varint.boundedVarint (payloadOf m.text).length) + have h2 : (payloadOf m.text).length ≤ maxMsg := by rw [payloadOfLength]; exact m.fits + simp only [encodeMsg, encodeText, List.length_append] + omega + +/-! ## The `ls` reply framing -/ + +/-- One `ls` entry is an ordinary message frame: the entry's text is +the protocol id, and the frame carries the id plus its newline. -/ +def lsEntries (ids : List Mock.Core.ProtocolId) : List Nat := + (ids.map (fun p => encodeText p.toString)).flatten + +/-- The body of an `ls` reply: one entry frame per id, then the final +newline byte that ends the list. -/ +def lsBody (ids : List Mock.Core.ProtocolId) : List Nat := lsEntries ids ++ [10] + +/-- The full `ls` reply: the body wrapped in one uvarint length +prefix, exactly like a message. -/ +def encodeLs (ids : List Mock.Core.ProtocolId) : List Nat := + Mock.Varint.encode (Mock.Varint.boundedVarint (lsBody ids).length) ++ lsBody ids + +/-- Walk the entries of one complete `ls` body. The fuel counts +entries; the caller passes the body's byte length, which is at least +the entry count because every entry carries its newline. `[10]` alone +is the terminator, not a leading byte: a leading `0x0A` can be a +legitimate one-byte prefix for a nine-byte id. -/ +def decodeEntries : Nat → List Nat → Except DecodeError (List Mock.Core.ProtocolId) + | 0, _ => .error .malformed + | fuel + 1, bs => + if bs = [10] then .ok [] + else match decodeMsg? bs with + | .error _ => .error .malformed + | .ok (m, rest) => (decodeEntries fuel rest).map (⟨m.text⟩ :: ·) + +/-- Decode one `ls` reply frame. The outer prefix goes through the +same split and cap as a message. An entry that ends mid-body is +malformed, because only the outer prefix and length can be completed +by more bytes. -/ +def decodeLs? (bs : List Nat) : + Except DecodeError (List Mock.Core.ProtocolId × List Nat) := + match splitFrame bs with + | .error e => .error e + | .ok (body, rest) => (decodeEntries body.length body).map (·, rest) + +/-- An entry frame is at least as long as its payload. -/ +theorem encodeTextLength (s : String) : (payloadOf s).length ≤ (encodeText s).length := by + simp [encodeText] + +/-- The entries of an encoded list read back in order, under fuel and +cap bounds. -/ +theorem decodeEntriesLs (ids : List Mock.Core.ProtocolId) (fuel : Nat) + (hf : ids.length < fuel) (hb : (lsEntries ids).length ≤ maxMsg) : + decodeEntries fuel (lsEntries ids ++ [10]) = .ok ids := by + induction ids generalizing fuel with + | nil => + cases fuel with + | zero => simp at hf + | succ f => simp [decodeEntries, lsEntries] + | cons p ps ih => + cases fuel with + | zero => simp at hf + | succ f => + have hlen : (lsEntries (p :: ps)).length = + (encodeText p.toString).length + (lsEntries ps).length := by + simp [lsEntries] + have hfit : (Bytes.ofString (p.toString ++ "\n")).size ≤ maxMsg := by + rw [← payloadOfLength]; have := encodeTextLength p.toString; omega + have hne : lsEntries (p :: ps) ++ [10] ≠ [10] := by + intro h + have hl := congrArg List.length h + rw [List.length_append, hlen] at hl + have h0 := List.length_pos_iff.mpr (payloadOfNe p.toString) + have h1 := encodeTextLength p.toString + simp only [List.length_singleton] at hl + omega + have hsplit : lsEntries (p :: ps) ++ [10] = + encodeText (Msg.text ⟨p.toString, hfit⟩) ++ (lsEntries ps ++ [10]) := by + simp [lsEntries] + simp only [decodeEntries, hne, ite_false] + rw [hsplit, decodeMsgSuffix ⟨p.toString, hfit⟩ (lsEntries ps ++ [10])] + change (Except.map (fun x => (⟨p.toString⟩ : Mock.Core.ProtocolId) :: x) + (decodeEntries f (lsEntries ps ++ [10])) = .ok (p :: ps)) + rw [ih f (by simp at hf; omega) (by omega)] + rfl + +/-- The byte length of the entries is at least the id count. -/ +theorem lsEntriesLength (ids : List Mock.Core.ProtocolId) : + ids.length ≤ (lsEntries ids).length := by + induction ids with + | nil => simp [lsEntries] + | cons p ps ih => + have h0 := List.length_pos_iff.mpr (payloadOfNe p.toString) + have := encodeTextLength p.toString + simp [lsEntries] at ih ⊢; omega + +/-- The `ls` reply round trip. The length prefix alone delimits the +entries, so no cleanliness hypothesis is needed. -/ +theorem decodeLsEncodeLs (ids : List Mock.Core.ProtocolId) + (hbody : (lsBody ids).length ≤ maxMsg) : + decodeLs? (encodeLs ids) = .ok (ids, []) := by + have hlim : (lsBody ids).length < Mock.Varint.limit := by + unfold Mock.Varint.limit maxMsg at *; omega + have hb : (Mock.Varint.boundedVarint (lsBody ids).length).val = (lsBody ids).length := by + simp [Mock.Varint.boundedVarint, Nat.mod_eq_of_lt hlim] + have hl := lsEntriesLength ids + unfold decodeLs? encodeLs splitFrame + rw [← List.append_nil (lsBody ids), Mock.Varint.decodeEncodeSuffix] + simp only [hb, List.append_nil] + have h1 : ¬ maxMsg < (lsBody ids).length := by omega + have h2 : ¬ (lsBody ids).length = 0 := by simp [lsBody] + simp only [h1, h2, ite_false, Nat.lt_irrefl, List.take_length, List.drop_length] + have hb2 : (lsEntries ids).length ≤ maxMsg := by simp [lsBody] at hbody; omega + rw [lsBody, decodeEntriesLs ids _ (by simp; omega) hb2] + rfl + +/-- A frame that splits back is exactly the prefix plus body plus +remainder. -/ +theorem splitFrameInv {bs body rest : List Nat} (h : splitFrame bs = .ok (body, rest)) : + Mock.Varint.encode (Mock.Varint.boundedVarint body.length) ++ body ++ rest = bs := by + unfold splitFrame at h + split at h + · simp at h + · rename_i v r0 hdec + have hbs := Mock.Varint.encodeDecode bs v r0 hdec + split at h + · simp at h + split at h + · simp at h + split at h + · simp at h + rename_i hcap _ hlen + simp only [Except.ok.injEq, Prod.mk.injEq] at h + obtain ⟨rfl, rfl⟩ := h + have hl : (r0.take v.val).length = v.val := by simp; omega + have hv : Mock.Varint.boundedVarint (r0.take v.val).length = v := by + cases v; simp_all [Mock.Varint.boundedVarint, Nat.mod_eq_of_lt] + rw [hv, List.append_assoc, List.take_append_drop] + exact hbs.symm + +/-- Entries that read back are exactly the encoding of the list. -/ +theorem decodeEntriesInv (f : Nat) (bs : List Nat) (ids : List Mock.Core.ProtocolId) + (h : decodeEntries f bs = .ok ids) : lsEntries ids ++ [10] = bs := by + induction f generalizing bs ids with + | zero => simp [decodeEntries] at h + | succ f ih => + unfold decodeEntries at h + split at h + · rename_i hb + simp only [Except.ok.injEq] at h + subst h + simp [lsEntries, hb] + · split at h + · simp at h + · rename_i m rest hm + cases hr : decodeEntries f rest with + | error e => simp [hr, Except.map] at h + | ok ids' => + simp [hr, Except.map] at h + subst h + have h1 := decodeMsgEncodeDecode bs m rest hm + have h2 := ih rest ids' hr + simp only [lsEntries, List.map_cons, List.flatten_cons, List.append_assoc] at h2 ⊢ + rw [h2] + exact h1 + +/-- The all-input inverse of the `ls` reply: every accepted input is +the encoding of the decoded list. -/ +theorem decodeLsInv {bs : List Nat} {ids : List Mock.Core.ProtocolId} {rest : List Nat} + (h : decodeLs? bs = .ok (ids, rest)) : encodeLs ids ++ rest = bs := by + unfold decodeLs? at h + split at h + · simp at h + · rename_i body r hs + cases he : decodeEntries body.length body with + | error e => simp [he, Except.map] at h + | ok ids' => + simp [he, Except.map] at h + obtain ⟨rfl, rfl⟩ := h + have hb := decodeEntriesInv _ _ _ he + have hs' := splitFrameInv hs + unfold encodeLs lsBody + rw [hb] + exact hs' + +end Libp2p.Negotiate diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean new file mode 100644 index 00000000..fb394ac3 --- /dev/null +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean @@ -0,0 +1,31 @@ +import Std + +/-! +# Core mocks for unit 1a + +`ProtocolId` and `Role` live in `libp2p/core` (W5.2, not merged yet). +This module holds them verbatim from the core ARCHITECTURE "Public +surface", so the negotiate core compiles against the frozen shapes. +The import swaps to `Libp2p.Core` when W5.2 lands. `Repr` joins the +derived classes per root §3.2, so `State`, `Event`, and `Action` can +derive `Repr` per root §3.3. +-/ + +set_option autoImplicit false + +namespace Libp2p.Negotiate.Mock.Core + +/-- A wrapped-string protocol id. Peers define ids, so the set stays +open. Code compares `ProtocolId` values only (root §3.4). -/ +structure ProtocolId where + toString : String + deriving DecidableEq, Hashable, Repr + +/-- The role of one side of a negotiation (root §4.9). No spec gives +roles a wire code, so it has no `toCode`/`ofCode?` pair. -/ +inductive Role where + | initiator + | responder + deriving DecidableEq, Repr, Hashable + +end Libp2p.Negotiate.Mock.Core diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean new file mode 100644 index 00000000..3a088982 --- /dev/null +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean @@ -0,0 +1,82 @@ +import Std +import Bytes + +/-! +# Temporary unsigned-varint interface + +W2.1 is not merged. The executable opaque definitions follow its +documented nine-byte minimal-form contract. The three axioms below are +dependency contracts, not negotiate results. They must disappear with +this mock. This module mirrors `Proto/Mock/Varint.lean`. +-/ + +set_option autoImplicit false + +namespace Libp2p.Negotiate.Mock.Varint + +open Bytes (DecodeError) + +/-- The dependency stops after nine groups, including an unterminated +last group. -/ +def maxBytes : Nat := 9 + +/-- Nine groups of seven bits fit exactly in this range. -/ +def limit : Nat := 2 ^ 63 + +/-- The range proof is erased at runtime. -/ +structure UVarint where + val : Nat + isLt : val < limit + deriving DecidableEq, Repr + +/-- Executable reference encoder. Callers use the opaque interface. -/ +private def encodeCore (n : Nat) : List Nat := + if n < 128 then [n] + else (n % 128 + 128) :: encodeCore (n / 128) +termination_by n +decreasing_by exact Nat.div_lt_self (by omega) (by decide) + +/-- The mock preserves the frozen argument type. -/ +opaque encode (v : UVarint) : List Nat := encodeCore v.val + +/-- The last permitted continuation byte is malformed, even without a +suffix. -/ +private def decodeCore : Nat → List Nat → Except DecodeError (Nat × List Nat) + | 0, _ => .error .malformed + | _ + 1, [] => .error .truncated + | fuel + 1, b :: bs => + if b ≥ 256 then .error .malformed + else if b < 128 then .ok (b, bs) + else do + let (high, rest) ← decodeCore fuel bs + if high = 0 then .error .malformed + else .ok (b % 128 + 128 * high, rest) + +/-- The decoder checks the bound before constructing its proof field. -/ +opaque decode? (bs : List Nat) : Except DecodeError (UVarint × List Nat) := do + let (n, rest) ← decodeCore maxBytes bs + if h : n < limit then .ok (⟨n, h⟩, rest) + else .error .malformed + +/-- Executable stand-in for the production decoder and consumed count. -/ +opaque decodeB? (bs : ByteArray) : Except DecodeError (UVarint × Nat) := do + let (v, rest) ← decode? (bs.toList.map UInt8.toNat) + .ok (v, bs.size - rest.length) + +/-- The reduced value the total encoders of `Message` use. The modulo +matches the range proof, so the reduction is exact below the limit. -/ +def boundedVarint (n : Nat) : UVarint := + ⟨n % limit, Nat.mod_lt _ (by decide)⟩ + +/-- W2.1's suffix round trip contract. -/ +axiom decodeEncodeSuffix (v : UVarint) (rest : List Nat) : + decode? (encode v ++ rest) = .ok (v, rest) + +/-- W2.1's accepted-input decomposition. -/ +axiom encodeDecode (bs : List Nat) (v : UVarint) (rest : List Nat) : + decode? bs = .ok (v, rest) → bs = encode v ++ rest + +/-- W2.1's budget lemma: an encoded varint spans at most nine bytes. -/ +axiom encodeLengthBound (v : UVarint) : (encode v).length ≤ maxBytes + +end Libp2p.Negotiate.Mock.Varint diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean new file mode 100644 index 00000000..f581e18d --- /dev/null +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean @@ -0,0 +1,504 @@ +import Libp2p.Negotiate.Message +import Libp2p.Negotiate.Mock.Core +import Bytes + +/-! +# The pure negotiation core + +`State`, `Event`, `Action`, and `step` are the pure state machine of +multistream-select (root §3.3). One `Event.bytes` feeds raw bytes, and +the core decodes exactly one frame per call, so the +malformed-versus-incomplete call stays in the core (§3.1). A decoded +frame goes through a private `classify` step that names the verdict, +then a private `act` step that performs it. Every theorem about choice +lives in `classify`; every theorem about writes and phase lives in +`act`. + +The step returns `Except Error (State × List Action)`, the root §3.3 +shape. `Action.error` of the package docs stays out: the error channel +carries the failure (root §3.5), and no contract asks for an error +action. +-/ + +set_option autoImplicit false + +namespace Libp2p.Negotiate + +open Bytes (DecodeError) + +/-- The phase of one negotiation. -/ +inductive Phase where + | headerPending + | negotiating + | awaitingAnswer + | selected + deriving Repr, DecidableEq + +/-- The core state. `role` is the side. `supported` is the responder's +ordered list. `proposals` is the initiator's remaining list, in order. +`buffer` holds the bytes no frame has consumed yet. -/ +structure State where + role : Mock.Core.Role + supported : List Mock.Core.ProtocolId + proposals : List Mock.Core.ProtocolId + buffer : List Nat + phase : Phase + deriving Repr, DecidableEq + +/-- One input to the core. Raw bytes arrive as one event; a proposal +is the local dialer's input. -/ +inductive Event where + | bytes (bs : List Nat) + | propose (p : Mock.Core.ProtocolId) + deriving Repr, DecidableEq + +/-- One output of the core. A write carries framed bytes. A select +ends the negotiation with the chosen id and the leftover bytes. -/ +inductive Action where + | write (bs : List Nat) + | select (proto : Mock.Core.ProtocolId) (leftover : List Nat) + deriving Repr, DecidableEq + +/-- The replies the responder sends, the closed set of §3.4. -/ +inductive Reply where + | na + | ls + deriving DecidableEq, Repr, Hashable + +/-- The wire text of a reply. -/ +def Reply.text : Reply → String + | .na => "na" + | .ls => "ls" + +/-- The table the inverse searches. -/ +def Reply.all : List Reply := [.na, .ls] + +/-- The computed inverse of `Reply.text`: a text names a reply when it +equals the reply's text. -/ +def Reply.ofText? (s : String) : Option Reply := Reply.all.find? (fun r => r.text = s) + +/-- The table conformance: the inverse reads exactly the reply whose +text it is given. -/ +theorem Reply.ofText?EqSome {s : String} {r : Reply} : + Reply.ofText? s = some r ↔ r.text = s := by + constructor + · intro h + have := List.find?_some h + simpa using this + · intro h + subst h + have hmem : r ∈ Reply.all := by cases r <;> simp [Reply.all] + unfold Reply.ofText? + cases r <;> simp [Reply.all, Reply.text] + +/-- The reply as a message. The text is the message text. -/ +def Reply.toMsg : Reply → Msg + | .na => ⟨"na", by decide⟩ + | .ls => ⟨"ls", by decide⟩ + +/-- The message form of the reply recognition. -/ +def Reply.ofMsg? (m : Msg) : Option Reply := Reply.ofText? m.text + +/-- Why a negotiation failed. `decode` carries the shared decode +error; `negotiationFailed` covers every peer violation the core sees. -/ +inductive Error where + | decode (e : DecodeError) + | negotiationFailed + deriving Repr, DecidableEq + +/-- The pure negotiation core of the spike: the first of `ours` that +`theirs` also supports. -/ +def choose (ours theirs : List Mock.Core.ProtocolId) : Option Mock.Core.ProtocolId := + List.find? theirs.contains ours + +/-- A chosen id is in our list and supported by theirs. -/ +theorem choose_spec {ours theirs : List Mock.Core.ProtocolId} {p : Mock.Core.ProtocolId} + (h : choose ours theirs = some p) : p ∈ ours ∧ theirs.contains p = true := by + unfold choose at h + exact ⟨List.mem_of_find?_eq_some h, List.find?_some h⟩ + +/-! ## The verdicts -/ + +/-- What a decoded text means for this state. Byte-free, so every +theorem about choice lives here. -/ +private inductive Verdict where + | echo + | listIds + | pick (p : Mock.Core.ProtocolId) + | refuse + | advance + | retry (q : Mock.Core.ProtocolId) (ps : List Mock.Core.ProtocolId) + | accept (p : Mock.Core.ProtocolId) + | fail + +/-- Classify a decoded text for a state. -/ +private def classify (s : State) (text : String) : Verdict := + match s.role, s.phase with + | .responder, .headerPending => if text = protocolId.toString then .echo else .fail + | .responder, .negotiating => + match Reply.ofText? text with + | some .ls => .listIds + | _ => + if text = protocolId.toString then .echo + else if (⟨text⟩ : Mock.Core.ProtocolId) ∈ s.supported then .pick ⟨text⟩ + else .refuse + | .initiator, .negotiating => if text = protocolId.toString then .advance else .fail + | .initiator, .awaitingAnswer => + match s.proposals with + | [] => .fail + | p :: ps => + if Reply.ofText? text = some .na then + match ps with + | [] => .fail + | q :: qs => .retry q (q :: qs) + else if text = p.toString then .accept p else .fail + | _, _ => .fail + +/-- Perform a verdict. Byte-free per verdict, so every theorem about +writes and phase lives here. -/ +private def act (s : State) (rest : List Nat) : Verdict → Except Error (State × List Action) + | .echo => .ok ({ s with buffer := rest, phase := .negotiating }, [.write (encodeText protocolId.toString)]) + | .listIds => .ok ({ s with buffer := rest }, [.write (encodeLs s.supported)]) + | .pick p => .ok ({ s with buffer := [], phase := .selected }, + [.write (encodeText p.toString), .select p rest]) + | .refuse => .ok ({ s with buffer := rest }, [.write (encodeMsg (Reply.toMsg .na))]) + | .advance => .ok ({ s with buffer := rest, phase := .awaitingAnswer }, []) + | .retry q ps => .ok ({ s with buffer := rest, proposals := ps }, [.write (encodeText q.toString)]) + | .accept p => .ok ({ s with buffer := [], phase := .selected }, [.select p rest]) + | .fail => .error .negotiationFailed + +/-- A proposal event. Only the first proposal of an initiator in the +header phase writes the coalesced header plus proposal frame. Every +other proposal event is a no-op, because local input cannot fail a +negotiation and cannot corrupt the core. -/ +private def propose (s : State) (p : Mock.Core.ProtocolId) : Except Error (State × List Action) := + if s.role = .initiator then + if s.phase = .headerPending then + if s.proposals = [] then + .ok ({ s with proposals := [p], phase := .negotiating }, + [.write (encodeMsg header ++ encodeText p.toString)]) + else .ok ({ s with proposals := s.proposals ++ [p] }, []) + else .ok (s, []) + else .ok (s, []) + +/-- One transition of the core. A `bytes` event appends to the buffer +and decodes one frame. `truncated` keeps the buffer and waits; +`malformed` is a protocol error; a decoded frame goes through +`classify` and `act`. -/ +def step (s : State) : Event → Except Error (State × List Action) + | .propose p => propose s p + | .bytes bs => + match decodeMsg? (s.buffer ++ bs) with + | .error .truncated => .ok ({ s with buffer := s.buffer ++ bs }, []) + | .error .malformed => .error (.decode .malformed) + | .ok (m, rest) => act s rest (classify s m.text) + +/-! ## The verdict lemmas -/ + +/-- A step that selects came from the pick or accept verdict. -/ +private theorem actSelect {s : State} {rest : List Nat} {v : Verdict} {s' : State} + {acts : List Action} {p : Mock.Core.ProtocolId} {r : List Nat} + (h : act s rest v = .ok (s', acts)) (hm : Action.select p r ∈ acts) : + v = .pick p ∨ v = .accept p := by + cases v <;> simp only [act] at h + <;> ((try simp only [Except.ok.injEq, Prod.mk.injEq] at h); + (try obtain ⟨-, rfl⟩ := h); (try simp_all)) + +/-- A pick comes from the responder's supported list. -/ +private theorem classifyPick {s : State} {t : String} {p : Mock.Core.ProtocolId} + (h : classify s t = .pick p) : p ∈ s.supported := by + unfold classify at h + repeat' split at h + all_goals simp_all + +/-- An accept comes from the head of the initiator's proposals. -/ +private theorem classifyAcceptInitiator {s : State} {t : String} {p : Mock.Core.ProtocolId} + (h : classify s t = .accept p) : p ∈ s.proposals := by + unfold classify at h + repeat' split at h + all_goals simp_all + +/-- An accept carries the accepted text. -/ +private theorem classifyAcceptText {s : State} {t : String} {p : Mock.Core.ProtocolId} + (h : classify s t = .accept p) : p.toString = t := by + unfold classify at h + repeat' split at h + all_goals simp_all + +/-- A responder never accepts. -/ +private theorem classifyAcceptResponder {s : State} {t : String} {p : Mock.Core.ProtocolId} + (hr : s.role = .responder) (h : classify s t = .accept p) : False := by + unfold classify at h + repeat' split at h + all_goals simp_all + +/-- An initiator never picks. -/ +private theorem classifyPickInitiator {s : State} {t : String} {p : Mock.Core.ProtocolId} + (hr : s.role = .initiator) (h : classify s t = .pick p) : False := by + unfold classify at h + repeat' split at h + all_goals simp_all + +/-- An initiator never sees the echo verdict. -/ +private theorem classifyEchoInitiator {s : State} {t : String} + (hr : s.role = .initiator) (h : classify s t = .echo) : False := by + unfold classify at h + repeat' split at h + all_goals simp_all + +/-- An initiator never sees the list verdict. -/ +private theorem classifyListIdsInitiator {s : State} {t : String} + (hr : s.role = .initiator) (h : classify s t = .listIds) : False := by + unfold classify at h + repeat' split at h + all_goals simp_all + +/-- An initiator never sees the refuse verdict. -/ +private theorem classifyRefuseInitiator {s : State} {t : String} + (hr : s.role = .initiator) (h : classify s t = .refuse) : False := by + unfold classify at h + repeat' split at h + all_goals simp_all + +/-- The `ls` text in the negotiation draws the list verdict. -/ +private theorem classifyListIds {s : State} {t : String} + (hrole : s.role = .responder) (hphase : s.phase = .negotiating) + (hls : Reply.ofText? t = some .ls) : classify s t = .listIds := by + unfold classify + rw [hrole, hphase] + rw [hls] + +/-- An unknown id in the negotiation draws the refuse verdict. -/ +private theorem classifyRefuse {s : State} {t : String} + (hrole : s.role = .responder) (hphase : s.phase = .negotiating) + (hnot : (⟨t⟩ : Mock.Core.ProtocolId) ∉ s.supported) + (hheader : t ≠ protocolId.toString) (hreply : Reply.ofText? t = none) : + classify s t = .refuse := by + unfold classify + rw [hrole, hphase] + simp only [hreply] + rw [ite_eq_right hheader, ite_eq_right hnot] + +/-- A retry pops one proposal: the new list is the tail of the old. -/ +private theorem classifyRetryTail {s : State} {t : String} {q : Mock.Core.ProtocolId} + {ps : List Mock.Core.ProtocolId} (h : classify s t = .retry q ps) : + s.proposals.tail = ps := by + unfold classify at h + repeat' split at h + all_goals (cases h <;> simp_all [List.tail_cons]) + +/-- A write came from one of the five writing verdicts. -/ +private theorem actWriteVerdict {s : State} {rest : List Nat} {v : Verdict} {s' : State} + {actions : List Action} {w : List Nat} + (h : act s rest v = .ok (s', actions)) (hw : Action.write w ∈ actions) : + v = .echo ∨ v = .listIds ∨ (∃ p, v = .pick p) ∨ v = .refuse ∨ + (∃ q ps, v = .retry q ps) := by + cases v with + | echo => exact Or.inl rfl + | listIds => exact Or.inr (Or.inl rfl) + | pick p => exact Or.inr (Or.inr (Or.inl ⟨p, rfl⟩)) + | refuse => exact Or.inr (Or.inr (Or.inr (Or.inl rfl))) + | retry q ps => exact Or.inr (Or.inr (Or.inr (Or.inr ⟨q, ps, rfl⟩))) + | advance => + simp only [act] at h + simp only [Except.ok.injEq, Prod.mk.injEq] at h + obtain ⟨-, rfl⟩ := h + simp at hw + | accept p => + simp only [act] at h + simp only [Except.ok.injEq, Prod.mk.injEq] at h + obtain ⟨-, rfl⟩ := h + simp at hw + | fail => + simp only [act] at h + cases h + +/-- The list verdict writes the `ls` reply and stays in the phase. -/ +private theorem actListIds {s : State} {rest : List Nat} {s' : State} {actions : List Action} + (h : act s rest .listIds = .ok (s', actions)) : + actions = [.write (encodeLs s.supported)] ∧ s'.phase = s.phase ∧ s'.buffer = rest := by + unfold act at h + simp only [Except.ok.injEq, Prod.mk.injEq] at h + obtain ⟨hstate, hact⟩ := h + subst hact + rw [← hstate] + simp + +/-- The refuse verdict writes the `na` frame and stays in the phase. -/ +private theorem actRefuse {s : State} {rest : List Nat} {s' : State} {actions : List Action} + (h : act s rest .refuse = .ok (s', actions)) : + actions = [.write (encodeMsg (Reply.toMsg .na))] ∧ s'.phase = s.phase ∧ s'.buffer = rest := by + unfold act at h + simp only [Except.ok.injEq, Prod.mk.injEq] at h + obtain ⟨hstate, hact⟩ := h + subst hact + rw [← hstate] + simp + +/-- The retry verdict replaces the proposals with the remaining list. -/ +private theorem actRetryWrites {s : State} {rest : List Nat} {q : Mock.Core.ProtocolId} + {ps : List Mock.Core.ProtocolId} {s' : State} {actions : List Action} + (h : act s rest (.retry q ps) = .ok (s', actions)) : s'.proposals = ps := by + unfold act at h + simp only [Except.ok.injEq, Prod.mk.injEq] at h + obtain ⟨hstate, _⟩ := h + rw [← hstate] + +/-! ## The step theorems -/ + +/-- A responder selects only from its supported list (contract 2). -/ +theorem step_select_responder_mem {s : State} {bs : List Nat} {s' : State} + {actions : List Action} {p : Mock.Core.ProtocolId} {rest : List Nat} + (hrole : s.role = .responder) + (hstep : step s (.bytes bs) = .ok (s', actions)) + (hmem : Action.select p rest ∈ actions) : p ∈ s.supported := by + simp only [step] at hstep + split at hstep + · simp only [Except.ok.injEq, Prod.mk.injEq] at hstep + obtain ⟨-, rfl⟩ := hstep + simp at hmem + · simp at hstep + · rcases actSelect hstep hmem with hv | hv + · exact classifyPick hv + · exact (classifyAcceptResponder hrole hv).elim + +/-- An initiator selects only from its own proposal list. -/ +theorem step_select_initiator_mem {s : State} {bs : List Nat} {s' : State} + {actions : List Action} {p : Mock.Core.ProtocolId} {rest : List Nat} + (hrole : s.role = .initiator) + (hstep : step s (.bytes bs) = .ok (s', actions)) + (hmem : Action.select p rest ∈ actions) : p ∈ s.proposals := by + simp only [step] at hstep + split at hstep + · simp only [Except.ok.injEq, Prod.mk.injEq] at hstep + obtain ⟨-, rfl⟩ := hstep + simp at hmem + · simp at hstep + · rcases actSelect hstep hmem with hv | hv + · exact (classifyPickInitiator hrole hv).elim + · exact classifyAcceptInitiator hv + +/-- A step that writes advances the initiator's proposal list by one +head: the new list is the old tail, so it is strictly shorter +(contract 2, the `na` retry). -/ +theorem step_initiator_na_progress {s : State} {bs : List Nat} {s' : State} + {actions : List Action} {p : Mock.Core.ProtocolId} {ps : List Mock.Core.ProtocolId} + {w : List Nat} + (hrole : s.role = .initiator) (hprop : s.proposals = p :: ps) + (hstep : step s (.bytes bs) = .ok (s', actions)) + (hwrite : Action.write w ∈ actions) : + s'.proposals = ps ∧ s'.proposals.length < s.proposals.length := by + simp only [step] at hstep + split at hstep + · simp only [Except.ok.injEq, Prod.mk.injEq] at hstep + obtain ⟨-, rfl⟩ := hstep + simp at hwrite + · simp at hstep + · rcases actWriteVerdict hstep hwrite with hc | hc | hc | hc | hc + · exact (classifyEchoInitiator hrole hc).elim + · exact (classifyListIdsInitiator hrole hc).elim + · rcases hc with ⟨p', hc⟩ + exact (classifyPickInitiator hrole hc).elim + · exact (classifyRefuseInitiator hrole hc).elim + · rcases hc with ⟨q, ps', hc⟩ + rw [hc] at hstep + have hnew : s'.proposals = ps' := actRetryWrites hstep + have htail : s.proposals.tail = ps' := classifyRetryTail hc + have hps : ps' = ps := by + rw [← htail, hprop] + rfl + constructor + · rw [hnew, hps] + · rw [hnew, hps, hprop] + simp only [List.length_cons] + omega + +/-- The `ls` answer writes the reply and stays in the negotiation +(contract 3). -/ +theorem step_responder_ls_stays {s : State} {bs : List Nat} {s' : State} + {actions : List Action} {m : Msg} {rest : List Nat} + (hrole : s.role = .responder) (hphase : s.phase = .negotiating) + (hdec : decodeMsg? (s.buffer ++ bs) = .ok (m, rest)) + (hls : Reply.ofMsg? m = some .ls) + (hstep : step s (.bytes bs) = .ok (s', actions)) : + actions = [.write (encodeLs s.supported)] ∧ s'.phase = .negotiating ∧ s'.buffer = rest := by + simp only [step, hdec] at hstep + have hc := classifyListIds hrole hphase hls + rw [hc] at hstep + have ha := actListIds hstep + rcases ha with ⟨hacts, hphase', hbuf⟩ + constructor + · exact hacts + constructor + · rw [hphase'] + exact hphase + · exact hbuf + +/-- An unknown id draws the `na` frame and stays in the negotiation +(contract 2). -/ +theorem step_responder_unknown_stays {s : State} {bs : List Nat} {s' : State} + {actions : List Action} {m : Msg} {rest : List Nat} {id : Mock.Core.ProtocolId} + (hrole : s.role = .responder) (hphase : s.phase = .negotiating) + (hdec : decodeMsg? (s.buffer ++ bs) = .ok (m, rest)) + (htext : m.text = id.toString) (hnot : id ∉ s.supported) + (hheader : m.text ≠ protocolId.toString) + (hreply : Reply.ofMsg? m = none) + (hstep : step s (.bytes bs) = .ok (s', actions)) : + actions = [.write (encodeMsg (Reply.toMsg .na))] ∧ s'.phase = .negotiating ∧ s'.buffer = rest := by + have hnot' : (⟨m.text⟩ : Mock.Core.ProtocolId) ∉ s.supported := by + intro hm + apply hnot + simpa [htext] using hm + simp only [step, hdec] at hstep + have hc := classifyRefuse hrole hphase hnot' hheader hreply + rw [hc] at hstep + have ha := actRefuse hstep + rcases ha with ⟨hacts, hphase', hbuf⟩ + constructor + · exact hacts + constructor + · rw [hphase'] + exact hphase + · exact hbuf + +/-- Conservation: the bytes the accepting initiator consumed, plus the +leftover, equal the bytes fed in (contract 5). -/ +theorem step_select_conservation {s : State} {bs : List Nat} {s' : State} + {p : Mock.Core.ProtocolId} {rest r : List Nat} {m : Msg} + (hrole : s.role = .initiator) + (hdec : decodeMsg? (s.buffer ++ bs) = .ok (m, r)) + (hstep : step s (.bytes bs) = .ok (s', [Action.select p rest])) : + s.buffer ++ bs = encodeText p.toString ++ rest := by + simp only [step, hdec] at hstep + have hmem : Action.select p rest ∈ [Action.select p rest] := by simp + have hv := actSelect hstep hmem + rcases hv with hc | hc + · exact (classifyPickInitiator hrole hc).elim + · have hpt : p.toString = m.text := classifyAcceptText hc + rw [hc] at hstep + have hr : r = rest := by + unfold act at hstep + simp only [Except.ok.injEq, Prod.mk.injEq] at hstep + obtain ⟨-, hact⟩ := hstep + injection hact with hsel hnil + injection hsel with hp hrest + have hlaw : encodeText m.text ++ r = s.buffer ++ bs := + decodeMsgEncodeDecode (s.buffer ++ bs) m r hdec + rw [← hlaw] + rw [← hpt] + rw [hr] + +/-- A truncated buffer waits: no actions, no error, the phase holds, +and the buffer keeps the bytes (§3.1). -/ +theorem step_truncated_waits {s : State} {bs : List Nat} {s' : State} {actions : List Action} + (hdec : decodeMsg? (s.buffer ++ bs) = .error .truncated) + (hstep : step s (.bytes bs) = .ok (s', actions)) : + actions = [] ∧ s'.phase = s.phase ∧ s'.buffer = s.buffer ++ bs := by + simp only [step, hdec] at hstep + simp only [Except.ok.injEq, Prod.mk.injEq] at hstep + obtain ⟨hstate, hact⟩ := hstep + subst hact + rw [← hstate] + simp + +end Libp2p.Negotiate From 6d9bc83628ad39c4d3e973c6788a87048ad73bd4 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:32:12 +0800 Subject: [PATCH 011/125] feat(negotiate): W5.5 1b, the upgrader, the seam mocks, and the upgrade suite --- Tests/Main.lean | 2 + Tests/Upgrade.lean | 647 ++++++++++++++++++ .../libp2p/negotiate/Libp2p/Negotiate.lean | 3 +- .../negotiate/Libp2p/Negotiate/Mock/Core.lean | 65 +- .../Libp2p/Negotiate/Mock/Identity.lean | 24 + .../Libp2p/Negotiate/Mock/Streams.lean | 54 ++ .../negotiate/Libp2p/Negotiate/Upgrade.lean | 506 ++++++++++++++ 7 files changed, 1294 insertions(+), 7 deletions(-) create mode 100644 Tests/Upgrade.lean create mode 100644 packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Identity.lean create mode 100644 packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Streams.lean create mode 100644 packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean diff --git a/Tests/Main.lean b/Tests/Main.lean index 2f7a268b..296416b6 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -17,6 +17,7 @@ import Tests.Hashes import Tests.Facade import Tests.CryptoCross import Tests.Negotiate +import Tests.Upgrade /-! # Test entry point @@ -49,4 +50,5 @@ def main : IO UInt32 := do Tests.Facade.run r Tests.CryptoCross.run r Tests.Negotiate.run r + Tests.Upgrade.run r r.summary diff --git a/Tests/Upgrade.lean b/Tests/Upgrade.lean new file mode 100644 index 00000000..9fb89151 --- /dev/null +++ b/Tests/Upgrade.lean @@ -0,0 +1,647 @@ +import Tests.Support +import Libp2p.Negotiate +import Conc +import Bytes + +/-! +# The upgrade suite (unit 1b) + +The test milestones of the negotiate package plan, unit 1b. The full +pipeline runs both directions over an in-process pipe pair, which +replaces the sim transport of streams (#32, unmerged). The fixed-key +plaintext security value and the minimal muxer value replace the Noise +and yamux milestones (#16, #17, unmerged). The deadline check advances +the test `Clock` of `base/conc`, so the suite waits on no wall clock. + +Every check that spawns a task joins it (PRINCIPLES 3.2), and every +park wakes by a close (PRINCIPLES 3.1). Shared cells change only by +`OnceRef` steps (ARCHITECTURE §4.1, rule 3). +-/ + +set_option autoImplicit false + +namespace Tests.Upgrade + +open Libp2p.Negotiate +open Conc +open Bytes (DecodeError) +open Lean (Json) + +/-! ## The test seam values -/ + +/-- One direction of a pipe pair: the read side and the write side. -/ +private structure Side where + read : Conc.Pipe + write : Conc.Pipe + +/-- The connection view of one side. The close reaches both +directions, like a transport close. -/ +private def sideConn (s : Side) : Mock.Streams.Connection where + read := s.read.read + write := s.write.write + close := do + s.read.close + s.write.close + +/-- A fresh pipe pair: two sides, one pipe per direction. -/ +private def newPair : IO (Side × Side) := do + let aToB ← Conc.Pipe.new 4 + let bToA ← Conc.Pipe.new 4 + return ({ read := aToB, write := bToA }, { read := bToA, write := aToB }) + +/-- A connection over a pipe pair whose far side the test holds and +never writes: the silent peer. The returned pipe is the one the +connection reads, so a check sees the close that wakes the park. -/ +private def silentPeer : IO (Mock.Streams.Connection × Conc.Pipe) := do + let toPeer ← Conc.Pipe.new 4 + let fromPeer ← Conc.Pipe.new 4 + return ({ read := fromPeer.read, write := toPeer.write, + close := do + fromPeer.close + toPeer.close }, + fromPeer) + +/-- The initiator's marker of the test handshake. -/ +private def markerI : ByteArray := Bytes.ofNatList [1, 1, 1, 1] + +/-- The responder's marker of the test handshake. -/ +private def markerR : ByteArray := Bytes.ofNatList [2, 2, 2, 2] + +/-- The fixed peer id of the dialing side. -/ +private def dialerId : Mock.Identity.PeerId := ⟨"12D3KooWTestDialer"⟩ + +/-- The fixed peer id of the listening side. -/ +private def listenerId : Mock.Identity.PeerId := ⟨"12D3KooWTestListener"⟩ + +/-- The fixed-key plaintext test security value. It replaces the Noise +milestone (#16, unmerged). The handshake writes its marker, reads the +peer's marker from the seed first and then from the connection, and +hands the unconsumed bytes to the next stage. `overread` adds one read +whose chunk joins the leftover, so a check can force the hand-over. +`seedRef` records the seed the negotiation handed over. -/ +private def secValue (overread : Bool) (seedRef : Conc.OnceRef (Option ByteArray)) : + Mock.Core.SecurityProtocol where + id := ⟨"/test/plain/1.0.0"⟩ + handshake := fun role conn seed => do + seedRef.set (some seed) + let myMark := if role = .initiator then markerI else markerR + match ← conn.write myMark with + | .error _ => throw (.closed "test handshake write failed") + | .ok () => pure () + let seedB := Bytes.toNatList seed + let rest ← + if seedB.length ≥ 4 then pure (Bytes.ofNatList (seedB.drop 4)) + else + match ← conn.read with + | none => throw (.closed "test handshake read ended before the marker") + | some chunk => pure (Bytes.ofNatList ((seedB ++ Bytes.toNatList chunk).drop 4)) + let over ← + if overread then + match ← conn.read with + | none => throw (.closed "test handshake over-read found the connection closed") + | some chunk => pure chunk + else pure ByteArray.empty + return { conn := conn, + localPeer := if role = .initiator then dialerId else listenerId, + remotePeer := if role = .initiator then listenerId else dialerId, + leftover := rest ++ over, + close := conn.close } + +/-- The test muxer value. It replaces the yamux milestone (#17, +unmerged). `open` records its leftover argument, the opened id, and a +ran flag in shared cells, and returns a no-op session. -/ +private def muxValue (id : Mock.Core.ProtocolId) (ranRef : Conc.OnceRef Bool) + (leftRef : Conc.OnceRef (Option ByteArray)) + (openRef : Conc.OnceRef (Option Mock.Core.ProtocolId)) : Mock.Core.Muxer where + id := id + «open» := fun _ _ leftover => do + ranRef.set true + leftRef.set (some leftover) + openRef.set (some id) + return { openStream := (throw (.protocol "the test session opens no stream") : + ExceptT Mock.Core.Error Conc.Async Mock.Streams.Stream), + accept := pure none, + close := pure (), + join := pure () } + +/-- Wrap a connection so the `n`-th write carries `extra` extra bytes. +The write order is the upgrade's own order: the negotiation frame, the +handshake marker, then the muxer frame. -/ +private def appendOnWrite (conn : Mock.Streams.Connection) (n : Nat) (extra : ByteArray) + (count : Conc.OnceRef Nat) : Mock.Streams.Connection where + read := conn.read + close := conn.close + write := fun b => do + let c ← (count.updateGet (fun c => (c, c + 1)) : BaseIO Nat) + conn.write (if c + 1 = n then b ++ extra else b) + +/-- Wrap a connection so the `n`-th write carries the marker after the +negotiation frame, and the marker write of the handshake drops. The +marker then arrives exactly once, through the seed. -/ +private def markerViaSeed (conn : Mock.Streams.Connection) (count : Conc.OnceRef Nat) : + Mock.Streams.Connection where + read := conn.read + close := conn.close + write := fun b => do + let c ← (count.updateGet (fun c => (c, c + 1)) : BaseIO Nat) + if c + 1 = 1 then conn.write (b ++ markerI) + else if c + 1 = 2 then return .ok () + else conn.write b + +/-- Wrap a connection so the `n`-th write is replaced by `withB`. -/ +private def replaceOnWrite (conn : Mock.Streams.Connection) (n : Nat) (withB : ByteArray) + (count : Conc.OnceRef Nat) : Mock.Streams.Connection where + read := conn.read + close := conn.close + write := fun b => do + let c ← (count.updateGet (fun c => (c, c + 1)) : BaseIO Nat) + conn.write (if c + 1 = n then withB else b) + +/-- Wrap a connection and record every written chunk. -/ +private def recordWrites (conn : Mock.Streams.Connection) (ref : Conc.OnceRef (List ByteArray)) : + Mock.Streams.Connection where + read := conn.read + close := conn.close + write := fun b => do + ref.update fun l => b :: l + conn.write b + +/-- The answer of one upgrade run. -/ +private abbrev UpAnswer := + Option (Except Mock.Core.Error (Mock.Core.MuxSession × Mock.Identity.PeerId)) + +/-! ## The pipeline checks -/ + +/-- The full pipeline, both directions, plus the wire-selected muxer +assertion (check 2). The test security value carries no advertisement +field, so the wire exchange alone selects the muxer. -/ +private def checkFullPipeline (r : Runner) : Async Unit := do + let (iside, rside) ← newPair + let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let dSeed : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let lSeed : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let dRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let dLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let lLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let dOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let lOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let tasks ← Conc.TaskSet.new + tasks.spawn do + dAnswer.set (some (← upgrade .initiator (sideConn iside) ByteArray.empty + (some listenerId) [secValue false dSeed] [muxValue muxId dRan dLeft dOpen])) + tasks.spawn do + lAnswer.set (some (← upgrade .responder (sideConn rside) ByteArray.empty none + [secValue false lSeed] [muxValue muxId lRan lLeft lOpen])) + tasks.joinAll + let d ← dAnswer.get + let l ← lAnswer.get + match d, l with + | some (.ok (ds, dp)), some (.ok (ls, lp)) => do + ds.close + ls.close + ds.join + ls.join + r.check "upgrade full pipeline dial and listen" + (dp == listenerId && lp == dialerId) + r.check "upgrade muxer selected on the wire" + ((← dOpen.get) == some muxId && (← lOpen.get) == some muxId) + r.check "upgrade both muxer sessions opened" + ((← dRan.get) && (← lRan.get)) + | _, _ => do + r.check "upgrade full pipeline dial and listen" false + r.check "upgrade muxer selected on the wire" false + r.check "upgrade both muxer sessions opened" false + (sideConn iside).close + (sideConn rside).close + +/-- The negotiation leftover seeds the handshake: the marker arrives +after the responder's select echo, in the same chunk. -/ +private def checkNegotiationLeftover (r : Runner) : Async Unit := do + let (iside, rside) ← newPair + let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let count : Conc.OnceRef Nat ← Conc.OnceRef.new 0 + let dSeed : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let lSeed : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let dRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let dLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let lLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let dOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let lOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let tasks ← Conc.TaskSet.new + tasks.spawn do + dAnswer.set (some (← upgrade .initiator (markerViaSeed (sideConn iside) count) + ByteArray.empty (some listenerId) [secValue false dSeed] + [muxValue muxId dRan dLeft dOpen])) + tasks.spawn do + lAnswer.set (some (← upgrade .responder (sideConn rside) ByteArray.empty none + [secValue false lSeed] [muxValue muxId lRan lLeft lOpen])) + tasks.joinAll + let d ← dAnswer.get + let l ← lAnswer.get + let good ← match d, l with + | some (.ok _), some (.ok _) => do + let s ← lSeed.get + pure (s == some markerI) + | _, _ => pure false + r.check "upgrade leftover negotiation seeds handshake" good + (sideConn iside).close + (sideConn rside).close + +/-- The handshake over-reads the muxer header chunk, and the muxer +stage still selects from the `SecureConn` leftover. -/ +private def checkHandshakeLeftover (r : Runner) : Async Unit := do + let (iside, rside) ← newPair + let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let dRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let dLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let lLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let dOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let lOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let tasks ← Conc.TaskSet.new + tasks.spawn do + dAnswer.set (some (← upgrade .initiator (sideConn iside) ByteArray.empty + (some listenerId) [secValue false (← Conc.OnceRef.new none)] + [muxValue muxId dRan dLeft dOpen])) + tasks.spawn do + lAnswer.set (some (← upgrade .responder (sideConn rside) ByteArray.empty none + [secValue true (← Conc.OnceRef.new none)] [muxValue muxId lRan lLeft lOpen])) + tasks.joinAll + let d ← dAnswer.get + let l ← lAnswer.get + let good ← match d, l with + | some (.ok _), some (.ok _) => do + let lf ← lLeft.get + let ran ← lRan.get + pure (lf == some ByteArray.empty && ran) + | _, _ => pure false + r.check "upgrade leftover handshake seeds muxer negotiation" good + (sideConn iside).close + (sideConn rside).close + +/-- The muxer negotiation over-reads extra bytes, and they reach +`Muxer.open` as its `ByteArray` argument. -/ +private def checkMuxOpenLeftover (r : Runner) : Async Unit := do + let (iside, rside) ← newPair + let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let extra := Bytes.ofNatList [9, 9, 9] + let count : Conc.OnceRef Nat ← Conc.OnceRef.new 0 + let dRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let dLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let lLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let dOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let lOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let tasks ← Conc.TaskSet.new + tasks.spawn do + dAnswer.set (some (← upgrade .initiator (appendOnWrite (sideConn iside) 3 extra count) + ByteArray.empty (some listenerId) [secValue false (← Conc.OnceRef.new none)] + [muxValue muxId dRan dLeft dOpen])) + tasks.spawn do + lAnswer.set (some (← upgrade .responder (sideConn rside) ByteArray.empty none + [secValue false (← Conc.OnceRef.new none)] [muxValue muxId lRan lLeft lOpen])) + tasks.joinAll + let d ← dAnswer.get + let l ← lAnswer.get + let good ← match d, l with + | some (.ok _), some (.ok _) => do + let lf ← lLeft.get + pure (lf == some extra) + | _, _ => pure false + r.check "upgrade leftover muxer negotiation seeds muxer open" good + (sideConn iside).close + (sideConn rside).close + +/-- The `na` retry: the dialer lists two muxer ids, the responder +supports only the second, and the chosen session is the second id. -/ +private def checkNaRetry (r : Runner) : Async Unit := do + let (iside, rside) ← newPair + let muxA : Mock.Core.ProtocolId := ⟨"/test/mux/a"⟩ + let muxB : Mock.Core.ProtocolId := ⟨"/test/mux/b"⟩ + let aRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let aOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let bRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let bOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let lOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let lWrites : Conc.OnceRef (List ByteArray) ← Conc.OnceRef.new [] + let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let tasks ← Conc.TaskSet.new + tasks.spawn do + dAnswer.set (some (← upgrade .initiator (sideConn iside) ByteArray.empty + (some listenerId) [secValue false (← Conc.OnceRef.new none)] + [muxValue muxA aRan (← Conc.OnceRef.new none) aOpen, + muxValue muxB bRan (← Conc.OnceRef.new none) bOpen])) + tasks.spawn do + lAnswer.set (some (← upgrade .responder (recordWrites (sideConn rside) lWrites) + ByteArray.empty none [secValue false (← Conc.OnceRef.new none)] + [muxValue muxB lRan (← Conc.OnceRef.new none) lOpen])) + tasks.joinAll + let d ← dAnswer.get + let l ← lAnswer.get + let good ← match d, l with + | some (.ok _), some (.ok _) => do + let bO ← bOpen.get + let lO ← lOpen.get + let aO ← aOpen.get + let aR ← aRan.get + let lW ← lWrites.get + pure (bO == some muxB && lO == some muxB && aO == none && !aR && + lW.contains (Bytes.ofNatList (encodeMsg (Reply.toMsg .na)))) + | _, _ => pure false + r.check "upgrade na retry lands on second muxer id" good + (sideConn iside).close + (sideConn rside).close + +/-- The identity mismatch: the dialer fails with authentication before +any muxer byte moves, and the teardown close ends the responder's +parked negotiation with `closed`. -/ +private def checkIdentityMismatch (r : Runner) : Async Unit := do + let (iside, rside) ← newPair + let wrongId : Mock.Identity.PeerId := ⟨"12D3KooWWrongPeer"⟩ + let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let dRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let tasks ← Conc.TaskSet.new + tasks.spawn do + dAnswer.set (some (← upgrade .initiator (sideConn iside) ByteArray.empty + (some wrongId) [secValue false (← Conc.OnceRef.new none)] + [muxValue muxId dRan (← Conc.OnceRef.new none) (← Conc.OnceRef.new none)])) + tasks.spawn do + lAnswer.set (some (← upgrade .responder (sideConn rside) ByteArray.empty none + [secValue false (← Conc.OnceRef.new none)] + [muxValue muxId lRan (← Conc.OnceRef.new none) (← Conc.OnceRef.new none)])) + tasks.joinAll + let d ← dAnswer.get + let l ← lAnswer.get + let good ← match d, l with + | some (.error (.authentication _)), some (.error (.closed _)) => do + let dR ← dRan.get + let lR ← lRan.get + pure (!dR && !lR) + | _, _ => pure false + r.check "upgrade identity mismatch fails before muxer open" good + (sideConn iside).close + (sideConn rside).close + +/-- The deadline: a silent peer times out, the teardown closes the +connection, and the parked read ends on its own. -/ +private def checkDeadline (r : Runner) : Async Unit := do + let tc ← Conc.TestClock.new + let (sc, farPipe) ← silentPeer + let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let answer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let tasks ← Conc.TaskSet.new + tasks.spawn do + answer.set (some (← upgradeWith tc.clock .initiator sc ByteArray.empty none + [secValue false (← Conc.OnceRef.new none)] + [muxValue muxId (← Conc.OnceRef.new false) (← Conc.OnceRef.new none) + (← Conc.OnceRef.new none)])) + r.check "upgrade deadline arms its timer" (← tc.nextTimer) + tc.advance Mock.Streams.upgradeDeadlineMs + tasks.joinAll + r.check "upgrade deadline on silent peer times out and closes" + (match ← answer.get with + | some (.error (.timeout _)) => true + | _ => false) + r.check "upgrade deadline close wakes the silent peer's read" + ((← farPipe.read) == none) + tc.close + +/-! ## The rejection checks -/ + +/-- The initiator with no security id to propose fails before any +byte moves. -/ +private def checkEmptySecurity (r : Runner) : Async Unit := do + let tc ← Conc.TestClock.new + let (sc, _) ← silentPeer + let answer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let tasks ← Conc.TaskSet.new + tasks.spawn do + answer.set (some (← upgradeWith tc.clock .initiator sc ByteArray.empty none [] + [muxValue ⟨"/test/mux/1.0.0"⟩ (← Conc.OnceRef.new false) (← Conc.OnceRef.new none) + (← Conc.OnceRef.new none)])) + tasks.joinAll + r.check "upgrade empty security list errors protocol" + (match ← answer.get with + | some (.error (.protocol "multistream negotiation failed")) => true + | _ => false) + tc.close + +/-- The handshake completes, then the initiator with no muxer id to +propose fails, and the teardown close reaches the responder. -/ +private def checkEmptyMuxer (r : Runner) : Async Unit := do + let (iside, rside) ← newPair + let muxB : Mock.Core.ProtocolId := ⟨"/test/mux/b"⟩ + let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let tcD ← Conc.TestClock.new + let tcL ← Conc.TestClock.new + let tasks ← Conc.TaskSet.new + tasks.spawn do + dAnswer.set (some (← upgradeWith tcD.clock .initiator (sideConn iside) ByteArray.empty + (some listenerId) [secValue false (← Conc.OnceRef.new none)] [])) + tasks.spawn do + lAnswer.set (some (← upgradeWith tcL.clock .responder (sideConn rside) ByteArray.empty none + [secValue false (← Conc.OnceRef.new none)] + [muxValue muxB lRan (← Conc.OnceRef.new none) (← Conc.OnceRef.new none)])) + tasks.joinAll + let d ← dAnswer.get + let l ← lAnswer.get + let good ← match d, l with + | some (.error (.protocol "multistream negotiation failed")), some (.error (.closed _)) => do + let lR ← lRan.get + pure (!lR) + | _, _ => pure false + r.check "upgrade empty muxer list errors protocol" good + (sideConn iside).close + (sideConn rside).close + tcD.close + tcL.close + +/-- The peer closes its pipe mid-negotiation: the upgrade answers +`closed`. -/ +private def checkEof (r : Runner) : Async Unit := do + let tc ← Conc.TestClock.new + let (iside, rside) ← newPair + let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let answer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let tasks ← Conc.TaskSet.new + tasks.spawn do + answer.set (some (← upgradeWith tc.clock .initiator (sideConn iside) ByteArray.empty none + [secValue false (← Conc.OnceRef.new none)] + [muxValue muxId (← Conc.OnceRef.new false) (← Conc.OnceRef.new none) + (← Conc.OnceRef.new none)])) + rside.write.close + tasks.joinAll + r.check "upgrade EOF during negotiation closed" + (match ← answer.get with + | some (.error (.closed _)) => true + | _ => false) + tc.close + +/-- The responder supports no proposed id: the dialer exhausts its +list and the `negotiationFailed` maps to `protocol`. -/ +private def checkExhausted (r : Runner) : Async Unit := do + let (iside, rside) ← newPair + let muxA : Mock.Core.ProtocolId := ⟨"/test/mux/a"⟩ + let muxB : Mock.Core.ProtocolId := ⟨"/test/mux/b"⟩ + let dRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let tcD ← Conc.TestClock.new + let tcL ← Conc.TestClock.new + let tasks ← Conc.TaskSet.new + tasks.spawn do + dAnswer.set (some (← upgradeWith tcD.clock .initiator (sideConn iside) ByteArray.empty + (some listenerId) [secValue false (← Conc.OnceRef.new none)] + [muxValue muxA dRan (← Conc.OnceRef.new none) (← Conc.OnceRef.new none)])) + tasks.spawn do + lAnswer.set (some (← upgradeWith tcL.clock .responder (sideConn rside) ByteArray.empty none + [secValue false (← Conc.OnceRef.new none)] + [muxValue muxB lRan (← Conc.OnceRef.new none) (← Conc.OnceRef.new none)])) + tasks.joinAll + let d ← dAnswer.get + let l ← lAnswer.get + let good ← match d, l with + | some (.error (.protocol "multistream negotiation failed")), some (.error (.closed _)) => do + let dR ← dRan.get + let lR ← lRan.get + pure (!dR && !lR) + | _, _ => pure false + r.check "upgrade exhausted proposals errors protocol" good + (sideConn iside).close + (sideConn rside).close + tcD.close + tcL.close + +/-- Malformed bytes on the wire fail the stage as `protocol`, and the +teardown close reaches the peer. -/ +private def checkHostile (r : Runner) : Async Unit := do + let (iside, rside) ← newPair + let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let garbage := Bytes.ofNatList (List.replicate 9 255) + let count : Conc.OnceRef Nat ← Conc.OnceRef.new 0 + let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let tcD ← Conc.TestClock.new + let tcL ← Conc.TestClock.new + let tasks ← Conc.TaskSet.new + tasks.spawn do + dAnswer.set (some (← upgradeWith tcD.clock .initiator + (replaceOnWrite (sideConn iside) 1 garbage count) ByteArray.empty (some listenerId) + [secValue false (← Conc.OnceRef.new none)] + [muxValue muxId (← Conc.OnceRef.new false) (← Conc.OnceRef.new none) + (← Conc.OnceRef.new none)])) + tasks.spawn do + lAnswer.set (some (← upgradeWith tcL.clock .responder (sideConn rside) ByteArray.empty none + [secValue false (← Conc.OnceRef.new none)] + [muxValue muxId (← Conc.OnceRef.new false) (← Conc.OnceRef.new none) + (← Conc.OnceRef.new none)])) + tasks.joinAll + let d ← dAnswer.get + let l ← lAnswer.get + r.check "upgrade hostile negotiation frame errors protocol" + (match d, l with + | some (.error (.closed _)), some (.error (.protocol "multistream decode: malformed")) => true + | _, _ => false) + (sideConn iside).close + (sideConn rside).close + tcD.close + tcL.close + +/-! ## The go trace replays -/ + +/-- Parse fixture hex through the shared lowercase reader. -/ +private def hexOf (h : String) : IO (List Nat) := + match Bytes.ofHex? h with + | some b => pure (Bytes.toNatList b) + | none => throw <| IO.userError "test fixture hex did not parse" + +/-- One captured trace: the initiator-to-responder hexes and the +responder-to-initiator hexes, in write order. -/ +private def traceHexes (file : String) (trace : List Json) : IO (List String × List String) := do + let entries ← trace.mapM fun e => do + let d ← Tests.jStr file e "dir" + let h ← Tests.jStr file e "hex" + pure (d, h) + pure (entries.filter (·.1 = "i2r") |>.map (·.2), entries.filter (·.1 = "r2i") |>.map (·.2)) + +/-- The go trace replays through the responder driver: the emitted +writes equal the recorded `r2i` bytes, byte for byte. -/ +private def checkGoTraces (r : Runner) : Async Unit := do + let goj ← Tests.loadJson "mss-go-trace.json" + let kadId : Mock.Core.ProtocolId := ⟨"/ipfs/kad/1.0.0"⟩ + let fullTrace ← Tests.jArr "mss-go-trace.json" goj "fullTrace" + let (i2rF, r2iF) ← traceHexes "mss-go-trace.json" fullTrace + let rs : State := { role := .responder, supported := [kadId], proposals := [], + buffer := [], phase := .headerPending } + let r2iB ← r2iF.mapM (fun h => (hexOf h : Async (List Nat))) + let chunkF ← hexOf i2rF[0]! + match drive 2 rs chunkF with + | .ok (.selected p _ leftover ws) => + r.check "upgrade go trace replay through the driver" + (p == kadId && leftover == [] && ws == r2iB) + | _ => r.check "upgrade go trace replay through the driver" false + + let naTrace ← Tests.jArr "mss-go-trace.json" goj "naTrace" + let (i2rN, r2iN) ← traceHexes "mss-go-trace.json" naTrace + let rn : State := { role := .responder, supported := [], proposals := [], + buffer := [], phase := .headerPending } + let r2iN' ← r2iN.mapM (fun h => (hexOf h : Async (List Nat))) + let chunkN ← hexOf i2rN[0]! + match drive 2 rn chunkN with + | .ok (.running _ _ leftover ws) => + r.check "upgrade go na trace replay through the driver" + (leftover == [] && ws == r2iN') + | _ => r.check "upgrade go na trace replay through the driver" false + + let lazyTrace ← Tests.jArr "mss-go-trace.json" goj "lazyTrace" + let (i2rL, _) ← traceHexes "mss-go-trace.json" lazyTrace + let lazyI2r ← hexOf i2rL[0]! + match initiatorStart [kadId] with + | .ok (s, ws) => + r.check "upgrade lazy coalesced write" + (ws == [lazyI2r] && s.phase = .negotiating && s.proposals == [kadId]) + | .error _ => r.check "upgrade lazy coalesced write" false + +/-! ## The suite -/ + +/-- The suite. -/ +def run (r : Runner) : IO Unit := do + r.section "multistream-select and the upgrader (unit 1b)" + "packages/libp2p/negotiate/docs/ARCHITECTURE.md" + let all : Conc.Async Unit := do + checkFullPipeline r + checkNegotiationLeftover r + checkHandshakeLeftover r + checkMuxOpenLeftover r + checkNaRetry r + checkIdentityMismatch r + checkDeadline r + checkEmptySecurity r + checkEmptyMuxer r + checkEof r + checkExhausted r + checkHostile r + checkGoTraces r + Conc.Async.run all + r.skip "upgrade noise yamux matrix over tcp" + "needs the TCP transport, the Noise value, and the yamux value; none are merged on this base (the W5.9 live gate)" + +end Tests.Upgrade diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate.lean b/packages/libp2p/negotiate/Libp2p/Negotiate.lean index a45e4440..8f3dd942 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate.lean @@ -1,11 +1,12 @@ import Libp2p.Negotiate.Constants import Libp2p.Negotiate.Message import Libp2p.Negotiate.Step +import Libp2p.Negotiate.Upgrade /-! # Libp2p.Negotiate The negotiate subpackage: multistream-select 1.0.0 and the upgrader (ARCHITECTURE §4.9). One import line per public submodule. The root -does not import the Mock modules. The upgrader lands with unit 1b. +does not import the Mock modules. -/ diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean index fb394ac3..01fa7e79 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean @@ -1,14 +1,23 @@ import Std +import Conc +import Libp2p.Negotiate.Mock.Streams +import Libp2p.Negotiate.Mock.Identity /-! -# Core mocks for unit 1a +# Core mocks for units 1a and 1b `ProtocolId` and `Role` live in `libp2p/core` (W5.2, not merged yet). -This module holds them verbatim from the core ARCHITECTURE "Public -surface", so the negotiate core compiles against the frozen shapes. -The import swaps to `Libp2p.Core` when W5.2 lands. `Repr` joins the -derived classes per root §3.2, so `State`, `Event`, and `Action` can -derive `Repr` per root §3.3. +Unit 1b adds `Error`, `SecurityProtocol`, `SecureConn`, `Muxer`, and +`MuxSession`. This module holds them verbatim from the core +ARCHITECTURE "Public surface", so the negotiate core and the upgrader +compile against the frozen shapes. The import swaps to `Libp2p.Core` +when W5.2 lands. `Repr` joins the derived classes per root §3.2, so +`State`, `Event`, and `Action` can derive `Repr` per root §3.3. + +`SecurityProtocol` and `Muxer` mention the streams `Connection`, and +`SecureConn` mentions the identity `PeerId`, so this module imports +the two sibling mocks. The imports are same-leaf and the lint's +rule 3 allows them. -/ set_option autoImplicit false @@ -28,4 +37,48 @@ inductive Role where | responder deriving DecidableEq, Repr, Hashable +/-- The coarse error of root §4.9. Each arm carries the error of the +implementation, in its rendered form. The upgrader and the host act on +this one type (root §3.5). -/ +inductive Error where + | protocol (s : String) + | authentication (s : String) + | timeout (s : String) + | closed (s : String) + deriving DecidableEq, Repr + +/-- The post-handshake connection. It holds the underlying +`Connection` because `Muxer.open` takes a `Connection` (core +ARCHITECTURE, "Public surface"). The `leftover` is every byte the +handshake read past its end; it seeds the reader of the next stage. -/ +structure SecureConn where + conn : Mock.Streams.Connection + localPeer : Mock.Identity.PeerId + remotePeer : Mock.Identity.PeerId + leftover : ByteArray + close : BaseIO Unit + +/-- The security seam. `handshake` consumes the `ByteArray` the +negotiation over-read before it reads the connection (root §4.7). -/ +structure SecurityProtocol where + id : ProtocolId + handshake : Role → Mock.Streams.Connection → ByteArray → + ExceptT Error Conc.Async SecureConn + +/-- The uniform session view of core ARCHITECTURE, "Public surface". +The upgrader returns the opened session and never calls its fields. -/ +structure MuxSession where + openStream : ExceptT Error Conc.Async Mock.Streams.Stream + accept : Conc.Async (Option Mock.Streams.Stream) + close : IO Unit + join : Conc.Async Unit + +/-- The muxer seam. The field is the frozen `open` of the core docs; +`open` is a Lean keyword, so the field is declared escaped and called +`mux.«open»`. -/ +structure Muxer where + id : ProtocolId + «open» : Role → Mock.Streams.Connection → ByteArray → + ExceptT Error Conc.Async MuxSession + end Libp2p.Negotiate.Mock.Core diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Identity.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Identity.lean new file mode 100644 index 00000000..90719d9f --- /dev/null +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Identity.lean @@ -0,0 +1,24 @@ +import Std + +/-! +# Identity mocks for unit 1b + +`PeerId` lives in `libp2p/identity` (#11, not merged yet). The real +type is a proof-carrying newtype over a validated multihash +(identity ARCHITECTURE, "Public surface"). The upgrader compares +`PeerId` values only, so this mock keeps the recorded one-field form +and the derived classes (plan.md, "Mock surface"). The import swaps +to `Libp2p.Identity` when the identity package lands. +-/ + +set_option autoImplicit false + +namespace Libp2p.Negotiate.Mock.Identity + +/-- The one-field mock of the validated multihash. The upgrader +compares values only. -/ +structure PeerId where + toString : String + deriving DecidableEq, Repr, Hashable + +end Libp2p.Negotiate.Mock.Identity diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Streams.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Streams.lean new file mode 100644 index 00000000..7ac4bd6c --- /dev/null +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Streams.lean @@ -0,0 +1,54 @@ +import Conc + +/-! +# Streams mocks for unit 1b + +The streams seam types live in `net/streams` (#32, not merged yet). +This module holds `Connection`, `BufReader`, and `Stream` verbatim from +the streams ARCHITECTURE "Public surface", and the one constant the +upgrader wires, so the upgrader compiles against the frozen shapes. +The imports swap to `Streams` when the streams package lands. + +`Connection` omits `localAddr` and `remoteAddr`. The upgrader never +reads an address, and a `Multiaddr` import would break the package's +own import list (negotiate ARCHITECTURE, "What the package must not +depend on"). `BufReader.readExactly` stays undeclared: the core owns +framing, and this package reads raw chunks (negotiate ARCHITECTURE, +"From the net group"). +-/ + +set_option autoImplicit false + +namespace Libp2p.Negotiate.Mock.Streams + +/-- A byte stream. The fields are exactly `Conc.Pipe`'s methods, so +the test pair needs no adapter (`Pipe.lean:33,41,46`). `read` returns +`none` on the end of the stream. -/ +structure Connection where + read : Conc.Async (Option ByteArray) + write : ByteArray → Conc.Async (Except Conc.Error Unit) + close : BaseIO Unit + +/-- One stage's reader (streams ARCHITECTURE, "No reader strand"). +Two stages never share one reader. -/ +structure BufReader where + read : Conc.Async (Option ByteArray) + +/-- The reader of one connection. Each upgrade stage builds its own +reader over the connection of that stage. -/ +def BufReader.ofConn (c : Connection) : BufReader := ⟨c.read⟩ + +/-- The application view over a muxer loop. The upgrader never calls +these fields; they type the `MuxSession` surface of the core mock. -/ +structure Stream where + read : Conc.Async (Option ByteArray) + write : ByteArray → Conc.Async (Except Conc.Error Unit) + close : IO Unit + reset : IO Unit + +/-- The 30 s upgrade deadline (root §5.4). The real home is +`Streams/Constants.lean`; this mock moves there when the streams +package lands (negotiate ARCHITECTURE, "Decisions"). -/ +def upgradeDeadlineMs : Nat := 30000 + +end Libp2p.Negotiate.Mock.Streams diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean new file mode 100644 index 00000000..78ace6db --- /dev/null +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean @@ -0,0 +1,506 @@ +import Libp2p.Negotiate.Step +import Libp2p.Negotiate.Message +import Libp2p.Negotiate.Mock.Core +import Libp2p.Negotiate.Mock.Streams +import Libp2p.Negotiate.Mock.Identity +import Conc +import Bytes + +/-! +# The upgrader + +`Upgrade` walks a raw `Connection` through security negotiation, the +security handshake, the identity check, and muxer negotiation into a +`MuxSession` (root §4.9, negotiate ARCHITECTURE, "Public surface"). The +pure part is `drive`: one fuel-bounded run over a byte cursor that +feeds the 1a core one `Event.bytes` call per complete frame. The IO +part is `negotiate`, one negotiation stage, and `upgradeWith`, the +three-stage pipeline under one `Conc.withinDeadline`. + +The driver owns the byte cursor. It never reads `s.buffer`; it resets +the buffer before every `step` call, and every unconsumed byte stays +in the `input` argument. The `leftover` field of the result is +authoritative (plan.md, "Deviations"). The shell always passes the +driver's own states and leftovers back, so the convention is closed +under use. + +The negotiation loop parks on the connection read until a selection, +an error, a close, or the deadline (root §3.3). No measure bounds that +wait, so the loop keeps the package's `repeat` form, the form of +`Conc.TaskSet.joinAll`, and stays outside the proved core. The pure +`drive` is total, and its theorems carry no IO obligation. + +The three stages thread the leftover bytes by contract 5 of the +package docs: the negotiation leftover seeds the handshake, the +`SecureConn` leftover seeds the muxer negotiation, and the muxer +negotiation leftover reaches `Muxer.open`. +-/ + +set_option autoImplicit false + +namespace Libp2p.Negotiate + +open Bytes (DecodeError) + +/-! ## The driver -/ + +/-- The answer of one driver run. + +* `selected p consumed leftover writes`: a `step` selected `p`. + `consumed` is the concatenation of the encoded frames the run + consumed, the selected frame included. `leftover` is the byte suffix + after it. `writes` are the `Action.write` payloads, in order. +* `waiting s consumed leftover writes`: `decodeMsg?` reported + `truncated` on `leftover`. The caller reads more bytes and appends + them; it must not call `drive` again without new bytes. +* `running s consumed leftover writes`: the fuel ran out after a + complete frame. The caller calls `drive` again with `leftover`; it + must not read. -/ +inductive DriveResult where + | selected (p : Mock.Core.ProtocolId) (consumed leftover : List Nat) + (writes : List (List Nat)) + | waiting (s : State) (consumed leftover : List Nat) (writes : List (List Nat)) + | running (s : State) (consumed leftover : List Nat) (writes : List (List Nat)) + deriving Repr, DecidableEq + +namespace DriveResult + +/-- The frames the run consumed, as their encoded bytes. -/ +@[simp] def consumed : DriveResult → List Nat + | .selected _ c _ _ => c + | .waiting _ c _ _ => c + | .running _ c _ _ => c + +/-- The bytes the run did not consume. -/ +@[simp] def leftover : DriveResult → List Nat + | .selected _ _ l _ => l + | .waiting _ _ l _ => l + | .running _ _ l _ => l + +end DriveResult + +/-- The `Action.write` payloads of an action list, in order. -/ +def writesOf (actions : List Action) : List (List Nat) := + actions.filterMap fun + | .write bs => some bs + | .select .. => none + +/-- The protocol an action list selects, if it selects one. -/ +def selectOf : List Action → Option Mock.Core.ProtocolId + | [] => none + | .write _ :: rest => selectOf rest + | .select p _ :: _ => some p + +/-- One fuel unit consumes at most one complete frame. The +accumulators keep the consumed bytes and the writes of the run. -/ +private def driveAux : Nat → State → List Nat → List Nat → List (List Nat) → + Except Error DriveResult + | 0, s, input, consumed, writes => .ok (.running s consumed input writes) + | fuel + 1, s, input, consumed, writes => + match decodeMsg? input with + | .error .truncated => .ok (.waiting s consumed input writes) + | .error .malformed => .error (.decode .malformed) + | .ok (m, rest) => + match step { s with buffer := [] } (.bytes input) with + | .error e => .error e + | .ok (s', actions) => + match selectOf actions with + | some p => + .ok (.selected p (consumed ++ encodeMsg m) rest + (writes ++ writesOf actions)) + | none => + driveAux fuel s' rest (consumed ++ encodeMsg m) + (writes ++ writesOf actions) + +/-- Drive the pure core over a byte cursor. `s.buffer` is a +don't-care: pass every unconsumed byte in `input`, and read the +`leftover` field of the result as authoritative. One fuel unit +consumes at most one frame. -/ +def drive (fuel : Nat) (s : State) (input : List Nat) : Except Error DriveResult := + driveAux fuel s input [] [] + +/-- The initiator's post-propose state and its one coalesced write. +The write holds the header and the first proposal back-to-back, the +lazy dialer's wire shape (contract 4 of the package docs). The state +carries the whole ordered list, so the core's `na` retry walks it. +An empty ordered list is a protocol error before any byte moves. -/ +def initiatorStart (ids : List Mock.Core.ProtocolId) : + Except Error (State × List (List Nat)) := + match ids with + | [] => .error .negotiationFailed + | p :: _ => + .ok ({ role := .initiator, supported := [], proposals := ids, buffer := [], + phase := .negotiating }, + [encodeMsg header ++ encodeText p.toString]) + +/-! ## The error mappings -/ + +/-- The rendered form of a decode error. `Bytes.DecodeError` carries no +`ToString` instance, so the mapping renders it here. -/ +def renderDecodeError : Bytes.DecodeError → String + | .truncated => "truncated" + | .malformed => "malformed" + +/-- Contract 8's one mapping point: every negotiation error becomes +the `protocol` arm, with the detail rendered. -/ +def mapNegotiateError : Error → Mock.Core.Error + | .decode e => .protocol ("multistream decode: " ++ renderDecodeError e) + | .negotiationFailed => .protocol "multistream negotiation failed" + +/-- The identity gate. `none` is the listen path, which accepts any +peer. The dial path compares the derived id against the expectation +and answers `authentication` on a difference, before any muxer byte +moves (negotiate ARCHITECTURE, "Decisions"). -/ +def checkIdentity (expected : Option Mock.Identity.PeerId) + (remote : Mock.Identity.PeerId) : Except Mock.Core.Error Unit := + match expected with + | none => .ok () + | some e => + if e = remote then .ok () + else .error (.authentication "remote peer id differs from the expected peer id") + +/-- The race answer flattened. `Conc.withinDeadline` constructs +`.error .deadlineExpired`; the `.closed` arm is required by the match +and is unreachable from `withinDeadline`. -/ +def settleUpgrade {α : Type} : + Except Conc.Error (Except Mock.Core.Error α) → Except Mock.Core.Error α + | .ok (.ok a) => .ok a + | .ok (.error e) => .error e + | .error .deadlineExpired => .error (.timeout "upgrade deadline expired") + | .error .closed => .error (.closed "upgrade deadline select closed") + +/-- The answer of one negotiation stage: the selected id and the bytes +the stage over-read. `ByteArray` carries no `Repr` in Lean 4.34, so +the structure derives none; the suite prints the fields it needs. -/ +structure StageResult where + proto : Mock.Core.ProtocolId + leftover : ByteArray + +/-! ## The driver theorems -/ + +/-- The accumulator invariant behind the conservation law: the bytes +fed in equal the bytes consumed plus the final leftover, for every +arm of the result. The proof is the one induction of the unit, on the +fuel; `decodeMsgEncodeDecode` reassociates each consumed frame. -/ +private theorem driveAux_conservation (fuel : Nat) : + ∀ (s : State) (input consumed : List Nat) (writes : List (List Nat)) + (r : DriveResult), + driveAux fuel s input consumed writes = .ok r → + consumed ++ input = r.consumed ++ r.leftover := by + induction fuel with + | zero => + intro s input consumed writes r h + simp only [driveAux, Except.ok.injEq] at h + subst h + simp + | succ fuel ih => + intro s input consumed writes r h + simp only [driveAux] at h + split at h + · simp only [Except.ok.injEq] at h + subst h + simp + · simp at h + · rename_i m rest hdec + split at h + · rename_i e + simp at h + · rename_i s' actions hstep + split at h + · rename_i p + simp only [Except.ok.injEq] at h + subst h + have hbs := decodeMsgEncodeDecode input m rest hdec + rw [← hbs] + simp [List.append_assoc] + · have hrec := ih s' rest (consumed ++ encodeMsg m) + (writes ++ writesOf actions) r h + have hbs := decodeMsgEncodeDecode input m rest hdec + rw [← hbs, ← List.append_assoc] + exact hrec + +/-- Every byte fed to the driver is consumed or left over. -/ +theorem drive_conservation {fuel : Nat} {s : State} {input : List Nat} {r : DriveResult} + (h : drive fuel s input = .ok r) : input = r.consumed ++ r.leftover := by + have h' := driveAux_conservation fuel s input [] [] r h + simpa using h' + +/-- U2. The selected arm: the bytes in equal the frames consumed plus +the leftover handed to the next stage (contract 5). -/ +theorem drive_selected_conservation {fuel : Nat} {s : State} {input : List Nat} + {p : Mock.Core.ProtocolId} {consumed leftover : List Nat} + {writes : List (List Nat)} + (h : drive fuel s input = .ok (.selected p consumed leftover writes)) : + input = consumed ++ leftover := by + simpa using drive_conservation h + +/-- U3. The truncated arm: every byte the run saw sits in the +consumed frames or in the waiting buffer, so the shell loses no byte +when it reads the next chunk. -/ +theorem drive_waiting_conservation {fuel : Nat} {s s' : State} {input : List Nat} + {consumed leftover : List Nat} {writes : List (List Nat)} + (h : drive fuel s input = .ok (.waiting s' consumed leftover writes)) : + input = consumed ++ leftover := by + simpa using drive_conservation h + +/-- The accumulator prefix invariant: a run appends the frames it +consumes to the accumulator it received. -/ +private theorem driveAux_consumed_prefix (fuel : Nat) : + ∀ (s : State) (input consumed : List Nat) (writes : List (List Nat)) + (r : DriveResult), + driveAux fuel s input consumed writes = .ok r → + ∃ tail : List Nat, r.consumed = consumed ++ tail := by + induction fuel with + | zero => + intro s input consumed writes r h + simp only [driveAux, Except.ok.injEq] at h + subst h + exact ⟨[], by simp⟩ + | succ fuel ih => + intro s input consumed writes r h + simp only [driveAux] at h + split at h + · simp only [Except.ok.injEq] at h + subst h + exact ⟨[], by simp⟩ + · simp at h + · rename_i m rest hdec + split at h + · rename_i e + simp at h + · rename_i s' actions hstep + split at h + · rename_i p + simp only [Except.ok.injEq] at h + subst h + exact ⟨encodeMsg m, rfl⟩ + · obtain ⟨tail, htail⟩ := + ih s' rest (consumed ++ encodeMsg m) (writes ++ writesOf actions) r h + exact ⟨encodeMsg m ++ tail, by rw [htail, List.append_assoc]⟩ + +/-- A frame the shell writes through `encodeMsg` decodes and appears +at the head of the consumed bytes. -/ +theorem drive_consumed_head {fuel : Nat} {s : State} {m : Msg} {rest : List Nat} + {r : DriveResult} + (h : drive (fuel + 1) s (encodeMsg m ++ rest) = .ok r) : + ∃ tail : List Nat, r.consumed = encodeMsg m ++ tail := by + have h' : driveAux (fuel + 1) s (encodeMsg m ++ rest) [] [] = .ok r := h + simp only [driveAux, encodeMsg, decodeMsgSuffix] at h' + split at h' + · rename_i e + simp at h' + · rename_i s' actions hstep + split at h' + · rename_i p + simp only [Except.ok.injEq] at h' + subst h' + exact ⟨[], by simp [encodeMsg]⟩ + · obtain ⟨tail, htail⟩ := + driveAux_consumed_prefix fuel s' rest ([] ++ encodeMsg m) (writesOf actions) r h' + exact ⟨tail, by simpa [encodeMsg, List.append_assoc] using htail⟩ + +/-- U4. Two stages compose: bytes in equal the consumed bytes of both +stages plus the final leftover. The reusable form of the brief's scope +item 2. -/ +theorem handover_compose {ε α β : Type} + {f : List Nat → Except ε (α × List Nat)} + {g : List Nat → Except ε (β × List Nat)} + {c₁ : α → List Nat} {c₂ : β → List Nat} + {bs₁ bs₂ l₁ l₂ : List Nat} {a₁ : α} {a₂ : β} + (law₁ : ∀ x a r, f x = .ok (a, r) → x = c₁ a ++ r) + (law₂ : ∀ x a r, g x = .ok (a, r) → x = c₂ a ++ r) + (h₁ : f bs₁ = .ok (a₁, l₁)) + (h₂ : g (l₁ ++ bs₂) = .ok (a₂, l₂)) : + bs₁ ++ bs₂ = c₁ a₁ ++ c₂ a₂ ++ l₂ := by + rw [law₁ bs₁ a₁ l₁ h₁, List.append_assoc, law₂ (l₁ ++ bs₂) a₂ l₂ h₂] + simp only [List.append_assoc] + +/-- U5. The pure form of the hand-over: the muxer stage is seeded with +the negotiation's leftover, and the two drives consume exactly the +bytes fed in. -/ +theorem drive_twostage {fuel₁ fuel₂ : Nat} {s₁ s₂ : State} + {input₁ input₂ : List Nat} {p₁ p₂ : Mock.Core.ProtocolId} + {c₁ c₂ l₁ l₂ : List Nat} {w₁ w₂ : List (List Nat)} + (h₁ : drive fuel₁ s₁ input₁ = .ok (.selected p₁ c₁ l₁ w₁)) + (h₂ : drive fuel₂ s₂ (l₁ ++ input₂) = .ok (.selected p₂ c₂ l₂ w₂)) : + input₁ ++ input₂ = c₁ ++ c₂ ++ l₂ := by + have h1 := drive_selected_conservation h₁ + have h2 := drive_selected_conservation h₂ + rw [h1, List.append_assoc, h2] + simp only [List.append_assoc] + +/-- U1. The shell's manual first write equals the core's propose +step for a singleton list, after both sides are read as write lists. +The driver therefore starts the negotiation the way the proved core +would. -/ +theorem initiatorStart_propose {p : Mock.Core.ProtocolId} : + initiatorStart [p] = + (step { role := .initiator, supported := [], proposals := [], buffer := [], + phase := .headerPending } (.propose p)).map + (fun sa => (sa.1, writesOf sa.2)) := rfl + +/-- An empty ordered list is a protocol error before any byte is +written. -/ +theorem initiatorStart_nil : initiatorStart [] = .error .negotiationFailed := rfl + +/-- U9. The one mapping point of contract 8: every `Negotiate.Error` +becomes the `protocol` arm, with the detail rendered. -/ +theorem mapNegotiateError_spec {e : Bytes.DecodeError} : + mapNegotiateError (.decode e) = + .protocol ("multistream decode: " ++ renderDecodeError e) ∧ + mapNegotiateError .negotiationFailed = + .protocol "multistream negotiation failed" := by + simp [mapNegotiateError] + +/-- U6. A differing derived id is authentication, before any muxer +byte moves. -/ +theorem checkIdentity_reject {expected remote : Mock.Identity.PeerId} + (h : expected ≠ remote) : + checkIdentity (some expected) remote = + .error (.authentication "remote peer id differs from the expected peer id") := by + simp [checkIdentity, h] + +/-- U7. The dial to the expected peer passes. -/ +theorem checkIdentity_accept {e : Mock.Identity.PeerId} : + checkIdentity (some e) e = .ok () := by + simp [checkIdentity] + +/-- U8. The listen path accepts any peer. -/ +theorem checkIdentity_none {r : Mock.Identity.PeerId} : + checkIdentity none r = .ok () := rfl + +/-- U10. The deadline wiring of contract 6: the race's expiry is the +`timeout` arm, and the seam errors pass through untouched. The +`.closed` arm is required by the match over `Conc.Error`. -/ +theorem settleUpgrade_spec {α : Type} {e : Mock.Core.Error} {a : α} : + settleUpgrade (α := α) (.error .deadlineExpired) = + .error (.timeout "upgrade deadline expired") ∧ + settleUpgrade (α := α) (.error .closed) = + .error (.closed "upgrade deadline select closed") ∧ + settleUpgrade (α := α) (.ok (.error e)) = .error e ∧ + settleUpgrade (.ok (.ok a)) = .ok a := by + simp [settleUpgrade] + +/-! ## The IO shell -/ + +/-- Write each frame payload through the connection, in order. A +failed write is the `closed` arm: the connection is gone. -/ +private def sendWrites (conn : Mock.Streams.Connection) (ws : List (List Nat)) : + ExceptT Mock.Core.Error Conc.Async Unit := do + for w in ws do + match ← conn.write (Bytes.ofNatList w) with + | .ok () => pure () + | .error _ => throw (.closed "connection write failed during the upgrade") + +/-- The negotiation loop. It parks on `reader.read` only when `drive` +reports `waiting`; a `running` answer costs no read, because a +complete frame is still buffered. The loop ends on a selection, an +error, a close, or the deadline, so no measure bounds it (root §3.3). +The `repeat` form keeps the definition free of a `partial` marker, +like `Conc.TaskSet.joinAll`. -/ +private def negotiateLoop (reader : Mock.Streams.BufReader) + (conn : Mock.Streams.Connection) (s : State) (input : List Nat) : + ExceptT Mock.Core.Error Conc.Async StageResult := do + let mut s := s + let mut input := input + repeat + match drive 1 s input with + | .error e => throw (mapNegotiateError e) + | .ok (.selected p _ leftover writes) => do + sendWrites conn writes + return { proto := p, leftover := Bytes.ofNatList leftover } + | .ok (.waiting s' _ leftover writes) => do + sendWrites conn writes + match ← reader.read with + | none => throw (.closed "multistream negotiation ended before a selection") + | some chunk => + s := s' + input := leftover ++ Bytes.toNatList chunk + | .ok (.running s' _ leftover writes) => do + sendWrites conn writes + s := s' + input := leftover + -- Every arm above returns or loops, so the `repeat` never falls + -- through. The tail keeps the loop total without an `Inhabited`. + throw (.closed "the negotiation loop ended without an answer") + +/-- One negotiation stage. The initiator writes the coalesced header +plus first proposal before the loop; the responder reads first. The +seed is the leftover of the previous stage. The end of the stream +mid-negotiation is `Core.Error.closed`; a `Negotiate.Error` maps once +through `mapNegotiateError`. -/ +def negotiate (role : Mock.Core.Role) (reader : Mock.Streams.BufReader) + (conn : Mock.Streams.Connection) (seed : ByteArray) + (ids : List Mock.Core.ProtocolId) : + ExceptT Mock.Core.Error Conc.Async StageResult := do + match role with + | .initiator => + match initiatorStart ids with + | .error e => throw (mapNegotiateError e) + | .ok (s, first) => do + sendWrites conn first + negotiateLoop reader conn s (Bytes.toNatList seed) + | .responder => + negotiateLoop reader conn + { role := .responder, supported := ids, proposals := [], buffer := [], + phase := .headerPending } + (Bytes.toNatList seed) + +/-- The pipeline: negotiate the security protocol, run the handshake, +check the identity, negotiate the muxer, open the session. Each lookup +is total; a miss after a selection is `protocol`. -/ +private def upgradeCore (role : Mock.Core.Role) (conn : Mock.Streams.Connection) + (leftover : ByteArray) (expected : Option Mock.Identity.PeerId) + (secs : List Mock.Core.SecurityProtocol) (muxers : List Mock.Core.Muxer) : + Conc.Async (Except Mock.Core.Error (Mock.Core.MuxSession × Mock.Identity.PeerId)) := do + let reader := Mock.Streams.BufReader.ofConn conn + match ← negotiate role reader conn leftover (secs.map (·.id)) with + | .error e => return .error e + | .ok stage => + match secs.find? (fun s => decide (s.id = stage.proto)) with + | none => return .error (.protocol "selected security protocol is not configured") + | some sec => + match ← sec.handshake role conn stage.leftover with + | .error e => return .error e + | .ok sc => + match checkIdentity expected sc.remotePeer with + | .error e => return .error e + | .ok () => + let muxReader := Mock.Streams.BufReader.ofConn sc.conn + match ← negotiate role muxReader sc.conn sc.leftover (muxers.map (·.id)) with + | .error e => return .error e + | .ok stage2 => + match muxers.find? (fun m => decide (m.id = stage2.proto)) with + | none => return .error (.protocol "selected muxer is not configured") + | some mux => + match ← mux.«open» role sc.conn stage2.leftover with + | .error e => return .error e + | .ok session => return .ok (session, sc.remotePeer) + +/-- The upgrade under one `Conc.withinDeadline`. The expiry closes +the connection once at the boundary (root §3.5), and the race's +`deadlineExpired` maps to the `timeout` arm through `settleUpgrade`. +The clock is a seam, so the deadline test can advance it. -/ +def upgradeWith (clock : Conc.Clock) (role : Mock.Core.Role) + (conn : Mock.Streams.Connection) (leftover : ByteArray) + (expected : Option Mock.Identity.PeerId) + (secs : List Mock.Core.SecurityProtocol) (muxers : List Mock.Core.Muxer) : + ExceptT Mock.Core.Error Conc.Async (Mock.Core.MuxSession × Mock.Identity.PeerId) := do + let answer ← Conc.withinDeadline clock Mock.Streams.upgradeDeadlineMs conn.close + (upgradeCore role conn leftover expected secs muxers) + match settleUpgrade answer with + | .ok pair => return pair + | .error e => do + conn.close + throw e + +/-- The frozen entry of the package docs. The clock is fixed to the +system clock here and stays a seam in `upgradeWith` for the deadline +test. -/ +def upgrade (role : Mock.Core.Role) (conn : Mock.Streams.Connection) + (leftover : ByteArray) (expected : Option Mock.Identity.PeerId) + (secs : List Mock.Core.SecurityProtocol) (muxers : List Mock.Core.Muxer) : + ExceptT Mock.Core.Error Conc.Async (Mock.Core.MuxSession × Mock.Identity.PeerId) := + upgradeWith Conc.Clock.system role conn leftover expected secs muxers + +end Libp2p.Negotiate From 9850843cf9efe029765ebba4a946610391e27a95 Mon Sep 17 00:00:00 2001 From: Wenbo Date: Tue, 3 Nov 2026 15:49:33 +0800 Subject: [PATCH 012/125] docs(negotiate): drop Action.error and record the decision --- packages/libp2p/negotiate/docs/ARCHITECTURE.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/libp2p/negotiate/docs/ARCHITECTURE.md b/packages/libp2p/negotiate/docs/ARCHITECTURE.md index 936b5045..8a2860e6 100644 --- a/packages/libp2p/negotiate/docs/ARCHITECTURE.md +++ b/packages/libp2p/negotiate/docs/ARCHITECTURE.md @@ -39,10 +39,11 @@ imports it and wires it; it keeps no copy. input buffer, and the phase. `Event.bytes` carries raw bytes, and the core decodes them, so the malformed-versus-incomplete call stays in the core (§3.1). `Event.propose` posts the id the initiator dials. - `Action.write` emits framed bytes, `Action.select` ends the - negotiation with the chosen `ProtocolId` and the leftover, and - `Action.error` reports `Negotiate.Error`. There is no tick: the - upgrader owns the deadline (§5.4). + `Action.write` emits framed bytes, and `Action.select` ends the + negotiation with the chosen `ProtocolId` and the leftover. A failure + leaves `step` through its `Except` channel, as §3.3 states, so + `Action` carries no error constructor. There is no tick: the upgrader + owns the deadline (§5.4). - `Reply` is the closed set of §3.4 with `na` and `ls`. Its wire form is the message text the codec carries, and no spec gives the replies a numeric code table, so it has no `toCode`/`ofCode?` pair, by the @@ -127,6 +128,12 @@ connection-level selection. The target folds it into data in and data out, so a captured trace replays with no network and no clock (§6), and the `-N1` run holds by construction. +- **`Action` carries no error constructor.** An earlier draft of this + document listed `Action.error`. Root §3.3 fixes the core shape as + `step : State → Event → Except ProtocolError (State × List Action)`, + so a failure leaves through the `Except` channel and never as an + action. The spike wrote `na` and then failed on a bad first header; + this core returns the error and writes nothing. - **Wire-based muxer selection, against the spike's extension selection.** The section above records it. - **The identity check sits between the handshake and the muxer stage, From 83937f9f0f3358fc183676179f4b1e999180c047 Mon Sep 17 00:00:00 2001 From: Wenbo Date: Tue, 3 Nov 2026 15:52:13 +0800 Subject: [PATCH 013/125] docs(negotiate): state what propose actually does --- packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean index f581e18d..c25f03f2 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean @@ -167,10 +167,11 @@ private def act (s : State) (rest : List Nat) : Verdict → Except Error (State | .accept p => .ok ({ s with buffer := [], phase := .selected }, [.select p rest]) | .fail => .error .negotiationFailed -/-- A proposal event. Only the first proposal of an initiator in the -header phase writes the coalesced header plus proposal frame. Every -other proposal event is a no-op, because local input cannot fail a -negotiation and cannot corrupt the core. -/ +/-- A proposal event. An initiator in the header phase records the id in +`proposals` and writes the coalesced header plus the first proposal frame. A +second proposal in the same phase extends the list and writes nothing. The +frame for every later id is written when `step` answers `na`, and `classify` +derives that answer from the recorded list. -/ private def propose (s : State) (p : Mock.Core.ProtocolId) : Except Error (State × List Action) := if s.role = .initiator then if s.phase = .headerPending then From 50209facdfa7c2f0e4ee4feb78646155bfb39b44 Mon Sep 17 00:00:00 2001 From: Wenbo Date: Tue, 3 Nov 2026 16:13:57 +0800 Subject: [PATCH 014/125] fix(negotiate): cap the send path, start the responder first, free the late session --- Tests/Upgrade.lean | 33 ++++++++++ .../negotiate/Libp2p/Negotiate/Message.lean | 6 ++ .../negotiate/Libp2p/Negotiate/Mock/Core.lean | 2 +- .../negotiate/Libp2p/Negotiate/Step.lean | 18 +++--- .../negotiate/Libp2p/Negotiate/Upgrade.lean | 62 ++++++++++++++++--- 5 files changed, 103 insertions(+), 18 deletions(-) diff --git a/Tests/Upgrade.lean b/Tests/Upgrade.lean index 9fb89151..e766d5b2 100644 --- a/Tests/Upgrade.lean +++ b/Tests/Upgrade.lean @@ -620,6 +620,37 @@ private def checkGoTraces (r : Runner) : Async Unit := do (ws == [lazyI2r] && s.phase = .negotiating && s.proposals == [kadId]) | .error _ => r.check "upgrade lazy coalesced write" false +/-! ## The send-side cap -/ + +/-- A protocol id whose frame is over the 1 KiB cap. -/ +private def overCapId : Mock.Core.ProtocolId := + ⟨String.ofList (List.replicate 1100 'a')⟩ + +/-- A configured id that would produce an over-cap frame is refused before +any byte moves, so the sender cannot emit a frame the decoder rejects. -/ +private def checkSendCap (r : Runner) : Async Unit := do + r.check "negotiate send cap refuses an over-long proposal" + (match initiatorStart [overCapId] with + | .error .negotiationFailed => true + | _ => false) + r.check "negotiate send cap refuses an over-long supported list" + (match responderStart [overCapId] with + | .error .negotiationFailed => true + | _ => false) + r.check "negotiate send cap admits a normal id" + (match initiatorStart [Mock.Core.ProtocolId.mk "/ipfs/kad/1.0.0"] with + | .ok _ => true + | _ => false) + +/-- The responder writes its header before it reads anything, so a dialer +that also waits does not stall. -/ +private def checkResponderFirst (r : Runner) : Async Unit := do + match responderStart [Mock.Core.ProtocolId.mk "/ipfs/kad/1.0.0"] with + | .ok (s, ws) => + r.check "negotiate responder sends its header first" + (ws == [encodeMsg header] && s.phase = .negotiating) + | .error _ => r.check "negotiate responder sends its header first" false + /-! ## The suite -/ /-- The suite. -/ @@ -639,6 +670,8 @@ def run (r : Runner) : IO Unit := do checkEof r checkExhausted r checkHostile r + checkSendCap r + checkResponderFirst r checkGoTraces r Conc.Async.run all r.skip "upgrade noise yamux matrix over tcp" diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean index be9b298b..3d35b9eb 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean @@ -68,6 +68,12 @@ through it. -/ def Msg.ofText? (s : String) : Option Msg := if h : (Bytes.ofString (s ++ "\n")).size ≤ maxMsg then some ⟨s, h⟩ else none +/-- A text whose frame fits the 1 KiB cap. The senders call this before +they write, so a configured id that would produce an over-cap frame is a +protocol error rather than a frame the decoder refuses (root §5.1). -/ +def fitsText (s : String) : Bool := + decide ((Bytes.ofString (s ++ "\n")).size ≤ maxMsg) + /-- The frame the codec writes for a message. -/ def encodeMsg (m : Msg) : List Nat := encodeText m.text diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean index 01fa7e79..68eba208 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean @@ -70,7 +70,7 @@ The upgrader returns the opened session and never calls its fields. -/ structure MuxSession where openStream : ExceptT Error Conc.Async Mock.Streams.Stream accept : Conc.Async (Option Mock.Streams.Stream) - close : IO Unit + close : BaseIO Unit join : Conc.Async Unit /-- The muxer seam. The field is the frozen `open` of the core docs; diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean index c25f03f2..991ba4dc 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean @@ -139,7 +139,7 @@ private def classify (s : State) (text : String) : Verdict := match Reply.ofText? text with | some .ls => .listIds | _ => - if text = protocolId.toString then .echo + if text = protocolId.toString then .advance else if (⟨text⟩ : Mock.Core.ProtocolId) ∈ s.supported then .pick ⟨text⟩ else .refuse | .initiator, .negotiating => if text = protocolId.toString then .advance else .fail @@ -162,7 +162,10 @@ private def act (s : State) (rest : List Nat) : Verdict → Except Error (State | .pick p => .ok ({ s with buffer := [], phase := .selected }, [.write (encodeText p.toString), .select p rest]) | .refuse => .ok ({ s with buffer := rest }, [.write (encodeMsg (Reply.toMsg .na))]) - | .advance => .ok ({ s with buffer := rest, phase := .awaitingAnswer }, []) + | .advance => + match s.role with + | .initiator => .ok ({ s with buffer := rest, phase := .awaitingAnswer }, []) + | .responder => .ok ({ s with buffer := rest }, []) | .retry q ps => .ok ({ s with buffer := rest, proposals := ps }, [.write (encodeText q.toString)]) | .accept p => .ok ({ s with buffer := [], phase := .selected }, [.select p rest]) | .fail => .error .negotiationFailed @@ -202,8 +205,10 @@ private theorem actSelect {s : State} {rest : List Nat} {v : Verdict} {s' : Stat (h : act s rest v = .ok (s', acts)) (hm : Action.select p r ∈ acts) : v = .pick p ∨ v = .accept p := by cases v <;> simp only [act] at h - <;> ((try simp only [Except.ok.injEq, Prod.mk.injEq] at h); - (try obtain ⟨-, rfl⟩ := h); (try simp_all)) + <;> ((try split at h) + <;> (try simp only [Except.ok.injEq, Prod.mk.injEq] at h) + <;> (try obtain ⟨-, rfl⟩ := h) + <;> (try simp_all)) /-- A pick comes from the responder's supported list. -/ private theorem classifyPick {s : State} {t : String} {p : Mock.Core.ProtocolId} @@ -302,9 +307,8 @@ private theorem actWriteVerdict {s : State} {rest : List Nat} {v : Verdict} {s' | retry q ps => exact Or.inr (Or.inr (Or.inr (Or.inr ⟨q, ps, rfl⟩))) | advance => simp only [act] at h - simp only [Except.ok.injEq, Prod.mk.injEq] at h - obtain ⟨-, rfl⟩ := h - simp at hw + split at h <;> simp only [Except.ok.injEq, Prod.mk.injEq] at h + all_goals (obtain ⟨-, rfl⟩ := h; simp at hw) | accept p => simp only [act] at h simp only [Except.ok.injEq, Prod.mk.injEq] at h diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean index 78ace6db..6358bbbb 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean @@ -129,9 +129,24 @@ def initiatorStart (ids : List Mock.Core.ProtocolId) : match ids with | [] => .error .negotiationFailed | p :: _ => - .ok ({ role := .initiator, supported := [], proposals := ids, buffer := [], + if ids.any (fun q => !fitsText q.toString) then .error .negotiationFailed + else + .ok ({ role := .initiator, supported := [], proposals := ids, buffer := [], + phase := .negotiating }, + [encodeMsg header ++ encodeText p.toString]) + +/-- The responder's start: validate the offered list, then send the header +at once. The spec lets either side send first, and the responder that waits +for the dialer's header stalls a dialer that also waits. The state starts in +`negotiating` because the header is already on the wire. -/ +def responderStart (ids : List Mock.Core.ProtocolId) : + Except Error (State × List (List Nat)) := + if ids.any (fun q => !fitsText q.toString) then .error .negotiationFailed + else if maxMsg < (lsBody ids).length then .error .negotiationFailed + else + .ok ({ role := .responder, supported := ids, proposals := [], buffer := [], phase := .negotiating }, - [encodeMsg header ++ encodeText p.toString]) + [encodeMsg header]) /-! ## The error mappings -/ @@ -333,11 +348,16 @@ theorem drive_twostage {fuel₁ fuel₂ : Nat} {s₁ s₂ : State} step for a singleton list, after both sides are read as write lists. The driver therefore starts the negotiation the way the proved core would. -/ -theorem initiatorStart_propose {p : Mock.Core.ProtocolId} : +theorem initiatorStart_propose {p : Mock.Core.ProtocolId} + (hfits : fitsText p.toString = true) : initiatorStart [p] = (step { role := .initiator, supported := [], proposals := [], buffer := [], phase := .headerPending } (.propose p)).map - (fun sa => (sa.1, writesOf sa.2)) := rfl + (fun sa => (sa.1, writesOf sa.2)) := by + have hany : ([p].any (fun q => !fitsText q.toString)) = false := by + simp [List.any, hfits] + simp only [initiatorStart, hany] + rfl /-- An empty ordered list is a protocol error before any byte is written. -/ @@ -441,10 +461,11 @@ def negotiate (role : Mock.Core.Role) (reader : Mock.Streams.BufReader) sendWrites conn first negotiateLoop reader conn s (Bytes.toNatList seed) | .responder => - negotiateLoop reader conn - { role := .responder, supported := ids, proposals := [], buffer := [], - phase := .headerPending } - (Bytes.toNatList seed) + match responderStart ids with + | .error e => throw (mapNegotiateError e) + | .ok (s, first) => do + sendWrites conn first + negotiateLoop reader conn s (Bytes.toNatList seed) /-- The pipeline: negotiate the security protocol, run the handshake, check the identity, negotiate the muxer, open the session. Each lookup @@ -477,10 +498,26 @@ private def upgradeCore (role : Mock.Core.Role) (conn : Mock.Streams.Connection) | .error e => return .error e | .ok session => return .ok (session, sc.remotePeer) +/-- Whether the deadline boundary already closed the connection. Both error +arms of `withinDeadline` run the expiry hook. -/ +private def boundaryClosed {α : Type} : Except Conc.Error α → Bool + | .error _ => true + | .ok _ => false + +/-- Give a late session back to its owner. A result that arrives after the +timer won never reaches a caller, so the release closes it here. -/ +private def releaseLate (r : Except Mock.Core.Error + (Mock.Core.MuxSession × Mock.Identity.PeerId)) : BaseIO Unit := + match r with + | .ok pair => pair.1.close + | .error _ => pure () + /-- The upgrade under one `Conc.withinDeadline`. The expiry closes the connection once at the boundary (root §3.5), and the race's `deadlineExpired` maps to the `timeout` arm through `settleUpgrade`. The clock is a seam, so the deadline test can advance it. -/ + + def upgradeWith (clock : Conc.Clock) (role : Mock.Core.Role) (conn : Mock.Streams.Connection) (leftover : ByteArray) (expected : Option Mock.Identity.PeerId) @@ -488,11 +525,16 @@ def upgradeWith (clock : Conc.Clock) (role : Mock.Core.Role) ExceptT Mock.Core.Error Conc.Async (Mock.Core.MuxSession × Mock.Identity.PeerId) := do let answer ← Conc.withinDeadline clock Mock.Streams.upgradeDeadlineMs conn.close (upgradeCore role conn leftover expected secs muxers) + releaseLate match settleUpgrade answer with | .ok pair => return pair | .error e => do - conn.close - throw e + -- Both error arms of the boundary run the expiry hook, which closed the + -- connection once. Only a failure inside the work leaves it open. + if boundaryClosed answer then throw e + else do + conn.close + throw e /-- The frozen entry of the package docs. The clock is fixed to the system clock here and stays a seam in `upgradeWith` for the deadline From d1a8513edbe4547dd0c6afcb91883b02f0a448ee Mon Sep 17 00:00:00 2001 From: Wenbo Date: Tue, 3 Nov 2026 16:34:50 +0800 Subject: [PATCH 015/125] style(negotiate): clear the naming and prose debt the review listed --- .../negotiate/Libp2p/Negotiate/Message.lean | 2 +- .../Libp2p/Negotiate/Mock/Varint.lean | 4 +- .../negotiate/Libp2p/Negotiate/Step.lean | 58 ++++++++--------- .../negotiate/Libp2p/Negotiate/Upgrade.lean | 62 +++++++++---------- .../libp2p/negotiate/docs/ARCHITECTURE.md | 3 + 5 files changed, 66 insertions(+), 63 deletions(-) diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean index 3d35b9eb..ca58e874 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean @@ -40,7 +40,7 @@ def encodeText (s : String) : List Nat := /-- A multistream message. `text` excludes the trailing newline. `fits` is the proof-carrying cap fact of root §3.7: the message fits -the 1 KiB cap, so the encoder writes the exact length, no modulo. -/ +the 1 KiB cap, so the encoder writes the exact length. -/ structure Msg where text : String fits : (Bytes.ofString (text ++ "\n")).size ≤ maxMsg diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean index 3a088982..eac8ec84 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean @@ -6,8 +6,8 @@ import Bytes W2.1 is not merged. The executable opaque definitions follow its documented nine-byte minimal-form contract. The three axioms below are -dependency contracts, not negotiate results. They must disappear with -this mock. This module mirrors `Proto/Mock/Varint.lean`. +dependency contracts. They state what W2.1 promises, and they must +disappear with this mock. This module mirrors `Proto/Mock/Varint.lean`. -/ set_option autoImplicit false diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean index 991ba4dc..ca63fdcf 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean @@ -9,7 +9,7 @@ import Bytes multistream-select (root §3.3). One `Event.bytes` feeds raw bytes, and the core decodes exactly one frame per call, so the malformed-versus-incomplete call stays in the core (§3.1). A decoded -frame goes through a private `classify` step that names the verdict, +frame goes through a private `classify` step that names the answer, then a private `act` step that performs it. Every theorem about choice lives in `classify`; every theorem about writes and phase lives in `act`. @@ -104,7 +104,7 @@ error; `negotiationFailed` covers every peer violation the core sees. -/ inductive Error where | decode (e : DecodeError) | negotiationFailed - deriving Repr, DecidableEq + deriving Repr, DecidableEq, Hashable /-- The pure negotiation core of the spike: the first of `ours` that `theirs` also supports. -/ @@ -112,16 +112,16 @@ def choose (ours theirs : List Mock.Core.ProtocolId) : Option Mock.Core.Protocol List.find? theirs.contains ours /-- A chosen id is in our list and supported by theirs. -/ -theorem choose_spec {ours theirs : List Mock.Core.ProtocolId} {p : Mock.Core.ProtocolId} +theorem chooseSpec {ours theirs : List Mock.Core.ProtocolId} {p : Mock.Core.ProtocolId} (h : choose ours theirs = some p) : p ∈ ours ∧ theirs.contains p = true := by unfold choose at h exact ⟨List.mem_of_find?_eq_some h, List.find?_some h⟩ -/-! ## The verdicts -/ +/-! ## The answers -/ /-- What a decoded text means for this state. Byte-free, so every theorem about choice lives here. -/ -private inductive Verdict where +private inductive Answer where | echo | listIds | pick (p : Mock.Core.ProtocolId) @@ -132,7 +132,7 @@ private inductive Verdict where | fail /-- Classify a decoded text for a state. -/ -private def classify (s : State) (text : String) : Verdict := +private def classify (s : State) (text : String) : Answer := match s.role, s.phase with | .responder, .headerPending => if text = protocolId.toString then .echo else .fail | .responder, .negotiating => @@ -154,9 +154,9 @@ private def classify (s : State) (text : String) : Verdict := else if text = p.toString then .accept p else .fail | _, _ => .fail -/-- Perform a verdict. Byte-free per verdict, so every theorem about +/-- Perform a answer. Byte-free per answer, so every theorem about writes and phase lives here. -/ -private def act (s : State) (rest : List Nat) : Verdict → Except Error (State × List Action) +private def act (s : State) (rest : List Nat) : Answer → Except Error (State × List Action) | .echo => .ok ({ s with buffer := rest, phase := .negotiating }, [.write (encodeText protocolId.toString)]) | .listIds => .ok ({ s with buffer := rest }, [.write (encodeLs s.supported)]) | .pick p => .ok ({ s with buffer := [], phase := .selected }, @@ -197,10 +197,10 @@ def step (s : State) : Event → Except Error (State × List Action) | .error .malformed => .error (.decode .malformed) | .ok (m, rest) => act s rest (classify s m.text) -/-! ## The verdict lemmas -/ +/-! ## The answer lemmas -/ -/-- A step that selects came from the pick or accept verdict. -/ -private theorem actSelect {s : State} {rest : List Nat} {v : Verdict} {s' : State} +/-- A step that selects came from the pick or accept answer. -/ +private theorem actSelect {s : State} {rest : List Nat} {v : Answer} {s' : State} {acts : List Action} {p : Mock.Core.ProtocolId} {r : List Nat} (h : act s rest v = .ok (s', acts)) (hm : Action.select p r ∈ acts) : v = .pick p ∨ v = .accept p := by @@ -245,28 +245,28 @@ private theorem classifyPickInitiator {s : State} {t : String} {p : Mock.Core.Pr repeat' split at h all_goals simp_all -/-- An initiator never sees the echo verdict. -/ +/-- An initiator never sees the echo answer. -/ private theorem classifyEchoInitiator {s : State} {t : String} (hr : s.role = .initiator) (h : classify s t = .echo) : False := by unfold classify at h repeat' split at h all_goals simp_all -/-- An initiator never sees the list verdict. -/ +/-- An initiator never sees the list answer. -/ private theorem classifyListIdsInitiator {s : State} {t : String} (hr : s.role = .initiator) (h : classify s t = .listIds) : False := by unfold classify at h repeat' split at h all_goals simp_all -/-- An initiator never sees the refuse verdict. -/ +/-- An initiator never sees the refuse answer. -/ private theorem classifyRefuseInitiator {s : State} {t : String} (hr : s.role = .initiator) (h : classify s t = .refuse) : False := by unfold classify at h repeat' split at h all_goals simp_all -/-- The `ls` text in the negotiation draws the list verdict. -/ +/-- The `ls` text in the negotiation draws the list answer. -/ private theorem classifyListIds {s : State} {t : String} (hrole : s.role = .responder) (hphase : s.phase = .negotiating) (hls : Reply.ofText? t = some .ls) : classify s t = .listIds := by @@ -274,7 +274,7 @@ private theorem classifyListIds {s : State} {t : String} rw [hrole, hphase] rw [hls] -/-- An unknown id in the negotiation draws the refuse verdict. -/ +/-- An unknown id in the negotiation draws the refuse answer. -/ private theorem classifyRefuse {s : State} {t : String} (hrole : s.role = .responder) (hphase : s.phase = .negotiating) (hnot : (⟨t⟩ : Mock.Core.ProtocolId) ∉ s.supported) @@ -293,8 +293,8 @@ private theorem classifyRetryTail {s : State} {t : String} {q : Mock.Core.Protoc repeat' split at h all_goals (cases h <;> simp_all [List.tail_cons]) -/-- A write came from one of the five writing verdicts. -/ -private theorem actWriteVerdict {s : State} {rest : List Nat} {v : Verdict} {s' : State} +/-- A write came from one of the five writing answers. -/ +private theorem actWriteAnswer {s : State} {rest : List Nat} {v : Answer} {s' : State} {actions : List Action} {w : List Nat} (h : act s rest v = .ok (s', actions)) (hw : Action.write w ∈ actions) : v = .echo ∨ v = .listIds ∨ (∃ p, v = .pick p) ∨ v = .refuse ∨ @@ -318,7 +318,7 @@ private theorem actWriteVerdict {s : State} {rest : List Nat} {v : Verdict} {s' simp only [act] at h cases h -/-- The list verdict writes the `ls` reply and stays in the phase. -/ +/-- The list answer writes the `ls` reply and stays in the phase. -/ private theorem actListIds {s : State} {rest : List Nat} {s' : State} {actions : List Action} (h : act s rest .listIds = .ok (s', actions)) : actions = [.write (encodeLs s.supported)] ∧ s'.phase = s.phase ∧ s'.buffer = rest := by @@ -329,7 +329,7 @@ private theorem actListIds {s : State} {rest : List Nat} {s' : State} {actions : rw [← hstate] simp -/-- The refuse verdict writes the `na` frame and stays in the phase. -/ +/-- The refuse answer writes the `na` frame and stays in the phase. -/ private theorem actRefuse {s : State} {rest : List Nat} {s' : State} {actions : List Action} (h : act s rest .refuse = .ok (s', actions)) : actions = [.write (encodeMsg (Reply.toMsg .na))] ∧ s'.phase = s.phase ∧ s'.buffer = rest := by @@ -340,7 +340,7 @@ private theorem actRefuse {s : State} {rest : List Nat} {s' : State} {actions : rw [← hstate] simp -/-- The retry verdict replaces the proposals with the remaining list. -/ +/-- The retry answer replaces the proposals with the remaining list. -/ private theorem actRetryWrites {s : State} {rest : List Nat} {q : Mock.Core.ProtocolId} {ps : List Mock.Core.ProtocolId} {s' : State} {actions : List Action} (h : act s rest (.retry q ps) = .ok (s', actions)) : s'.proposals = ps := by @@ -352,7 +352,7 @@ private theorem actRetryWrites {s : State} {rest : List Nat} {q : Mock.Core.Prot /-! ## The step theorems -/ /-- A responder selects only from its supported list (contract 2). -/ -theorem step_select_responder_mem {s : State} {bs : List Nat} {s' : State} +theorem stepSelectResponderMem {s : State} {bs : List Nat} {s' : State} {actions : List Action} {p : Mock.Core.ProtocolId} {rest : List Nat} (hrole : s.role = .responder) (hstep : step s (.bytes bs) = .ok (s', actions)) @@ -368,7 +368,7 @@ theorem step_select_responder_mem {s : State} {bs : List Nat} {s' : State} · exact (classifyAcceptResponder hrole hv).elim /-- An initiator selects only from its own proposal list. -/ -theorem step_select_initiator_mem {s : State} {bs : List Nat} {s' : State} +theorem stepSelectInitiatorMem {s : State} {bs : List Nat} {s' : State} {actions : List Action} {p : Mock.Core.ProtocolId} {rest : List Nat} (hrole : s.role = .initiator) (hstep : step s (.bytes bs) = .ok (s', actions)) @@ -386,7 +386,7 @@ theorem step_select_initiator_mem {s : State} {bs : List Nat} {s' : State} /-- A step that writes advances the initiator's proposal list by one head: the new list is the old tail, so it is strictly shorter (contract 2, the `na` retry). -/ -theorem step_initiator_na_progress {s : State} {bs : List Nat} {s' : State} +theorem stepInitiatorNaProgress {s : State} {bs : List Nat} {s' : State} {actions : List Action} {p : Mock.Core.ProtocolId} {ps : List Mock.Core.ProtocolId} {w : List Nat} (hrole : s.role = .initiator) (hprop : s.proposals = p :: ps) @@ -399,7 +399,7 @@ theorem step_initiator_na_progress {s : State} {bs : List Nat} {s' : State} obtain ⟨-, rfl⟩ := hstep simp at hwrite · simp at hstep - · rcases actWriteVerdict hstep hwrite with hc | hc | hc | hc | hc + · rcases actWriteAnswer hstep hwrite with hc | hc | hc | hc | hc · exact (classifyEchoInitiator hrole hc).elim · exact (classifyListIdsInitiator hrole hc).elim · rcases hc with ⟨p', hc⟩ @@ -420,7 +420,7 @@ theorem step_initiator_na_progress {s : State} {bs : List Nat} {s' : State} /-- The `ls` answer writes the reply and stays in the negotiation (contract 3). -/ -theorem step_responder_ls_stays {s : State} {bs : List Nat} {s' : State} +theorem stepResponderLsStays {s : State} {bs : List Nat} {s' : State} {actions : List Action} {m : Msg} {rest : List Nat} (hrole : s.role = .responder) (hphase : s.phase = .negotiating) (hdec : decodeMsg? (s.buffer ++ bs) = .ok (m, rest)) @@ -441,7 +441,7 @@ theorem step_responder_ls_stays {s : State} {bs : List Nat} {s' : State} /-- An unknown id draws the `na` frame and stays in the negotiation (contract 2). -/ -theorem step_responder_unknown_stays {s : State} {bs : List Nat} {s' : State} +theorem stepResponderUnknownStays {s : State} {bs : List Nat} {s' : State} {actions : List Action} {m : Msg} {rest : List Nat} {id : Mock.Core.ProtocolId} (hrole : s.role = .responder) (hphase : s.phase = .negotiating) (hdec : decodeMsg? (s.buffer ++ bs) = .ok (m, rest)) @@ -468,7 +468,7 @@ theorem step_responder_unknown_stays {s : State} {bs : List Nat} {s' : State} /-- Conservation: the bytes the accepting initiator consumed, plus the leftover, equal the bytes fed in (contract 5). -/ -theorem step_select_conservation {s : State} {bs : List Nat} {s' : State} +theorem stepSelectConservation {s : State} {bs : List Nat} {s' : State} {p : Mock.Core.ProtocolId} {rest r : List Nat} {m : Msg} (hrole : s.role = .initiator) (hdec : decodeMsg? (s.buffer ++ bs) = .ok (m, r)) @@ -495,7 +495,7 @@ theorem step_select_conservation {s : State} {bs : List Nat} {s' : State} /-- A truncated buffer waits: no actions, no error, the phase holds, and the buffer keeps the bytes (§3.1). -/ -theorem step_truncated_waits {s : State} {bs : List Nat} {s' : State} {actions : List Action} +theorem stepTruncatedWaits {s : State} {bs : List Nat} {s' : State} {actions : List Action} (hdec : decodeMsg? (s.buffer ++ bs) = .error .truncated) (hstep : step s (.bytes bs) = .ok (s', actions)) : actions = [] ∧ s'.phase = s.phase ∧ s'.buffer = s.buffer ++ bs := by diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean index 6358bbbb..2897d860 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean @@ -20,7 +20,7 @@ three-stage pipeline under one `Conc.withinDeadline`. The driver owns the byte cursor. It never reads `s.buffer`; it resets the buffer before every `step` call, and every unconsumed byte stays in the `input` argument. The `leftover` field of the result is -authoritative (plan.md, "Deviations"). The shell always passes the +authoritative. The shell always passes the driver's own states and leftovers back, so the convention is closed under use. @@ -112,8 +112,8 @@ private def driveAux : Nat → State → List Nat → List Nat → List (List Na driveAux fuel s' rest (consumed ++ encodeMsg m) (writes ++ writesOf actions) -/-- Drive the pure core over a byte cursor. `s.buffer` is a -don't-care: pass every unconsumed byte in `input`, and read the +/-- Drive the pure core over a byte cursor. `s.buffer` is +unused: pass every unconsumed byte in `input`, and read the `leftover` field of the result as authoritative. One fuel unit consumes at most one frame. -/ def drive (fuel : Nat) (s : State) (input : List Nat) : Except Error DriveResult := @@ -235,28 +235,28 @@ private theorem driveAux_conservation (fuel : Nat) : exact hrec /-- Every byte fed to the driver is consumed or left over. -/ -theorem drive_conservation {fuel : Nat} {s : State} {input : List Nat} {r : DriveResult} +theorem driveConservation {fuel : Nat} {s : State} {input : List Nat} {r : DriveResult} (h : drive fuel s input = .ok r) : input = r.consumed ++ r.leftover := by have h' := driveAux_conservation fuel s input [] [] r h simpa using h' -/-- U2. The selected arm: the bytes in equal the frames consumed plus +/-- The selected arm: the bytes in equal the frames consumed plus the leftover handed to the next stage (contract 5). -/ -theorem drive_selected_conservation {fuel : Nat} {s : State} {input : List Nat} +theorem driveSelectedConservation {fuel : Nat} {s : State} {input : List Nat} {p : Mock.Core.ProtocolId} {consumed leftover : List Nat} {writes : List (List Nat)} (h : drive fuel s input = .ok (.selected p consumed leftover writes)) : input = consumed ++ leftover := by - simpa using drive_conservation h + simpa using driveConservation h -/-- U3. The truncated arm: every byte the run saw sits in the +/-- The truncated arm: every byte the run saw sits in the consumed frames or in the waiting buffer, so the shell loses no byte when it reads the next chunk. -/ -theorem drive_waiting_conservation {fuel : Nat} {s s' : State} {input : List Nat} +theorem driveWaitingConservation {fuel : Nat} {s s' : State} {input : List Nat} {consumed leftover : List Nat} {writes : List (List Nat)} (h : drive fuel s input = .ok (.waiting s' consumed leftover writes)) : input = consumed ++ leftover := by - simpa using drive_conservation h + simpa using driveConservation h /-- The accumulator prefix invariant: a run appends the frames it consumes to the accumulator it received. -/ @@ -295,7 +295,7 @@ private theorem driveAux_consumed_prefix (fuel : Nat) : /-- A frame the shell writes through `encodeMsg` decodes and appears at the head of the consumed bytes. -/ -theorem drive_consumed_head {fuel : Nat} {s : State} {m : Msg} {rest : List Nat} +theorem driveConsumedHead {fuel : Nat} {s : State} {m : Msg} {rest : List Nat} {r : DriveResult} (h : drive (fuel + 1) s (encodeMsg m ++ rest) = .ok r) : ∃ tail : List Nat, r.consumed = encodeMsg m ++ tail := by @@ -314,10 +314,10 @@ theorem drive_consumed_head {fuel : Nat} {s : State} {m : Msg} {rest : List Nat} driveAux_consumed_prefix fuel s' rest ([] ++ encodeMsg m) (writesOf actions) r h' exact ⟨tail, by simpa [encodeMsg, List.append_assoc] using htail⟩ -/-- U4. Two stages compose: bytes in equal the consumed bytes of both -stages plus the final leftover. The reusable form of the brief's scope +/-- Two stages compose: bytes in equal the consumed bytes of both +stages plus the final leftover. The reusable form of the pipeline's scope item 2. -/ -theorem handover_compose {ε α β : Type} +theorem handoverCompose {ε α β : Type} {f : List Nat → Except ε (α × List Nat)} {g : List Nat → Except ε (β × List Nat)} {c₁ : α → List Nat} {c₂ : β → List Nat} @@ -330,25 +330,25 @@ theorem handover_compose {ε α β : Type} rw [law₁ bs₁ a₁ l₁ h₁, List.append_assoc, law₂ (l₁ ++ bs₂) a₂ l₂ h₂] simp only [List.append_assoc] -/-- U5. The pure form of the hand-over: the muxer stage is seeded with +/-- The pure form of the hand-over: the muxer stage is seeded with the negotiation's leftover, and the two drives consume exactly the bytes fed in. -/ -theorem drive_twostage {fuel₁ fuel₂ : Nat} {s₁ s₂ : State} +theorem driveTwostage {fuel₁ fuel₂ : Nat} {s₁ s₂ : State} {input₁ input₂ : List Nat} {p₁ p₂ : Mock.Core.ProtocolId} {c₁ c₂ l₁ l₂ : List Nat} {w₁ w₂ : List (List Nat)} (h₁ : drive fuel₁ s₁ input₁ = .ok (.selected p₁ c₁ l₁ w₁)) (h₂ : drive fuel₂ s₂ (l₁ ++ input₂) = .ok (.selected p₂ c₂ l₂ w₂)) : input₁ ++ input₂ = c₁ ++ c₂ ++ l₂ := by - have h1 := drive_selected_conservation h₁ - have h2 := drive_selected_conservation h₂ + have h1 := driveSelectedConservation h₁ + have h2 := driveSelectedConservation h₂ rw [h1, List.append_assoc, h2] simp only [List.append_assoc] -/-- U1. The shell's manual first write equals the core's propose +/-- The shell's manual first write equals the core's propose step for a singleton list, after both sides are read as write lists. The driver therefore starts the negotiation the way the proved core would. -/ -theorem initiatorStart_propose {p : Mock.Core.ProtocolId} +theorem initiatorStartPropose {p : Mock.Core.ProtocolId} (hfits : fitsText p.toString = true) : initiatorStart [p] = (step { role := .initiator, supported := [], proposals := [], buffer := [], @@ -361,38 +361,38 @@ theorem initiatorStart_propose {p : Mock.Core.ProtocolId} /-- An empty ordered list is a protocol error before any byte is written. -/ -theorem initiatorStart_nil : initiatorStart [] = .error .negotiationFailed := rfl +theorem initiatorStartNil : initiatorStart [] = .error .negotiationFailed := rfl -/-- U9. The one mapping point of contract 8: every `Negotiate.Error` +/-- The one mapping point of contract 8: every `Negotiate.Error` becomes the `protocol` arm, with the detail rendered. -/ -theorem mapNegotiateError_spec {e : Bytes.DecodeError} : +theorem mapNegotiateErrorSpec {e : Bytes.DecodeError} : mapNegotiateError (.decode e) = .protocol ("multistream decode: " ++ renderDecodeError e) ∧ mapNegotiateError .negotiationFailed = .protocol "multistream negotiation failed" := by simp [mapNegotiateError] -/-- U6. A differing derived id is authentication, before any muxer +/-- A differing derived id is authentication, before any muxer byte moves. -/ -theorem checkIdentity_reject {expected remote : Mock.Identity.PeerId} +theorem checkIdentityReject {expected remote : Mock.Identity.PeerId} (h : expected ≠ remote) : checkIdentity (some expected) remote = .error (.authentication "remote peer id differs from the expected peer id") := by simp [checkIdentity, h] -/-- U7. The dial to the expected peer passes. -/ -theorem checkIdentity_accept {e : Mock.Identity.PeerId} : +/-- The dial to the expected peer passes. -/ +theorem checkIdentityAccept {e : Mock.Identity.PeerId} : checkIdentity (some e) e = .ok () := by simp [checkIdentity] -/-- U8. The listen path accepts any peer. -/ -theorem checkIdentity_none {r : Mock.Identity.PeerId} : +/-- The listen path accepts any peer. -/ +theorem checkIdentityNone {r : Mock.Identity.PeerId} : checkIdentity none r = .ok () := rfl -/-- U10. The deadline wiring of contract 6: the race's expiry is the +/-- The deadline wiring of contract 6: the race's expiry is the `timeout` arm, and the seam errors pass through untouched. The `.closed` arm is required by the match over `Conc.Error`. -/ -theorem settleUpgrade_spec {α : Type} {e : Mock.Core.Error} {a : α} : +theorem settleUpgradeSpec {α : Type} {e : Mock.Core.Error} {a : α} : settleUpgrade (α := α) (.error .deadlineExpired) = .error (.timeout "upgrade deadline expired") ∧ settleUpgrade (α := α) (.error .closed) = diff --git a/packages/libp2p/negotiate/docs/ARCHITECTURE.md b/packages/libp2p/negotiate/docs/ARCHITECTURE.md index 8a2860e6..caa6e247 100644 --- a/packages/libp2p/negotiate/docs/ARCHITECTURE.md +++ b/packages/libp2p/negotiate/docs/ARCHITECTURE.md @@ -25,6 +25,9 @@ owned by `net/streams` and lives in `Streams/Constants.lean` (the streams pair, contracts; the security pair, contract 7). This package imports it and wires it; it keeps no copy. +Until W4.1 merges, `Mock/Streams.lean` holds that value, so the copy lives +in the mock. The swap to `Streams` deletes the mock with it. + ## Public surface - `Msg` is the multistream message: one uvarint length prefix, then From 9a8d34410946b457e2409ddeba28511b2dd2aa0a Mon Sep 17 00:00:00 2001 From: Wenbo Date: Tue, 3 Nov 2026 16:47:14 +0800 Subject: [PATCH 016/125] refactor(negotiate): drop the unreachable proposal branch --- packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean index ca63fdcf..f15e7402 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean @@ -170,18 +170,18 @@ private def act (s : State) (rest : List Nat) : Answer → Except Error (State | .accept p => .ok ({ s with buffer := [], phase := .selected }, [.select p rest]) | .fail => .error .negotiationFailed -/-- A proposal event. An initiator in the header phase records the id in -`proposals` and writes the coalesced header plus the first proposal frame. A -second proposal in the same phase extends the list and writes nothing. The -frame for every later id is written when `step` answers `na`, and `classify` -derives that answer from the recorded list. -/ +/-- A proposal event. An initiator in the header phase with an empty list +records the id and writes the coalesced header plus the first proposal frame. +Any other proposal event leaves the state unchanged: `initiatorStart` seeds +the ordered list before the first frame moves, and the `na` retry walks that +list from `classify`. -/ private def propose (s : State) (p : Mock.Core.ProtocolId) : Except Error (State × List Action) := if s.role = .initiator then if s.phase = .headerPending then if s.proposals = [] then .ok ({ s with proposals := [p], phase := .negotiating }, [.write (encodeMsg header ++ encodeText p.toString)]) - else .ok ({ s with proposals := s.proposals ++ [p] }, []) + else .ok (s, []) else .ok (s, []) else .ok (s, []) From 7d59469f5bb239e641d2d49f6d67aab41d42f5ea Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Tue, 3 Nov 2026 18:37:33 +0700 Subject: [PATCH 017/125] docs(negotiate): correct the merged-dependency claims and two prose nits --- Tests/Negotiate.lean | 2 +- Tests/Upgrade.lean | 3 ++- .../negotiate/Libp2p/Negotiate/Mock/Streams.lean | 9 +++++---- .../libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean | 10 ++++++---- packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean | 2 +- .../libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean | 1 - packages/libp2p/negotiate/docs/ARCHITECTURE.md | 5 +++-- 7 files changed, 18 insertions(+), 14 deletions(-) diff --git a/Tests/Negotiate.lean b/Tests/Negotiate.lean index 5d013488..360d4a38 100644 --- a/Tests/Negotiate.lean +++ b/Tests/Negotiate.lean @@ -253,6 +253,6 @@ def run (r : Runner) : IO Unit := do r.check "mss generic round-trip harness" (Bytes.checkRoundTrips samples == true) r.skip "mss generic model pin" - "needs the ByteArray adapter (stretch S1, plan section 7); lands with unit 1b" + "needs the ByteArray adapter, a stretch item this pull request defers; the model decoder is the production path today" end Tests.Negotiate diff --git a/Tests/Upgrade.lean b/Tests/Upgrade.lean index e766d5b2..210769df 100644 --- a/Tests/Upgrade.lean +++ b/Tests/Upgrade.lean @@ -8,7 +8,8 @@ import Bytes The test milestones of the negotiate package plan, unit 1b. The full pipeline runs both directions over an in-process pipe pair, which -replaces the sim transport of streams (#32, unmerged). The fixed-key +stands in for the sim transport of streams until the mock drop (#32 +has merged; the swap is owed, ledger issue #25). The fixed-key plaintext security value and the minimal muxer value replace the Noise and yamux milestones (#16, #17, unmerged). The deadline check advances the test `Clock` of `base/conc`, so the suite waits on no wall clock. diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Streams.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Streams.lean index 7ac4bd6c..149a54de 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Streams.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Streams.lean @@ -3,11 +3,12 @@ import Conc /-! # Streams mocks for unit 1b -The streams seam types live in `net/streams` (#32, not merged yet). -This module holds `Connection`, `BufReader`, and `Stream` verbatim from -the streams ARCHITECTURE "Public surface", and the one constant the +The streams package has merged (#32), and this module stands until the +integration swap (the mock-substitution ledger, issue #25). +This module holds `Connection`, `BufReader`, and `Stream` from the +streams ARCHITECTURE "Public surface", and the one constant the upgrader wires, so the upgrader compiles against the frozen shapes. -The imports swap to `Streams` when the streams package lands. +The imports swap to `Streams` at the integration. `Connection` omits `localAddr` and `remoteAddr`. The upgrader never reads an address, and a `Multiaddr` import would break the package's diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean index eac8ec84..20899111 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean @@ -4,10 +4,12 @@ import Bytes /-! # Temporary unsigned-varint interface -W2.1 is not merged. The executable opaque definitions follow its -documented nine-byte minimal-form contract. The three axioms below are -dependency contracts. They state what W2.1 promises, and they must -disappear with this mock. This module mirrors `Proto/Mock/Varint.lean`. +W2.1 has merged (#34), and this module stands until the integration +swap (the mock-substitution ledger, issue #25). The executable opaque +definitions follow its documented nine-byte minimal-form contract. The +three axioms below are dependency contracts: the merged package proves +each one as a theorem, and they disappear with this mock. This module +mirrors `Proto/Mock/Varint.lean`. -/ set_option autoImplicit false diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean index f15e7402..75626074 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean @@ -154,7 +154,7 @@ private def classify (s : State) (text : String) : Answer := else if text = p.toString then .accept p else .fail | _, _ => .fail -/-- Perform a answer. Byte-free per answer, so every theorem about +/-- Perform an answer. Byte-free per answer, so every theorem about writes and phase lives here. -/ private def act (s : State) (rest : List Nat) : Answer → Except Error (State × List Action) | .echo => .ok ({ s with buffer := rest, phase := .negotiating }, [.write (encodeText protocolId.toString)]) diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean index 2897d860..f0abc592 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean @@ -517,7 +517,6 @@ the connection once at the boundary (root §3.5), and the race's `deadlineExpired` maps to the `timeout` arm through `settleUpgrade`. The clock is a seam, so the deadline test can advance it. -/ - def upgradeWith (clock : Conc.Clock) (role : Mock.Core.Role) (conn : Mock.Streams.Connection) (leftover : ByteArray) (expected : Option Mock.Identity.PeerId) diff --git a/packages/libp2p/negotiate/docs/ARCHITECTURE.md b/packages/libp2p/negotiate/docs/ARCHITECTURE.md index caa6e247..e99dc781 100644 --- a/packages/libp2p/negotiate/docs/ARCHITECTURE.md +++ b/packages/libp2p/negotiate/docs/ARCHITECTURE.md @@ -25,8 +25,9 @@ owned by `net/streams` and lives in `Streams/Constants.lean` (the streams pair, contracts; the security pair, contract 7). This package imports it and wires it; it keeps no copy. -Until W4.1 merges, `Mock/Streams.lean` holds that value, so the copy lives -in the mock. The swap to `Streams` deletes the mock with it. +W4.1 has merged. Until the integration drops `Mock/Streams.lean`, the +mock holds that value, so the copy lives there. The swap deletes the +mock with it. ## Public surface From 6378f3e11d743463b95a67b08b40845a5b792964 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:34:38 +0800 Subject: [PATCH 018/125] test(negotiate): run the two-party upgrade over the splitting sim The done-when of #18 names the byte-split two-party runs over the sim. The suite ran both parties over pipe pairs, and every write arrived as one chunk, so a reader that depended on write boundaries could not fail in the suite. The streams package merged as #32 and its sim takes `splitWrites`, so the runs use the real sim: every write arrives one byte at a time. The suite adapts the sim connection to the mock shape the upgrader takes until the mock drop (#25). A chunk-agnostic handshake reads the fixed-key marker with a read loop, so the split transport is legal for the whole pipeline. Six checks: the negotiation in both roles, the `na` retry, the size of the responder's chunks, and the full pipeline in both directions with the muxer selected on the wire. The tests change no package code and no mock. Gates on this head: `lake build --wfail` 96 jobs; 7760 passed and 0 failed under the default run and under `+RTS -N1`; 3 skipped; the import lint checked 65 module files with 0 violations. --- Tests/Upgrade.lean | 166 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/Tests/Upgrade.lean b/Tests/Upgrade.lean index 210769df..82939445 100644 --- a/Tests/Upgrade.lean +++ b/Tests/Upgrade.lean @@ -2,6 +2,8 @@ import Tests.Support import Libp2p.Negotiate import Conc import Bytes +import Streams +import Streams.Mock.Multiaddr /-! # The upgrade suite (unit 1b) @@ -652,6 +654,168 @@ private def checkResponderFirst (r : Runner) : Async Unit := do (ws == [encodeMsg header] && s.phase = .negotiating) | .error _ => r.check "negotiate responder sends its header first" false +/-! ## The byte-split two-party runs over the sim -/ + +/-- The mock-shaped view of a sim connection. A sim write throws +`IO.Error`; the mock seam reports the same failure through its `Except` +channel, which is the one the upgrader matches. -/ +private def simConn (c : Streams.Connection) : Mock.Streams.Connection where + read := c.read + write := fun b => do + let wrote ← try + c.write b + pure true + catch _ => pure false + if wrote then return .ok () else return .error .closed + close := discard c.close.toBaseIO + +/-- Record the size of every chunk that a connection's reader answers. +The split checks read this to show that the sim delivered one byte at a +time. -/ +private def recordReadSizes (conn : Mock.Streams.Connection) (sizes : Conc.OnceRef (List Nat)) : + Mock.Streams.Connection where + read := do + match ← conn.read with + | none => return none + | some chunk => do + sizes.update fun l => chunk.size :: l + return (some chunk) + write := conn.write + close := conn.close + +/-- A multiaddr from its text form. -/ +private def simAddr (text : String) : Async Streams.Mock.Multiformats.Multiaddr := do + match Streams.Mock.Multiformats.Multiaddr.parse? text with + | .ok a => return a + | .error _ => throw <| IO.userError s!"test address {text} does not parse" + +/-- A connected pair over a fresh sim net. `split` makes every write of +the net arrive one byte at a time. -/ +private def simPair (split : Bool) : Async (Mock.Streams.Connection × Mock.Streams.Connection) := do + let net ← Streams.Sim.Net.new split + let l ← net.transport.listen (← simAddr "/ip4/127.0.0.1/tcp/4101") + let some dialer ← net.transport.dial l.addr | throw <| IO.userError "dial failed" + let some listener ← l.accept | throw <| IO.userError "accept failed" + l.close + return (simConn dialer, simConn listener) + +/-- Read exactly `n` bytes, from the seed first and then from the +connection. A peer that closes early answers `none`. Under the split +sim the marker arrives one byte per chunk, so the split handshake reads +it here. -/ +private def readExactly (conn : Mock.Streams.Connection) (seed : ByteArray) (n : Nat) : + Async (Option (List Nat)) := do + let mut acc := Bytes.toNatList seed + for _ in [0:n] do + if acc.length < n then + match ← conn.read with + | none => return none + | some chunk => acc := acc ++ Bytes.toNatList chunk + return some acc + +/-- The fixed-key test handshake, chunk-agnostic: it reads its marker with +`readExactly`, so a split write arrives in whatever chunks the transport +chose. It replaces the Noise milestone (#16, unmerged) as `secValue` +does, and it exists so the pipeline can run over the splitting sim. -/ +private def secValueExact (seedRef : Conc.OnceRef (Option ByteArray)) : + Mock.Core.SecurityProtocol where + id := ⟨"/test/plain/1.0.0"⟩ + handshake := fun role conn seed => do + seedRef.set (some seed) + let myMark := if role = .initiator then markerI else markerR + match ← conn.write myMark with + | .error _ => throw (.closed "test handshake write failed") + | .ok () => pure () + match ← readExactly conn seed 4 with + | none => throw (.closed "test handshake read ended before the marker") + | some four => + return { conn := conn, + localPeer := if role = .initiator then dialerId else listenerId, + remotePeer := if role = .initiator then listenerId else dialerId, + leftover := Bytes.ofNatList (four.drop 4), + close := conn.close } + +/-- The multistream exchange of both roles over a splitting sim, for a +supported id and for the `na` retry. Each run also asserts that the +responder's reader saw one byte per chunk, which shows the sim split the +writes. -/ +private def checkSplitNegotiate (r : Runner) : Async Unit := do + let kadId : Mock.Core.ProtocolId := ⟨"/ipfs/kad/1.0.0"⟩ + let idId : Mock.Core.ProtocolId := ⟨"/ipfs/id/1.0.0"⟩ + let runOne (name : String) (iIds rIds : List Mock.Core.ProtocolId) + (expect : Mock.Core.ProtocolId) : Async Unit := do + let (ic, rc) ← simPair true + let sizes : Conc.OnceRef (List Nat) ← Conc.OnceRef.new [] + let split := recordReadSizes rc sizes + let iReader := Mock.Streams.BufReader.ofConn ic + let rReader := Mock.Streams.BufReader.ofConn split + let iAnswer : Conc.OnceRef (Option (Except Mock.Core.Error StageResult)) ← + Conc.OnceRef.new none + let rAnswer : Conc.OnceRef (Option (Except Mock.Core.Error StageResult)) ← + Conc.OnceRef.new none + let tasks ← Conc.TaskSet.new + tasks.spawn do + iAnswer.set (some (← negotiate .initiator iReader ic ByteArray.empty iIds)) + tasks.spawn do + rAnswer.set (some (← negotiate .responder rReader split ByteArray.empty rIds)) + tasks.joinAll + let iAns ← iAnswer.get + let rAns ← rAnswer.get + let both := match iAns, rAns with + | some (.ok i), some (.ok rr) => i.proto == expect && rr.proto == expect + | _, _ => false + let seen ← sizes.get + r.check name both + r.check s!"{name}, one byte per chunk" + (!seen.isEmpty && seen.all (fun n => decide (n ≤ 1))) + ic.close + rc.close + runOne "split sim negotiation selects the id in both roles" [kadId] [kadId] kadId + runOne "split sim negotiation walks the na retry" [kadId, idId] [idId] idId + +/-- The whole pipeline over a splitting sim, both directions: the +negotiation, the handshake, the identity check, the muxer negotiation, +and the open. This is the done-when run of the issue, over the sim. +-/ +private def checkSplitPipeline (r : Runner) : Async Unit := do + let (ic, rc) ← simPair true + let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let dSeed : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let lSeed : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let dRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false + let dLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let lLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let dOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let lOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none + let tasks ← Conc.TaskSet.new + tasks.spawn do + dAnswer.set (some (← upgrade .initiator ic ByteArray.empty (some listenerId) + [secValueExact dSeed] [muxValue muxId dRan dLeft dOpen])) + tasks.spawn do + lAnswer.set (some (← upgrade .responder rc ByteArray.empty none + [secValueExact lSeed] [muxValue muxId lRan lLeft lOpen])) + tasks.joinAll + let d ← dAnswer.get + let l ← lAnswer.get + match d, l with + | some (.ok (ds, dp)), some (.ok (ls, lp)) => do + ds.close + ls.close + ds.join + ls.join + r.check "split sim upgrade pipeline, dial and listen" + (dp == listenerId && lp == dialerId) + r.check "split sim upgrade selects the muxer on the wire" + ((← dOpen.get) == some muxId && (← lOpen.get) == some muxId) + | _, _ => do + r.check "split sim upgrade pipeline, dial and listen" false + r.check "split sim upgrade selects the muxer on the wire" false + ic.close + rc.close + /-! ## The suite -/ /-- The suite. -/ @@ -674,6 +838,8 @@ def run (r : Runner) : IO Unit := do checkSendCap r checkResponderFirst r checkGoTraces r + checkSplitNegotiate r + checkSplitPipeline r Conc.Async.run all r.skip "upgrade noise yamux matrix over tcp" "needs the TCP transport, the Noise value, and the yamux value; none are merged on this base (the W5.9 live gate)" From 0699717563cc625277e1eaf23e747d0607e2249f Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Thu, 5 Nov 2026 17:21:45 +0700 Subject: [PATCH 019/125] docs(protocols): state the stored-and-unread window in the score params The TopicParams docstring said the mesh delivery window feeds the core's delivery accounting. The core stores the window and never reads it, and every accepted message from a mesh peer counts toward P3. The docstring now says that, and Core.lean keeps the record. Two blank-line nits ride along. --- .../libp2p/protocols/Libp2p/Protocols/Gossipsub/Core.lean | 1 + .../libp2p/protocols/Libp2p/Protocols/Gossipsub/Rpc.lean | 1 - .../libp2p/protocols/Libp2p/Protocols/Gossipsub/Score.lean | 5 +++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Core.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Core.lean index 7c5b5d98..4326fb6d 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Core.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Core.lean @@ -1603,6 +1603,7 @@ theorem scoreOf_gossipState (p : Params) (s : State) (now : Millis) (order : Lis (q : Peer) : scoreOf p (gossipState p s now order) q = scoreOf p (heartbeatNext p s now order) q := by simp [scoreOf, gossipState, peerOf, heartbeatNext] + theorem tickPruneNegative (p : Params) (s s' : State) (acts : List Action) (now : Millis) (order : List Peer) (hdue : heartbeatDue p s now) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Rpc.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Rpc.lean index 3c2e4e6f..76df1f43 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Rpc.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Rpc.lean @@ -860,7 +860,6 @@ theorem roundTripPeers (p : ControlPrune) (h : ControlPrune.Valid p) : p'.peers = p.peers := ⟨p, ControlPrune.decodeEncode p h, rfl⟩ - /-! ## The length-delimited frame step One RPC frame is a uvarint length and the message bytes, under diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Score.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Score.lean index 4ec108c5..9f2fbacd 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Score.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Score.lean @@ -86,8 +86,9 @@ namespace Libp2p.Protocols.Gossipsub.Score /-! ## Parameters -/ /-- The per-topic score parameters of gossipsub v1.1. The field order follows the topic -parameter table of the spec. `meshMessageDeliveriesWindow` and `meshMessageDeliveriesActivation` -feed the core's delivery accounting; `topicScore` reads only the activation. -/ +parameter table of the spec. `topicScore` reads the activation. The core stores the window +and never reads it, so every accepted message from a mesh peer counts toward `P3` +(`Core.lean` records the omission). -/ structure TopicParams where /-- The weight of this topic in the total score. -/ topicWeight : Int From b2fb433af3ec6bb36b6b2bce717cec77326a0451 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:45:58 +0800 Subject: [PATCH 020/125] refactor(negotiate): use the real varint and streams packages The package drops Mock/Varint and Mock/Streams. Message imports Varint and keeps `boundedVarint` as the total length constructor. The upgrader imports Streams.Conn, Streams.BufReader, and Streams.Constants: it writes through the real Connection, keeps the BufReader that each read returns, and takes the 30 s deadline from Streams. Core keeps its mock protocol and error types, but its connection and stream fields now name Streams.Connection and Streams.Stream. That edit is forced by the dropped module: Core imported Mock.Streams and used its types. The identity mock is unchanged. The upgrade suite drops its pipe-to-mock adapter, gives the pipe connections local and remote addresses, and runs the split checks on Streams.Sim connections directly. Tests/Upgrade imports the modules it uses instead of the Streams root, so its own import closure does not reach Streams.Tcp. The test executable still builds TCP and the shim through Tests/Streams. Docstrings and the package architecture note now match the code: the responder writes its header before the loop, `Action` has no error constructor, and the deadline closes the connection through `closeQuietly`. --- Tests/Negotiate.lean | 19 +- Tests/Upgrade.lean | 298 +++++++++--------- .../libp2p/negotiate/Libp2p/Negotiate.lean | 3 +- .../negotiate/Libp2p/Negotiate/Constants.lean | 4 +- .../negotiate/Libp2p/Negotiate/Message.lean | 95 +++--- .../negotiate/Libp2p/Negotiate/Mock/Core.lean | 29 +- .../Libp2p/Negotiate/Mock/Streams.lean | 55 ---- .../Libp2p/Negotiate/Mock/Varint.lean | 84 ----- .../negotiate/Libp2p/Negotiate/Step.lean | 24 +- .../negotiate/Libp2p/Negotiate/Upgrade.lean | 112 ++++--- .../libp2p/negotiate/docs/ARCHITECTURE.md | 44 +-- 11 files changed, 329 insertions(+), 438 deletions(-) delete mode 100644 packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Streams.lean delete mode 100644 packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean diff --git a/Tests/Negotiate.lean b/Tests/Negotiate.lean index 360d4a38..278caee0 100644 --- a/Tests/Negotiate.lean +++ b/Tests/Negotiate.lean @@ -1,5 +1,6 @@ import Tests.Support import Libp2p.Negotiate +import Varint import Bytes /-! @@ -88,23 +89,23 @@ def run (r : Runner) : IO Unit := do /- Model-level strictness. -/ r.check "mss decode zero length malformed" - (failsWith DecodeError.malformed (decodeMsg? (Mock.Varint.encode (Mock.Varint.boundedVarint 0) ++ [10]))) + (failsWith DecodeError.malformed (decodeMsg? (Varint.encode (boundedVarint 0) ++ [10]))) r.check "mss decode no trailing newline malformed" - (failsWith DecodeError.malformed (decodeMsg? (Mock.Varint.encode (Mock.Varint.boundedVarint 2) ++ [110, 97]))) + (failsWith DecodeError.malformed (decodeMsg? (Varint.encode (boundedVarint 2) ++ [110, 97]))) r.check "mss decode bad UTF-8 malformed" - (failsWith DecodeError.malformed (decodeMsg? (Mock.Varint.encode (Mock.Varint.boundedVarint 2) ++ [195, 10]))) + (failsWith DecodeError.malformed (decodeMsg? (Varint.encode (boundedVarint 2) ++ [195, 10]))) r.check "mss decode overlong varint malformed" (failsWith DecodeError.malformed (decodeMsg? (List.replicate 12 255))) r.check "mss decode short varint truncated" (failsWith DecodeError.truncated (decodeMsg? [128])) r.check "mss decode short payload truncated" - (failsWith DecodeError.truncated (decodeMsg? (Mock.Varint.encode (Mock.Varint.boundedVarint 3) ++ [110, 97]))) + (failsWith DecodeError.truncated (decodeMsg? (Varint.encode (boundedVarint 3) ++ [110, 97]))) r.check "mss decode empty input truncated" (failsWith DecodeError.truncated (decodeMsg? [])) r.check "mss decode cap malformed" - (failsWith DecodeError.malformed (decodeMsg? (Mock.Varint.encode (Mock.Varint.boundedVarint (maxMsg + 1)) ++ []))) + (failsWith DecodeError.malformed (decodeMsg? (Varint.encode (boundedVarint (maxMsg + 1)) ++ []))) r.check "mss decode non-byte model value malformed" - (failsWith DecodeError.malformed (decodeMsg? (Mock.Varint.encode (Mock.Varint.boundedVarint 2) ++ [300, 10]))) + (failsWith DecodeError.malformed (decodeMsg? (Varint.encode (boundedVarint 2) ++ [300, 10]))) let mna ← msgOf "na" r.check "mss decode suffix" (succeedsWith (mna, [7, 8]) (decodeMsg? (encodeMsg mna ++ [7, 8]))) @@ -138,7 +139,7 @@ def run (r : Runner) : IO Unit := do let ids : List Mock.Core.ProtocolId := protos.map fun s => ⟨s⟩ r.check "mss ls body encode" (lsBody ids == bodyBytes) r.check "mss ls reply encode" - (encodeLs ids == Mock.Varint.encode (Mock.Varint.boundedVarint bodyBytes.length) ++ bodyBytes) + (encodeLs ids == Varint.encode (boundedVarint bodyBytes.length) ++ bodyBytes) r.check "mss ls reply decode" (succeedsWith (ids, []) (decodeLs? (encodeLs ids))) /- The go traces. -/ @@ -226,7 +227,7 @@ def run (r : Runner) : IO Unit := do r.check "mss step hostile frame errors" (failsWith (Error.decode .malformed) - (step sNeg (.bytes (Mock.Varint.encode (Mock.Varint.boundedVarint (maxMsg + 1)) ++ [])))) + (step sNeg (.bytes (Varint.encode (boundedVarint (maxMsg + 1)) ++ [])))) let sBuf : State := { role := .responder, supported := [], proposals := [], buffer := [], phase := .negotiating } match step sBuf (.bytes [128]) with @@ -253,6 +254,6 @@ def run (r : Runner) : IO Unit := do r.check "mss generic round-trip harness" (Bytes.checkRoundTrips samples == true) r.skip "mss generic model pin" - "needs the ByteArray adapter, a stretch item this pull request defers; the model decoder is the production path today" + "needs the ByteArray adapter, which is not built; the suite runs the model decoder, which is the production path" end Tests.Negotiate diff --git a/Tests/Upgrade.lean b/Tests/Upgrade.lean index 82939445..eb3f1b26 100644 --- a/Tests/Upgrade.lean +++ b/Tests/Upgrade.lean @@ -2,19 +2,23 @@ import Tests.Support import Libp2p.Negotiate import Conc import Bytes -import Streams +import Streams.Conn +import Streams.BufReader +import Streams.Stream +import Streams.Constants +import Streams.Sim import Streams.Mock.Multiaddr /-! # The upgrade suite (unit 1b) The test milestones of the negotiate package plan, unit 1b. The full -pipeline runs both directions over an in-process pipe pair, which -stands in for the sim transport of streams until the mock drop (#32 -has merged; the swap is owed, ledger issue #25). The fixed-key -plaintext security value and the minimal muxer value replace the Noise -and yamux milestones (#16, #17, unmerged). The deadline check advances -the test `Clock` of `base/conc`, so the suite waits on no wall clock. +pipeline runs both directions over an in-process pipe pair, and the +byte-split runs use the in-memory sim transport of `Streams.Sim`. The +fixed-key plaintext security value and the minimal muxer value replace +the Noise and yamux milestones (#16, #17, unmerged). The deadline check +advances the test `Clock` of `base/conc`, so the suite waits on no wall +clock. Every check that spawns a task joins it (PRINCIPLES 3.2), and every park wakes by a close (PRINCIPLES 3.1). Shared cells change only by @@ -37,14 +41,32 @@ private structure Side where read : Conc.Pipe write : Conc.Pipe +/-- A multiaddr from its text form. -/ +private def simAddr (text : String) : Async Streams.Mock.Multiformats.Multiaddr := do + match Streams.Mock.Multiformats.Multiaddr.parse? text with + | .ok a => return a + | .error _ => throw <| IO.userError s!"test address {text} does not parse" + +/-- The connection view of a pipe pair. The streams seam reports a +failed write by throwing `IO.Error`, while `Conc.Pipe.write` reports +the same failure in its `Except` answer. -/ +private def pipeConn (read write : Conc.Pipe) : Async Streams.Connection := do + let addr ← simAddr "/memory/0" + return { read := read.read + write := fun b => do + match ← write.write b with + | .ok () => pure () + | .error _ => throw (IO.Error.resourceVanished 0 "test pipe closed") + close := do + read.close + write.close + localAddr := addr + remoteAddr := addr } + /-- The connection view of one side. The close reaches both directions, like a transport close. -/ -private def sideConn (s : Side) : Mock.Streams.Connection where - read := s.read.read - write := s.write.write - close := do - s.read.close - s.write.close +private def sideConn (s : Side) : Async Streams.Connection := + pipeConn s.read s.write /-- A fresh pipe pair: two sides, one pipe per direction. -/ private def newPair : IO (Side × Side) := do @@ -55,14 +77,10 @@ private def newPair : IO (Side × Side) := do /-- A connection over a pipe pair whose far side the test holds and never writes: the silent peer. The returned pipe is the one the connection reads, so a check sees the close that wakes the park. -/ -private def silentPeer : IO (Mock.Streams.Connection × Conc.Pipe) := do +private def silentPeer : Async (Streams.Connection × Conc.Pipe) := do let toPeer ← Conc.Pipe.new 4 let fromPeer ← Conc.Pipe.new 4 - return ({ read := fromPeer.read, write := toPeer.write, - close := do - fromPeer.close - toPeer.close }, - fromPeer) + return (← pipeConn fromPeer toPeer, fromPeer) /-- The initiator's marker of the test handshake. -/ private def markerI : ByteArray := Bytes.ofNatList [1, 1, 1, 1] @@ -88,9 +106,11 @@ private def secValue (overread : Bool) (seedRef : Conc.OnceRef (Option ByteArray handshake := fun role conn seed => do seedRef.set (some seed) let myMark := if role = .initiator then markerI else markerR - match ← conn.write myMark with - | .error _ => throw (.closed "test handshake write failed") - | .ok () => pure () + let wrote ← (try + conn.write myMark + pure true + catch _ => pure false : Conc.Async Bool) + if !wrote then throw (.closed "test handshake write failed") let seedB := Bytes.toNatList seed let rest ← if seedB.length ≥ 4 then pure (Bytes.ofNatList (seedB.drop 4)) @@ -108,7 +128,7 @@ private def secValue (overread : Bool) (seedRef : Conc.OnceRef (Option ByteArray localPeer := if role = .initiator then dialerId else listenerId, remotePeer := if role = .initiator then listenerId else dialerId, leftover := rest ++ over, - close := conn.close } + close := Streams.Connection.closeQuietly conn } /-- The test muxer value. It replaces the yamux milestone (#17, unmerged). `open` records its leftover argument, the opened id, and a @@ -122,7 +142,7 @@ private def muxValue (id : Mock.Core.ProtocolId) (ranRef : Conc.OnceRef Bool) leftRef.set (some leftover) openRef.set (some id) return { openStream := (throw (.protocol "the test session opens no stream") : - ExceptT Mock.Core.Error Conc.Async Mock.Streams.Stream), + ExceptT Mock.Core.Error Conc.Async Streams.Stream), accept := pure none, close := pure (), join := pure () } @@ -130,44 +150,40 @@ private def muxValue (id : Mock.Core.ProtocolId) (ranRef : Conc.OnceRef Bool) /-- Wrap a connection so the `n`-th write carries `extra` extra bytes. The write order is the upgrade's own order: the negotiation frame, the handshake marker, then the muxer frame. -/ -private def appendOnWrite (conn : Mock.Streams.Connection) (n : Nat) (extra : ByteArray) - (count : Conc.OnceRef Nat) : Mock.Streams.Connection where - read := conn.read - close := conn.close - write := fun b => do - let c ← (count.updateGet (fun c => (c, c + 1)) : BaseIO Nat) - conn.write (if c + 1 = n then b ++ extra else b) - -/-- Wrap a connection so the `n`-th write carries the marker after the -negotiation frame, and the marker write of the handshake drops. The -marker then arrives exactly once, through the seed. -/ -private def markerViaSeed (conn : Mock.Streams.Connection) (count : Conc.OnceRef Nat) : - Mock.Streams.Connection where - read := conn.read - close := conn.close - write := fun b => do - let c ← (count.updateGet (fun c => (c, c + 1)) : BaseIO Nat) - if c + 1 = 1 then conn.write (b ++ markerI) - else if c + 1 = 2 then return .ok () - else conn.write b +private def appendOnWrite (conn : Streams.Connection) (n : Nat) (extra : ByteArray) + (count : Conc.OnceRef Nat) : Streams.Connection := + { conn with + write := fun b => do + let c ← (count.updateGet (fun c => (c, c + 1)) : BaseIO Nat) + conn.write (if c + 1 = n then b ++ extra else b) } + +/-- Wrap a connection so the first write carries the marker after the +negotiation frame, and the second write, the handshake's marker, drops. +The marker then arrives exactly once, through the seed. -/ +private def markerViaSeed (conn : Streams.Connection) (count : Conc.OnceRef Nat) : + Streams.Connection := + { conn with + write := fun b => do + let c ← (count.updateGet (fun c => (c, c + 1)) : BaseIO Nat) + if c + 1 = 1 then conn.write (b ++ markerI) + else if c + 1 = 2 then pure () + else conn.write b } /-- Wrap a connection so the `n`-th write is replaced by `withB`. -/ -private def replaceOnWrite (conn : Mock.Streams.Connection) (n : Nat) (withB : ByteArray) - (count : Conc.OnceRef Nat) : Mock.Streams.Connection where - read := conn.read - close := conn.close - write := fun b => do - let c ← (count.updateGet (fun c => (c, c + 1)) : BaseIO Nat) - conn.write (if c + 1 = n then withB else b) +private def replaceOnWrite (conn : Streams.Connection) (n : Nat) (withB : ByteArray) + (count : Conc.OnceRef Nat) : Streams.Connection := + { conn with + write := fun b => do + let c ← (count.updateGet (fun c => (c, c + 1)) : BaseIO Nat) + conn.write (if c + 1 = n then withB else b) } /-- Wrap a connection and record every written chunk. -/ -private def recordWrites (conn : Mock.Streams.Connection) (ref : Conc.OnceRef (List ByteArray)) : - Mock.Streams.Connection where - read := conn.read - close := conn.close - write := fun b => do - ref.update fun l => b :: l - conn.write b +private def recordWrites (conn : Streams.Connection) (ref : Conc.OnceRef (List ByteArray)) : + Streams.Connection := + { conn with + write := fun b => do + ref.update fun l => b :: l + conn.write b } /-- The answer of one upgrade run. -/ private abbrev UpAnswer := @@ -176,7 +192,7 @@ private abbrev UpAnswer := /-! ## The pipeline checks -/ /-- The full pipeline, both directions, plus the wire-selected muxer -assertion (check 2). The test security value carries no advertisement +assertion. The test security value carries no advertisement field, so the wire exchange alone selects the muxer. -/ private def checkFullPipeline (r : Runner) : Async Unit := do let (iside, rside) ← newPair @@ -193,10 +209,10 @@ private def checkFullPipeline (r : Runner) : Async Unit := do let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new tasks.spawn do - dAnswer.set (some (← upgrade .initiator (sideConn iside) ByteArray.empty + dAnswer.set (some (← upgrade .initiator (← sideConn iside) ByteArray.empty (some listenerId) [secValue false dSeed] [muxValue muxId dRan dLeft dOpen])) tasks.spawn do - lAnswer.set (some (← upgrade .responder (sideConn rside) ByteArray.empty none + lAnswer.set (some (← upgrade .responder (← sideConn rside) ByteArray.empty none [secValue false lSeed] [muxValue muxId lRan lLeft lOpen])) tasks.joinAll let d ← dAnswer.get @@ -217,11 +233,13 @@ private def checkFullPipeline (r : Runner) : Async Unit := do r.check "upgrade full pipeline dial and listen" false r.check "upgrade muxer selected on the wire" false r.check "upgrade both muxer sessions opened" false - (sideConn iside).close - (sideConn rside).close + (← sideConn iside).close + (← sideConn rside).close -/-- The negotiation leftover seeds the handshake: the marker arrives -after the responder's select echo, in the same chunk. -/ +/-- The negotiation leftover seeds the handshake: the dialer's first +write carries its marker after the header and proposal, so the +responder's negotiation over-reads the marker and hands it to the +handshake. -/ private def checkNegotiationLeftover (r : Runner) : Async Unit := do let (iside, rside) ← newPair let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ @@ -238,11 +256,11 @@ private def checkNegotiationLeftover (r : Runner) : Async Unit := do let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new tasks.spawn do - dAnswer.set (some (← upgrade .initiator (markerViaSeed (sideConn iside) count) + dAnswer.set (some (← upgrade .initiator (markerViaSeed (← sideConn iside) count) ByteArray.empty (some listenerId) [secValue false dSeed] [muxValue muxId dRan dLeft dOpen])) tasks.spawn do - lAnswer.set (some (← upgrade .responder (sideConn rside) ByteArray.empty none + lAnswer.set (some (← upgrade .responder (← sideConn rside) ByteArray.empty none [secValue false lSeed] [muxValue muxId lRan lLeft lOpen])) tasks.joinAll let d ← dAnswer.get @@ -253,8 +271,8 @@ private def checkNegotiationLeftover (r : Runner) : Async Unit := do pure (s == some markerI) | _, _ => pure false r.check "upgrade leftover negotiation seeds handshake" good - (sideConn iside).close - (sideConn rside).close + (← sideConn iside).close + (← sideConn rside).close /-- The handshake over-reads the muxer header chunk, and the muxer stage still selects from the `SecureConn` leftover. -/ @@ -271,11 +289,11 @@ private def checkHandshakeLeftover (r : Runner) : Async Unit := do let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new tasks.spawn do - dAnswer.set (some (← upgrade .initiator (sideConn iside) ByteArray.empty + dAnswer.set (some (← upgrade .initiator (← sideConn iside) ByteArray.empty (some listenerId) [secValue false (← Conc.OnceRef.new none)] [muxValue muxId dRan dLeft dOpen])) tasks.spawn do - lAnswer.set (some (← upgrade .responder (sideConn rside) ByteArray.empty none + lAnswer.set (some (← upgrade .responder (← sideConn rside) ByteArray.empty none [secValue true (← Conc.OnceRef.new none)] [muxValue muxId lRan lLeft lOpen])) tasks.joinAll let d ← dAnswer.get @@ -287,8 +305,8 @@ private def checkHandshakeLeftover (r : Runner) : Async Unit := do pure (lf == some ByteArray.empty && ran) | _, _ => pure false r.check "upgrade leftover handshake seeds muxer negotiation" good - (sideConn iside).close - (sideConn rside).close + (← sideConn iside).close + (← sideConn rside).close /-- The muxer negotiation over-reads extra bytes, and they reach `Muxer.open` as its `ByteArray` argument. -/ @@ -307,11 +325,11 @@ private def checkMuxOpenLeftover (r : Runner) : Async Unit := do let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new tasks.spawn do - dAnswer.set (some (← upgrade .initiator (appendOnWrite (sideConn iside) 3 extra count) + dAnswer.set (some (← upgrade .initiator (appendOnWrite (← sideConn iside) 3 extra count) ByteArray.empty (some listenerId) [secValue false (← Conc.OnceRef.new none)] [muxValue muxId dRan dLeft dOpen])) tasks.spawn do - lAnswer.set (some (← upgrade .responder (sideConn rside) ByteArray.empty none + lAnswer.set (some (← upgrade .responder (← sideConn rside) ByteArray.empty none [secValue false (← Conc.OnceRef.new none)] [muxValue muxId lRan lLeft lOpen])) tasks.joinAll let d ← dAnswer.get @@ -322,8 +340,8 @@ private def checkMuxOpenLeftover (r : Runner) : Async Unit := do pure (lf == some extra) | _, _ => pure false r.check "upgrade leftover muxer negotiation seeds muxer open" good - (sideConn iside).close - (sideConn rside).close + (← sideConn iside).close + (← sideConn rside).close /-- The `na` retry: the dialer lists two muxer ids, the responder supports only the second, and the chosen session is the second id. -/ @@ -342,12 +360,12 @@ private def checkNaRetry (r : Runner) : Async Unit := do let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new tasks.spawn do - dAnswer.set (some (← upgrade .initiator (sideConn iside) ByteArray.empty + dAnswer.set (some (← upgrade .initiator (← sideConn iside) ByteArray.empty (some listenerId) [secValue false (← Conc.OnceRef.new none)] [muxValue muxA aRan (← Conc.OnceRef.new none) aOpen, muxValue muxB bRan (← Conc.OnceRef.new none) bOpen])) tasks.spawn do - lAnswer.set (some (← upgrade .responder (recordWrites (sideConn rside) lWrites) + lAnswer.set (some (← upgrade .responder (recordWrites (← sideConn rside) lWrites) ByteArray.empty none [secValue false (← Conc.OnceRef.new none)] [muxValue muxB lRan (← Conc.OnceRef.new none) lOpen])) tasks.joinAll @@ -364,8 +382,8 @@ private def checkNaRetry (r : Runner) : Async Unit := do lW.contains (Bytes.ofNatList (encodeMsg (Reply.toMsg .na)))) | _, _ => pure false r.check "upgrade na retry lands on second muxer id" good - (sideConn iside).close - (sideConn rside).close + (← sideConn iside).close + (← sideConn rside).close /-- The identity mismatch: the dialer fails with authentication before any muxer byte moves, and the teardown close ends the responder's @@ -380,11 +398,11 @@ private def checkIdentityMismatch (r : Runner) : Async Unit := do let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new tasks.spawn do - dAnswer.set (some (← upgrade .initiator (sideConn iside) ByteArray.empty + dAnswer.set (some (← upgrade .initiator (← sideConn iside) ByteArray.empty (some wrongId) [secValue false (← Conc.OnceRef.new none)] [muxValue muxId dRan (← Conc.OnceRef.new none) (← Conc.OnceRef.new none)])) tasks.spawn do - lAnswer.set (some (← upgrade .responder (sideConn rside) ByteArray.empty none + lAnswer.set (some (← upgrade .responder (← sideConn rside) ByteArray.empty none [secValue false (← Conc.OnceRef.new none)] [muxValue muxId lRan (← Conc.OnceRef.new none) (← Conc.OnceRef.new none)])) tasks.joinAll @@ -397,8 +415,8 @@ private def checkIdentityMismatch (r : Runner) : Async Unit := do pure (!dR && !lR) | _, _ => pure false r.check "upgrade identity mismatch fails before muxer open" good - (sideConn iside).close - (sideConn rside).close + (← sideConn iside).close + (← sideConn rside).close /-- The deadline: a silent peer times out, the teardown closes the connection, and the parked read ends on its own. -/ @@ -414,7 +432,7 @@ private def checkDeadline (r : Runner) : Async Unit := do [muxValue muxId (← Conc.OnceRef.new false) (← Conc.OnceRef.new none) (← Conc.OnceRef.new none)])) r.check "upgrade deadline arms its timer" (← tc.nextTimer) - tc.advance Mock.Streams.upgradeDeadlineMs + tc.advance Streams.unauthDeadlineMs tasks.joinAll r.check "upgrade deadline on silent peer times out and closes" (match ← answer.get with @@ -456,10 +474,10 @@ private def checkEmptyMuxer (r : Runner) : Async Unit := do let tcL ← Conc.TestClock.new let tasks ← Conc.TaskSet.new tasks.spawn do - dAnswer.set (some (← upgradeWith tcD.clock .initiator (sideConn iside) ByteArray.empty + dAnswer.set (some (← upgradeWith tcD.clock .initiator (← sideConn iside) ByteArray.empty (some listenerId) [secValue false (← Conc.OnceRef.new none)] [])) tasks.spawn do - lAnswer.set (some (← upgradeWith tcL.clock .responder (sideConn rside) ByteArray.empty none + lAnswer.set (some (← upgradeWith tcL.clock .responder (← sideConn rside) ByteArray.empty none [secValue false (← Conc.OnceRef.new none)] [muxValue muxB lRan (← Conc.OnceRef.new none) (← Conc.OnceRef.new none)])) tasks.joinAll @@ -471,8 +489,8 @@ private def checkEmptyMuxer (r : Runner) : Async Unit := do pure (!lR) | _, _ => pure false r.check "upgrade empty muxer list errors protocol" good - (sideConn iside).close - (sideConn rside).close + (← sideConn iside).close + (← sideConn rside).close tcD.close tcL.close @@ -485,7 +503,7 @@ private def checkEof (r : Runner) : Async Unit := do let answer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new tasks.spawn do - answer.set (some (← upgradeWith tc.clock .initiator (sideConn iside) ByteArray.empty none + answer.set (some (← upgradeWith tc.clock .initiator (← sideConn iside) ByteArray.empty none [secValue false (← Conc.OnceRef.new none)] [muxValue muxId (← Conc.OnceRef.new false) (← Conc.OnceRef.new none) (← Conc.OnceRef.new none)])) @@ -511,11 +529,11 @@ private def checkExhausted (r : Runner) : Async Unit := do let tcL ← Conc.TestClock.new let tasks ← Conc.TaskSet.new tasks.spawn do - dAnswer.set (some (← upgradeWith tcD.clock .initiator (sideConn iside) ByteArray.empty + dAnswer.set (some (← upgradeWith tcD.clock .initiator (← sideConn iside) ByteArray.empty (some listenerId) [secValue false (← Conc.OnceRef.new none)] [muxValue muxA dRan (← Conc.OnceRef.new none) (← Conc.OnceRef.new none)])) tasks.spawn do - lAnswer.set (some (← upgradeWith tcL.clock .responder (sideConn rside) ByteArray.empty none + lAnswer.set (some (← upgradeWith tcL.clock .responder (← sideConn rside) ByteArray.empty none [secValue false (← Conc.OnceRef.new none)] [muxValue muxB lRan (← Conc.OnceRef.new none) (← Conc.OnceRef.new none)])) tasks.joinAll @@ -528,8 +546,8 @@ private def checkExhausted (r : Runner) : Async Unit := do pure (!dR && !lR) | _, _ => pure false r.check "upgrade exhausted proposals errors protocol" good - (sideConn iside).close - (sideConn rside).close + (← sideConn iside).close + (← sideConn rside).close tcD.close tcL.close @@ -547,12 +565,12 @@ private def checkHostile (r : Runner) : Async Unit := do let tasks ← Conc.TaskSet.new tasks.spawn do dAnswer.set (some (← upgradeWith tcD.clock .initiator - (replaceOnWrite (sideConn iside) 1 garbage count) ByteArray.empty (some listenerId) + (replaceOnWrite (← sideConn iside) 1 garbage count) ByteArray.empty (some listenerId) [secValue false (← Conc.OnceRef.new none)] [muxValue muxId (← Conc.OnceRef.new false) (← Conc.OnceRef.new none) (← Conc.OnceRef.new none)])) tasks.spawn do - lAnswer.set (some (← upgradeWith tcL.clock .responder (sideConn rside) ByteArray.empty none + lAnswer.set (some (← upgradeWith tcL.clock .responder (← sideConn rside) ByteArray.empty none [secValue false (← Conc.OnceRef.new none)] [muxValue muxId (← Conc.OnceRef.new false) (← Conc.OnceRef.new none) (← Conc.OnceRef.new none)])) @@ -563,8 +581,8 @@ private def checkHostile (r : Runner) : Async Unit := do (match d, l with | some (.error (.closed _)), some (.error (.protocol "multistream decode: malformed")) => true | _, _ => false) - (sideConn iside).close - (sideConn rside).close + (← sideConn iside).close + (← sideConn rside).close tcD.close tcL.close @@ -656,54 +674,34 @@ private def checkResponderFirst (r : Runner) : Async Unit := do /-! ## The byte-split two-party runs over the sim -/ -/-- The mock-shaped view of a sim connection. A sim write throws -`IO.Error`; the mock seam reports the same failure through its `Except` -channel, which is the one the upgrader matches. -/ -private def simConn (c : Streams.Connection) : Mock.Streams.Connection where - read := c.read - write := fun b => do - let wrote ← try - c.write b - pure true - catch _ => pure false - if wrote then return .ok () else return .error .closed - close := discard c.close.toBaseIO - -/-- Record the size of every chunk that a connection's reader answers. +/-- Record the size of every chunk that a connection's read returns. The split checks read this to show that the sim delivered one byte at a time. -/ -private def recordReadSizes (conn : Mock.Streams.Connection) (sizes : Conc.OnceRef (List Nat)) : - Mock.Streams.Connection where - read := do - match ← conn.read with - | none => return none - | some chunk => do - sizes.update fun l => chunk.size :: l - return (some chunk) - write := conn.write - close := conn.close - -/-- A multiaddr from its text form. -/ -private def simAddr (text : String) : Async Streams.Mock.Multiformats.Multiaddr := do - match Streams.Mock.Multiformats.Multiaddr.parse? text with - | .ok a => return a - | .error _ => throw <| IO.userError s!"test address {text} does not parse" +private def recordReadSizes (conn : Streams.Connection) (sizes : Conc.OnceRef (List Nat)) : + Streams.Connection := + { conn with + read := do + match ← conn.read with + | none => return none + | some chunk => do + sizes.update fun l => chunk.size :: l + return (some chunk) } /-- A connected pair over a fresh sim net. `split` makes every write of the net arrive one byte at a time. -/ -private def simPair (split : Bool) : Async (Mock.Streams.Connection × Mock.Streams.Connection) := do +private def simPair (split : Bool) : Async (Streams.Connection × Streams.Connection) := do let net ← Streams.Sim.Net.new split let l ← net.transport.listen (← simAddr "/ip4/127.0.0.1/tcp/4101") let some dialer ← net.transport.dial l.addr | throw <| IO.userError "dial failed" let some listener ← l.accept | throw <| IO.userError "accept failed" l.close - return (simConn dialer, simConn listener) + return (dialer, listener) -/-- Read exactly `n` bytes, from the seed first and then from the -connection. A peer that closes early answers `none`. Under the split -sim the marker arrives one byte per chunk, so the split handshake reads -it here. -/ -private def readExactly (conn : Mock.Streams.Connection) (seed : ByteArray) (n : Nat) : +/-- Read until at least `n` bytes are held, from the seed first and +then from the connection. The answer keeps any extra bytes. A peer that +closes early answers `none`. Under the split sim the marker arrives one +byte per chunk, so the split handshake reads it here. -/ +private def readExactly (conn : Streams.Connection) (seed : ByteArray) (n : Nat) : Async (Option (List Nat)) := do let mut acc := Bytes.toNatList seed for _ in [0:n] do @@ -713,19 +711,21 @@ private def readExactly (conn : Mock.Streams.Connection) (seed : ByteArray) (n : | some chunk => acc := acc ++ Bytes.toNatList chunk return some acc -/-- The fixed-key test handshake, chunk-agnostic: it reads its marker with -`readExactly`, so a split write arrives in whatever chunks the transport -chose. It replaces the Noise milestone (#16, unmerged) as `secValue` -does, and it exists so the pipeline can run over the splitting sim. -/ +/-- The fixed-key test handshake, chunk-agnostic: it reads its marker +with `readExactly`, so it accepts whatever chunks the transport chose. +It replaces the Noise milestone (#16, unmerged) as `secValue` does, and +it exists so the pipeline can run over the splitting sim. -/ private def secValueExact (seedRef : Conc.OnceRef (Option ByteArray)) : Mock.Core.SecurityProtocol where id := ⟨"/test/plain/1.0.0"⟩ handshake := fun role conn seed => do seedRef.set (some seed) let myMark := if role = .initiator then markerI else markerR - match ← conn.write myMark with - | .error _ => throw (.closed "test handshake write failed") - | .ok () => pure () + let wrote ← (try + conn.write myMark + pure true + catch _ => pure false : Conc.Async Bool) + if !wrote then throw (.closed "test handshake write failed") match ← readExactly conn seed 4 with | none => throw (.closed "test handshake read ended before the marker") | some four => @@ -733,7 +733,7 @@ private def secValueExact (seedRef : Conc.OnceRef (Option ByteArray)) : localPeer := if role = .initiator then dialerId else listenerId, remotePeer := if role = .initiator then listenerId else dialerId, leftover := Bytes.ofNatList (four.drop 4), - close := conn.close } + close := Streams.Connection.closeQuietly conn } /-- The multistream exchange of both roles over a splitting sim, for a supported id and for the `na` retry. Each run also asserts that the @@ -747,8 +747,8 @@ private def checkSplitNegotiate (r : Runner) : Async Unit := do let (ic, rc) ← simPair true let sizes : Conc.OnceRef (List Nat) ← Conc.OnceRef.new [] let split := recordReadSizes rc sizes - let iReader := Mock.Streams.BufReader.ofConn ic - let rReader := Mock.Streams.BufReader.ofConn split + let iReader := Streams.BufReader.new ic + let rReader := Streams.BufReader.new split let iAnswer : Conc.OnceRef (Option (Except Mock.Core.Error StageResult)) ← Conc.OnceRef.new none let rAnswer : Conc.OnceRef (Option (Except Mock.Core.Error StageResult)) ← @@ -775,8 +775,8 @@ private def checkSplitNegotiate (r : Runner) : Async Unit := do /-- The whole pipeline over a splitting sim, both directions: the negotiation, the handshake, the identity check, the muxer negotiation, -and the open. This is the done-when run of the issue, over the sim. --/ +and the open. This is the two-party run that the definition of done of +#18 requires. -/ private def checkSplitPipeline (r : Runner) : Async Unit := do let (ic, rc) ← simPair true let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ @@ -842,6 +842,6 @@ def run (r : Runner) : IO Unit := do checkSplitPipeline r Conc.Async.run all r.skip "upgrade noise yamux matrix over tcp" - "needs the TCP transport, the Noise value, and the yamux value; none are merged on this base (the W5.9 live gate)" + "needs the Noise value and the yamux value, which are not merged on this base (the W5.9 live gate)" end Tests.Upgrade diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate.lean b/packages/libp2p/negotiate/Libp2p/Negotiate.lean index 8f3dd942..fea468b5 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate.lean @@ -8,5 +8,6 @@ import Libp2p.Negotiate.Upgrade The negotiate subpackage: multistream-select 1.0.0 and the upgrader (ARCHITECTURE §4.9). One import line per public submodule. The root -does not import the Mock modules. +names no Mock module directly. Its submodules still expose `Mock.Core` +and `Mock.Identity` until W5.1 and W5.2 land. -/ diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Constants.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Constants.lean index 0ae29546..e1659d4e 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Constants.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Constants.lean @@ -12,8 +12,8 @@ set_option autoImplicit false namespace Libp2p.Negotiate -/-- The multistream 1.0.0 protocol id. The only string literal of the -package (root §3.4). -/ +/-- The multistream 1.0.0 protocol id. The only protocol id literal of +the package (root §3.4). -/ def protocolId : Mock.Core.ProtocolId := ⟨"/multistream/1.0.0"⟩ /-- The 1 KiB multistream cap (root §5.1, contract 1 of the package diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean index ca58e874..e70f841d 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean @@ -1,5 +1,5 @@ import Libp2p.Negotiate.Constants -import Libp2p.Negotiate.Mock.Varint +import Varint import Bytes /-! @@ -11,9 +11,10 @@ newline (ARCHITECTURE §3.2, package docs "Public surface"). The decoder splits one frame at the index the prefix computes, then runs the computed inverse of the payload map: it reads the body as -UTF-8, re-encodes it, and compares. One comparison rejects a missing -trailing newline, a model value at or above 256, and bad UTF-8. This -keeps the `LawfulCodec` inverse to one `encodeDecode` step. +UTF-8, re-encodes it, and compares. `Bytes.toString?` rejects bad +UTF-8. The comparison rejects a missing trailing newline and a model +value at or above 256. So the `encodeDecode` proof needs no UTF-8 +reasoning. The 1 KiB cap is checked in `splitFrame`, before any payload inspection (contract 1). The `ls` reply framing walks its entries as @@ -34,9 +35,15 @@ between texts and payloads. -/ def payloadOf (s : String) : List Nat := Bytes.toNatList (Bytes.ofString (s ++ "\n")) +/-- A total `UVarint` constructor for a length. `initiatorStart` and +`responderStart` keep the upgrade's lengths at or under `maxMsg`; the +modulo keeps the constructor total for any input. -/ +def boundedVarint (n : Nat) : Varint.UVarint := + ⟨n % Varint.limit, Nat.mod_lt _ (by decide)⟩ + /-- The total text encoder: a uvarint length prefix, then the payload. -/ def encodeText (s : String) : List Nat := - Mock.Varint.encode (Mock.Varint.boundedVarint (payloadOf s).length) ++ payloadOf s + Varint.encode (boundedVarint (payloadOf s).length) ++ payloadOf s /-- A multistream message. `text` excludes the trailing newline. `fits` is the proof-carrying cap fact of root §3.7: the message fits @@ -63,14 +70,14 @@ theorem payloadOfEq (s : String) : theorem payloadOfNe (s : String) : payloadOf s ≠ [] := by rw [payloadOfEq]; simp -/-- The checked constructor. The tests and `header` build messages -through it. -/ +/-- The checked constructor. The tests build messages through it. -/ def Msg.ofText? (s : String) : Option Msg := if h : (Bytes.ofString (s ++ "\n")).size ≤ maxMsg then some ⟨s, h⟩ else none -/-- A text whose frame fits the 1 KiB cap. The senders call this before -they write, so a configured id that would produce an over-cap frame is a -protocol error rather than a frame the decoder refuses (root §5.1). -/ +/-- A text whose frame fits the 1 KiB cap. `initiatorStart` and +`responderStart` call this before any write, so a configured id that +would produce an over-cap frame is a protocol error rather than a frame +the decoder refuses (root §5.1). -/ def fitsText (s : String) : Bool := decide ((Bytes.ofString (s ++ "\n")).size ≤ maxMsg) @@ -87,7 +94,7 @@ is checked first, before any payload inspection (contract 1). A zero declared length is malformed. A payload cut short is truncated, so the caller keeps waiting. -/ def splitFrame (bs : List Nat) : Except DecodeError (List Nat × List Nat) := - match Mock.Varint.decode? bs with + match Varint.decode? bs with | .error e => .error e | .ok (v, rest) => if maxMsg < v.val then .error .malformed @@ -150,13 +157,13 @@ def decodeMsg? (bs : List Nat) : Except DecodeError (Msg × List Nat) := and the remainder. -/ theorem splitFrameEncode (s : String) (h : (payloadOf s).length ≤ maxMsg) (rest : List Nat) : splitFrame (encodeText s ++ rest) = .ok (payloadOf s, rest) := by - have hlim : (payloadOf s).length < Mock.Varint.limit := by - unfold Mock.Varint.limit maxMsg at *; omega - have hb : (Mock.Varint.boundedVarint (payloadOf s).length).val = (payloadOf s).length := by - simp [Mock.Varint.boundedVarint, Nat.mod_eq_of_lt hlim] + have hlim : (payloadOf s).length < Varint.limit := by + unfold Varint.limit Varint.maxBytes maxMsg at *; omega + have hb : (boundedVarint (payloadOf s).length).val = (payloadOf s).length := by + simp [boundedVarint, Nat.mod_eq_of_lt hlim] have hpos : 0 < (payloadOf s).length := List.length_pos_iff.mpr (payloadOfNe s) unfold splitFrame encodeText - rw [List.append_assoc, Mock.Varint.decodeEncodeSuffix] + rw [List.append_assoc, Varint.decodeEncodeSuffix] simp only [hb] have h1 : ¬ maxMsg < (payloadOf s).length := by omega have h2 : ¬ (payloadOf s).length = 0 := by omega @@ -184,7 +191,7 @@ theorem decodeMsgEncodeDecode (bs : List Nat) (m : Msg) (rest : List Nat) split at hsplit · simp at hsplit · rename_i v r0 hdec - have hbs := Mock.Varint.encodeDecode bs v r0 hdec + have hbs := Varint.encodeDecode hdec split at hsplit · simp at hsplit split at hsplit @@ -203,8 +210,8 @@ theorem decodeMsgEncodeDecode (bs : List Nat) (m : Msg) (rest : List Nat) obtain ⟨rfl, rfl⟩ := h have hl : (payloadOf t).length = v.val := by rw [hp]; simp; omega - have hv : Mock.Varint.boundedVarint (payloadOf t).length = v := by - cases v; simp_all [Mock.Varint.boundedVarint, Nat.mod_eq_of_lt] + have hv : boundedVarint (payloadOf t).length = v := by + cases v; simp_all [boundedVarint, Nat.mod_eq_of_lt] rw [encodeText, hv, hp, List.append_assoc, List.take_append_drop] exact hbs.symm · simp at h @@ -217,7 +224,7 @@ instance : Bytes.Codec Msg where theorem decodeMsgEncode (m : Msg) : decodeMsg? (encodeMsg m) = .ok (m, []) := by simpa [encodeMsg] using decodeMsgSuffix m [] -/-- The round trip law, the frozen demand of §3.2. -/ +/-- The round-trip law that §3.2 requires. -/ instance : Bytes.LawfulCodec Msg where decodeEncode := decodeMsgEncode encodeDecode := by @@ -226,37 +233,37 @@ instance : Bytes.LawfulCodec Msg where /-- The cap fires before the payload is read, for every payload that follows the prefix (contract 1). -/ -theorem decodeMsgCap {v : Mock.Varint.UVarint} {payload : List Nat} +theorem decodeMsgCap {v : Varint.UVarint} {payload : List Nat} (h : maxMsg < v.val) : - decodeMsg? (Mock.Varint.encode v ++ payload) = .error .malformed := by - simp [decodeMsg?, splitFrame, Mock.Varint.decodeEncodeSuffix, h] + decodeMsg? (Varint.encode v ++ payload) = .error .malformed := by + simp [decodeMsg?, splitFrame, Varint.decodeEncodeSuffix, h] /-- A payload cut short is incomplete: more bytes may complete it. -/ -theorem decodeMsgTruncated {v : Mock.Varint.UVarint} {payload : List Nat} +theorem decodeMsgTruncated {v : Varint.UVarint} {payload : List Nat} (hv0 : 0 < v.val) (hcap : v.val ≤ maxMsg) (hshort : payload.length < v.val) : - decodeMsg? (Mock.Varint.encode v ++ payload) = .error .truncated := by + decodeMsg? (Varint.encode v ++ payload) = .error .truncated := by unfold decodeMsg? splitFrame - rw [Mock.Varint.decodeEncodeSuffix] + rw [Varint.decodeEncodeSuffix] have h1 : ¬ maxMsg < v.val := by omega have h2 : ¬ v.val = 0 := by omega simp only [h1, h2, hshort, ite_true, ite_false] /-- A zero declared length is malformed. -/ -theorem decodeMsgZeroLength {v : Mock.Varint.UVarint} {payload : List Nat} +theorem decodeMsgZeroLength {v : Varint.UVarint} {payload : List Nat} (h : v.val = 0) : - decodeMsg? (Mock.Varint.encode v ++ payload) = .error .malformed := by - simp [decodeMsg?, splitFrame, Mock.Varint.decodeEncodeSuffix, h, maxMsg] + decodeMsg? (Varint.encode v ++ payload) = .error .malformed := by + simp [decodeMsg?, splitFrame, Varint.decodeEncodeSuffix, h, maxMsg] /-- A body whose last byte is not the newline is malformed. -/ -theorem decodeMsgNoNewline {v : Mock.Varint.UVarint} {payload : List Nat} +theorem decodeMsgNoNewline {v : Varint.UVarint} {payload : List Nat} (hv0 : 0 < v.val) (hcap : v.val ≤ maxMsg) (hlen : payload.length = v.val) (hlast : payload.getLast? ≠ some 10) : - decodeMsg? (Mock.Varint.encode v ++ payload) = .error .malformed := by + decodeMsg? (Varint.encode v ++ payload) = .error .malformed := by have hnone : textOf? (List.take v.val payload) = none := by rw [← hlen, List.take_length] exact textOf?None hlast unfold decodeMsg? splitFrame - rw [Mock.Varint.decodeEncodeSuffix] + rw [Varint.decodeEncodeSuffix] have h1 : ¬ maxMsg < v.val := by omega have h2 : ¬ v.val = 0 := by omega have h3 : ¬ payload.length < v.val := by omega @@ -266,8 +273,8 @@ theorem decodeMsgNoNewline {v : Mock.Varint.UVarint} {payload : List Nat} /-- The framing budget: a message frame spans its payload plus at most nine prefix bytes. -/ theorem encodeMsgLength (m : Msg) : - (encodeMsg m).length ≤ maxMsg + Mock.Varint.maxBytes := by - have h1 := Mock.Varint.encodeLengthBound (Mock.Varint.boundedVarint (payloadOf m.text).length) + (encodeMsg m).length ≤ maxMsg + Varint.maxBytes := by + have h1 := Varint.lengthEncodeLe (boundedVarint (payloadOf m.text).length) have h2 : (payloadOf m.text).length ≤ maxMsg := by rw [payloadOfLength]; exact m.fits simp only [encodeMsg, encodeText, List.length_append] omega @@ -286,7 +293,7 @@ def lsBody (ids : List Mock.Core.ProtocolId) : List Nat := lsEntries ids ++ [10] /-- The full `ls` reply: the body wrapped in one uvarint length prefix, exactly like a message. -/ def encodeLs (ids : List Mock.Core.ProtocolId) : List Nat := - Mock.Varint.encode (Mock.Varint.boundedVarint (lsBody ids).length) ++ lsBody ids + Varint.encode (boundedVarint (lsBody ids).length) ++ lsBody ids /-- Walk the entries of one complete `ls` body. The fuel counts entries; the caller passes the body's byte length, which is at least @@ -367,13 +374,13 @@ entries, so no cleanliness hypothesis is needed. -/ theorem decodeLsEncodeLs (ids : List Mock.Core.ProtocolId) (hbody : (lsBody ids).length ≤ maxMsg) : decodeLs? (encodeLs ids) = .ok (ids, []) := by - have hlim : (lsBody ids).length < Mock.Varint.limit := by - unfold Mock.Varint.limit maxMsg at *; omega - have hb : (Mock.Varint.boundedVarint (lsBody ids).length).val = (lsBody ids).length := by - simp [Mock.Varint.boundedVarint, Nat.mod_eq_of_lt hlim] + have hlim : (lsBody ids).length < Varint.limit := by + unfold Varint.limit Varint.maxBytes maxMsg at *; omega + have hb : (boundedVarint (lsBody ids).length).val = (lsBody ids).length := by + simp [boundedVarint, Nat.mod_eq_of_lt hlim] have hl := lsEntriesLength ids unfold decodeLs? encodeLs splitFrame - rw [← List.append_nil (lsBody ids), Mock.Varint.decodeEncodeSuffix] + rw [← List.append_nil (lsBody ids), Varint.decodeEncodeSuffix] simp only [hb, List.append_nil] have h1 : ¬ maxMsg < (lsBody ids).length := by omega have h2 : ¬ (lsBody ids).length = 0 := by simp [lsBody] @@ -385,12 +392,12 @@ theorem decodeLsEncodeLs (ids : List Mock.Core.ProtocolId) /-- A frame that splits back is exactly the prefix plus body plus remainder. -/ theorem splitFrameInv {bs body rest : List Nat} (h : splitFrame bs = .ok (body, rest)) : - Mock.Varint.encode (Mock.Varint.boundedVarint body.length) ++ body ++ rest = bs := by + Varint.encode (boundedVarint body.length) ++ body ++ rest = bs := by unfold splitFrame at h split at h · simp at h · rename_i v r0 hdec - have hbs := Mock.Varint.encodeDecode bs v r0 hdec + have hbs := Varint.encodeDecode hdec split at h · simp at h split at h @@ -401,8 +408,8 @@ theorem splitFrameInv {bs body rest : List Nat} (h : splitFrame bs = .ok (body, simp only [Except.ok.injEq, Prod.mk.injEq] at h obtain ⟨rfl, rfl⟩ := h have hl : (r0.take v.val).length = v.val := by simp; omega - have hv : Mock.Varint.boundedVarint (r0.take v.val).length = v := by - cases v; simp_all [Mock.Varint.boundedVarint, Nat.mod_eq_of_lt] + have hv : boundedVarint (r0.take v.val).length = v := by + cases v; simp_all [boundedVarint, Nat.mod_eq_of_lt] rw [hv, List.append_assoc, List.take_append_drop] exact hbs.symm diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean index 68eba208..891be73b 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean @@ -1,6 +1,7 @@ import Std import Conc -import Libp2p.Negotiate.Mock.Streams +import Streams.Conn +import Streams.Stream import Libp2p.Negotiate.Mock.Identity /-! @@ -8,16 +9,18 @@ import Libp2p.Negotiate.Mock.Identity `ProtocolId` and `Role` live in `libp2p/core` (W5.2, not merged yet). Unit 1b adds `Error`, `SecurityProtocol`, `SecureConn`, `Muxer`, and -`MuxSession`. This module holds them verbatim from the core -ARCHITECTURE "Public surface", so the negotiate core and the upgrader -compile against the frozen shapes. The import swaps to `Libp2p.Core` -when W5.2 lands. `Repr` joins the derived classes per root §3.2, so -`State`, `Event`, and `Action` can derive `Repr` per root §3.3. +`MuxSession`. This module follows the core ARCHITECTURE "Public +surface", so the negotiate core and the upgrader compile against the +frozen shapes. It differs in three places: `Repr` is derived, +`MuxSession.close` is `BaseIO Unit`, and `SecureConn` has no completion +to join. The import swaps to `Libp2p.Core` when W5.2 lands. `Repr` +joins the derived classes per root §3.2, so `State`, `Event`, and +`Action` can derive `Repr` per root §3.3. `SecurityProtocol` and `Muxer` mention the streams `Connection`, and `SecureConn` mentions the identity `PeerId`, so this module imports -the two sibling mocks. The imports are same-leaf and the lint's -rule 3 allows them. +`Streams.Conn`, `Streams.Stream`, and the identity mock. The identity +mock stays until W5.1 lands. -/ set_option autoImplicit false @@ -52,7 +55,7 @@ inductive Error where ARCHITECTURE, "Public surface"). The `leftover` is every byte the handshake read past its end; it seeds the reader of the next stage. -/ structure SecureConn where - conn : Mock.Streams.Connection + conn : Streams.Connection localPeer : Mock.Identity.PeerId remotePeer : Mock.Identity.PeerId leftover : ByteArray @@ -62,14 +65,14 @@ structure SecureConn where negotiation over-read before it reads the connection (root §4.7). -/ structure SecurityProtocol where id : ProtocolId - handshake : Role → Mock.Streams.Connection → ByteArray → + handshake : Role → Streams.Connection → ByteArray → ExceptT Error Conc.Async SecureConn /-- The uniform session view of core ARCHITECTURE, "Public surface". The upgrader returns the opened session and never calls its fields. -/ structure MuxSession where - openStream : ExceptT Error Conc.Async Mock.Streams.Stream - accept : Conc.Async (Option Mock.Streams.Stream) + openStream : ExceptT Error Conc.Async Streams.Stream + accept : Conc.Async (Option Streams.Stream) close : BaseIO Unit join : Conc.Async Unit @@ -78,7 +81,7 @@ structure MuxSession where `mux.«open»`. -/ structure Muxer where id : ProtocolId - «open» : Role → Mock.Streams.Connection → ByteArray → + «open» : Role → Streams.Connection → ByteArray → ExceptT Error Conc.Async MuxSession end Libp2p.Negotiate.Mock.Core diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Streams.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Streams.lean deleted file mode 100644 index 149a54de..00000000 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Streams.lean +++ /dev/null @@ -1,55 +0,0 @@ -import Conc - -/-! -# Streams mocks for unit 1b - -The streams package has merged (#32), and this module stands until the -integration swap (the mock-substitution ledger, issue #25). -This module holds `Connection`, `BufReader`, and `Stream` from the -streams ARCHITECTURE "Public surface", and the one constant the -upgrader wires, so the upgrader compiles against the frozen shapes. -The imports swap to `Streams` at the integration. - -`Connection` omits `localAddr` and `remoteAddr`. The upgrader never -reads an address, and a `Multiaddr` import would break the package's -own import list (negotiate ARCHITECTURE, "What the package must not -depend on"). `BufReader.readExactly` stays undeclared: the core owns -framing, and this package reads raw chunks (negotiate ARCHITECTURE, -"From the net group"). --/ - -set_option autoImplicit false - -namespace Libp2p.Negotiate.Mock.Streams - -/-- A byte stream. The fields are exactly `Conc.Pipe`'s methods, so -the test pair needs no adapter (`Pipe.lean:33,41,46`). `read` returns -`none` on the end of the stream. -/ -structure Connection where - read : Conc.Async (Option ByteArray) - write : ByteArray → Conc.Async (Except Conc.Error Unit) - close : BaseIO Unit - -/-- One stage's reader (streams ARCHITECTURE, "No reader strand"). -Two stages never share one reader. -/ -structure BufReader where - read : Conc.Async (Option ByteArray) - -/-- The reader of one connection. Each upgrade stage builds its own -reader over the connection of that stage. -/ -def BufReader.ofConn (c : Connection) : BufReader := ⟨c.read⟩ - -/-- The application view over a muxer loop. The upgrader never calls -these fields; they type the `MuxSession` surface of the core mock. -/ -structure Stream where - read : Conc.Async (Option ByteArray) - write : ByteArray → Conc.Async (Except Conc.Error Unit) - close : IO Unit - reset : IO Unit - -/-- The 30 s upgrade deadline (root §5.4). The real home is -`Streams/Constants.lean`; this mock moves there when the streams -package lands (negotiate ARCHITECTURE, "Decisions"). -/ -def upgradeDeadlineMs : Nat := 30000 - -end Libp2p.Negotiate.Mock.Streams diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean deleted file mode 100644 index 20899111..00000000 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Varint.lean +++ /dev/null @@ -1,84 +0,0 @@ -import Std -import Bytes - -/-! -# Temporary unsigned-varint interface - -W2.1 has merged (#34), and this module stands until the integration -swap (the mock-substitution ledger, issue #25). The executable opaque -definitions follow its documented nine-byte minimal-form contract. The -three axioms below are dependency contracts: the merged package proves -each one as a theorem, and they disappear with this mock. This module -mirrors `Proto/Mock/Varint.lean`. --/ - -set_option autoImplicit false - -namespace Libp2p.Negotiate.Mock.Varint - -open Bytes (DecodeError) - -/-- The dependency stops after nine groups, including an unterminated -last group. -/ -def maxBytes : Nat := 9 - -/-- Nine groups of seven bits fit exactly in this range. -/ -def limit : Nat := 2 ^ 63 - -/-- The range proof is erased at runtime. -/ -structure UVarint where - val : Nat - isLt : val < limit - deriving DecidableEq, Repr - -/-- Executable reference encoder. Callers use the opaque interface. -/ -private def encodeCore (n : Nat) : List Nat := - if n < 128 then [n] - else (n % 128 + 128) :: encodeCore (n / 128) -termination_by n -decreasing_by exact Nat.div_lt_self (by omega) (by decide) - -/-- The mock preserves the frozen argument type. -/ -opaque encode (v : UVarint) : List Nat := encodeCore v.val - -/-- The last permitted continuation byte is malformed, even without a -suffix. -/ -private def decodeCore : Nat → List Nat → Except DecodeError (Nat × List Nat) - | 0, _ => .error .malformed - | _ + 1, [] => .error .truncated - | fuel + 1, b :: bs => - if b ≥ 256 then .error .malformed - else if b < 128 then .ok (b, bs) - else do - let (high, rest) ← decodeCore fuel bs - if high = 0 then .error .malformed - else .ok (b % 128 + 128 * high, rest) - -/-- The decoder checks the bound before constructing its proof field. -/ -opaque decode? (bs : List Nat) : Except DecodeError (UVarint × List Nat) := do - let (n, rest) ← decodeCore maxBytes bs - if h : n < limit then .ok (⟨n, h⟩, rest) - else .error .malformed - -/-- Executable stand-in for the production decoder and consumed count. -/ -opaque decodeB? (bs : ByteArray) : Except DecodeError (UVarint × Nat) := do - let (v, rest) ← decode? (bs.toList.map UInt8.toNat) - .ok (v, bs.size - rest.length) - -/-- The reduced value the total encoders of `Message` use. The modulo -matches the range proof, so the reduction is exact below the limit. -/ -def boundedVarint (n : Nat) : UVarint := - ⟨n % limit, Nat.mod_lt _ (by decide)⟩ - -/-- W2.1's suffix round trip contract. -/ -axiom decodeEncodeSuffix (v : UVarint) (rest : List Nat) : - decode? (encode v ++ rest) = .ok (v, rest) - -/-- W2.1's accepted-input decomposition. -/ -axiom encodeDecode (bs : List Nat) (v : UVarint) (rest : List Nat) : - decode? bs = .ok (v, rest) → bs = encode v ++ rest - -/-- W2.1's budget lemma: an encoded varint spans at most nine bytes. -/ -axiom encodeLengthBound (v : UVarint) : (encode v).length ≤ maxBytes - -end Libp2p.Negotiate.Mock.Varint diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean index 75626074..687eaeb0 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean @@ -10,14 +10,13 @@ multistream-select (root §3.3). One `Event.bytes` feeds raw bytes, and the core decodes exactly one frame per call, so the malformed-versus-incomplete call stays in the core (§3.1). A decoded frame goes through a private `classify` step that names the answer, -then a private `act` step that performs it. Every theorem about choice -lives in `classify`; every theorem about writes and phase lives in -`act`. +then a private `act` step that performs it. The theorems about the +chosen id reason over `classify`; the theorems about writes and phase +reason over `act`. The step returns `Except Error (State × List Action)`, the root §3.3 -shape. `Action.error` of the package docs stays out: the error channel -carries the failure (root §3.5), and no contract asks for an error -action. +shape. `Action` has no error constructor (package docs, "Decisions"): +the `Except` channel carries the failure (root §3.5). -/ set_option autoImplicit false @@ -99,8 +98,9 @@ def Reply.toMsg : Reply → Msg /-- The message form of the reply recognition. -/ def Reply.ofMsg? (m : Msg) : Option Reply := Reply.ofText? m.text -/-- Why a negotiation failed. `decode` carries the shared decode -error; `negotiationFailed` covers every peer violation the core sees. -/ +/-- Why a negotiation failed. `decode` carries a frame the decoder +rejects. `negotiationFailed` covers a bad exchange, an exhausted +proposal list, and an invalid local list at start. -/ inductive Error where | decode (e : DecodeError) | negotiationFailed @@ -119,8 +119,8 @@ theorem chooseSpec {ours theirs : List Mock.Core.ProtocolId} {p : Mock.Core.Prot /-! ## The answers -/ -/-- What a decoded text means for this state. Byte-free, so every -theorem about choice lives here. -/ +/-- What a decoded text means for this state. Byte-free, so the +theorems about the chosen id reason over this classification. -/ private inductive Answer where | echo | listIds @@ -154,8 +154,8 @@ private def classify (s : State) (text : String) : Answer := else if text = p.toString then .accept p else .fail | _, _ => .fail -/-- Perform an answer. Byte-free per answer, so every theorem about -writes and phase lives here. -/ +/-- Perform an answer. It decodes nothing, so its theorems need no +decoder facts. -/ private def act (s : State) (rest : List Nat) : Answer → Except Error (State × List Action) | .echo => .ok ({ s with buffer := rest, phase := .negotiating }, [.write (encodeText protocolId.toString)]) | .listIds => .ok ({ s with buffer := rest }, [.write (encodeLs s.supported)]) diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean index f0abc592..ab8ff907 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean @@ -1,8 +1,10 @@ import Libp2p.Negotiate.Step import Libp2p.Negotiate.Message import Libp2p.Negotiate.Mock.Core -import Libp2p.Negotiate.Mock.Streams import Libp2p.Negotiate.Mock.Identity +import Streams.Conn +import Streams.BufReader +import Streams.Constants import Conc import Bytes @@ -20,9 +22,8 @@ three-stage pipeline under one `Conc.withinDeadline`. The driver owns the byte cursor. It never reads `s.buffer`; it resets the buffer before every `step` call, and every unconsumed byte stays in the `input` argument. The `leftover` field of the result is -authoritative. The shell always passes the -driver's own states and leftovers back, so the convention is closed -under use. +authoritative. The shell passes the driver's own states and leftovers +back unchanged. The negotiation loop parks on the connection read until a selection, an error, a close, or the deadline (root §3.3). No measure bounds that @@ -186,7 +187,7 @@ def settleUpgrade {α : Type} : /-- The answer of one negotiation stage: the selected id and the bytes the stage over-read. `ByteArray` carries no `Repr` in Lean 4.34, so -the structure derives none; the suite prints the fields it needs. -/ +the structure derives none; the suite compares the fields it needs. -/ structure StageResult where proto : Mock.Core.ProtocolId leftover : ByteArray @@ -195,8 +196,8 @@ structure StageResult where /-- The accumulator invariant behind the conservation law: the bytes fed in equal the bytes consumed plus the final leftover, for every -arm of the result. The proof is the one induction of the unit, on the -fuel; `decodeMsgEncodeDecode` reassociates each consumed frame. -/ +arm of the result. The proof inducts on the fuel; +`decodeMsgEncodeDecode` reassociates each consumed frame. -/ private theorem driveAux_conservation (fuel : Nat) : ∀ (s : State) (input consumed : List Nat) (writes : List (List Nat)) (r : DriveResult), @@ -315,8 +316,8 @@ theorem driveConsumedHead {fuel : Nat} {s : State} {m : Msg} {rest : List Nat} exact ⟨tail, by simpa [encodeMsg, List.append_assoc] using htail⟩ /-- Two stages compose: bytes in equal the consumed bytes of both -stages plus the final leftover. The reusable form of the pipeline's scope -item 2. -/ +stages plus the final leftover. The reusable form of the leftover +hand-over (contract 5). -/ theorem handoverCompose {ε α β : Type} {f : List Nat → Except ε (α × List Nat)} {g : List Nat → Except ε (β × List Nat)} @@ -330,9 +331,9 @@ theorem handoverCompose {ε α β : Type} rw [law₁ bs₁ a₁ l₁ h₁, List.append_assoc, law₂ (l₁ ++ bs₂) a₂ l₂ h₂] simp only [List.append_assoc] -/-- The pure form of the hand-over: the muxer stage is seeded with -the negotiation's leftover, and the two drives consume exactly the -bytes fed in. -/ +/-- The pure form of one hand-over: when a second drive starts from +the first drive's leftover, the bytes fed in equal the bytes both +drives consumed plus the final leftover. -/ theorem driveTwostage {fuel₁ fuel₂ : Nat} {s₁ s₂ : State} {input₁ input₂ : List Nat} {p₁ p₂ : Mock.Core.ProtocolId} {c₁ c₂ l₁ l₂ : List Nat} {w₁ w₂ : List (List Nat)} @@ -344,10 +345,8 @@ theorem driveTwostage {fuel₁ fuel₂ : Nat} {s₁ s₂ : State} rw [h1, List.append_assoc, h2] simp only [List.append_assoc] -/-- The shell's manual first write equals the core's propose -step for a singleton list, after both sides are read as write lists. -The driver therefore starts the negotiation the way the proved core -would. -/ +/-- The shell's manual first write equals the core's propose step for +a singleton list. The theorem does not cover a longer list. -/ theorem initiatorStartPropose {p : Mock.Core.ProtocolId} (hfits : fitsText p.toString = true) : initiatorStart [p] = @@ -403,26 +402,37 @@ theorem settleUpgradeSpec {α : Type} {e : Mock.Core.Error} {a : α} : /-! ## The IO shell -/ +/-- Write one frame, and turn a fault of the connection into the +`closed` arm. `Connection.write` throws `IO.Error`, which is outside +this shell's `Core.Error` channel. -/ +private def writeFrame (conn : Streams.Connection) (b : ByteArray) : + ExceptT Mock.Core.Error Conc.Async Unit := do + let ok ← (try + conn.write b + pure true + catch _ => pure false : Conc.Async Bool) + if ok then pure () else throw (.closed "connection write failed during the upgrade") + /-- Write each frame payload through the connection, in order. A failed write is the `closed` arm: the connection is gone. -/ -private def sendWrites (conn : Mock.Streams.Connection) (ws : List (List Nat)) : +private def sendWrites (conn : Streams.Connection) (ws : List (List Nat)) : ExceptT Mock.Core.Error Conc.Async Unit := do for w in ws do - match ← conn.write (Bytes.ofNatList w) with - | .ok () => pure () - | .error _ => throw (.closed "connection write failed during the upgrade") + writeFrame conn (Bytes.ofNatList w) /-- The negotiation loop. It parks on `reader.read` only when `drive` -reports `waiting`; a `running` answer costs no read, because a -complete frame is still buffered. The loop ends on a selection, an -error, a close, or the deadline, so no measure bounds it (root §3.3). -The `repeat` form keeps the definition free of a `partial` marker, -like `Conc.TaskSet.joinAll`. -/ -private def negotiateLoop (reader : Mock.Streams.BufReader) - (conn : Mock.Streams.Connection) (s : State) (input : List Nat) : +reports `waiting`; a `running` answer costs no read, because the driver +has not yet tried to decode `leftover`. `reader.read` returns the next +reader, and the loop keeps it for the next read. The read parks for as +long as the peer stays silent, so no measure bounds the loop (root +§3.3). The `repeat` form keeps the definition free of a `partial` +marker, like `Conc.TaskSet.joinAll`. -/ +private def negotiateLoop (reader : Streams.BufReader) + (conn : Streams.Connection) (s : State) (input : List Nat) : ExceptT Mock.Core.Error Conc.Async StageResult := do let mut s := s let mut input := input + let mut reader := reader repeat match drive 1 s input with | .error e => throw (mapNegotiateError e) @@ -433,24 +443,26 @@ private def negotiateLoop (reader : Mock.Streams.BufReader) sendWrites conn writes match ← reader.read with | none => throw (.closed "multistream negotiation ended before a selection") - | some chunk => + | some (chunk, reader') => s := s' input := leftover ++ Bytes.toNatList chunk + reader := reader' | .ok (.running s' _ leftover writes) => do sendWrites conn writes s := s' input := leftover -- Every arm above returns or loops, so the `repeat` never falls - -- through. The tail keeps the loop total without an `Inhabited`. + -- through. The tail gives the `do` block its result type without an + -- `Inhabited`. throw (.closed "the negotiation loop ended without an answer") -/-- One negotiation stage. The initiator writes the coalesced header -plus first proposal before the loop; the responder reads first. The -seed is the leftover of the previous stage. The end of the stream -mid-negotiation is `Core.Error.closed`; a `Negotiate.Error` maps once -through `mapNegotiateError`. -/ -def negotiate (role : Mock.Core.Role) (reader : Mock.Streams.BufReader) - (conn : Mock.Streams.Connection) (seed : ByteArray) +/-- One negotiation stage. Both roles write before the loop: the +initiator writes the coalesced header plus first proposal, and the +responder writes its header. The seed is the leftover of the previous +stage. The end of the stream mid-negotiation is `Core.Error.closed`; a +`Negotiate.Error` maps once through `mapNegotiateError`. -/ +def negotiate (role : Mock.Core.Role) (reader : Streams.BufReader) + (conn : Streams.Connection) (seed : ByteArray) (ids : List Mock.Core.ProtocolId) : ExceptT Mock.Core.Error Conc.Async StageResult := do match role with @@ -470,11 +482,11 @@ def negotiate (role : Mock.Core.Role) (reader : Mock.Streams.BufReader) /-- The pipeline: negotiate the security protocol, run the handshake, check the identity, negotiate the muxer, open the session. Each lookup is total; a miss after a selection is `protocol`. -/ -private def upgradeCore (role : Mock.Core.Role) (conn : Mock.Streams.Connection) +private def upgradeCore (role : Mock.Core.Role) (conn : Streams.Connection) (leftover : ByteArray) (expected : Option Mock.Identity.PeerId) (secs : List Mock.Core.SecurityProtocol) (muxers : List Mock.Core.Muxer) : Conc.Async (Except Mock.Core.Error (Mock.Core.MuxSession × Mock.Identity.PeerId)) := do - let reader := Mock.Streams.BufReader.ofConn conn + let reader := Streams.BufReader.new conn match ← negotiate role reader conn leftover (secs.map (·.id)) with | .error e => return .error e | .ok stage => @@ -487,7 +499,7 @@ private def upgradeCore (role : Mock.Core.Role) (conn : Mock.Streams.Connection) match checkIdentity expected sc.remotePeer with | .error e => return .error e | .ok () => - let muxReader := Mock.Streams.BufReader.ofConn sc.conn + let muxReader := Streams.BufReader.new sc.conn match ← negotiate role muxReader sc.conn sc.leftover (muxers.map (·.id)) with | .error e => return .error e | .ok stage2 => @@ -498,14 +510,15 @@ private def upgradeCore (role : Mock.Core.Role) (conn : Mock.Streams.Connection) | .error e => return .error e | .ok session => return .ok (session, sc.remotePeer) -/-- Whether the deadline boundary already closed the connection. Both error -arms of `withinDeadline` run the expiry hook. -/ +/-- Whether the deadline boundary already closed the connection. +`withinDeadline` returns an error value only on expiry, after the hook +ran. -/ private def boundaryClosed {α : Type} : Except Conc.Error α → Bool | .error _ => true | .ok _ => false -/-- Give a late session back to its owner. A result that arrives after the -timer won never reaches a caller, so the release closes it here. -/ +/-- Close a session that finished after the timer won. No caller +receives that session. -/ private def releaseLate (r : Except Mock.Core.Error (Mock.Core.MuxSession × Mock.Identity.PeerId)) : BaseIO Unit := match r with @@ -518,27 +531,28 @@ the connection once at the boundary (root §3.5), and the race's The clock is a seam, so the deadline test can advance it. -/ def upgradeWith (clock : Conc.Clock) (role : Mock.Core.Role) - (conn : Mock.Streams.Connection) (leftover : ByteArray) + (conn : Streams.Connection) (leftover : ByteArray) (expected : Option Mock.Identity.PeerId) (secs : List Mock.Core.SecurityProtocol) (muxers : List Mock.Core.Muxer) : ExceptT Mock.Core.Error Conc.Async (Mock.Core.MuxSession × Mock.Identity.PeerId) := do - let answer ← Conc.withinDeadline clock Mock.Streams.upgradeDeadlineMs conn.close + let answer ← Conc.withinDeadline clock Streams.unauthDeadlineMs + (Streams.Connection.closeQuietly conn) (upgradeCore role conn leftover expected secs muxers) releaseLate match settleUpgrade answer with | .ok pair => return pair | .error e => do - -- Both error arms of the boundary run the expiry hook, which closed the - -- connection once. Only a failure inside the work leaves it open. + -- An error from the boundary means the expiry hook closed the + -- connection. A failure inside the work leaves it open. if boundaryClosed answer then throw e else do - conn.close + Streams.Connection.closeQuietly conn throw e /-- The frozen entry of the package docs. The clock is fixed to the system clock here and stays a seam in `upgradeWith` for the deadline test. -/ -def upgrade (role : Mock.Core.Role) (conn : Mock.Streams.Connection) +def upgrade (role : Mock.Core.Role) (conn : Streams.Connection) (leftover : ByteArray) (expected : Option Mock.Identity.PeerId) (secs : List Mock.Core.SecurityProtocol) (muxers : List Mock.Core.Muxer) : ExceptT Mock.Core.Error Conc.Async (Mock.Core.MuxSession × Mock.Identity.PeerId) := diff --git a/packages/libp2p/negotiate/docs/ARCHITECTURE.md b/packages/libp2p/negotiate/docs/ARCHITECTURE.md index e99dc781..9a0bb013 100644 --- a/packages/libp2p/negotiate/docs/ARCHITECTURE.md +++ b/packages/libp2p/negotiate/docs/ARCHITECTURE.md @@ -23,11 +23,9 @@ All declarations live in the `Libp2p.Negotiate` namespace, and the root module re-exports the public submodules. The 30 s upgrade deadline is owned by `net/streams` and lives in `Streams/Constants.lean` (the streams pair, contracts; the security pair, contract 7). This package -imports it and wires it; it keeps no copy. - -W4.1 has merged. Until the integration drops `Mock/Streams.lean`, the -mock holds that value, so the copy lives there. The swap deletes the -mock with it. +imports it and wires it; it keeps no copy. The connection, buffered +reader, and stream types come from `Streams.Conn`, `Streams.BufReader`, +and `Streams.Stream`. ## Public surface @@ -40,9 +38,13 @@ mock with it. `\n` (the spike's `lsPayload`, kept). - The core surface: `State`, `Event`, `Action`, and `step` (§3.3). `State` holds the role, the responder's ordered supported list, the - input buffer, and the phase. `Event.bytes` carries raw bytes, and the - core decodes them, so the malformed-versus-incomplete call stays in - the core (§3.1). `Event.propose` posts the id the initiator dials. + initiator's remaining proposals, the input buffer, and the phase. + `Event.bytes` carries raw bytes, and the core decodes them, so the + malformed-versus-incomplete call stays in + the core (§3.1). `Event.propose` records one id and writes the + coalesced first frame. It acts only on an initiator in the header + phase with an empty list. The upgrader does not send it: + `initiatorStart` seeds the whole ordered list. `Action.write` emits framed bytes, and `Action.select` ends the negotiation with the chosen `ProtocolId` and the leftover. A failure leaves `step` through its `Except` channel, as §3.3 states, so @@ -62,9 +64,8 @@ mock with it. fresh dial, where no earlier stage can leave bytes. It is non-empty when an accept path peeked before it handed the connection over, or when the transport delivers bytes together with the connection. The - output: the `MuxSession` and the verified remote `PeerId`; a pair or - a small structure is a refinement this package fixes at - implementation time. Errors surface as `Core.Error` (§3.5). + output is the pair of the `MuxSession` and the verified remote + `PeerId`. Errors surface as `Core.Error` (§3.5). ## The muxer-selection decision @@ -77,9 +78,10 @@ order, and the responder answers `na` until a known id arrives. The spike's extension path (`Upgrade.matchMuxers` over `SecureConn.remoteMuxers`, with go's initiator-preference rule) stays out: it would need a recorded `libp2p/core` refinement to carry the -bytes, and no root contract reads the extension. Interop is covered: a -go peer that selects from the extensions sees an empty advertisement -from us, and both sides fall back to the wire negotiation, the spike's +bytes, and no root contract reads the extension. Interop relies on the +fallback, and W5.9 tests it against go-libp2p: a go peer that selects +from the extensions sees an empty advertisement from us, and both sides +fall back to the wire negotiation, the spike's own fallback rule (the security pair records the same reasoning). `MuxSession.openStream` carrying a `ProtocolId` (the mux pair, decisions) is a stream-level label, and it plays no part in @@ -121,8 +123,8 @@ connection-level selection. The lists are parameters, and a new `SecurityProtocol` or `Muxer` value lands with no edit to the upgrader (PRINCIPLES 1.3). 8. **Errors map to `Core.Error` once, at the upgrade boundary** (§3.5). - The core reports `Negotiate.Error`; `Upgrade.lean` lifts it with one - `Except.mapError`, and a failed identity check reports + The core reports `Negotiate.Error`; `Upgrade.lean` maps it through + one function, `mapNegotiateError`, and a failed identity check reports `Core.Error.authentication`. The host matches on `Core.Error` alone. ## Decisions @@ -136,8 +138,9 @@ connection-level selection. document listed `Action.error`. Root §3.3 fixes the core shape as `step : State → Event → Except ProtocolError (State × List Action)`, so a failure leaves through the `Except` channel and never as an - action. The spike wrote `na` and then failed on a bad first header; - this core returns the error and writes nothing. + action. In phase `headerPending`, this core returns the error and + writes nothing. The upgrader starts its responder in `negotiating`, + so a bad first frame there reads as a proposal and draws `na`. - **Wire-based muxer selection, against the spike's extension selection.** The section above records it. - **The identity check sits between the handshake and the muxer stage, @@ -172,8 +175,9 @@ connection-level selection. pair's rule (the security pair records the same import). No import of `libp2p/security`, `libp2p/mux`, `libp2p/host`, or `libp2p/protocols`: the ordered lists carry the implementations. -- From the net group, `net/streams` for `Connection`, `BufReader`, and - `readExactly` (§2: libp2p → net), recorded per the core pair's rule. +- From the net group, `net/streams` for `Connection`, `BufReader`, + `Stream`, and the deadline constant (§2: libp2p → net), recorded per + the core pair's rule. The negotiation shell reads raw chunks through its own `BufReader`, one reader per stage (§4.7, §5.6). The frame decode and the cap live in the core, so the package has no `Framing` import. From 78aa924a7ca7995fd3b0e30f6ef4fb349387ae78 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:37:12 +0800 Subject: [PATCH 021/125] fix(protocols): validate identify addresses with the real multiaddr decoder The identify validate now checks every listen and observed address by decoding its bytes with Multiformats.Multiaddr.ofBytes? instead of the local mock predicate. The mock module and its architecture row are gone. The invalid-address tests use a truncated ip4 component, because the real decoder accepts the empty address. Identify and ping name the Streams modules they use instead of the package root. Mock/Core.lean still imports the Streams root, so Identify and Ping still reach Streams.Tcp through that mock. The package docs now describe the current validation, cap, push, and ping behavior. --- Tests/Protocols.lean | 27 +++-- .../protocols/Libp2p/Protocols/Constants.lean | 3 +- .../protocols/Libp2p/Protocols/Identify.lean | 68 ++++++----- .../Libp2p/Protocols/Mock/Identity.lean | 4 +- .../Libp2p/Protocols/Mock/Multiaddr.lean | 21 ---- .../protocols/Libp2p/Protocols/Ping.lean | 15 ++- .../libp2p/protocols/docs/ARCHITECTURE.md | 107 ++++++++++-------- packages/libp2p/protocols/docs/PLAN.md | 4 +- 8 files changed, 124 insertions(+), 125 deletions(-) delete mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean diff --git a/Tests/Protocols.lean b/Tests/Protocols.lean index 67d245a1..a3d1dff5 100644 --- a/Tests/Protocols.lean +++ b/Tests/Protocols.lean @@ -3,10 +3,11 @@ import Proto import Bytes import Varint import Conc -import Streams +import Streams.Error +import Streams.Framing +import Streams.Stream import Libp2p.Protocols import Libp2p.Protocols.Mock.Identity -import Libp2p.Protocols.Mock.Multiaddr import Libp2p.Protocols.Mock.Core import Libp2p.Protocols.Mock.Host @@ -17,8 +18,9 @@ Mirrors `vendor/specs/ping/ping.md` (32-byte echo, protocol id `/ipfs/ping/1.0.0`) and the captured go-libp2p Identify protobuf (`identify-go.json`). The suite replays the vector and drives the pure `Identify.validate` and `Ping.checkEcho` checks. The mock key -and multiaddr stand-ins run their executable bodies here, so every -mock outcome of the validate appears in a check. +and host stand-ins run their executable bodies here, and the address +checks run the real Multiformats decoder, so every validate outcome +appears in a check. The exchange cases cover the identify client against the responder handler, the push handler against the in-memory peerstore double, the @@ -34,7 +36,6 @@ namespace Tests.Protocols open Libp2p.Protocols open Libp2p.Protocols.Mock.Identity -open Libp2p.Protocols.Mock.Multiaddr /-- The model decode over the byte list. -/ private def decodeModel (bs : List Nat) : Except Pb.Error (Identify × List Nat) := @@ -315,7 +316,7 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do (failsWith (Ping.Error.wrongSize 33) res) tc.close - -- ping: an echo over one payload, split across chunks, is rejected + -- ping: an echo whose last read crosses 32 bytes is rejected do let pair ← mkPair let tc ← Conc.TestClock.new @@ -453,8 +454,8 @@ def run (r : Runner) : IO Unit := do (m.protocols == wantProtocols) r.check "identify vector agentVersion equals JSON agentVersion" (m.agentVersion == some wantAgent) - -- the binary form of /ip4/127.0.0.1/tcp/35371; the real - -- Multiaddr text codec is unmerged + -- the binary form of /ip4/127.0.0.1/tcp/35371; the wire + -- carries bytes, not text r.check "identify vector listenAddrs has one entry, the binary form of JSON listenAddrs[0]" (m.listenAddrs == [[4, 127, 0, 0, 1, 6, 138, 43]]) -- the full captured bytes, prefix and payload, through `clientCore` @@ -469,7 +470,8 @@ def run (r : Runner) : IO Unit := do r.skip "identify vector peer id derivation skipped" "the peer id derivation is an opaque mock; identity #11 replaces it" - -- validate: rejection and acceptance, through the mock bodies + -- validate: rejection and acceptance, through the mock key and the + -- real address decoder r.check "identify over-cap payload rejected before decode" (failsWith Identify.Error.overCap (Identify.validate (List.replicate 65537 0) none)) @@ -486,10 +488,13 @@ def run (r : Runner) : IO Unit := do r.check "identify key mismatch rejected" (failsWith Identify.Error.keyMismatch (Identify.validate (msgPayload mismatchMsg) (some otherId))) - let badListen := { emptyIdentify with publicKey := some [1, 2], listenAddrs := [[]] } + -- `[4]` is an ip4 tag with no address bytes: the real decoder + -- rejects it. The empty list is a valid empty address, so it cannot + -- stand in for a bad address. + let badListen := { emptyIdentify with publicKey := some [1, 2], listenAddrs := [[4]] } r.check "identify invalid listen address rejected" (failsWith Identify.Error.badAddress (Identify.validate (msgPayload badListen) none)) - let badObs := { emptyIdentify with publicKey := some [1, 2], observedAddr := some [] } + let badObs := { emptyIdentify with publicKey := some [1, 2], observedAddr := some [4] } r.check "identify invalid observed address rejected" (failsWith Identify.Error.badAddress (Identify.validate (msgPayload badObs) none)) -- a continuation byte with no terminator: truncated framing diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean b/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean index 7159b0d1..cce18df1 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean @@ -3,7 +3,8 @@ import Libp2p.Protocols.Mock.Core /-! # Protocol constants -One home per literal (DRY). The module holds the protocol ids of +Other modules read these values from this module and do not repeat the +literals. The module holds the protocol ids of identify, identify push, and ping, the identify message cap, the identify protocol version, and the ping payload size. -/ diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean index 565f57d9..96a48dcb 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean @@ -1,9 +1,11 @@ import Proto import Bytes import Conc -import Streams +import Streams.Error +import Streams.Framing +import Streams.Stream +import Multiformats.Multiaddr.Binary import Libp2p.Protocols.Mock.Identity -import Libp2p.Protocols.Mock.Multiaddr import Libp2p.Protocols.Mock.Host import Libp2p.Protocols.Constants @@ -11,9 +13,9 @@ import Libp2p.Protocols.Constants # Identify The pure half of `/ipfs/id/1.0.0`: the `Identify` message with its -`ProtoMessage` instance, the strict field table, the validate, and the -table theorems. The IO half: the responder, the client, the push -handler, and the theorems on `clientCore`. +`ProtoMessage` instance, the strict field table, the validate, the +table theorems, and `clientCore` with its theorems. The IO half: the +responder, the client, and the push handler. The wire shape comes from go-libp2p v0.50.0 `identify.pb.go`: fields 1, 2, 3, 4, 5, 6, and 8. Field 8 is the signed peer record. The emit @@ -25,14 +27,12 @@ wrong wire type, or a present string field with invalid UTF-8, is permissive: entries of another wire type are skipped, and invalid UTF-8 entries are dropped, as the Proto accessors already do. -The strings read back through the real `Bytes.toString?`. `Pb.getStr?` -runs the opaque mock of `Pb.Mock.Bytes`, which no theorem can relate to -`Bytes.ofString`. +The strings read back through `Bytes.toString?`. The private read loop `readFrameFrom` is a `partial def`: a silent peer never ends the loop, and the deadline of `client` or `push` ends it. `client` and `push` read one frame per stream and drop the bytes -after it. Go merges chunked identify messages, a recorded delta. +after it. Go merges chunked identify messages; this client reads one. The read rejects a zero-length frame, the spike's rule. -/ @@ -56,7 +56,7 @@ structure Identify where namespace Identify -open Mock.Identity Mock.Multiaddr +open Mock.Identity /-- The identify protocol id. -/ def protocolId : Mock.Core.ProtocolId := Constants.identifyId @@ -64,9 +64,9 @@ def protocolId : Mock.Core.ProtocolId := Constants.identifyId /-- The identify push protocol id. -/ def pushId : Mock.Core.ProtocolId := Constants.identifyPushId -/-- The wire view: one entry per present field, in the ascending -order 1, 2, 3, 4, 5, 6, 8. Strings go to bytes with the real -`Bytes` package. -/ +/-- The wire view: one entry per present singular field and per +element of a repeated field, in the ascending order 1, 2, 3, 4, 5, 6, +8. Strings go to bytes with the real `Bytes` package. -/ def entries (m : Identify) : List Pb.Entry := m.publicKey.toList.map (fun b => (1, Pb.Value.bytes b)) ++ m.listenAddrs.map (fun b => (2, Pb.Value.bytes b)) @@ -204,18 +204,26 @@ inductive Error where | stream (e : Streams.Error) deriving DecidableEq, BEq, Repr +/-- A binary address is valid exactly when the real Multiformats codec +decodes it. The decoder accepts the empty address; a truncated or +unknown component fails. -/ +def validAddress (bytes : List Nat) : Bool := + (Multiformats.Multiaddr.ofBytes? bytes).isOk + /-- The address checks: every listening address and the observed -address, when present, must be valid bytes. -/ +address, when present, must decode as a multiaddr. -/ private def checkAddrs (m : Identify) (k : Mock.Identity.PubKey) : Except Error (Identify × Mock.Identity.PeerId) := - if ∀ a ∈ m.listenAddrs, validBytes a then - if ∀ a ∈ m.observedAddr.toList, validBytes a then + if ∀ a ∈ m.listenAddrs, validAddress a then + if ∀ a ∈ m.observedAddr.toList, validAddress a then .ok (m, peerIdOfPubKey k) else .error .badAddress else .error .badAddress -/-- Validate one received response: the derived peer id must match -the expected id, and the size must be at most the cap. -/ +/-- Validate one received response: the size cap, the decode, the +presence and parse of `publicKey`, the derived id when the caller +supplies one, and every listen and observed address through +`Multiformats.Multiaddr.ofBytes?`. -/ def validate (payload : List Nat) (expected : Option Mock.Identity.PeerId) : Except Error (Identify × Mock.Identity.PeerId) := if Constants.identifyCap < payload.length then .error .overCap @@ -239,12 +247,12 @@ are all valid. -/ private theorem checkAddrsSound (m : Identify) (k : Mock.Identity.PubKey) (m' : Identify) (pid : Mock.Identity.PeerId) (h : checkAddrs m k = .ok (m', pid)) : - (∀ a ∈ m.listenAddrs, validBytes a) ∧ - (∀ a ∈ m.observedAddr.toList, validBytes a) ∧ + (∀ a ∈ m.listenAddrs, validAddress a) ∧ + (∀ a ∈ m.observedAddr.toList, validAddress a) ∧ m' = m ∧ pid = peerIdOfPubKey k := by unfold checkAddrs at h - by_cases ha : ∀ a ∈ m.listenAddrs, validBytes a - · by_cases ho : ∀ a ∈ m.observedAddr.toList, validBytes a + by_cases ha : ∀ a ∈ m.listenAddrs, validAddress a + · by_cases ho : ∀ a ∈ m.observedAddr.toList, validAddress a · rw [ite_eq_left ha, ite_eq_left ho] at h simp only [Except.ok.injEq, Prod.mk.injEq] at h obtain ⟨rfl, rfl⟩ := h @@ -276,8 +284,8 @@ theorem validateAccepts (payload : List Nat) (m : Identify) (kb : List Nat) (hpub : m.publicKey = some kb) (hun : Mock.Identity.unmarshalPubKey? kb = some k) (hderive : ∀ e, expected = some e → Mock.Identity.peerIdOfPubKey k = e) - (haddrs : ∀ a ∈ m.listenAddrs, validBytes a) - (hobs : ∀ a ∈ m.observedAddr.toList, validBytes a) : + (haddrs : ∀ a ∈ m.listenAddrs, validAddress a) + (hobs : ∀ a ∈ m.observedAddr.toList, validAddress a) : validate payload expected = .ok (m, peerIdOfPubKey k) := by unfold validate rw [ite_eq_right (by omega)] @@ -319,8 +327,8 @@ theorem validateSound (payload : List Nat) (expected : Option Mock.Identity.Peer ∃ kb k, m.publicKey = some kb ∧ Mock.Identity.unmarshalPubKey? kb = some k ∧ pid = Mock.Identity.peerIdOfPubKey k ∧ (∀ e, expected = some e → Mock.Identity.peerIdOfPubKey k = e) ∧ - (∀ a ∈ m.listenAddrs, validBytes a) ∧ - (∀ a ∈ m.observedAddr.toList, validBytes a) := by + (∀ a ∈ m.listenAddrs, validAddress a) ∧ + (∀ a ∈ m.observedAddr.toList, validAddress a) := by unfold validate at h by_cases hcap : Constants.identifyCap < payload.length · rw [ite_eq_left hcap] at h @@ -412,8 +420,8 @@ path runs. -/ over `Constants.identifyCap`, or a payload value of 256 or more. `Frame.decode?` checks the cap before it takes the body. -`clientCore` and `readFrameFrom` both call it, so the theorems and the -IO path share every wire decision. -/ +`clientCore` and `readFrameFrom` both call it. Only `readFrameFrom` +decides the end of the stream, and no theorem covers that decision. -/ private def decodeFrame? (bytes : List Nat) : Except Error (Option (List Nat × List Nat)) := match Streams.Framing.Frame.decode? .uvarint Constants.identifyCap bytes with @@ -460,8 +468,8 @@ private def readFrame (s : Streams.Stream) : /-- `withinDeadline` needs a `BaseIO` expiry. The stream's `close` is the write half-close, so it does not wake a parked `read`. `reset` stops both directions, so it wakes the read. The body starts the reset with -`asTask`, as `Conc.Wrap.spawn` does. No owner joins this task, and the -code ignores its result. -/ +`asTask`, as `Conc.Wrap.spawn` does. Nothing joins this task and the +code ignores its result, an exception to §4.1 rule 2. -/ private def resetQuietly (s : Streams.Stream) : BaseIO Unit := discard (s.reset.asTask.toBaseIO) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean index 4f1f40ff..bcc689fd 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean @@ -11,8 +11,8 @@ leaves the signatures open. This mock fixes minimal signatures. replaces this module at integration. The stand-in bodies are executable, so the suite can drive both outcomes -of `unmarshalPubKey?`. The theorems of `Identify` stay true for any -replacement, because they are conditional on these opaque results. +of `unmarshalPubKey?`. The theorems take these results as premises, so a +replacement with the same signatures keeps them. -/ set_option autoImplicit false diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean deleted file mode 100644 index 4f93d1bb..00000000 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean +++ /dev/null @@ -1,21 +0,0 @@ -import Std - -/-! -# Multiaddr mock - -`Multiformats` (issue #8, PR #36, unmerged) owns the real -`Multiaddr.ofByteArray?`. This mock declares the minimal validity -predicate that the identify validate needs. The real check replaces -it at integration. --/ - -set_option autoImplicit false - -namespace Libp2p.Protocols.Mock.Multiaddr - -/-- Executable stand-in: the empty address is invalid, every other -buffer is valid. -/ -opaque validBytes (bs : List Nat) : Bool := - !bs.isEmpty - -end Libp2p.Protocols.Mock.Multiaddr diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean b/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean index c066077e..77b3d86d 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean @@ -1,6 +1,7 @@ import Bytes import Conc -import Streams +import Streams.Error +import Streams.Stream import Libp2p.Protocols.Mock.Core import Libp2p.Protocols.Constants @@ -12,14 +13,13 @@ size, and the echo check. The IO half: the echo handler and the initiator, which share the pure `splitPayloads` and its theorems. Per `vendor/specs/ping/ping.md`: the initiator sends 32 random -bytes, and the responder echoes them. A payload of another size, or -an echo that differs from the sent payload, is a protocol error. +bytes, and the responder echoes them. In this package, the initiator +treats an echo of another size or other bytes as a protocol error. The private echo loop `echoLoop` and the initiator's `awaitEcho` are `partial def`s: the peer may stay silent, and no measure shows that a -read returns. A partial payload at the end of the stream is -`.truncated`, a recorded delta from the spike's responder, which -closes silently. +read returns. A stream that ends before one whole payload arrives is +`.truncated`. -/ set_option autoImplicit false @@ -37,8 +37,7 @@ def payloadSize : Nat := Constants.pingSize inductive Error where | wrongSize (got : Nat) | mismatch - /-- The stream ended with a partial payload, a recorded delta from - the spike's responder. -/ + /-- The stream ended before one whole payload arrived. -/ | truncated /-- The stream returned an error. -/ | stream (e : Streams.Error) diff --git a/packages/libp2p/protocols/docs/ARCHITECTURE.md b/packages/libp2p/protocols/docs/ARCHITECTURE.md index 7f3a2afe..8171057b 100644 --- a/packages/libp2p/protocols/docs/ARCHITECTURE.md +++ b/packages/libp2p/protocols/docs/ARCHITECTURE.md @@ -16,10 +16,10 @@ group is §7, row 10). The spike has no req/resp or gossipsub code. | Module | Holds, and why | |---|---| | `Libp2p/Protocols.lean` | the root module; one import line per public submodule | -| `Libp2p/Protocols/Constants.lean` | the protocol ids and the caps: `/ipfs/id/1.0.0`, `/ipfs/id/push/1.0.0`, `/ipfs/ping/1.0.0`, the `/eth2/.../ssz_sn` pattern of §8, gossipsub's `/meshsub/1.1.0`, one message cap per protocol, and the protocol version that the identify responder writes; one home per fact (§3.8, §3.4) | +| `Libp2p/Protocols/Constants.lean` | the protocol ids and the caps: `/ipfs/id/1.0.0`, `/ipfs/id/push/1.0.0`, `/ipfs/ping/1.0.0`, the `/eth2/.../ssz_sn` pattern of §8, gossipsub's `/meshsub/1.1.0`, one message cap per protocol, and the protocol version that the identify responder writes; other modules read these values from this module and do not repeat the literals (§3.8, §3.4) | | `Libp2p/Protocols/Identify.lean` | the `Identify` message with its `ProtoMessage` instance (§3.2), `Identify.Error`, the validate, the client that reads and validates one response, and the handler values for identify and push | | `Libp2p/Protocols/Ping.lean` | `Ping.Error`, the echo handler, and the initiator that sends 32 bytes and awaits the echo | -| `Libp2p/Protocols/Mock/{Identity,Multiaddr,Core,Host}.lean` | the mocks of the unmerged dependencies (RULES.md, "Working ahead of the tree"); the integration deletes them | +| `Libp2p/Protocols/Mock/{Identity,Core,Host}.lean` | the mocks of the unmerged dependencies (RULES.md, "Working ahead of the tree"); the integration deletes them | | `Libp2p/Protocols/ReqResp/Frame.lean` | the `/eth2/.../ssz_sn` framing, a uvarint length over a snappy-compressed chunk, as a `Codec` with a `LawfulCodec` instance (§3.2); the snappy call goes through the `codec/snappy` binding (root PLAN W6.2); the codec feeds the core | | `Libp2p/Protocols/ReqResp/Core.lean` | the response-chunk state machine of §3.3: `State`, `Event`, `Action`, the error inductive, and the total `step`; the snappy decompression context carries across chunks, and the chunk count and the error/EOF distinction live in the state | | `Libp2p/Protocols/ReqResp/Exchange.lean` | the client and handler seam over the loop: the client function, the handler value the registry stores, and the deadlines | @@ -38,42 +38,41 @@ to `libp2p/security` beside the handshake it serves. The `Envelope` and `PeerRecord` tables stay deferred (§8; §10, row 17). One protocol per module line keeps the responsibility split of PRINCIPLES 1.2; this package holds four protocols, and no module holds two. The echo demo of -§2's tree comment (`identify, ping, echo (demo)`) lands here as the -example handler value the demo node registers. §4.9 names no echo, so -it takes no module line and lives with the test support. +§2's tree comment (`identify, ping, echo (demo)`) is not in this +package; §4.9 names no echo, so no module line covers it. ## Public surface - **Identify.** `Identify.protocolId` and `Identify.pushId` are the `ProtocolId` constants (§3.4 names `Identify.protocolId` as the root example; the literals live only in `Constants.lean`). - When the caller supplies the connected peer id, `Identify.validate` - checks that the id derived from `publicKey` matches it. The function - also checks that the message size is at most the cap. The client (the - spike's `requestR` shape) reads - one response, validates it, and returns the metadata for the - peerstore. The client reads one frame per stream and drops the bytes - after it. Go merges up to 10 frames; this is a recorded delta. The + `Identify.validate` checks, in order: the size cap, the decode, the + presence and parse of `publicKey`, the derived id when the caller + supplies one, and every listen and observed address through + `Multiformats.Multiaddr.ofBytes?`. The client (the spike's `requestR` + shape) reads one response, validates it, and returns the metadata for + the peerstore. The client reads one frame per stream and drops the + bytes after it. Go merges up to 10 frames; this client reads one. The two handler values serve inbound streams and register under their ids. The responder builds the message from the host's identity, addresses, protocol list, observed address, and agent version, as the - spike's `respond` does. The push sender is deferred. A received push - updates the peerstore through the host. The push stores the - addresses. The peerstore surface of the host doc holds addresses - only, so a push's protocols list has no store yet. This is a recorded - delta from go, whose push handler updates both. A push must carry the - public key. The identify spec lets a peer send a partial update and - says to ignore missing fields. `push` runs `validate`, so a push with - no `publicKey` field fails with `.missingKey`. This is a recorded - delta. + spike's `respond` does. The push sender is not in this package yet; + root W5.7 lists identify (+ push). A received push updates the + peerstore through the host. The push stores the addresses. The + peerstore surface of the host doc holds addresses only, so a push's + protocols list has no store yet. Go's push handler stores both. A + push frame must carry the public key. The spec says to ignore missing + fields; this handler rejects the push: `push` runs `validate`, so a + push with no `publicKey` field fails with `.missingKey`. A stream + that ends before any byte returns `.ok ()` and leaves the peerstore + unchanged; go clears the peer's protocols. - **Ping.** `Ping.protocolId` is the constant. The handler echoes each 32-byte payload until the stream closes (the spike's `handle`). The - initiator sends 32 bytes, awaits the echo, and checks the length; - the spike's `Ping.ping` is the shape. The initiator's echo read - carries no deadline yet. The deadline rule of §5.4 binds the - unauthenticated phase only. A silent peer parks the initiator until - the stream closes. This deferral is a recorded delta from the - spike. + initiator sends 32 bytes, awaits the echo, and checks its length and + bytes; the spike's `Ping.ping` is the shape. The initiator's echo + read has no deadline, as in the spike. §5.4 requires a deadline only + in the unauthenticated phase. A silent peer parks the initiator until + the stream closes. - **Req/resp.** The framing codec feeds the core. The client side dials a stream through the host, sends one request under the framing, and awaits the response chunks under a deadline. The handler side serves @@ -84,38 +83,45 @@ it takes no module line and lives with the test support. over the loop; the mesh decisions live in the core, and no caller sees them. -The client sides live in this package. §2 bans protocol logic in -`cli/`, and the spike's `Main.lean` and `PingNode.lean` call exactly -these: `Identify.client` (the spike's `requestR` over a bare stream), `Ping.ping`, -and the echo demo handler. +The client sides live in this package because §2 bans protocol logic in +`cli/`. `Identify.client` replaces the spike's `Identify.request` +(`requestR` over a bare stream), which the spike's `Main.lean` calls. +Both spike programs call `Ping.ping`. ## Owned contracts and how the package keeps them 1. **Every length-prefixed read is capped before allocation** (§5.1). - Ping's cap is the spec's fixed 32 bytes. Identify's cap is inherited - from the spike: 65536 bytes, and a zero length is a protocol error - (the spike's `requestR`). Unit 1 inherits the number, or records a - change with a reason. Unit 2 fixes the req/resp uvarint maximum - against the eth2 framing rules, and unit 3 fixes the gossipsub RPC - maximum against the go implementation's default, which the W6.4 gate - checks. Each number lands in `Constants.lean`. An over-long length - is a protocol error, and the message is never allocated. + Ping has no length prefix. Its payload size is the spec's 32 bytes, + and `awaitEcho` rejects a longer accumulator after the read. + Identify's cap is inherited from the spike: 65536 bytes, and a zero + length is a protocol error (the spike's `requestR`). The package + keeps that number in `Constants.identifyCap`. Unit 2 fixes the + req/resp uvarint maximum against the eth2 framing rules, and unit 3 + fixes the gossipsub RPC maximum against the go implementation's + default, which the W6.4 gate checks. Each number lands in + `Constants.lean`. An over-long length is a protocol error, and the + message is never allocated. 2. **A protocol failure is distinct from a clean stream end** (§5.2; root PLAN W6.3's deliverable). The §3.5 rule governs: a package that holds several independent protocols owns one error inductive per protocol, each mapped into `Core.Error` at its own seam. This package holds four, so `Identify.Error`, `Ping.Error`, the req/resp - error in `ReqResp/Core.lean`, and `ProtocolError` each map once, at - its own seam value, with `Except.mapError`. A framing failure is - `Except E`. A peer that finished its half of the exchange is `none`. + error in `ReqResp/Core.lean`, and `ProtocolError` are mapped at + their seam values. `Identify.Error` maps in `pushHandler` with + `Except.mapError` and directly in `respondTo`. A framing failure is + `Except E`. A peer that finished its half of the exchange is `none`; + `Ping.ping` is the exception: a stream end before the echo is + `.truncated`, because the exchange did not finish. No function reports both through one `Option`. 3. **Req/resp exchanges run under deadlines** (root PLAN W6.3). The mechanism is `withinDeadline` (§5.4). A response in flight fails at its deadline. An idle connection between exchanges never expires, per §5.4's scope rule. -4. **Identify and ping take no `step`** (§3.3, last rule). Each has a - pure codec, a pure `validate`, and a short IO function that reads - one message, validates it, and answers. +4. **Identify and ping take no `step`** (§3.3, last rule). Identify has + a pure codec and a pure `validate`. Ping has the pure `checkEcho` + and no codec. `Identify.client` reads and validates one frame, + `Identify.respondTo` writes one frame, and the ping handler echoes + until the stream closes. 5. **The gossipsub router is one pure `step` with time as an `Event.tick`** (§3.3; root PLAN W6.4). Mesh, scoring, and heartbeat hold in the same `State`, and every timer fires as an event. The @@ -127,8 +133,8 @@ and the echo demo handler. 6. **Signing policies are a recorded decision** (root PLAN W6.4 names the space). The vendored pubsub spec fixes two axes, signature creation and signature verification. Unit 3 records the - choice against the spec's options; the go and rust interop of the - W6.4 gate is the constraint that keeps the choice honest. + choice against the spec's options. The chosen policy must pass the + go and rust interop tests of the W6.4 gate. 7. **SSZ stays client-side** (§8). The package frames opaque payload bytes and never encodes or decodes SSZ. A consumer project brings the payload types. @@ -143,9 +149,10 @@ and the echo demo handler. (§8 lists both as new). Unit 2 designs the framing and the response-chunk core from §3.3 and the eth2 framing rules the root records: uvarint length, snappy, and client-side SSZ. Unit 3 designs - the router from §3.3 and the vendored gossipsub spec. The W6.4 gate, - the interop suite against go and rust with no behavior difference, - closes conformance where no vectors exist. + the router from §3.3 and the vendored gossipsub spec. The W6.4 gate + runs the interop suite against go and rust and requires no behavior + difference. It replaces conformance vectors, which do not exist for + these protocols. - **Snappy arrives only through the binding of root PLAN W6.2** (§4 preamble: FFI first). The owner decided the home: `codec/snappy`, a standalone binding package in the codec group. diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index 1f3d9362..ce10a765 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -60,8 +60,8 @@ in the codec group wrapping upstream `google/snappy`. the load check. - The ping initiator rejects a wrong length: the spike's `Ping.ping` sends 32 bytes, awaits the echo, and fails on any other size. A - partial payload at EOF is a protocol error, a recorded delta from the - spike's responder, which closes silently (§5.2). + stream that ends before one whole payload arrives is a protocol error + (§5.2). - The suite passes under `+RTS -N1`. ## Test milestones (unit 2) From c6e926be868701ce2f95418b54be138690ebd4ec Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:33:26 +0800 Subject: [PATCH 022/125] docs(protocols): give the trickle regressions an owner in the plan Issue #23's done-when names trickle regressions, and no package document named them. Unit 3b owns the loop that schedules the delay, so the row is recorded under that unit. --- packages/libp2p/protocols/docs/PLAN.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index 1f3d9362..6e7257e6 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -23,6 +23,10 @@ sub-split below, with the reasons. Unit 0 is done. +Unit 3b owns the trickle regressions that issue #23's done-when names. The pure `step` decides +which peer receives which gossip on a tick. The randomised delay that spreads those messages +across the heartbeat interval belongs to the loop, and the regression is a test of that loop. + The splits follow the deliverable size. Unit 2a is pure codec work that starts when the snappy binding exists, and unit 2b needs the host's streams for the core's loop and the exchange, so the two wait on From 35196cd2ddeed377a32a86d50bb14558febf6eb6 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:29:46 +0800 Subject: [PATCH 023/125] feat(protocols): add the identify push sender The done-list of the issue names identify with push. The receiver landed earlier; this adds the sender. `Identify.pushTo` writes the local record on the stream that the host opened under `pushId`, and then half-closes. The host owns the schedule. Go's identify service pushes one record per local address or protocol set change (the spike's `vendor/go-libp2p/p2p/protocol/identify/id.go`, the `loop` and `sendPushes` functions), so the sender starts no timer. The package docs record that decision, and the package plan gains the sender milestone. The responder and the sender share one private writer, so a push and a response carry identical wire bytes by construction. The new test runs the sender against the push handler over the stream pair, and reads the addresses back from the peerstore double. Local checks on this head: the protocols module and its suite build with `--wfail` (71 jobs); the suite passes in isolation with 46 passed and 0 failed; the import lint checks 102 module files with 0 violations. The full suite runs in CI. --- Tests/Protocols.lean | 20 ++++++++++++ .../protocols/Libp2p/Protocols/Identify.lean | 32 ++++++++++++++++--- .../libp2p/protocols/docs/ARCHITECTURE.md | 11 +++++-- packages/libp2p/protocols/docs/PLAN.md | 4 +++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/Tests/Protocols.lean b/Tests/Protocols.lean index a3d1dff5..9af49dec 100644 --- a/Tests/Protocols.lean +++ b/Tests/Protocols.lean @@ -210,6 +210,26 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do (stored == [addr]) tc.close + -- identify: the push sender announces the record and the receiver stores it + do + let pair ← mkPair + let tc ← Conc.TestClock.new + let store ← Mock.Host.inMemory + let key : PubKey := ⟨.ed25519, [7, 8]⟩ + let pid : PeerId := peerIdOfPubKey key + let addr := [4, 127, 0, 0, 1, 6, 138, 43] + let ts ← Conc.TaskSet.new + ts.spawn do + runHandler (Identify.pushHandler store pid tc.clock 1000) pair.b + let sent ← Identify.pushTo key [addr] [] none "" none pair.a + ts.joinAll + let stored ← store.addrsOf pid + r.check "identify e2e push sender announces the record" + ((match sent with + | Except.ok _ => true + | Except.error _ => false) && stored == [addr]) + tc.close + -- identify: the client rejects an over-cap frame before the body do let pair ← mkPair diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean index 96a48dcb..e6be2ddd 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean @@ -519,11 +519,12 @@ def pushHandler (store : Mock.Host.Peerstore) (expected : Mock.Identity.PeerId) handle := fun s => ExceptT.mk ((Except.mapError mapIdentifyError) <$> push store expected clock timeoutMs s) } -/-- The responder body. Write one length-delimited message and -half-close. -/ -def respondTo (key : Mock.Identity.PubKey) (addrs : List (List Nat)) +/-- Write one length-delimited message and half-close. The +responder and the push sender share this body: both announce the same +record, and only the protocol id of the stream differs. -/ +private def writeRecord (key : Mock.Identity.PubKey) (addrs : List (List Nat)) (protocols : List String) (observed : Option (List Nat)) (agent : String) - (spr : Option (List Nat) := none) (s : Streams.Stream) : + (spr : Option (List Nat)) (s : Streams.Stream) : Conc.Async (Except Mock.Core.Error Unit) := do match respond? key addrs protocols observed agent spr with | .error e => return .error (mapIdentifyError e) @@ -538,6 +539,29 @@ def respondTo (key : Mock.Identity.PubKey) (addrs : List (List Nat)) discard s.close return .ok () +/-- The responder body. Write one length-delimited message and +half-close. -/ +def respondTo (key : Mock.Identity.PubKey) (addrs : List (List Nat)) + (protocols : List String) (observed : Option (List Nat)) (agent : String) + (spr : Option (List Nat) := none) (s : Streams.Stream) : + Conc.Async (Except Mock.Core.Error Unit) := + writeRecord key addrs protocols observed agent spr s + +/-- The push sender: announce this peer's record on the stream that the +host opened under `pushId`, then half-close. + +The host owns the schedule. Go's identify service subscribes to +`EvtLocalAddressesUpdated` and `EvtLocalProtocolsUpdated` and pushes +one record per event (the spike's `vendor/go-libp2p/p2p/protocol/identify/id.go`, +the `loop` and `sendPushes` functions). This package sends one record +per call and starts no timer, so the host decides when the local record +changed. -/ +def pushTo (key : Mock.Identity.PubKey) (addrs : List (List Nat)) + (protocols : List String) (observed : Option (List Nat)) (agent : String) + (spr : Option (List Nat) := none) (s : Streams.Stream) : + Conc.Async (Except Mock.Core.Error Unit) := + writeRecord key addrs protocols observed agent spr s + /-- The responder handler value. -/ def respondHandler (key : Mock.Identity.PubKey) (addrs : List (List Nat)) (protocols : List String) (observed : Option (List Nat)) (agent : String) diff --git a/packages/libp2p/protocols/docs/ARCHITECTURE.md b/packages/libp2p/protocols/docs/ARCHITECTURE.md index 8171057b..e9a7fb87 100644 --- a/packages/libp2p/protocols/docs/ARCHITECTURE.md +++ b/packages/libp2p/protocols/docs/ARCHITECTURE.md @@ -17,7 +17,7 @@ group is §7, row 10). The spike has no req/resp or gossipsub code. |---|---| | `Libp2p/Protocols.lean` | the root module; one import line per public submodule | | `Libp2p/Protocols/Constants.lean` | the protocol ids and the caps: `/ipfs/id/1.0.0`, `/ipfs/id/push/1.0.0`, `/ipfs/ping/1.0.0`, the `/eth2/.../ssz_sn` pattern of §8, gossipsub's `/meshsub/1.1.0`, one message cap per protocol, and the protocol version that the identify responder writes; other modules read these values from this module and do not repeat the literals (§3.8, §3.4) | -| `Libp2p/Protocols/Identify.lean` | the `Identify` message with its `ProtoMessage` instance (§3.2), `Identify.Error`, the validate, the client that reads and validates one response, and the handler values for identify and push | +| `Libp2p/Protocols/Identify.lean` | the `Identify` message with its `ProtoMessage` instance (§3.2), `Identify.Error`, the validate, the client that reads and validates one response, the push sender, and the handler values for identify and push | | `Libp2p/Protocols/Ping.lean` | `Ping.Error`, the echo handler, and the initiator that sends 32 bytes and awaits the echo | | `Libp2p/Protocols/Mock/{Identity,Core,Host}.lean` | the mocks of the unmerged dependencies (RULES.md, "Working ahead of the tree"); the integration deletes them | | `Libp2p/Protocols/ReqResp/Frame.lean` | the `/eth2/.../ssz_sn` framing, a uvarint length over a snappy-compressed chunk, as a `Codec` with a `LawfulCodec` instance (§3.2); the snappy call goes through the `codec/snappy` binding (root PLAN W6.2); the codec feeds the core | @@ -56,8 +56,13 @@ package; §4.9 names no echo, so no module line covers it. two handler values serve inbound streams and register under their ids. The responder builds the message from the host's identity, addresses, protocol list, observed address, and agent version, as the - spike's `respond` does. The push sender is not in this package yet; - root W5.7 lists identify (+ push). A received push updates the + spike's `respond` does. `Identify.pushTo` announces the same record + on the stream that the host opened under `pushId`, and then + half-closes. The host owns the schedule: go's identify service pushes + one record per local address or protocol set change (the spike's + `vendor/go-libp2p/p2p/protocol/identify/id.go`, the `loop` and + `sendPushes` functions), so the sender starts no timer and the host + decides when the local record changed. A received push updates the peerstore through the host. The push stores the addresses. The peerstore surface of the host doc holds addresses only, so a push's protocols list has no store yet. Go's push handler stores both. A diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index ce10a765..1e0a9889 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -55,6 +55,10 @@ in the codec group wrapping upstream `google/snappy`. the connected peer, and a message over the cap. - A push through the push handler updates the peerstore through the host, and the entry reads back. +- The push sender announces the local record and the receiver stores + it: `Identify.pushTo` writes one frame on the push stream, the push + handler on the other end validates it, and `addrsOf` reads the + addresses back. - The ping handler echoes 32 bytes and repeats until the stream closes. The concurrent-ping legs of the spike's 1000-stream stress stay as the load check. From 190b2dee60090ac68ccd3a5e6e4f36b22d259c5f Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:44:30 +0800 Subject: [PATCH 024/125] fix(protocols): tighten the push sender schedule and test the half-close The review of the previous commit found three gaps. First, the schedule wording claimed one push per event; go skips a push when the snapshot did not change, merges pending pushes into one round, and skips a connection that already carries the current record. Second, the half-close was untested because the test stream `close` is a no-op; the test now closes the pipe and asserts the clean end. Third, two contract sentences still named `respondTo` where the shared writer lives. The module doc, the package docs, and the error message now say record where they mean the identify message. The docs record the go delta: go splits a record that carries a signed peer record and passes 2 KiB into two frames, and caps one response at 8 KiB, while this package writes one frame and caps at 64 KiB. Local checks on this head: the protocols module and its suite build with `--wfail` (71 jobs); the suite passes in isolation with 47 passed and 0 failed, including the new half-close check; the import lint checks 102 module files with 0 violations. --- Tests/Protocols.lean | 5 +++- .../protocols/Libp2p/Protocols/Identify.lean | 19 +++++++------- .../libp2p/protocols/docs/ARCHITECTURE.md | 25 +++++++++++-------- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/Tests/Protocols.lean b/Tests/Protocols.lean index 9af49dec..552eec3e 100644 --- a/Tests/Protocols.lean +++ b/Tests/Protocols.lean @@ -221,13 +221,16 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do let ts ← Conc.TaskSet.new ts.spawn do runHandler (Identify.pushHandler store pid tc.clock 1000) pair.b - let sent ← Identify.pushTo key [addr] [] none "" none pair.a + let sent ← Identify.pushTo key [addr] [] none "" none + { pair.a with close := pair.ab.close } ts.joinAll + let ended ← pair.b.read let stored ← store.addrsOf pid r.check "identify e2e push sender announces the record" ((match sent with | Except.ok _ => true | Except.error _ => false) && stored == [addr]) + r.check "identify e2e push sender half-closes the stream" (isOkNone ended) tc.close -- identify: the client rejects an over-cap frame before the body diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean index e6be2ddd..b20e7885 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean @@ -15,7 +15,7 @@ import Libp2p.Protocols.Constants The pure half of `/ipfs/id/1.0.0`: the `Identify` message with its `ProtoMessage` instance, the strict field table, the validate, the table theorems, and `clientCore` with its theorems. The IO half: the -responder, the client, and the push handler. +responder, the client, the push handler, and the push sender. The wire shape comes from go-libp2p v0.50.0 `identify.pb.go`: fields 1, 2, 3, 4, 5, 6, and 8. Field 8 is the signed peer record. The emit @@ -486,7 +486,7 @@ def client (s : Streams.Stream) (clock : Conc.Clock) (timeoutMs : Nat) | .ok (.ok (some payload)) => return (some <$> validate payload expected) /-- The one map from this protocol's error into the coarse core error. -`pushHandler` and `respondTo` call it at the seam. -/ +`pushHandler` and `writeRecord` call it at the seam. -/ def mapIdentifyError : Error → Mock.Core.Error | .stream e => Mock.Core.mapStreamError e | .timeout => .timeout "identify: the deadline expired" @@ -531,7 +531,7 @@ private def writeRecord (key : Mock.Identity.PubKey) (addrs : List (List Nat)) | .ok msg => match Streams.Framing.encodeB? .uvarint Constants.identifyCap (Pb.encodeMessageB msg) with - | none => return .error (.protocol "the identify response exceeds the cap") + | none => return .error (.protocol "the identify record exceeds the cap") | some frame => match ← s.write frame with | .error e => return .error (Mock.Core.mapStreamError e) @@ -550,12 +550,13 @@ def respondTo (key : Mock.Identity.PubKey) (addrs : List (List Nat)) /-- The push sender: announce this peer's record on the stream that the host opened under `pushId`, then half-close. -The host owns the schedule. Go's identify service subscribes to -`EvtLocalAddressesUpdated` and `EvtLocalProtocolsUpdated` and pushes -one record per event (the spike's `vendor/go-libp2p/p2p/protocol/identify/id.go`, -the `loop` and `sendPushes` functions). This package sends one record -per call and starts no timer, so the host decides when the local record -changed. -/ +The host calls the sender after the local address set or the local +protocol set changes. Go's identify service skips a push when the +snapshot did not change, merges pending pushes into one round, and +skips a connection that already carries the current record (the +spike's `vendor/go-libp2p/p2p/protocol/identify/id.go`, the `loop` and +`sendPushes` functions). This package sends one record per call and +starts no timer. -/ def pushTo (key : Mock.Identity.PubKey) (addrs : List (List Nat)) (protocols : List String) (observed : Option (List Nat)) (agent : String) (spr : Option (List Nat) := none) (s : Streams.Stream) : diff --git a/packages/libp2p/protocols/docs/ARCHITECTURE.md b/packages/libp2p/protocols/docs/ARCHITECTURE.md index e9a7fb87..fed7849b 100644 --- a/packages/libp2p/protocols/docs/ARCHITECTURE.md +++ b/packages/libp2p/protocols/docs/ARCHITECTURE.md @@ -52,18 +52,23 @@ package; §4.9 names no echo, so no module line covers it. `Multiformats.Multiaddr.ofBytes?`. The client (the spike's `requestR` shape) reads one response, validates it, and returns the metadata for the peerstore. The client reads one frame per stream and drops the - bytes after it. Go merges up to 10 frames; this client reads one. The + bytes after it. Go merges up to 10 frames; this client reads one. Go + splits a record that carries a signed peer record and passes 2 KiB + into two frames, and caps one response at 8 KiB; this package writes + one frame and caps at 64 KiB. The two handler values serve inbound streams and register under their ids. The responder builds the message from the host's identity, addresses, protocol list, observed address, and agent version, as the spike's `respond` does. `Identify.pushTo` announces the same record on the stream that the host opened under `pushId`, and then - half-closes. The host owns the schedule: go's identify service pushes - one record per local address or protocol set change (the spike's - `vendor/go-libp2p/p2p/protocol/identify/id.go`, the `loop` and - `sendPushes` functions), so the sender starts no timer and the host - decides when the local record changed. A received push updates the - peerstore through the host. The push stores the addresses. The + half-closes. The host calls the sender after the local address set or + the local protocol set changes. Go's identify service skips a push + when the snapshot did not change, merges pending pushes into one + round, and skips a connection that already carries the current record + (the spike's `vendor/go-libp2p/p2p/protocol/identify/id.go`, the + `loop` and `sendPushes` functions). This package sends one record per + call and starts no timer. A received push updates the peerstore + through the host. The push stores the addresses. The peerstore surface of the host doc holds addresses only, so a push's protocols list has no store yet. Go's push handler stores both. A push frame must carry the public key. The spec says to ignore missing @@ -113,7 +118,7 @@ Both spike programs call `Ping.ping`. package holds four, so `Identify.Error`, `Ping.Error`, the req/resp error in `ReqResp/Core.lean`, and `ProtocolError` are mapped at their seam values. `Identify.Error` maps in `pushHandler` with - `Except.mapError` and directly in `respondTo`. A framing failure is + `Except.mapError` and directly in `writeRecord`. A framing failure is `Except E`. A peer that finished its half of the exchange is `none`; `Ping.ping` is the exception: a stream end before the echo is `.truncated`, because the exchange did not finish. @@ -125,8 +130,8 @@ Both spike programs call `Ping.ping`. 4. **Identify and ping take no `step`** (§3.3, last rule). Identify has a pure codec and a pure `validate`. Ping has the pure `checkEcho` and no codec. `Identify.client` reads and validates one frame, - `Identify.respondTo` writes one frame, and the ping handler echoes - until the stream closes. + `Identify.respondTo` and `Identify.pushTo` write one record, and the + ping handler echoes until the stream closes. 5. **The gossipsub router is one pure `step` with time as an `Event.tick`** (§3.3; root PLAN W6.4). Mesh, scoring, and heartbeat hold in the same `State`, and every timer fires as an event. The From cefa7e2f6c355b0b80510db045f1207a864d889a Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:41:14 +0800 Subject: [PATCH 025/125] feat(protocols): add the host seam and the in-memory switch to the mocks --- .../protocols/Libp2p/Protocols/Mock/Core.lean | 10 +- .../protocols/Libp2p/Protocols/Mock/Host.lean | 149 +++++++++++++++++- 2 files changed, 156 insertions(+), 3 deletions(-) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean index 8b3f0f9b..6ef4a895 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean @@ -1,6 +1,7 @@ import Std import Conc import Streams +import Libp2p.Protocols.Mock.Identity /-! # Core seam mock @@ -30,10 +31,15 @@ inductive Error where deriving DecidableEq, BEq, Repr /-- The handler seam: the id it registers under, and the callback a -served stream reaches. -/ +served stream reaches. The host looks a handler up by the protocol id +alone, and it knows the peer at the call site, so the seam carries the +peer. A gossipsub handler needs that id, because the core scores and +filters by the sender of each RPC. -/ structure StreamHandler where + /-- The id the handler serves. -/ id : ProtocolId - handle : Streams.Stream → ExceptT Error Conc.Async Unit + /-- The offered stream and the peer that opened it. -/ + handle : Identity.PeerId → Streams.Stream → ExceptT Error Conc.Async Unit /-- One map from a stream error to the coarse error, shared by every handler of this package. `closed` stays closed, `reset` is a protocol diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean index 295aa6f2..be4cb610 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean @@ -1,9 +1,11 @@ import Std import Conc +import Streams import Libp2p.Protocols.Mock.Identity +import Libp2p.Protocols.Mock.Core /-! -# Host peerstore mock +# Host seam mock `Libp2p.Host` (issue #19) is not merged. This mock declares the peerstore surface that the identify push handler updates, from @@ -11,6 +13,11 @@ peerstore surface that the identify push handler updates, from at add, and `addrsOf`. The host doc freezes the names and the dedup rule, and it leaves the signatures open. This mock fixes minimal signatures. The real host package replaces this module at integration. + +The module also declares `HostSeam`, the two calls the gossipsub loop +makes, and `Switch`, an in-memory double of that seam. The field names +follow PR #61 (`Host.newStream`, `Host.setHandler`), so the swap at +integration touches one file. -/ set_option autoImplicit false @@ -44,4 +51,144 @@ opaque inMemory : IO Peerstore := do return (cur.find? (fun e => e.1 == p)).map (·.2) |>.getD [] return { addAddrs, addrsOf } +/-! ## The stream seam of a loop -/ + +/-- What a protocol loop calls out to. -/ +structure HostSeam where + /-- Open and negotiate one stream to the peer, choosing among the + protocols. -/ + newStream : PeerId → List Core.ProtocolId → + Conc.Async (Except Core.Error (Core.ProtocolId × Streams.Stream)) + /-- Register the handler of one protocol id. -/ + setHandler : Core.StreamHandler → BaseIO Unit + +/-! ## The in-memory switch + +The switch stands in for the session table of the host and its serve +task. Two peers meet over a pair of pipes, with no transport. -/ + +/-- One peer of the switch. -/ +private structure Switch.Entry where + /-- The peer. -/ + peer : PeerId + /-- The handlers the peer registered, the oldest first. -/ + handlers : List Core.StreamHandler + /-- The owner of the handler tasks of the inbound streams of the peer. -/ + tasks : Conc.TaskSet + +/-- The state of a switch. -/ +private structure Switch.State where + /-- The peers. -/ + entries : List Switch.Entry + /-- Every pipe the switch opened. The list lives as long as the switch, + which a test double allows. -/ + pipes : List Conc.Pipe + /-- True after `Switch.close`. -/ + closed : Bool + +/-- The in-memory double of the session table: the peers, their handlers, +and the pipes of their streams. -/ +structure Switch where + private state : Conc.OnceRef Switch.State + +namespace Switch + +/-- A switch with no peers. -/ +def new : BaseIO Switch := + return ⟨← Conc.OnceRef.new { entries := [], pipes := [], closed := false }⟩ + +/-- A stream over a pair of pipes. `read` reads `incoming`, and `write` +writes `outgoing`. `close` closes `outgoing`, the half-close. `reset` +closes both, so the peer of a reset reads the end of the stream and its +writes fail. -/ +private def streamOf (incoming outgoing : Conc.Pipe) : Streams.Stream where + read := do + let chunk ← incoming.read + return .ok chunk + write bytes := do + match ← outgoing.write bytes with + | .ok () => return .ok () + | .error _ => return .error .closed + close := (outgoing.close : BaseIO Unit) + reset := do + (incoming.close : BaseIO Unit) + (outgoing.close : BaseIO Unit) + +/-- The first protocol of the list that the handlers serve, with its +handler. -/ +private def choose (handlers : List Core.StreamHandler) : + List Core.ProtocolId → Option (Core.ProtocolId × Core.StreamHandler) + | [] => none + | p :: ps => + match handlers.find? (fun h => decide (h.id = p)) with + | some h => some (p, h) + | none => choose handlers ps + +/-- Run one handler on an inbound stream. A handler error fails the task, +so the `joinAll` of the owner reports it. -/ +private def serve (handler : Core.StreamHandler) (peer : PeerId) + (stream : Streams.Stream) : Conc.Async Unit := do + match ← (handler.handle peer stream).run with + | .ok () => return () + | .error e => throw <| IO.userError s!"handler {handler.id.toString}: {repr e}" + +/-- Open a stream from `me` to `peer`: pick the protocol, make the pipes, +spawn the handler on the task set of `peer`, and answer the initiator +stream. The call never parks. Eight chunks is far more than one RPC frame +needs, and the bound stays explicit. -/ +private def open_ (sw : Switch) (me peer : PeerId) (protocols : List Core.ProtocolId) : + BaseIO (Except Core.Error (Core.ProtocolId × Streams.Stream)) := do + let st ← sw.state.get + let some entry := st.entries.find? (fun e => decide (e.peer = peer)) + | return .error (.protocol s!"no peer {peer.text}") + let some (id, handler) := choose entry.handlers protocols + | return .error (.protocol s!"peer {peer.text} serves none of the protocols") + let toPeer ← Conc.Pipe.new 8 + let fromPeer ← Conc.Pipe.new 8 + -- One atomic step checks the flag and records the pipes, so `close` + -- sees every pipe of a stream that opens before it. + let opened ← sw.state.updateGet fun s => + if s.closed then (false, s) + else (true, { s with pipes := toPeer :: fromPeer :: s.pipes }) + if !opened then return .error (.closed "the switch closed") + entry.tasks.spawn (serve handler me (streamOf toPeer fromPeer)) + return .ok (id, streamOf fromPeer toPeer) + +/-- The seam of the peer `me`, and the task set that owns the handler +tasks of its inbound streams. Each peer starts with an empty handler +table, and a handler registered through one seam serves only that peer. -/ +def host (sw : Switch) (me : PeerId) : BaseIO (HostSeam × Conc.TaskSet) := do + let tasks ← Conc.TaskSet.new + sw.state.update fun s => + { s with + entries := { peer := me, handlers := [], tasks } :: + s.entries.filter (fun e => decide (e.peer ≠ me)) } + let seam : HostSeam := + { newStream := fun peer protocols => (sw.open_ me peer protocols : BaseIO _) + setHandler := fun handler => + sw.state.update fun s => + { s with + entries := s.entries.map fun e => + if e.peer = me then { e with handlers := e.handlers ++ [handler] } else e } } + return (seam, tasks) + +/-- End every stream the switch opened, then join the handler tasks +(ARCHITECTURE §4.1, rule 2). A close wakes every parked read and write. +A failed handler task does not stop the joins of the other peers. The call +throws the first error after the last join. A second call does nothing. -/ +def close (sw : Switch) : Conc.Async Unit := do + let st ← (sw.state.updateGet (fun s => (s, { s with closed := true, pipes := [] })) : + BaseIO Switch.State) + for p in st.pipes do + (p.close : BaseIO Unit) + let mut failure : Option IO.Error := none + for e in st.entries do + try + e.tasks.joinAll + catch err => + failure := failure <|> some err + if let some err := failure then throw err + +end Switch + end Libp2p.Protocols.Mock.Host From ccd0207d2ba04b113fff85e5b3942a04031e8196 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:41:14 +0800 Subject: [PATCH 026/125] feat(protocols): W6.4 unit 3b, the gossipsub loop over the core --- .../libp2p/protocols/Libp2p/Protocols.lean | 1 + .../Libp2p/Protocols/Gossipsub/Node.lean | 404 ++++++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean diff --git a/packages/libp2p/protocols/Libp2p/Protocols.lean b/packages/libp2p/protocols/Libp2p/Protocols.lean index 24df8765..d2c870ca 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols.lean @@ -5,6 +5,7 @@ import Libp2p.Protocols.Gossipsub.Score import Libp2p.Protocols.Gossipsub.Cache import Libp2p.Protocols.Gossipsub.Sign import Libp2p.Protocols.Gossipsub.Core +import Libp2p.Protocols.Gossipsub.Node /-! # Libp2p.Protocols diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean new file mode 100644 index 00000000..cacb6c0b --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -0,0 +1,404 @@ +import Conc +import Streams +import Bytes +import Libp2p.Protocols.Constants +import Libp2p.Protocols.Mock.Core +import Libp2p.Protocols.Mock.Host +import Libp2p.Protocols.Gossipsub.Core + +/-! +# Gossipsub node: the loop over the router core + +The core of this unit is the pure `Core.step`. This module holds the one +task that drives it, and the publish and subscribe surface at the seam +level (ARCHITECTURE, "Modules"; root ARCHITECTURE §3.3). + +## The loop + +One task owns the router state. A bounded `Chan` carries the events of the +application and the host. The clock seam carries the heartbeat and the +delayed gossip. The loop parks in `select` over the two, so the timer costs +no task, and a `TestClock` drives every time decision of a test. + +`loop` and `readFrames` are `partial`. Each runs until a close reaches what +it parks on, and no measure bounds the number of events or chunks before +that close (root CLAUDE.md, "`partial def`"). + +## Ownership + +The task set of the node owns the loop task, and `stop` joins it. The host +owns the handler tasks of the inbound streams. Nothing cancels a task: +`stop` closes the event queue, and the close wakes the parked loop. The +router state and the pending gossip are local to the loop, so no cell +guards them. The subscription table is the one shared cell, and it changes +through `OnceRef.update` (root ARCHITECTURE §4.1). + +## Recorded decisions + +- **No signer.** The core validates with `strictNoSign` (`Core.validationOk`), + which requires `from` and `seqno` to be absent. So `publish` signs nothing + and never writes the peer id of the node. +- **A local publish delivers nothing locally.** `Core.publishLocal` emits no + deliver action, because the application already holds its own message. + The callback of the publisher stays silent. +- **The loop announces a subscription change.** `Core.join` sends the topics + only when a peer joins. The core emits nothing for a subscribe after the + join, so that peer would never learn the topic. The go implementation + announces a change in its host registry, outside the router. The loop + does the same with `subOptRpc`, after the core step. +- **The gossip spread is fixed.** The go implementation draws the delay of + its gossip from a random source. `defaultTrickle` is a fixed function, so a + run replays exactly. The tolerance of the unit 3c gate on latency is an + open question on the pull request. The peer order of a tick is the order + of the state, for the same reason. +- **One outbound stream per RPC.** `sendRpc` opens a stream, writes one frame, + and half-closes it. A send runs on the loop task, so a host whose + `newStream` parks also parks the loop. Two RPCs to one peer can reorder, + because each stream has its own handler task. The unit 3c gate decides if + this shape stays. +- **The inbound handler reads frames until the end of the stream.** A peer + that keeps one stream open and writes many frames gets every frame served. +- **The host posts `peerJoined` before it serves a stream of that peer.** The + core ignores an RPC from an unknown peer (`Core.rpcStep`), so an earlier + stream loses its RPC. +- **A send failure has no sink.** `sendRpc` answers `Unit`, and the mock host has + no failure log. The host reports stream failures at integration. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub.Node + +open Mock.Host (HostSeam) + +/-- The delay of the `i`-th gossip send of a heartbeat: a fixed spread below +one second, so two nodes do not line up and a run replays. The multiplier +is a prime, so the delays do not repeat with a short period. -/ +def defaultTrickle (i : Nat) : Millis := + (i * 7919 + 1) % 1000 + +/-- The configuration of one node: the router parameters, the clock the +loop reads, and the spread of the heartbeat gossip. -/ +structure Config where + /-- The router parameters. -/ + params : Core.Params := Core.defaults + /-- The clock of the loop's timer. -/ + clock : Conc.Clock := Conc.Clock.system + /-- The delay of the `i`-th gossip send of one heartbeat, in milliseconds. + The caller fixes it, so a test gets exact times. -/ + trickle : Nat → Millis := defaultTrickle + +/-- A running node. Every field is a seam over the loop task, which is the +only owner of the router state. -/ +structure Node where + /-- Publish the payload on the topic. `false` when the queue of the loop + is full or the node stopped. -/ + publish : TopicId → ByteArray → BaseIO Bool + /-- Register a delivery callback. The callback runs on the loop task, so + it must not park. `false` under the same conditions as `publish`, and the + callback then stays unregistered. -/ + subscribe : TopicId → (Message → BaseIO Unit) → BaseIO Bool + /-- Drop every callback of the topic, and leave the topic. `false` under the + same conditions as `publish`. -/ + unsubscribe : TopicId → BaseIO Bool + /-- Tell the loop that a peer joined. The connection notifiee of the host + is the caller at integration. -/ + peerJoined : Peer → Core.PubsubProto → BaseIO Bool + /-- Tell the loop that a peer left. -/ + peerLeft : Peer → BaseIO Bool + /-- Close the event queue and join the loop task. The loop serves the + queued events first, and drops the gossip that waits for its time. This + field runs in `Async` because a join parks, and a parked `BaseIO` action would + hold a thread. A second call does nothing. -/ + stop : Conc.Async Unit + +/-! ## One outbound RPC -/ + +/-- True when the control message of the RPC holds an IHAVE. The loop +delays these sends. `Core.rpcHasIhave` is a `Prop`, and the loop needs a `Bool`. -/ +def hasIhave (r : Rpc.RPC) : Bool := + match r.control with + | some c => !c.ihave.isEmpty + | none => false + +/-- One subscription record: `true` subscribes, and `false` unsubscribes. +`Core.helloRpc` holds only positive records, so the loop builds this record +beside the code that sends it. -/ +def subOptRpc (t : TopicId) (on : Bool) : Rpc.RPC := + { subscriptions := [{ subscribe := on, topicid := t.name }], publish := [], control := none } + +/-- Send one RPC on a new stream, and half-close the stream. An RPC over +`Constants.rpcCap` is never sent. Every failure ends this send and leaves +the loop running, and the module doc records why nothing reports it. -/ +def sendRpc (host : HostSeam) (to : Peer) (rpc : Rpc.RPC) : Conc.Async Unit := do + let some frame := Rpc.encodeFrame? rpc | return () + try + match ← host.newStream to [Constants.meshsubId] with + | .error _ => return () + | .ok (_, stream) => + match ← stream.write (Bytes.ofNatList frame) with + | .ok () => stream.close + | .error _ => stream.reset + catch _ => + return () + +/-! ## The inbound handler -/ + +/-- The bytes of `buf` from `pos` that `Rpc.decodeFrame?` needs for its answer. +`none` asks for another chunk. + +The prefix decides, so the decoder reads each frame once, and a peer that +sends many small chunks costs no repeated decode. A length over the cap, or +a bad prefix, needs only the prefix bytes. So the handler keeps at most one +frame under the cap and one chunk (root ARCHITECTURE §5.1). -/ +private def frameWindow (buf : ByteArray) (pos : Nat) : Option Nat := + let head := Bytes.toNatList (buf.extract pos (pos + Varint.maxBytes)) + match Streams.Framing.decodeLength? .uvarint head with + | .ok (len, rest) => + let whole := (head.length - rest.length) + len + if Constants.rpcCap < len then some head.length + else if pos + whole ≤ buf.size then some whole + else none + | .error .truncated => none + | .error .malformed => some head.length + +/-- Post every frame of the stream to the queue, until the stream ends. + +`pos` is the start of the next frame in `buf`, so a chunk that holds many +frames costs one pass over its bytes. The answer is `.error` for a bad frame, +for an end of stream inside a frame, and for a failed read. A closed queue +means the node stops, and the handler returns. -/ +private partial def readFrames (events : Conc.Chan Core.Event) (peer : Peer) + (stream : Streams.Stream) (buf : ByteArray) (pos : Nat) : + Conc.Async (Except Mock.Core.Error Unit) := do + match frameWindow buf pos with + | some n => + match Rpc.decodeFrame? (Bytes.toNatList (buf.extract pos (pos + n))) with + | .ok (some (payload, _)) => + -- Park: the loop frees a slot, or `stop` closes the queue. The window + -- holds one frame, so the rest is empty and the next frame starts at + -- `pos + n`. + match ← events.send (.rpc peer payload) with + | .ok () => readFrames events peer stream buf (pos + n) + | .error _ => return .ok () + | .ok none => return .error (.protocol "the RPC frame ended early") + | .error _ => return .error (.protocol "the RPC frame is malformed") + | none => + -- Park: the peer writes, half-closes, or resets. + match ← stream.read with + | .error e => return .error (Mock.Core.mapStreamError e) + | .ok none => + return if pos = buf.size then .ok () + else .error (.protocol "the stream ended inside an RPC frame") + | .ok (some chunk) => readFrames events peer stream (buf.extract pos buf.size ++ chunk) 0 + +/-- The handler of `Constants.meshsubId`. Each frame becomes one `Event.rpc` +with the opener of the stream as its sender. A protocol error resets the +stream and fails this handler. The loop keeps running. -/ +def handler (events : Conc.Chan Core.Event) : Mock.Core.StreamHandler where + id := Constants.meshsubId + handle peer stream := ExceptT.mk do + let answer ← readFrames events peer stream ByteArray.empty 0 + match answer with + | .ok () => stream.close + | .error _ => stream.reset + return answer + +/-! ## The subscription table -/ + +/-- One delivery callback, with the id that lets `subscribe` take it back. -/ +private structure Subscription where + /-- The id of this registration. -/ + id : Nat + /-- The topic. -/ + topic : TopicId + /-- The callback. -/ + callback : Message → BaseIO Unit + +/-- The shared table of the callbacks, in registration order. -/ +private structure Subscriptions where + /-- The next free id. -/ + next : Nat + /-- The callbacks. -/ + entries : List Subscription + +/-- Call every callback of the topic of `m`, in registration order. -/ +private def deliver (subs : Conc.OnceRef Subscriptions) (m : Message) : BaseIO Unit := do + for sub in (← subs.get).entries do + if sub.topic = m.topic then + sub.callback m + +/-! ## The loop -/ + +/-- What the loop reads and never changes. -/ +private structure Env where + /-- The host seam. -/ + host : HostSeam + /-- The configuration. -/ + config : Config + /-- The subscription table. -/ + subs : Conc.OnceRef Subscriptions + /-- The event queue. -/ + events : Conc.Chan Core.Event + +/-- One gossip send that waits for its due time. -/ +private structure Pending where + /-- The due time. -/ + due : Millis + /-- The receiver. -/ + peer : Peer + /-- The RPC. -/ + rpc : Rpc.RPC + +/-- The local state of the loop. -/ +private structure LoopState where + /-- The router state. -/ + router : Core.State + /-- The gossip sends that wait for their due time. -/ + pending : List Pending + /-- The time of the next heartbeat. -/ + nextTick : Millis + +/-- What wakes the loop: the timer, or the event queue. `none` is a closed +and empty queue. -/ +private inductive Wake where + | timer + | event (e : Option Core.Event) + +/-- Run the actions of one core step, in order, and answer the delayed +sends. `gossipAt` is the time of the tick when a tick made the actions. The +`i`-th IHAVE send of that tick waits for `trickle i` milliseconds, and every +other action runs now. -/ +private def perform (env : Env) (gossipAt : Option Millis) (actions : List Core.Action) : + Conc.Async (List Pending) := do + let mut delayed : Array Pending := #[] + for action in actions do + match action with + | .deliver m => (deliver env.subs m : BaseIO Unit) + | .sendRpc to rpc => + match gossipAt with + | some now => + if hasIhave rpc then + delayed := delayed.push + { due := now + env.config.trickle delayed.size, peer := to, rpc := rpc } + else + sendRpc env.host to rpc + | none => sendRpc env.host to rpc + return delayed.toList + +/-- One core step, then its actions. Only an RPC that does not decode makes +the core answer `.error`. That answer keeps the state, and the loop runs on. -/ +private def stepAndAct (env : Env) (st : LoopState) (event : Core.Event) : Conc.Async LoopState := do + match Core.step env.config.params st.router event with + | .error (.decode _) => return st + | .ok (router, actions) => + let gossipAt := match event with + | .tick now _ => some now + | _ => none + let delayed ← perform env gossipAt actions + return { st with router, pending := st.pending ++ delayed } + +/-- Tell every known peer about a subscription change (module doc). -/ +private def announce (env : Env) (router : Core.State) : Core.Event → Conc.Async Unit + | .subscribe t => do + for (peer, _) in router.peers do + sendRpc env.host peer (subOptRpc t true) + | .unsubscribe t => do + for (peer, _) in router.peers do + sendRpc env.host peer (subOptRpc t false) + | _ => return () + +/-- The loop. Each pass sends the gossip that came due, runs the heartbeat +when it came due, arms one timer for the next due time, and parks. + +A due time in the past gives a timer of 0 ms: the `Nat` subtraction stops at +0, and such a timer completes at once. The tick runs at the top of the pass, +so an event queue that stays full cannot hold a heartbeat back. The peer +order of a tick comes from the state of the loop, its only writer. -/ +private partial def loop (env : Env) (st : LoopState) : Conc.Async Unit := do + let clock := env.config.clock + -- The ascription marks the action `BaseIO`, so the lift into `Async` applies + -- (see `Conc.TaskSet.joinAll`). + let now ← (clock.nowMs : BaseIO Nat) + let (due, later) := st.pending.partition (fun p => decide (p.due ≤ now)) + for p in due.mergeSort (fun a b => decide (a.due ≤ b.due)) do + sendRpc env.host p.peer p.rpc + let st : LoopState := { st with pending := later } + let st ← + if st.nextTick ≤ now then do + let order := st.router.peers.map (·.1) + let st ← stepAndAct env st (.tick now order) + pure { st with nextTick := now + env.config.params.heartbeatInterval } + else + pure st + let wake := st.pending.foldl (fun m p => Nat.min m p.due) st.nextTick + let timer ← (clock.after (wake - now) : BaseIO (Conc.Park Unit)) + -- Park: a send or a close of the queue wakes it, and so does the timer. + match ← Conc.select #[Conc.Park.recv env.events Wake.event, timer.map fun () => Wake.timer] with + | .timer => loop env st + | .event none => return () + | .event (some e) => + let st ← stepAndAct env st e + announce env st.router e + loop env st + +/-- The router state of a node with no peers at time `now`. -/ +private def initialState (p : Core.Params) (now : Millis) : Core.State := + { peers := [] + mesh := [] + fanout := [] + mcache := Cache.MessageCache.empty p.window + seen := Cache.SeenCache.empty p.seen + backoff := [] + iwant := [] + topics := [] + now := now + lastHeartbeat := now } + +/-- The published message of a payload. The four signed fields stay absent, +as `strictNoSign` requires. -/ +private def messageOf (topic : TopicId) (payload : ByteArray) : Message := + { «from» := none + data := some (Bytes.toNatList payload) + seqno := none + topic := topic + signature := none + key := none } + +/-- Build the state, register the handler, and start the loop. The event +queue holds 64 events. A full queue parks an inbound handler, which carries +the back-pressure to the peer, and it refuses a `BaseIO` post of the +application. -/ +def start (host : HostSeam) (config : Config := {}) : BaseIO Node := do + let now ← config.clock.nowMs + let subs : Conc.OnceRef Subscriptions ← Conc.OnceRef.new { next := 0, entries := [] } + let events : Conc.Chan Core.Event ← Conc.Chan.new 64 + host.setHandler (handler events) + let tasks ← Conc.TaskSet.new + tasks.spawn <| loop { host, config, subs, events } + { router := initialState config.params now + pending := [] + nextTick := now + config.params.heartbeatInterval } + return { + publish := fun topic payload => events.trySend (.publish (messageOf topic payload)) + subscribe := fun topic callback => do + -- The cell changes first, so a delivery after the event finds the + -- callback. A refused event takes this one registration back. + let id ← subs.updateGet fun s => + (s.next, + { next := s.next + 1 + entries := s.entries ++ [{ id := s.next, topic, callback }] }) + let sent ← events.trySend (.subscribe topic) + if !sent then + subs.update fun s => { s with entries := s.entries.filter (fun e => e.id != id) } + return sent + unsubscribe := fun topic => do + subs.update fun s => { s with entries := s.entries.filter (fun e => e.topic != topic) } + events.trySend (.unsubscribe topic) + peerJoined := fun peer proto => events.trySend (.peerJoined peer proto) + peerLeft := fun peer => events.trySend (.peerLeft peer) + stop := do + (events.close : BaseIO Unit) + tasks.joinAll } + +end Libp2p.Protocols.Gossipsub.Node From 245165429265c6f003eb9d47d9a8caa22fd218ae Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:52:26 +0800 Subject: [PATCH 027/125] test(protocols): W6.4 unit 3b, the loop suite over the in-memory switch --- Tests/Gossipsub/Node.lean | 647 ++++++++++++++++++++++++++++++++++++++ Tests/Main.lean | 2 + 2 files changed, 649 insertions(+) create mode 100644 Tests/Gossipsub/Node.lean diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean new file mode 100644 index 00000000..b19f3cd9 --- /dev/null +++ b/Tests/Gossipsub/Node.lean @@ -0,0 +1,647 @@ +import Tests.Support +import Libp2p.Protocols.Gossipsub.Node + +/-! +# Gossipsub suite: the node loop + +The checks of unit 3b at the seam of the loop: two nodes over the in-memory +switch, the inbound handler, the heartbeat, the gossip spread, and the stop. +No check reads the router state. A check watches what the seam shows: a +delivery callback, or the RPCs that a probe peer decodes. + +## How a check waits + +Each node takes its own `TestClock`. The loop arms one timer in each pass, so +`settle tc n` returns after the loop has served `n - 1` events since its start +and parked again. A pass holds no other park, so the count gives an exact +order with no sleep. A wait for a timer or a delivery has a real deadline of +`patience` milliseconds, so a missing event fails its check. `stop` and +`Switch.close` have no deadline. Each one wakes what it joins with a close. + +A node posts an event per inbound RPC, so each count below names its events +in a comment. +-/ + +set_option autoImplicit false + +namespace Tests.Gossipsub.Node + +open Conc +open Libp2p.Protocols +open Libp2p.Protocols.Gossipsub +open Libp2p.Protocols.Gossipsub.Node +open Libp2p.Protocols.Mock.Host (Switch HostSeam) + +/-! ## Fixtures -/ + +private def ta : TopicId := ⟨"t"⟩ + +private def pa : Peer := ⟨[1], "a"⟩ +private def pb : Peer := ⟨[2], "b"⟩ +private def pc : Peer := ⟨[3], "c"⟩ +private def pd : Peer := ⟨[4], "d"⟩ +private def px : Peer := ⟨[9], "x"⟩ + +/-- The real deadline of every wait of this suite, in milliseconds. A +passing run never reaches it. -/ +private def patience : Nat := 5000 + +private def hello (t : TopicId) : Rpc.RPC := Core.helloRpc [t] + +private def msgOf (topic : TopicId) (payload : List Nat) : Message := + { «from» := none, data := some payload, seqno := none, topic := topic, + signature := none, key := none } + +/-- The bytes of one RPC frame. -/ +private def frameOf (rpc : Rpc.RPC) : List Nat := + (Rpc.encodeFrame? rpc).getD [] + +/-- A frame around a body that does not decode as an RPC. -/ +private def badBody : List Nat := [255] + +/-- The configuration of a node on a test clock. -/ +private def configOn (tc : TestClock) (params : Core.Params := Core.defaults) + (trickle : Nat → Millis := defaultTrickle) : Config := + { params, clock := tc.clock, trickle } + +/-- A delivery callback that keeps the message for the check. -/ +private def keep (inbox : Chan Message) : Message → BaseIO Unit := + fun m => discard (inbox.trySend m) + +/-- Wait until the loop has armed `n` more timers. `false` at the deadline. The +expiry closes the clock, and the close wakes `nextTimer`. -/ +private def settle (tc : TestClock) (n : Nat) : Async Bool := do + for _ in [0:n] do + match ← withinDeadline Clock.system patience tc.close tc.nextTimer with + | .ok true => pure () + | _ => return false + return true + +/-- The next value of the channel, or `none` at the deadline. -/ +private def within {α : Type} (c : Chan α) : Async (Option α) := do + let timer ← (Park.sleep patience none : BaseIO (Park (Option α))) + select #[Park.recv c id, timer] + +/-- True when the channel holds no value now. -/ +private def quiet {α : Type} (c : Chan α) : Async Bool := do + return (← (c.tryRecv : BaseIO (Option α))).isNone + +/-- True when the task set fails: its handler answered an error. -/ +private def joinFails (ts : TaskSet) : Async Bool := do + try + ts.joinAll + return false + catch _ => + return true + +/-- Stop the nodes, close the switch, and close the clocks. -/ +private def teardown (sw : Switch) (nodes : List Node) (clocks : List TestClock) : Async Unit := do + for n in nodes do + n.stop + sw.close + for c in clocks do + (c.close : BaseIO Unit) + +/-! ## The probe peer -/ + +/-- One RPC that a probe decoded. -/ +private structure Seen where + /-- The probe that decoded it. -/ + receiver : Peer + /-- The opener of the stream. -/ + sender : Peer + /-- The RPC. -/ + rpc : Rpc.RPC + +/-- Every byte of a stream. -/ +private partial def readAll (s : Streams.Stream) (acc : ByteArray) : Async ByteArray := do + match ← s.read with + | .ok (some chunk) => readAll s (acc ++ chunk) + | .ok none => return acc + | .error _ => return acc + +/-- A raw peer on the switch: its handler decodes each stream as one RPC and +keeps it for the check. A probe has no router, so a check sees exactly what +the node sent. -/ +private def probe (sw : Switch) (me : Peer) (seen : Chan Seen) : Async HostSeam := do + let (seam, _) ← (sw.host me : BaseIO (HostSeam × TaskSet)) + (seam.setHandler + { id := Constants.meshsubId + handle := fun sender stream => ExceptT.lift do + let bytes ← readAll stream ByteArray.empty + match Rpc.decodeFrame? (Bytes.toNatList bytes) with + | .ok (some (payload, _)) => + match Core.decodeRpc payload with + | .ok rpc => discard (seen.trySend { receiver := me, sender, rpc } : BaseIO Bool) + | .error _ => pure () + | _ => pure () } : BaseIO Unit) + return seam + +/-- Open a stream, write the chunks one by one, and half-close it. -/ +private def send (seam : HostSeam) (to : Peer) (chunks : List (List Nat)) : + Async (Option Streams.Stream) := do + match ← seam.newStream to [Constants.meshsubId] with + | .error _ => return none + | .ok (_, stream) => + for chunk in chunks do + discard <| stream.write (Bytes.ofNatList chunk) + stream.close + return some stream + +private def isGraftOf (t : TopicId) (rpc : Rpc.RPC) : Bool := + rpc.control.any (fun c => c.graft.any (fun g => g.topicId == t.name)) + +/-! ## The switch -/ + +/-- The switch contract: the handler table of each peer, the protocol choice, +the half-close, the reset, and the close. -/ +private def switchChecks (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let (seamA, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let (seamB, tasksB) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) + let echoId : Mock.Core.ProtocolId := ⟨"/echo"⟩ + let sender : OnceRef (Option Peer) ← (OnceRef.new none : BaseIO (OnceRef (Option Peer))) + (seamB.setHandler + { id := echoId + handle := fun from_ stream => ExceptT.lift do + (sender.set (some from_) : BaseIO Unit) + let bytes ← readAll stream ByteArray.empty + discard <| stream.write bytes + stream.close } : BaseIO Unit) + let unknownPeer ← seamA.newStream px [echoId] + r.check "switch.unknown-peer" (match unknownPeer with | .error (.protocol _) => true | _ => false) + let noProtocol ← seamA.newStream pb [⟨"/other"⟩] + r.check "switch.no-common-protocol" + (match noProtocol with | .error (.protocol _) => true | _ => false) + let otherSide ← seamB.newStream pa [echoId] + r.check "switch.a-handler-serves-its-own-peer" + (match otherSide with | .error (.protocol _) => true | _ => false) + match ← seamA.newStream pb [⟨"/other"⟩, echoId] with + | .error _ => r.check "switch.first-served-protocol" false + | .ok (id, stream) => + r.check "switch.first-served-protocol" (id == echoId) + discard <| stream.write (Bytes.ofNatList [1, 2, 3]) + stream.close + let echoed ← readAll stream ByteArray.empty + r.check "switch.half-close-keeps-the-read-direction" (Bytes.toNatList echoed == [1, 2, 3]) + tasksB.joinAll + r.check "switch.handler-gets-the-opener" ((← (sender.get : BaseIO (Option Peer))) == some pa) + let afterClose ← stream.write (Bytes.ofNatList [4]) + r.check "switch.write-after-close-fails" + (match afterClose with | .error .closed => true | _ => false) + -- A handler that parks on a read ends when the switch closes. + (seamA.setHandler + { id := echoId + handle := fun _ stream => ExceptT.lift do + discard <| readAll stream ByteArray.empty } : BaseIO Unit) + discard <| seamB.newStream pa [echoId] + sw.close + r.check "switch.close-ends-a-parked-handler" true + let afterSwitchClose ← seamB.newStream pa [echoId] + r.check "switch.no-stream-after-close" + (match afterSwitchClose with | .error (.closed _) => true | _ => false) + sw.close + r.check "switch.second-close-does-nothing" true + +/-! ## Two nodes -/ + +/-- Test 1: the whole path of one publish. The path covers `newStream`, the +handler, the frame decode, `Event.rpc`, the acceptance of the core, and the +delivery. -/ +private def twoNodes (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let tcA ← (TestClock.new : BaseIO TestClock) + let tcB ← (TestClock.new : BaseIO TestClock) + let (seamA, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let (seamB, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) + let a ← (start seamA (configOn tcA) : BaseIO Node) + let b ← (start seamB (configOn tcB) : BaseIO Node) + let inbox : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) + r.check "node.two.join-b" (← (b.peerJoined pa .meshsub : BaseIO Bool)) + -- B: the start, and the join. B knows A before the hello of A arrives. + r.check "node.two.b-knows-a" (← settle tcB 2) + r.check "node.two.subscribe" (← (a.subscribe ta (keep inbox) : BaseIO Bool)) + r.check "node.two.join-a" (← (a.peerJoined pb .meshsub : BaseIO Bool)) + -- A: the start, the hello of B, the subscribe, and the join. + r.check "node.two.a-settles" (← settle tcA 4) + -- B: the hello of A, which names the topic. + r.check "node.two.b-reads-the-hello" (← settle tcB 1) + r.check "node.two.publish" (← (b.publish ta (Bytes.ofNatList [7, 8, 9]) : BaseIO Bool)) + let got ← within inbox + r.check "node.two.delivery-has-the-payload" (got == some (msgOf ta [7, 8, 9])) + r.check "node.two.one-delivery" (← quiet inbox) + teardown sw [a, b] [tcA, tcB] + +/-- Test 2: a subscribe after the join reaches the peer, and a later +unsubscribe sends the negative record. -/ +private def announcement (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let tcA ← (TestClock.new : BaseIO TestClock) + let tcB ← (TestClock.new : BaseIO TestClock) + let (seamA, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let (seamB, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) + let a ← (start seamA (configOn tcA) : BaseIO Node) + let b ← (start seamB (configOn tcB) : BaseIO Node) + let inbox : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) + discard (b.peerJoined pa .meshsub : BaseIO Bool) + -- B: the start, and the join. + r.check "node.announce.b-knows-a" (← settle tcB 2) + discard (a.peerJoined pb .meshsub : BaseIO Bool) + -- A: the start, the hello of B, and the join. + r.check "node.announce.a-knows-b" (← settle tcA 3) + -- B: the empty hello of A. + r.check "node.announce.b-reads-the-empty-hello" (← settle tcB 1) + discard (a.subscribe ta (keep inbox) : BaseIO Bool) + -- B: the record of the subscribe. The hello carried no topic. + r.check "node.announce.b-reads-the-record" (← settle tcB 1) + discard (b.publish ta (Bytes.ofNatList [1]) : BaseIO Bool) + let got ← within inbox + r.check "node.announce.subscribe-after-join-reaches-the-peer" (got == some (msgOf ta [1])) + -- The fanout of B keeps A, so a later publish still reaches A. The callback + -- is gone. + discard (a.unsubscribe ta : BaseIO Bool) + discard (b.publish ta (Bytes.ofNatList [2]) : BaseIO Bool) + -- A: the subscribe, the first publish, the unsubscribe, and the second + -- publish. + r.check "node.announce.a-serves-the-leave" (← settle tcA 4) + r.check "node.announce.unsubscribe-drops-the-callback" (← quiet inbox) + teardown sw [a, b] [tcA, tcB] + -- The records at the seam: one positive, one negative. + let sw ← (Switch.new : BaseIO Switch) + let tc ← (TestClock.new : BaseIO TestClock) + let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) + discard <| probe sw pc seen + let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let n ← (start seam (configOn tc) : BaseIO Node) + discard (n.peerJoined pc .meshsub : BaseIO Bool) + let first ← within seen + r.check "node.announce.hello-has-no-topic" + (first.any (fun s => s.rpc.subscriptions.isEmpty)) + discard (n.subscribe ta (keep inbox) : BaseIO Bool) + let on ← within seen + r.check "node.announce.positive-record" (on.any (fun s => s.rpc == subOptRpc ta true)) + discard (n.unsubscribe ta : BaseIO Bool) + let off ← within seen + r.check "node.announce.negative-record" (off.any (fun s => s.rpc == subOptRpc ta false)) + r.check "node.announce.opener-is-the-node" (off.any (fun s => s.sender == pa && s.receiver == pc)) + teardown sw [n] [tc] + +/-! ## The inbound handler -/ + +/-- Test 3: a bad frame fails its handler and resets its stream, and a body +that does not decode leaves the loop running. The other checks pin the read +loop of the handler. -/ +private def handlerChecks (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let tc ← (TestClock.new : BaseIO TestClock) + let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) + let seamA ← probe sw pa seen + let (seamB, tasksB) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) + let b ← (start seamB (configOn tc) : BaseIO Node) + let inbox : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) + discard (b.subscribe ta (keep inbox) : BaseIO Bool) + discard (b.peerJoined pa .meshsub : BaseIO Bool) + -- B: the start, the subscribe, and the join. + r.check "node.handler.setup" (← settle tc 3) + -- A length over the cap: the handler fails before any body arrives. + let overCap := Streams.Framing.encodeLength .uvarint (Constants.rpcCap + 1) + let stream? ← send seamA pb [overCap] + r.check "node.handler.over-cap-fails" (← joinFails tasksB) + match stream? with + | none => r.check "node.handler.over-cap-resets" false + | some stream => + let written ← stream.write (Bytes.ofNatList [1]) + r.check "node.handler.over-cap-resets" + (match written with | .error .closed => true | _ => false) + -- A prefix that never ends. + discard <| send seamA pb [List.replicate 12 0x80] + r.check "node.handler.long-prefix-fails" (← joinFails tasksB) + -- The stream ends inside a frame. + discard <| send seamA pb [Streams.Framing.encodeLength .uvarint 5 ++ [1]] + r.check "node.handler.end-inside-a-frame-fails" (← joinFails tasksB) + -- The stream ends inside a prefix. + discard <| send seamA pb [[0x80]] + r.check "node.handler.end-inside-a-prefix-fails" (← joinFails tasksB) + -- A stream with no frame ends clean. + discard <| send seamA pb [] + r.check "node.handler.empty-stream-is-clean" (!(← joinFails tasksB)) + -- A frame whose body does not decode: the handler is clean, and the core + -- answers the error to the loop. + r.check "node.handler.body-does-not-decode" + (match Core.decodeRpc badBody with | .error _ => true | .ok _ => false) + discard <| send seamA pb [Streams.Framing.encodeLength .uvarint badBody.length ++ badBody] + r.check "node.handler.bad-body-reaches-the-loop" (← settle tc 1) + r.check "node.handler.bad-body-is-clean" (!(← joinFails tasksB)) + -- The loop survives: a later RPC gets delivered. + discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [1]))] + let got ← within inbox + r.check "node.handler.loop-survives-a-bad-body" (got == some (msgOf ta [1])) + -- Two frames in one chunk are two RPCs. + discard <| send seamA pb + [frameOf (Core.publishRpc (msgOf ta [2])) ++ frameOf (Core.publishRpc (msgOf ta [3]))] + let two ← within inbox + let three ← within inbox + r.check "node.handler.two-frames-in-one-chunk" + (two == some (msgOf ta [2]) && three == some (msgOf ta [3])) + -- One frame in single bytes is one RPC. + discard <| send seamA pb ((frameOf (Core.publishRpc (msgOf ta [4]))).map (fun x => [x])) + let four ← within inbox + r.check "node.handler.one-frame-in-single-bytes" (four == some (msgOf ta [4])) + r.check "node.handler.split-frame-is-clean" (!(← joinFails tasksB)) + -- A frame of a peer that left is ignored. The event of the leave comes + -- first, so the order holds. + discard (b.peerLeft pa : BaseIO Bool) + -- B: the four RPC frames since the bad body, and the leave. + r.check "node.handler.peer-left" (← settle tc 5) + discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [5]))] + r.check "node.handler.unknown-peer-reaches-the-loop" (← settle tc 1) + r.check "node.handler.unknown-peer-delivers-nothing" (← quiet inbox) + teardown sw [b] [tc] + +/-- The send path: an RPC over the cap opens no stream, and a peer with no +handler does not fault the caller. -/ +private def sendChecks (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let seen : Chan Seen ← (Chan.new 4 : BaseIO (Chan Seen)) + let seam ← probe sw pa seen + let opened : OnceRef Nat ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) + let counting : HostSeam := + { seam with + newStream := fun peer protocols => do + (opened.update (· + 1) : BaseIO Unit) + seam.newStream peer protocols } + sendRpc counting pa (Core.publishRpc (msgOf ta (List.replicate Constants.rpcCap 7))) + r.check "node.send.over-cap-opens-no-stream" ((← (opened.get : BaseIO Nat)) == 0) + sendRpc counting px (subOptRpc ta true) + r.check "node.send.no-peer-is-no-fault" ((← (opened.get : BaseIO Nat)) == 1) + sendRpc counting pa (subOptRpc ta true) + let got ← within seen + r.check "node.send.one-frame-round-trips" (got.any (fun s => s.rpc == subOptRpc ta true)) + sw.close + +/-! ## The heartbeat -/ + +/-- Test 4: a heartbeat fills the mesh. The probe sees the graft at the +seam, and a later publish of a real pair reaches the peer through the mesh. -/ +private def tickGrafts (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let tc ← (TestClock.new : BaseIO TestClock) + let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) + discard <| probe sw pc seen + let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let n ← (start seam (configOn tc) : BaseIO Node) + let inbox : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) + discard (n.subscribe ta (keep inbox) : BaseIO Bool) + discard (n.peerJoined pc .meshsub : BaseIO Bool) + -- The start, the subscribe, and the join. + r.check "node.tick.setup" (← settle tc 3) + let first ← within seen + r.check "node.tick.hello-names-the-topic" (first.any (fun s => s.rpc == hello ta)) + r.check "node.tick.no-graft-before-the-heartbeat" (← quiet seen) + (tc.advance Core.defaults.heartbeatInterval : BaseIO Unit) + let graft ← within seen + r.check "node.tick.heartbeat-sends-the-graft" (graft.any (fun s => isGraftOf ta s.rpc)) + -- The next heartbeat comes one interval later. The mesh is full, so it + -- sends nothing new. + r.check "node.tick.loop-rearms" (← settle tc 1) + teardown sw [n] [tc] + -- A real pair. + let sw ← (Switch.new : BaseIO Switch) + let tcA ← (TestClock.new : BaseIO TestClock) + let tcB ← (TestClock.new : BaseIO TestClock) + let (seamA, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let (seamB, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) + let a ← (start seamA (configOn tcA) : BaseIO Node) + let b ← (start seamB (configOn tcB) : BaseIO Node) + let inbox : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) + discard (b.subscribe ta (keep inbox) : BaseIO Bool) + discard (b.peerJoined pa .meshsub : BaseIO Bool) + -- B: the start, the subscribe, and the join. + r.check "node.tick.pair-b" (← settle tcB 3) + discard (a.subscribe ta (fun _ => pure ()) : BaseIO Bool) + discard (a.peerJoined pb .meshsub : BaseIO Bool) + discard (a.publish ta (Bytes.ofNatList [1]) : BaseIO Bool) + -- A: the start, the hello of B, the subscribe, the join, and the publish. + r.check "node.tick.pair-a" (← settle tcA 5) + r.check "node.tick.empty-mesh-sends-nothing" (← quiet inbox) + (tcA.advance Core.defaults.heartbeatInterval : BaseIO Unit) + -- The pass that runs the heartbeat arms the next timer after it. + r.check "node.tick.pair-heartbeat" (← settle tcA 1) + discard (a.publish ta (Bytes.ofNatList [2]) : BaseIO Bool) + let got ← within inbox + r.check "node.tick.publish-reaches-the-mesh" (got == some (msgOf ta [2])) + teardown sw [a, b] [tcA, tcB] + +/-- The parameters of the spread check: an empty mesh, so each peer takes the +gossip of the topic. -/ +private def gossipParams : Core.Params := + { Core.defaults with mesh := { d := 0, dLow := 0, dHigh := 0, dScore := 0, dLazy := 2 } } + +/-- Test 5: the trickle regression. The `i`-th IHAVE of one heartbeat leaves at +the time of the heartbeat plus `trickle i`, and no earlier. -/ +private def trickleSpread (r : Tests.Runner) : Async Unit := do + r.check "node.trickle.default-is-below-one-second" + ((List.range 64).all (fun i => defaultTrickle i < 1000)) + r.check "node.trickle.default-spreads" (defaultTrickle 0 != defaultTrickle 1) + let sw ← (Switch.new : BaseIO Switch) + let tc ← (TestClock.new : BaseIO TestClock) + let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) + discard <| probe sw pc seen + discard <| probe sw pd seen + let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let spread : Nat → Millis := fun i => (i + 1) * 10 + let n ← (start seam (configOn tc gossipParams spread) : BaseIO Node) + discard (n.subscribe ta (fun _ => pure ()) : BaseIO Bool) + discard (n.peerJoined pc .meshsub : BaseIO Bool) + discard (n.peerJoined pd .meshsub : BaseIO Bool) + discard (n.publish ta (Bytes.ofNatList [1]) : BaseIO Bool) + -- The start, the subscribe, two joins, and the publish. + r.check "node.trickle.setup" (← settle tc 5) + let hello1 ← within seen + let hello2 ← within seen + r.check "node.trickle.hellos" + (hello1.any (fun s => !hasIhave s.rpc) && hello2.any (fun s => !hasIhave s.rpc)) + (tc.advance gossipParams.heartbeatInterval : BaseIO Unit) + -- The heartbeat ran, and the loop armed the timer of the first send. + r.check "node.trickle.heartbeat" (← settle tc 1) + r.check "node.trickle.no-send-at-the-heartbeat" (← quiet seen) + (tc.advance 9 : BaseIO Unit) + r.check "node.trickle.no-send-before-its-time" (← quiet seen) + (tc.advance 1 : BaseIO Unit) + let first ← within seen + r.check "node.trickle.first-send-at-10-ms" (first.any (fun s => hasIhave s.rpc)) + -- The loop sent the first, and armed the timer of the second. + r.check "node.trickle.first-pass" (← settle tc 1) + r.check "node.trickle.second-waits-for-the-clock" (← quiet seen) + (tc.advance 9 : BaseIO Unit) + r.check "node.trickle.second-not-before-its-time" (← quiet seen) + (tc.advance 1 : BaseIO Unit) + let second ← within seen + r.check "node.trickle.second-send-at-20-ms" (second.any (fun s => hasIhave s.rpc)) + r.check "node.trickle.two-receivers" + (match first, second with + | some x, some y => x.receiver != y.receiver + | _, _ => false) + teardown sw [n] [tc] + +/-- The production clock: a real heartbeat sends the graft, and `stop` wakes a +loop that parks on a real timer. `Config` defaults to this clock. -/ +private def systemClock (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) + discard <| probe sw pc seen + let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let n ← (start seam { params := { Core.defaults with heartbeatInterval := 20 } } : BaseIO Node) + discard (n.subscribe ta (fun _ => pure ()) : BaseIO Bool) + discard (n.peerJoined pc .meshsub : BaseIO Bool) + let first ← within seen + r.check "node.system.hello" (first.any (fun s => s.rpc == hello ta)) + let graft ← within seen + r.check "node.system.real-heartbeat-sends-the-graft" (graft.any (fun s => isGraftOf ta s.rpc)) + n.stop + r.check "node.system.stop-wakes-a-timer-park" true + sw.close + +/-! ## The callbacks and the queue -/ + +/-- The callbacks of a topic run in registration order, and the callback of +another topic stays silent. -/ +private def callbackOrder (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let tc ← (TestClock.new : BaseIO TestClock) + let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) + let seamA ← probe sw pa seen + let (seamB, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) + let b ← (start seamB (configOn tc) : BaseIO Node) + let order : Chan Nat ← (Chan.new 8 : BaseIO (Chan Nat)) + let tag : Nat → Message → BaseIO Unit := fun n _ => discard (order.trySend n) + discard (b.subscribe ta (tag 1) : BaseIO Bool) + discard (b.subscribe ⟨"u"⟩ (tag 9) : BaseIO Bool) + discard (b.subscribe ta (tag 2) : BaseIO Bool) + discard (b.peerJoined pa .meshsub : BaseIO Bool) + -- The start, three subscribes, and the join. + r.check "node.callbacks.setup" (← settle tc 5) + discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [1]))] + let one ← within order + let two ← within order + r.check "node.callbacks.registration-order" (one == some 1 && two == some 2) + -- One more pass, for the RPC. + r.check "node.callbacks.served" (← settle tc 1) + r.check "node.callbacks.other-topic-stays-silent" (← quiet order) + teardown sw [b] [tc] + +/-- A full queue refuses a post, and a refused `subscribe` leaves no callback. +The first send of the loop parks on a gate, so the loop reads no event, and +the queue fills to its bound. -/ +private def fullQueue (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let tc ← (TestClock.new : BaseIO TestClock) + let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) + let seamA ← probe sw pa seen + let (seamB, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) + let gate : Chan Unit ← (Chan.new 1 : BaseIO (Chan Unit)) + let reached : Chan Unit ← (Chan.new 1 : BaseIO (Chan Unit)) + let gated : HostSeam := + { seamB with + newStream := fun peer protocols => do + discard (reached.trySend () : BaseIO Bool) + -- Park: the close of the gate wakes it, and later sends pass at once. + discard gate.recv + seamB.newStream peer protocols } + let b ← (start gated (configOn tc) : BaseIO Node) + let kept : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) + let refused : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) + discard (b.peerJoined pa .meshsub : BaseIO Bool) + r.check "node.queue.loop-parks-on-the-gate" ((← within reached).isSome) + r.check "node.queue.subscribe-takes-a-slot" (← (b.subscribe ta (keep kept) : BaseIO Bool)) + -- 63 slots remain of the 64. + let mut taken := 0 + for i in [0:70] do + if ← (b.publish ⟨"u"⟩ (Bytes.ofNatList [i]) : BaseIO Bool) then + taken := taken + 1 + r.check "node.queue.bound-is-64" (taken == 63) + r.check "node.queue.full-refuses-subscribe" + (!(← (b.subscribe ta (keep refused) : BaseIO Bool))) + (gate.close : BaseIO Unit) + -- The start, the join, the subscribe, and 63 publishes. + r.check "node.queue.loop-drains" (← settle tc 66) + -- The default message id is the payload, so this payload differs from the + -- 70 above. + discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [200]))] + let got ← within kept + r.check "node.queue.accepted-callback-runs" (got == some (msgOf ta [200])) + r.check "node.queue.refused-callback-stays-silent" (← quiet refused) + teardown sw [b] [tc] + +/-! ## Local publish and stop -/ + +/-- Test 6: a publish with no peers opens no stream, and the core emits no +deliver action for a local publish, so the callback of the publisher stays +silent (`Core.publishLocal`). The loop keeps running. -/ +private def localPublish (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let tc ← (TestClock.new : BaseIO TestClock) + let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let opened : OnceRef Nat ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) + let counting : HostSeam := + { seam with + newStream := fun peer protocols => do + (opened.update (· + 1) : BaseIO Unit) + seam.newStream peer protocols } + let n ← (start counting (configOn tc) : BaseIO Node) + let inbox : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) + discard (n.subscribe ta (keep inbox) : BaseIO Bool) + r.check "node.local.publish-subscribed" (← (n.publish ta (Bytes.ofNatList [1]) : BaseIO Bool)) + r.check "node.local.publish-unsubscribed" (← (n.publish ⟨"u"⟩ (Bytes.ofNatList [2]) : BaseIO Bool)) + -- The start, the subscribe, and two publishes. + r.check "node.local.loop-serves-the-publishes" (← settle tc 4) + r.check "node.local.no-stream" ((← (opened.get : BaseIO Nat)) == 0) + r.check "node.local.no-local-delivery" (← quiet inbox) + teardown sw [n] [tc] + +/-- Test 7: `stop` joins the loop, every later post answers `false`, an inbound +stream of a stopped node ends clean, and the switch closes without a hang. -/ +private def stopChecks (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let tc ← (TestClock.new : BaseIO TestClock) + let seen : Chan Seen ← (Chan.new 4 : BaseIO (Chan Seen)) + let seamA ← probe sw pa seen + let (seamB, tasksB) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) + let b ← (start seamB (configOn tc) : BaseIO Node) + discard (b.peerJoined pa .meshsub : BaseIO Bool) + -- A park on the queue wakes by the close, with no help from the clock. + r.check "node.stop.setup" (← settle tc 2) + b.stop + r.check "node.stop.returns" true + b.stop + r.check "node.stop.second-stop-does-nothing" true + r.check "node.stop.publish-answers-false" (!(← (b.publish ta (Bytes.ofNatList [1]) : BaseIO Bool))) + r.check "node.stop.subscribe-answers-false" (!(← (b.subscribe ta (fun _ => pure ()) : BaseIO Bool))) + r.check "node.stop.unsubscribe-answers-false" (!(← (b.unsubscribe ta : BaseIO Bool))) + r.check "node.stop.join-answers-false" (!(← (b.peerJoined pa .meshsub : BaseIO Bool))) + r.check "node.stop.leave-answers-false" (!(← (b.peerLeft pa : BaseIO Bool))) + discard <| send seamA pb [frameOf (subOptRpc ta true)] + r.check "node.stop.inbound-frame-is-clean" (!(← joinFails tasksB)) + sw.close + r.check "node.stop.switch-closes" true + (tc.close : BaseIO Unit) + +/-- Run the checks of this module. -/ +def run (r : Tests.Runner) : IO Unit := do + r.section "gossipsub Node" + "packages/libp2p/protocols/docs/PLAN.md, unit 3 test milestones" + Conc.Async.run do + switchChecks r + twoNodes r + announcement r + handlerChecks r + sendChecks r + tickGrafts r + trickleSpread r + systemClock r + callbackOrder r + fullQueue r + localPublish r + stopChecks r + +end Tests.Gossipsub.Node diff --git a/Tests/Main.lean b/Tests/Main.lean index ea49ee88..9b54a108 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -5,6 +5,7 @@ import Tests.Gossipsub.Sign import Tests.Gossipsub.Cache import Tests.Gossipsub.Score import Tests.Gossipsub.Rpc +import Tests.Gossipsub.Node import Tests.Concurrency import Tests.Protobuf import Tests.Bytes @@ -41,6 +42,7 @@ def main : IO UInt32 := do Tests.Gossipsub.Cache.run r Tests.Gossipsub.Score.run r Tests.Gossipsub.Rpc.run r + Tests.Gossipsub.Node.run r Tests.Concurrency.run r Tests.Protobuf.run r Bytes.runTests r From 49ef702b779f1c084d508b8e06229e07eb9e8d26 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:52:27 +0800 Subject: [PATCH 028/125] docs(protocols): name the trickle owner and the loop checks in the unit 3 milestones --- packages/libp2p/protocols/docs/PLAN.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index 6e7257e6..12f38b6d 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -23,10 +23,6 @@ sub-split below, with the reasons. Unit 0 is done. -Unit 3b owns the trickle regressions that issue #23's done-when names. The pure `step` decides -which peer receives which gossip on a tick. The randomised delay that spreads those messages -across the heartbeat interval belongs to the loop, and the regression is a test of that loop. - The splits follow the deliverable size. Unit 2a is pure codec work that starts when the snappy binding exists, and unit 2b needs the host's streams for the core's loop and the exchange, so the two wait on @@ -97,6 +93,23 @@ in the codec group wrapping upstream `google/snappy`. bookkeeping, at the level the root names (root PLAN W6.4). - The signing-policy tests, once unit 3b records the choice against the two axes of the pubsub spec. +- Unit 3b owns the trickle regressions that issue #23's done-when names. + The pure `step` decides which peer receives which gossip on a tick. The + loop owns the delay that spreads those messages across the heartbeat + interval. `Tests/Gossipsub/Node.lean` pins that delay on a test clock. +- The loop suite, `Tests/Gossipsub/Node.lean`, runs over the in-memory + switch of `Mock/Host.lean`: + - One publish crosses two nodes and reaches the callback. + - A subscribe after the join reaches the peer, and an unsubscribe sends + the negative record. + - A bad frame fails its handler and resets its stream. A body that does + not decode leaves the loop running. + - The handler serves every frame of a stream, in any chunking. + - A heartbeat sends the graft, and a later publish reaches the mesh. + - The `i`-th IHAVE of a heartbeat leaves at `trickle i`, and no earlier. + - A publish with no peers opens no stream and delivers nothing locally. + - `stop` joins the loop, a later post answers `false`, and the switch + closes. - The gate (unit 3c): the interop suite against go and rust runs with no behavior difference, per root PLAN W6.4. This is a live suite over foreign nodes, and it closes the unit where no vector file can. From be3351c6a29d607335b276d1fdcd3bede10be610 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:53:56 +0800 Subject: [PATCH 029/125] test(protocols): pin the over-cap reset on an open stream and the send order --- Tests/Gossipsub/Node.lean | 75 +++++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index b19f3cd9..0a8c52d7 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -137,17 +137,38 @@ private def probe (sw : Switch) (me : Peer) (seen : Chan Seen) : Async HostSeam | _ => pure () } : BaseIO Unit) return seam -/-- Open a stream, write the chunks one by one, and half-close it. -/ -private def send (seam : HostSeam) (to : Peer) (chunks : List (List Nat)) : +/-- Open a stream, and write the chunks one by one. The stream stays open. -/ +private def sendOpen (seam : HostSeam) (to : Peer) (chunks : List (List Nat)) : Async (Option Streams.Stream) := do match ← seam.newStream to [Constants.meshsubId] with | .error _ => return none | .ok (_, stream) => for chunk in chunks do discard <| stream.write (Bytes.ofNatList chunk) - stream.close return some stream +/-- Open a stream, write the chunks one by one, and half-close it. -/ +private def send (seam : HostSeam) (to : Peer) (chunks : List (List Nat)) : + Async (Option Streams.Stream) := do + let stream? ← sendOpen seam to chunks + if let some stream := stream? then + stream.close + return stream? + +/-- True when the handlers of the task set fail while the stream stays open, +so the bytes alone made them fail. The expiry resets the stream, and the reset +wakes a handler that still reads. -/ +private def failsOnOpenStream (ts : TaskSet) (stream? : Option Streams.Stream) : Async Bool := do + let some stream := stream? | return false + -- The expiry step of `withinDeadline` is `BaseIO` and the reset is `Async`, so + -- the reset runs as a task with an owner of its own. + let waker ← (TaskSet.new : BaseIO TaskSet) + let answer ← withinDeadline Clock.system patience (waker.spawn stream.reset) (joinFails ts) + waker.joinAll + return match answer with + | .ok failed => failed + | .error _ => false + private def isGraftOf (t : TopicId) (rpc : Rpc.RPC) : Bool := rpc.control.any (fun c => c.graft.any (fun g => g.topicId == t.name)) @@ -305,8 +326,8 @@ private def handlerChecks (r : Tests.Runner) : Async Unit := do r.check "node.handler.setup" (← settle tc 3) -- A length over the cap: the handler fails before any body arrives. let overCap := Streams.Framing.encodeLength .uvarint (Constants.rpcCap + 1) - let stream? ← send seamA pb [overCap] - r.check "node.handler.over-cap-fails" (← joinFails tasksB) + let stream? ← sendOpen seamA pb [overCap] + r.check "node.handler.over-cap-fails-on-the-prefix" (← failsOnOpenStream tasksB stream?) match stream? with | none => r.check "node.handler.over-cap-resets" false | some stream => @@ -314,8 +335,8 @@ private def handlerChecks (r : Tests.Runner) : Async Unit := do r.check "node.handler.over-cap-resets" (match written with | .error .closed => true | _ => false) -- A prefix that never ends. - discard <| send seamA pb [List.replicate 12 0x80] - r.check "node.handler.long-prefix-fails" (← joinFails tasksB) + let longPrefix? ← sendOpen seamA pb [List.replicate 12 0x80] + r.check "node.handler.long-prefix-fails" (← failsOnOpenStream tasksB longPrefix?) -- The stream ends inside a frame. discard <| send seamA pb [Streams.Framing.encodeLength .uvarint 5 ++ [1]] r.check "node.handler.end-inside-a-frame-fails" (← joinFails tasksB) @@ -484,6 +505,45 @@ private def trickleSpread (r : Tests.Runner) : Async Unit := do | _, _ => false) teardown sw [n] [tc] +/-- The receivers of the IHAVE sends of one heartbeat, in send order, when both +sends come due in one pass of the loop. -/ +private def gossipOrder (spread : Nat → Millis) : Async (List Peer) := do + let sw ← (Switch.new : BaseIO Switch) + let tc ← (TestClock.new : BaseIO TestClock) + let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) + discard <| probe sw pc seen + discard <| probe sw pd seen + let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let opened : OnceRef (List Peer) ← (OnceRef.new [] : BaseIO (OnceRef (List Peer))) + let recording : HostSeam := + { seam with + newStream := fun peer protocols => do + (opened.update (· ++ [peer]) : BaseIO Unit) + seam.newStream peer protocols } + let n ← (start recording (configOn tc gossipParams spread) : BaseIO Node) + discard (n.subscribe ta (fun _ => pure ()) : BaseIO Bool) + discard (n.peerJoined pc .meshsub : BaseIO Bool) + discard (n.peerJoined pd .meshsub : BaseIO Bool) + discard (n.publish ta (Bytes.ofNatList [1]) : BaseIO Bool) + discard <| settle tc 5 + (tc.advance gossipParams.heartbeatInterval : BaseIO Unit) + discard <| settle tc 1 + (tc.advance 20 : BaseIO Unit) + -- The pass that sends both arms the timer of the next heartbeat after them. + discard <| settle tc 1 + let order ← (opened.get : BaseIO (List Peer)) + teardown sw [n] [tc] + -- The first two streams carried the hellos. + return order.drop 2 + +/-- Two sends that come due in one pass leave in the order of their due times, +whatever the order of the heartbeat that made them. -/ +private def trickleOrder (r : Tests.Runner) : Async Unit := do + let rising ← gossipOrder (fun i => (i + 1) * 10) + let falling ← gossipOrder (fun i => 20 - i * 10) + r.check "node.trickle.two-sends-in-one-pass" (rising.length == 2) + r.check "node.trickle.due-time-orders-the-sends" (rising == falling.reverse) + /-- The production clock: a real heartbeat sends the graft, and `stop` wakes a loop that parks on a real timer. `Config` defaults to this clock. -/ private def systemClock (r : Tests.Runner) : Async Unit := do @@ -638,6 +698,7 @@ def run (r : Tests.Runner) : IO Unit := do sendChecks r tickGrafts r trickleSpread r + trickleOrder r systemClock r callbackOrder r fullQueue r From 7e70b571148eb5072704d838f50569ca11cf3f91 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:13:04 +0800 Subject: [PATCH 030/125] fix(protocols): cap the inbound buffer and serve one frame per stream --- Tests/Gossipsub/Node.lean | 15 ++- .../Libp2p/Protocols/Gossipsub/Node.lean | 104 ++++++++---------- packages/libp2p/protocols/docs/PLAN.md | 3 +- 3 files changed, 59 insertions(+), 63 deletions(-) diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index 0a8c52d7..6303436f 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -337,15 +337,18 @@ private def handlerChecks (r : Tests.Runner) : Async Unit := do -- A prefix that never ends. let longPrefix? ← sendOpen seamA pb [List.replicate 12 0x80] r.check "node.handler.long-prefix-fails" (← failsOnOpenStream tasksB longPrefix?) + -- An oversized chunk is refused before the handler appends it. + let tooLarge? ← sendOpen seamA pb [List.replicate (Constants.rpcCap + 6) 0] + r.check "node.handler.oversized-chunk-fails" (← failsOnOpenStream tasksB tooLarge?) -- The stream ends inside a frame. discard <| send seamA pb [Streams.Framing.encodeLength .uvarint 5 ++ [1]] r.check "node.handler.end-inside-a-frame-fails" (← joinFails tasksB) -- The stream ends inside a prefix. discard <| send seamA pb [[0x80]] r.check "node.handler.end-inside-a-prefix-fails" (← joinFails tasksB) - -- A stream with no frame ends clean. + -- EOF before a complete frame is a protocol error. discard <| send seamA pb [] - r.check "node.handler.empty-stream-is-clean" (!(← joinFails tasksB)) + r.check "node.handler.empty-stream-fails" (← joinFails tasksB) -- A frame whose body does not decode: the handler is clean, and the core -- answers the error to the loop. r.check "node.handler.body-does-not-decode" @@ -357,12 +360,16 @@ private def handlerChecks (r : Tests.Runner) : Async Unit := do discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [1]))] let got ← within inbox r.check "node.handler.loop-survives-a-bad-body" (got == some (msgOf ta [1])) - -- Two frames in one chunk are two RPCs. + -- Trailing bytes in the same read reject the whole frame. discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [2])) ++ frameOf (Core.publishRpc (msgOf ta [3]))] + r.check "node.handler.trailing-frame-fails" (← joinFails tasksB) + r.check "node.handler.trailing-frame-delivers-nothing" (← quiet inbox) + discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [2]))] let two ← within inbox + discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [3]))] let three ← within inbox - r.check "node.handler.two-frames-in-one-chunk" + r.check "node.handler.separate-streams-deliver" (two == some (msgOf ta [2]) && three == some (msgOf ta [3])) -- One frame in single bytes is one RPC. discard <| send seamA pb ((frameOf (Core.publishRpc (msgOf ta [4]))).map (fun x => [x])) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index cacb6c0b..464ec42b 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -20,9 +20,9 @@ application and the host. The clock seam carries the heartbeat and the delayed gossip. The loop parks in `select` over the two, so the timer costs no task, and a `TestClock` drives every time decision of a test. -`loop` and `readFrames` are `partial`. Each runs until a close reaches what -it parks on, and no measure bounds the number of events or chunks before -that close (root CLAUDE.md, "`partial def`"). +`loop` and `readFrame` are `partial`. A peer controls the number of events +and chunks. No measure bounds the wait for a complete frame or a close +(root CLAUDE.md, "`partial def`"). ## Ownership @@ -35,6 +35,16 @@ through `OnceRef.update` (root ARCHITECTURE §4.1). ## Recorded decisions +- **The stop field runs in Async.** The brief gives it a BaseIO type. + TaskSet.joinAll runs in Async, because joining can park. This field keeps + that type and leaves the blocking run to the application edge. +- **The spread index counts sends within each heartbeat.** Config describes + this index, while the brief's loop sketch increments it once per tick. + Each delayed send gets its own index, and the next heartbeat starts at 0. +- **A refused subscribe removes its callback.** The cell changes before the + post, so a refused post removes that registration by its unique id. +- **The queue carries Core.Event directly.** The loop owns all delayed sends, + so no external sender needs the brief's unused send message constructor. - **No signer.** The core validates with `strictNoSign` (`Core.validationOk`), which requires `from` and `seqno` to be absent. So `publish` signs nothing and never writes the peer id of the node. @@ -56,8 +66,8 @@ through `OnceRef.update` (root ARCHITECTURE §4.1). `newStream` parks also parks the loop. Two RPCs to one peer can reorder, because each stream has its own handler task. The unit 3c gate decides if this shape stays. -- **The inbound handler reads frames until the end of the stream.** A peer - that keeps one stream open and writes many frames gets every frame served. +- **The inbound handler serves one frame per stream.** Bytes after the first + frame in the same read are a protocol error, as the unit 3b brief requires. - **The host posts `peerJoined` before it serves a stream of that peer.** The core ignores an RPC from an unknown peer (`Core.rpcStep`), so an earlier stream loses its RPC. @@ -71,9 +81,9 @@ namespace Libp2p.Protocols.Gossipsub.Node open Mock.Host (HostSeam) -/-- The delay of the `i`-th gossip send of a heartbeat: a fixed spread below -one second, so two nodes do not line up and a run replays. The multiplier -is a prime, so the delays do not repeat with a short period. -/ +/-- The delay of the i-th gossip send of a heartbeat: a fixed spread below +one second. Each run replays the same delays. Nodes with the same schedule +can align their sends. -/ def defaultTrickle (i : Nat) : Millis := (i * 7919 + 1) % 1000 @@ -144,61 +154,39 @@ def sendRpc (host : HostSeam) (to : Peer) (rpc : Rpc.RPC) : Conc.Async Unit := d /-! ## The inbound handler -/ -/-- The bytes of `buf` from `pos` that `Rpc.decodeFrame?` needs for its answer. -`none` asks for another chunk. - -The prefix decides, so the decoder reads each frame once, and a peer that -sends many small chunks costs no repeated decode. A length over the cap, or -a bad prefix, needs only the prefix bytes. So the handler keeps at most one -frame under the cap and one chunk (root ARCHITECTURE §5.1). -/ -private def frameWindow (buf : ByteArray) (pos : Nat) : Option Nat := - let head := Bytes.toNatList (buf.extract pos (pos + Varint.maxBytes)) - match Streams.Framing.decodeLength? .uvarint head with - | .ok (len, rest) => - let whole := (head.length - rest.length) + len - if Constants.rpcCap < len then some head.length - else if pos + whole ≤ buf.size then some whole - else none - | .error .truncated => none - | .error .malformed => some head.length - -/-- Post every frame of the stream to the queue, until the stream ends. - -`pos` is the start of the next frame in `buf`, so a chunk that holds many -frames costs one pass over its bytes. The answer is `.error` for a bad frame, -for an end of stream inside a frame, and for a failed read. A closed queue -means the node stops, and the handler returns. -/ -private partial def readFrames (events : Conc.Chan Core.Event) (peer : Peer) - (stream : Streams.Stream) (buf : ByteArray) (pos : Nat) : +/-- Read one frame and post its payload. The buffer stays under the RPC cap +plus five prefix bytes. The size check runs before an append allocates a new +buffer. A bad prefix, trailing bytes, or EOF inside the frame fails the +handler. A closed event queue means the node stops. -/ +private partial def readFrame (events : Conc.Chan Core.Event) (peer : Peer) + (stream : Streams.Stream) (buf : ByteArray) : Conc.Async (Except Mock.Core.Error Unit) := do - match frameWindow buf pos with - | some n => - match Rpc.decodeFrame? (Bytes.toNatList (buf.extract pos (pos + n))) with - | .ok (some (payload, _)) => - -- Park: the loop frees a slot, or `stop` closes the queue. The window - -- holds one frame, so the rest is empty and the next frame starts at - -- `pos + n`. - match ← events.send (.rpc peer payload) with - | .ok () => readFrames events peer stream buf (pos + n) - | .error _ => return .ok () - | .ok none => return .error (.protocol "the RPC frame ended early") + -- Park: the peer writes, half-closes, or resets. + match ← stream.read with + | .error e => return .error (Mock.Core.mapStreamError e) + | .ok none => return .error (.protocol "the stream ended before an RPC frame") + | .ok (some chunk) => + if Constants.rpcCap + 5 < buf.size + chunk.size then + return .error (.protocol "the RPC buffer exceeds the cap") + let buf := buf ++ chunk + match Rpc.decodeFrame? (Bytes.toNatList buf) with + | .ok (some (payload, rest)) => + if !rest.isEmpty then + return .error (.protocol "the stream has bytes after its RPC frame") + -- Park: the loop frees a slot, or stop closes the queue. + discard <| events.send (.rpc peer payload) + return .ok () + | .ok none => readFrame events peer stream buf | .error _ => return .error (.protocol "the RPC frame is malformed") - | none => - -- Park: the peer writes, half-closes, or resets. - match ← stream.read with - | .error e => return .error (Mock.Core.mapStreamError e) - | .ok none => - return if pos = buf.size then .ok () - else .error (.protocol "the stream ended inside an RPC frame") - | .ok (some chunk) => readFrames events peer stream (buf.extract pos buf.size ++ chunk) 0 - -/-- The handler of `Constants.meshsubId`. Each frame becomes one `Event.rpc` -with the opener of the stream as its sender. A protocol error resets the -stream and fails this handler. The loop keeps running. -/ + +/-- The handler of Constants.meshsubId. It serves one frame per stream. +Trailing bytes in the same read are a protocol error. The handler returns +as soon as it posts the frame, so it leaves later reads to the host. A +protocol error resets the stream. The loop keeps running. -/ def handler (events : Conc.Chan Core.Event) : Mock.Core.StreamHandler where id := Constants.meshsubId handle peer stream := ExceptT.mk do - let answer ← readFrames events peer stream ByteArray.empty 0 + let answer ← readFrame events peer stream ByteArray.empty match answer with | .ok () => stream.close | .error _ => stream.reset diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index 12f38b6d..c47714bf 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -104,7 +104,8 @@ in the codec group wrapping upstream `google/snappy`. the negative record. - A bad frame fails its handler and resets its stream. A body that does not decode leaves the loop running. - - The handler serves every frame of a stream, in any chunking. + - The handler serves one frame in any chunking. Trailing bytes fail the + handler, and the buffer cap is checked before an append. - A heartbeat sends the graft, and a later publish reaches the mesh. - The `i`-th IHAVE of a heartbeat leaves at `trickle i`, and no earlier. - A publish with no peers opens no stream and delivers nothing locally. From bcfae27342aa33c6c389c359d1e1c66a9b6b63a1 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:22:01 +0800 Subject: [PATCH 031/125] fix(protocols): reuse peer streams and observe RPC receipts before the core --- Tests/Gossipsub/Node.lean | 177 ++++++++++--- .../Libp2p/Protocols/Gossipsub/Node.lean | 233 ++++++++++++------ packages/libp2p/protocols/docs/PLAN.md | 8 +- 3 files changed, 303 insertions(+), 115 deletions(-) diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index 6303436f..53526809 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -120,21 +120,27 @@ private partial def readAll (s : Streams.Stream) (acc : ByteArray) : Async ByteA | .ok none => return acc | .error _ => return acc -/-- A raw peer on the switch: its handler decodes each stream as one RPC and -keeps it for the check. A probe has no router, so a check sees exactly what -the node sent. -/ +/-- Decode each frame as soon as its bytes arrive. The probe reads until EOF, +so the node can reuse one stream for many RPCs. -/ +private partial def probeRead (seen : Chan Seen) (me sender : Peer) + (stream : Streams.Stream) (buf : List Nat) : Async Unit := do + match Rpc.decodeFrame? buf with + | .ok (some (payload, rest)) => + if let .ok rpc := Core.decodeRpc payload then + discard (seen.trySend { receiver := me, sender, rpc } : BaseIO Bool) + probeRead seen me sender stream rest + | .error _ => stream.reset + | .ok none => + match ← stream.read with + | .ok (some bytes) => probeRead seen me sender stream (buf ++ Bytes.toNatList bytes) + | _ => stream.close + +/-- A raw peer whose handler records each RPC before the stream closes. -/ private def probe (sw : Switch) (me : Peer) (seen : Chan Seen) : Async HostSeam := do let (seam, _) ← (sw.host me : BaseIO (HostSeam × TaskSet)) (seam.setHandler { id := Constants.meshsubId - handle := fun sender stream => ExceptT.lift do - let bytes ← readAll stream ByteArray.empty - match Rpc.decodeFrame? (Bytes.toNatList bytes) with - | .ok (some (payload, _)) => - match Core.decodeRpc payload with - | .ok rpc => discard (seen.trySend { receiver := me, sender, rpc } : BaseIO Bool) - | .error _ => pure () - | _ => pure () } : BaseIO Unit) + handle := fun sender stream => ExceptT.lift (probeRead seen me sender stream []) } : BaseIO Unit) return seam /-- Open a stream, and write the chunks one by one. The stream stays open. -/ @@ -337,18 +343,18 @@ private def handlerChecks (r : Tests.Runner) : Async Unit := do -- A prefix that never ends. let longPrefix? ← sendOpen seamA pb [List.replicate 12 0x80] r.check "node.handler.long-prefix-fails" (← failsOnOpenStream tasksB longPrefix?) - -- An oversized chunk is refused before the handler appends it. - let tooLarge? ← sendOpen seamA pb [List.replicate (Constants.rpcCap + 6) 0] - r.check "node.handler.oversized-chunk-fails" (← failsOnOpenStream tasksB tooLarge?) + -- A large chunk with an invalid prefix fails without an unbounded append. + let tooLarge? ← sendOpen seamA pb [List.replicate (Constants.rpcCap + 6) 0x80] + r.check "node.handler.large-malformed-chunk-fails" (← failsOnOpenStream tasksB tooLarge?) -- The stream ends inside a frame. discard <| send seamA pb [Streams.Framing.encodeLength .uvarint 5 ++ [1]] r.check "node.handler.end-inside-a-frame-fails" (← joinFails tasksB) -- The stream ends inside a prefix. discard <| send seamA pb [[0x80]] r.check "node.handler.end-inside-a-prefix-fails" (← joinFails tasksB) - -- EOF before a complete frame is a protocol error. + -- EOF between frames is clean. discard <| send seamA pb [] - r.check "node.handler.empty-stream-fails" (← joinFails tasksB) + r.check "node.handler.empty-stream-is-clean" (!(← joinFails tasksB)) -- A frame whose body does not decode: the handler is clean, and the core -- answers the error to the loop. r.check "node.handler.body-does-not-decode" @@ -360,16 +366,12 @@ private def handlerChecks (r : Tests.Runner) : Async Unit := do discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [1]))] let got ← within inbox r.check "node.handler.loop-survives-a-bad-body" (got == some (msgOf ta [1])) - -- Trailing bytes in the same read reject the whole frame. + -- Two frames in one chunk are processed in arrival order. discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [2])) ++ frameOf (Core.publishRpc (msgOf ta [3]))] - r.check "node.handler.trailing-frame-fails" (← joinFails tasksB) - r.check "node.handler.trailing-frame-delivers-nothing" (← quiet inbox) - discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [2]))] let two ← within inbox - discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [3]))] let three ← within inbox - r.check "node.handler.separate-streams-deliver" + r.check "node.handler.two-frames-in-one-chunk" (two == some (msgOf ta [2]) && three == some (msgOf ta [3])) -- One frame in single bytes is one RPC. discard <| send seamA pb ((frameOf (Core.publishRpc (msgOf ta [4]))).map (fun x => [x])) @@ -398,15 +400,95 @@ private def sendChecks (r : Tests.Runner) : Async Unit := do newStream := fun peer protocols => do (opened.update (· + 1) : BaseIO Unit) seam.newStream peer protocols } - sendRpc counting pa (Core.publishRpc (msgOf ta (List.replicate Constants.rpcCap 7))) + let streams ← sendRpc counting [] pa (Core.publishRpc (msgOf ta (List.replicate Constants.rpcCap 7))) r.check "node.send.over-cap-opens-no-stream" ((← (opened.get : BaseIO Nat)) == 0) - sendRpc counting px (subOptRpc ta true) + let streams ← sendRpc counting streams px (subOptRpc ta true) r.check "node.send.no-peer-is-no-fault" ((← (opened.get : BaseIO Nat)) == 1) - sendRpc counting pa (subOptRpc ta true) + let streams ← sendRpc counting streams pa (subOptRpc ta true) let got ← within seen r.check "node.send.one-frame-round-trips" (got.any (fun s => s.rpc == subOptRpc ta true)) + let streams ← sendRpc counting streams pa (subOptRpc ta false) + let next ← within seen + r.check "node.send.second-frame-on-the-same-stream" + (next.any (fun s => s.rpc == subOptRpc ta false)) + r.check "node.send.reuses-the-peer-stream" ((← (opened.get : BaseIO Nat)) == 2) + -- End the pipe behind the cached stream. Its next write fails, so the send + -- removes the stream. A later send opens a fresh one. + for (_, stream) in streams do stream.reset + let streams ← sendRpc counting streams pa (subOptRpc ta true) + r.check "node.send.failed-write-retires-the-stream" streams.isEmpty + let streams ← sendRpc counting streams pa (subOptRpc ta true) + let reopened ← within seen + r.check "node.send.reopens-after-failure" + ((← (opened.get : BaseIO Nat)) == 3 && reopened.any (fun s => s.rpc == subOptRpc ta true)) + for (_, stream) in streams do stream.close sw.close +/-! ## Receipt observation and stream ownership -/ + +/-- The receipt observer sees duplicate bodies before deduplication. Its +callback runs before the delivery callback of the accepted message. -/ +private def receiptChecks (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let tc ← (TestClock.new : BaseIO TestClock) + let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) + let source ← probe sw pa seen + let (seam, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) + let receipts : Chan (Peer × List Nat) ← (Chan.new 8 : BaseIO (Chan (Peer × List Nat))) + let order : OnceRef (List Nat) ← (OnceRef.new [] : BaseIO (OnceRef (List Nat))) + let cfg : Config := { configOn tc with onRpc := fun peer bytes => do + order.update (· ++ [1]) + discard <| receipts.trySend (peer, bytes) } + let n ← (start seam cfg : BaseIO Node) + discard (n.subscribe ta (fun _ => order.update (· ++ [2])) : BaseIO Bool) + discard (n.peerJoined pa .meshsub : BaseIO Bool) + r.check "node.receipt.setup" (← settle tc 3) + let rpc := Core.publishRpc (msgOf ta [7]) + let body := Pb.encodeMessage rpc + let frame := frameOf rpc + let bad := Streams.Framing.encodeLength .uvarint badBody.length ++ badBody + discard <| send source pb [frame ++ frame ++ bad] + -- The accepted RPC, its duplicate, and the bad protobuf body. + r.check "node.receipt.three-events" (← settle tc 3) + let first ← within receipts + let duplicate ← within receipts + let invalid ← within receipts + r.check "node.receipt.sender-and-body" + (first == some (pa, body) && duplicate == some (pa, body)) + r.check "node.receipt.bad-protobuf-is-observed" (invalid == some (pa, badBody)) + r.check "node.receipt.before-deduplication-and-delivery" + ((← (order.get : BaseIO (List Nat))) == [1, 2, 1, 1]) + teardown sw [n] [tc] + +/-- A node keeps its stream across announcements, then retires it when the +peer leaves. Stop half-closes the replacement stream, so its handler joins. -/ +private def streamOwnership (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let tc ← (TestClock.new : BaseIO TestClock) + let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) + discard <| probe sw pc seen + let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let opens : OnceRef Nat ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) + let counting : HostSeam := { seam with newStream := fun peer protocols => do + (opens.update (· + 1) : BaseIO Unit) + seam.newStream peer protocols } + let n ← (start counting (configOn tc) : BaseIO Node) + discard (n.peerJoined pc .meshsub : BaseIO Bool) + discard <| within seen + discard (n.subscribe ta (fun _ => pure ()) : BaseIO Bool) + discard <| within seen + discard (n.unsubscribe ta : BaseIO Bool) + discard <| within seen + r.check "node.stream.one-stream-for-three-rpcs" ((← (opens.get : BaseIO Nat)) == 1) + discard (n.peerLeft pc : BaseIO Bool) + discard (n.peerJoined pc .meshsub : BaseIO Bool) + discard <| within seen + r.check "node.stream.peer-leave-retires-the-stream" ((← (opens.get : BaseIO Nat)) == 2) + n.stop + sw.close + r.check "node.stream.stop-closes-outbound-before-the-host-joins" true + (tc.close : BaseIO Unit) + /-! ## The heartbeat -/ /-- Test 4: a heartbeat fills the mesh. The probe sees the graft at the @@ -468,9 +550,8 @@ private def gossipParams : Core.Params := /-- Test 5: the trickle regression. The `i`-th IHAVE of one heartbeat leaves at the time of the heartbeat plus `trickle i`, and no earlier. -/ private def trickleSpread (r : Tests.Runner) : Async Unit := do - r.check "node.trickle.default-is-below-one-second" - ((List.range 64).all (fun i => defaultTrickle i < 1000)) - r.check "node.trickle.default-spreads" (defaultTrickle 0 != defaultTrickle 1) + r.check "node.trickle.default-is-zero" + ((List.range 64).all (fun i => defaultTrickle i == 0)) let sw ← (Switch.new : BaseIO Switch) let tc ← (TestClock.new : BaseIO TestClock) let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) @@ -512,6 +593,30 @@ private def trickleSpread (r : Tests.Runner) : Async Unit := do | _, _ => false) teardown sw [n] [tc] +/-- With the default zero delay, both IHAVEs reach their peers on the +heartbeat. The test clock needs no second advance. -/ +private def defaultGossip (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let tc ← (TestClock.new : BaseIO TestClock) + let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) + discard <| probe sw pc seen + discard <| probe sw pd seen + let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let n ← (start seam (configOn tc gossipParams) : BaseIO Node) + discard (n.subscribe ta (fun _ => pure ()) : BaseIO Bool) + discard (n.peerJoined pc .meshsub : BaseIO Bool) + discard (n.peerJoined pd .meshsub : BaseIO Bool) + discard (n.publish ta (Bytes.ofNatList [1]) : BaseIO Bool) + r.check "node.trickle.zero-default-setup" (← settle tc 5) + discard <| within seen + discard <| within seen + (tc.advance gossipParams.heartbeatInterval : BaseIO Unit) + let first ← within seen + let second ← within seen + r.check "node.trickle.zero-default-sends-at-the-heartbeat" + (first.any (fun s => hasIhave s.rpc) && second.any (fun s => hasIhave s.rpc)) + teardown sw [n] [tc] + /-- The receivers of the IHAVE sends of one heartbeat, in send order, when both sends come due in one pass of the loop. -/ private def gossipOrder (spread : Nat → Millis) : Async (List Peer) := do @@ -525,8 +630,14 @@ private def gossipOrder (spread : Nat → Millis) : Async (List Peer) := do let recording : HostSeam := { seam with newStream := fun peer protocols => do - (opened.update (· ++ [peer]) : BaseIO Unit) - seam.newStream peer protocols } + match ← seam.newStream peer protocols with + | .error err => return .error err + | .ok (id, stream) => + return .ok (id, { stream with write := fun bytes => do + if let .ok (some (payload, _)) := Rpc.decodeFrame? (Bytes.toNatList bytes) then + if let .ok rpc := Core.decodeRpc payload then + if hasIhave rpc then (opened.update (· ++ [peer]) : BaseIO Unit) + stream.write bytes }) } let n ← (start recording (configOn tc gossipParams spread) : BaseIO Node) discard (n.subscribe ta (fun _ => pure ()) : BaseIO Bool) discard (n.peerJoined pc .meshsub : BaseIO Bool) @@ -540,8 +651,7 @@ private def gossipOrder (spread : Nat → Millis) : Async (List Peer) := do discard <| settle tc 1 let order ← (opened.get : BaseIO (List Peer)) teardown sw [n] [tc] - -- The first two streams carried the hellos. - return order.drop 2 + return order /-- Two sends that come due in one pass leave in the order of their due times, whatever the order of the heartbeat that made them. -/ @@ -703,8 +813,11 @@ def run (r : Tests.Runner) : IO Unit := do announcement r handlerChecks r sendChecks r + receiptChecks r + streamOwnership r tickGrafts r trickleSpread r + defaultGossip r trickleOrder r systemClock r callbackOrder r diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index 464ec42b..a254e06e 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -20,7 +20,7 @@ application and the host. The clock seam carries the heartbeat and the delayed gossip. The loop parks in `select` over the two, so the timer costs no task, and a `TestClock` drives every time decision of a test. -`loop` and `readFrame` are `partial`. A peer controls the number of events +`loop` and `readFrames` are `partial`. A peer controls the number of events and chunks. No measure bounds the wait for a complete frame or a close (root CLAUDE.md, "`partial def`"). @@ -38,6 +38,9 @@ through `OnceRef.update` (root ARCHITECTURE §4.1). - **The stop field runs in Async.** The brief gives it a BaseIO type. TaskSet.joinAll runs in Async, because joining can park. This field keeps that type and leaves the blocking run to the application edge. +- **Actions return the next stream table and pending sends.** This follows + the yamux performAll discipline. These actions use Async because opening + and writing streams can park. The loop assigns each returned state. - **The spread index counts sends within each heartbeat.** Config describes this index, while the brief's loop sketch increments it once per tick. Each delayed send gets its own index, and the next heartbeat starts at 0. @@ -56,22 +59,23 @@ through `OnceRef.update` (root ARCHITECTURE §4.1). join, so that peer would never learn the topic. The go implementation announces a change in its host registry, outside the router. The loop does the same with `subOptRpc`, after the core step. -- **The gossip spread is fixed.** The go implementation draws the delay of - its gossip from a random source. `defaultTrickle` is a fixed function, so a - run replays exactly. The tolerance of the unit 3c gate on latency is an - open question on the pull request. The peer order of a tick is the order - of the state, for the same reason. -- **One outbound stream per RPC.** `sendRpc` opens a stream, writes one frame, - and half-closes it. A send runs on the loop task, so a host whose - `newStream` parks also parks the loop. Two RPCs to one peer can reorder, - because each stream has its own handler task. The unit 3c gate decides if - this shape stays. -- **The inbound handler serves one frame per stream.** Bytes after the first - frame in the same read are a protocol error, as the unit 3b brief requires. +- **The default gossip delay is zero.** The pinned go implementation flushes + heartbeat gossip at once. Config.trickle keeps an explicit delay for tests + and callers that need one, as section 12 of the brief records. +- **The loop keeps one outbound stream per peer.** Each action returns the + updated stream table. The loop closes the streams when it ends, and resets + a stream when its peer leaves or a write fails. The next send can reopen it. + Opening and writing run on the loop task, so either call can park the loop. +- **The inbound handler serves consecutive frames.** Go and rust keep their + outbound stream open. The handler bounds each frame before appending bytes, + and serves several frames from one chunk in order. +- **The receipt observer runs before the core step.** Config.onRpc receives + the sender and payload bytes of every RPC, including duplicates and bad + protobuf bodies. Frame errors fail the handler before they reach it. - **The host posts `peerJoined` before it serves a stream of that peer.** The core ignores an RPC from an unknown peer (`Core.rpcStep`), so an earlier stream loses its RPC. -- **A send failure has no sink.** `sendRpc` answers `Unit`, and the mock host has +- **A send failure has no sink.** The send retires the stream, and the mock host has no failure log. The host reports stream failures at integration. -/ @@ -81,11 +85,9 @@ namespace Libp2p.Protocols.Gossipsub.Node open Mock.Host (HostSeam) -/-- The delay of the i-th gossip send of a heartbeat: a fixed spread below -one second. Each run replays the same delays. Nodes with the same schedule -can align their sends. -/ -def defaultTrickle (i : Nat) : Millis := - (i * 7919 + 1) % 1000 +/-- The default sends heartbeat gossip at once, as the pinned go router does. +Callers can provide a delay through Config.trickle. -/ +def defaultTrickle (_i : Nat) : Millis := 0 /-- The configuration of one node: the router parameters, the clock the loop reads, and the spread of the heartbeat gossip. -/ @@ -97,6 +99,9 @@ structure Config where /-- The delay of the `i`-th gossip send of one heartbeat, in milliseconds. The caller fixes it, so a test gets exact times. -/ trickle : Nat → Millis := defaultTrickle + /-- Observe each inbound RPC before validation and deduplication. The callback + runs on the loop task and must not park. The bytes are the unframed body. -/ + onRpc : Peer → List Nat → BaseIO Unit := fun _ _ => pure () /-- A running node. Every field is a seam over the loop task, which is the only owner of the router state. -/ @@ -137,56 +142,102 @@ beside the code that sends it. -/ def subOptRpc (t : TopicId) (on : Bool) : Rpc.RPC := { subscriptions := [{ subscribe := on, topicid := t.name }], publish := [], control := none } -/-- Send one RPC on a new stream, and half-close the stream. An RPC over -`Constants.rpcCap` is never sent. Every failure ends this send and leaves -the loop running, and the module doc records why nothing reports it. -/ -def sendRpc (host : HostSeam) (to : Peer) (rpc : Rpc.RPC) : Conc.Async Unit := do - let some frame := Rpc.encodeFrame? rpc | return () +/-- The outbound streams owned by the loop, one per peer. Each send returns +its next table, so the loop assigns from the answer. -/ +abbrev Outbound := List (Peer × Streams.Stream) + +/-- Retire a peer's outbound stream. Reset wakes both directions. -/ +private def retire (streams : Outbound) (peer : Peer) : Conc.Async Outbound := do + for (p, stream) in streams do + if p = peer then stream.reset + return streams.filter (fun e => e.1 != peer) + +/-- Write one frame and return the stream table. A successful write keeps +its stream open. A failed write resets and removes it. -/ +private def writeFrame (streams : Outbound) (peer : Peer) (stream : Streams.Stream) + (frame : List Nat) : Conc.Async Outbound := do + try + match ← stream.write (Bytes.ofNatList frame) with + | .ok () => return streams + | .error _ => retire streams peer + catch _ => retire streams peer + +/-- Send one RPC through the peer's stream and return the next stream table. +An RPC over Constants.rpcCap opens no stream. A failed open leaves the table +unchanged. The host reports send failures at integration. -/ +def sendRpc (host : HostSeam) (streams : Outbound) (to : Peer) (rpc : Rpc.RPC) : + Conc.Async Outbound := do + let some frame := Rpc.encodeFrame? rpc | return streams + if let some (_, stream) := streams.find? (fun e => e.1 == to) then + return ← writeFrame streams to stream frame try match ← host.newStream to [Constants.meshsubId] with - | .error _ => return () - | .ok (_, stream) => - match ← stream.write (Bytes.ofNatList frame) with - | .ok () => stream.close - | .error _ => stream.reset - catch _ => - return () + | .error _ => return streams + | .ok (_, stream) => writeFrame ((to, stream) :: streams) to stream frame + catch _ => return streams + +/-- Half-close each outbound stream when the loop ends. The peer's read +wakes at EOF, and its host joins the inbound handler. -/ +private def closeOutbound (streams : Outbound) : Conc.Async Unit := do + for (_, stream) in streams do + stream.close /-! ## The inbound handler -/ -/-- Read one frame and post its payload. The buffer stays under the RPC cap -plus five prefix bytes. The size check runs before an append allocates a new -buffer. A bad prefix, trailing bytes, or EOF inside the frame fails the -handler. A closed event queue means the node stops. -/ -private partial def readFrame (events : Conc.Chan Core.Event) (peer : Peer) - (stream : Streams.Stream) (buf : ByteArray) : +/-- The total frame size once its prefix is complete. A prefix under the +RPC cap can use up to Varint.maxBytes bytes. The decoder checks the length +before the handler appends any body bytes. -/ +private def frameSize (buf : ByteArray) : Except Mock.Core.Error (Option Nat) := + let head := Bytes.toNatList (buf.extract 0 Varint.maxBytes) + match Streams.Framing.decodeLength? .uvarint head with + | .error .truncated => .ok none + | .error .malformed => .error (.protocol "the RPC prefix is malformed") + | .ok (size, rest) => + if Constants.rpcCap < size then .error (.protocol "the RPC exceeds the cap") + else .ok (some (head.length - rest.length + size)) + +/-- Read consecutive frames. The buffer holds only the current frame. +A chunk may hold several frames, so pos marks the bytes already consumed. +The handler appends only the bytes needed by the prefix or current body. +Its buffer stays under the RPC cap plus Varint.maxBytes, even when one +chunk holds many frames. EOF between frames is clean. -/ +private partial def readFrames (events : Conc.Chan Core.Event) (peer : Peer) + (stream : Streams.Stream) (buf chunk : ByteArray) (pos : Nat) : Conc.Async (Except Mock.Core.Error Unit) := do - -- Park: the peer writes, half-closes, or resets. - match ← stream.read with - | .error e => return .error (Mock.Core.mapStreamError e) - | .ok none => return .error (.protocol "the stream ended before an RPC frame") - | .ok (some chunk) => - if Constants.rpcCap + 5 < buf.size + chunk.size then - return .error (.protocol "the RPC buffer exceeds the cap") - let buf := buf ++ chunk - match Rpc.decodeFrame? (Bytes.toNatList buf) with - | .ok (some (payload, rest)) => - if !rest.isEmpty then - return .error (.protocol "the stream has bytes after its RPC frame") - -- Park: the loop frees a slot, or stop closes the queue. - discard <| events.send (.rpc peer payload) - return .ok () - | .ok none => readFrame events peer stream buf - | .error _ => return .error (.protocol "the RPC frame is malformed") - -/-- The handler of Constants.meshsubId. It serves one frame per stream. -Trailing bytes in the same read are a protocol error. The handler returns -as soon as it posts the frame, so it leaves later reads to the host. A -protocol error resets the stream. The loop keeps running. -/ + match frameSize buf with + | .error e => return .error e + | .ok size? => + if let some size := size? then + if size ≤ buf.size then + match Rpc.decodeFrame? (Bytes.toNatList buf) with + | .ok (some (payload, [])) => + -- Park: the loop frees a slot, or stop closes the event queue. + match ← events.send (.rpc peer payload) with + | .error _ => return .ok () + | .ok () => return ← readFrames events peer stream ByteArray.empty chunk pos + | _ => return .error (.protocol "the RPC frame is malformed") + if pos < chunk.size then + -- An incomplete prefix needs one byte. A complete prefix fixes the + -- exact body size, so no append can exceed that frame's bound. + let need := size?.map (fun size => size - buf.size) |>.getD 1 + let take := Nat.min need (chunk.size - pos) + readFrames events peer stream (buf ++ chunk.extract pos (pos + take)) chunk (pos + take) + else + -- Park: the peer writes, half-closes, or resets. + match ← stream.read with + | .error e => return .error (Mock.Core.mapStreamError e) + | .ok none => + return if buf.isEmpty then .ok () + else .error (.protocol "the stream ended inside an RPC frame") + | .ok (some bytes) => readFrames events peer stream buf bytes 0 + +/-- Serve every RPC of a peer's stream in arrival order. Frame errors reset +both directions. A clean EOF closes our write direction. The host owns and +joins this handler task. -/ def handler (events : Conc.Chan Core.Event) : Mock.Core.StreamHandler where id := Constants.meshsubId handle peer stream := ExceptT.mk do - let answer ← readFrame events peer stream ByteArray.empty + let answer ← readFrames events peer stream ByteArray.empty ByteArray.empty 0 match answer with | .ok () => stream.close | .error _ => stream.reset @@ -242,6 +293,8 @@ private structure Pending where private structure LoopState where /-- The router state. -/ router : Core.State + /-- The outbound streams owned by this loop. -/ + streams : Outbound /-- The gossip sends that wait for their due time. -/ pending : List Pending /-- The time of the next heartbeat. -/ @@ -257,9 +310,11 @@ private inductive Wake where sends. `gossipAt` is the time of the tick when a tick made the actions. The `i`-th IHAVE send of that tick waits for `trickle i` milliseconds, and every other action runs now. -/ -private def perform (env : Env) (gossipAt : Option Millis) (actions : List Core.Action) : - Conc.Async (List Pending) := do +private def perform (env : Env) (streams : Outbound) (gossipAt : Option Millis) + (actions : List Core.Action) : Conc.Async (Outbound × List Pending) := do + let mut streams := streams let mut delayed : Array Pending := #[] + let mut gossipIndex := 0 for action in actions do match action with | .deliver m => (deliver env.subs m : BaseIO Unit) @@ -267,34 +322,48 @@ private def perform (env : Env) (gossipAt : Option Millis) (actions : List Core. match gossipAt with | some now => if hasIhave rpc then - delayed := delayed.push - { due := now + env.config.trickle delayed.size, peer := to, rpc := rpc } + let delay := env.config.trickle gossipIndex + gossipIndex := gossipIndex + 1 + if delay = 0 then + streams ← sendRpc env.host streams to rpc + else + delayed := delayed.push { due := now + delay, peer := to, rpc := rpc } else - sendRpc env.host to rpc - | none => sendRpc env.host to rpc - return delayed.toList + streams ← sendRpc env.host streams to rpc + | none => streams ← sendRpc env.host streams to rpc + return (streams, delayed.toList) /-- One core step, then its actions. Only an RPC that does not decode makes the core answer `.error`. That answer keeps the state, and the loop runs on. -/ private def stepAndAct (env : Env) (st : LoopState) (event : Core.Event) : Conc.Async LoopState := do + if let .rpc peer bytes := event then + (env.config.onRpc peer bytes : BaseIO Unit) + let st ← match event with + | .peerLeft peer => do + let streams ← retire st.streams peer + pure { st with streams, pending := st.pending.filter (fun p => p.peer != peer) } + | _ => pure st match Core.step env.config.params st.router event with | .error (.decode _) => return st | .ok (router, actions) => let gossipAt := match event with | .tick now _ => some now | _ => none - let delayed ← perform env gossipAt actions - return { st with router, pending := st.pending ++ delayed } + let (streams, delayed) ← perform env st.streams gossipAt actions + return { st with router, streams, pending := st.pending ++ delayed } /-- Tell every known peer about a subscription change (module doc). -/ -private def announce (env : Env) (router : Core.State) : Core.Event → Conc.Async Unit - | .subscribe t => do - for (peer, _) in router.peers do - sendRpc env.host peer (subOptRpc t true) - | .unsubscribe t => do - for (peer, _) in router.peers do - sendRpc env.host peer (subOptRpc t false) - | _ => return () +private def announce (env : Env) (st : LoopState) (event : Core.Event) : + Conc.Async LoopState := do + let record? := match event with + | .subscribe t => some (subOptRpc t true) + | .unsubscribe t => some (subOptRpc t false) + | _ => none + let mut streams := st.streams + if let some rpc := record? then + for (peer, _) in st.router.peers do + streams ← sendRpc env.host streams peer rpc + return { st with streams } /-- The loop. Each pass sends the gossip that came due, runs the heartbeat when it came due, arms one timer for the next due time, and parks. @@ -309,9 +378,10 @@ private partial def loop (env : Env) (st : LoopState) : Conc.Async Unit := do -- (see `Conc.TaskSet.joinAll`). let now ← (clock.nowMs : BaseIO Nat) let (due, later) := st.pending.partition (fun p => decide (p.due ≤ now)) + let mut streams := st.streams for p in due.mergeSort (fun a b => decide (a.due ≤ b.due)) do - sendRpc env.host p.peer p.rpc - let st : LoopState := { st with pending := later } + streams ← sendRpc env.host streams p.peer p.rpc + let st : LoopState := { st with streams, pending := later } let st ← if st.nextTick ≤ now then do let order := st.router.peers.map (·.1) @@ -324,10 +394,10 @@ private partial def loop (env : Env) (st : LoopState) : Conc.Async Unit := do -- Park: a send or a close of the queue wakes it, and so does the timer. match ← Conc.select #[Conc.Park.recv env.events Wake.event, timer.map fun () => Wake.timer] with | .timer => loop env st - | .event none => return () + | .event none => closeOutbound st.streams | .event (some e) => let st ← stepAndAct env st e - announce env st.router e + let st ← announce env st e loop env st /-- The router state of a node with no peers at time `now`. -/ @@ -365,6 +435,7 @@ def start (host : HostSeam) (config : Config := {}) : BaseIO Node := do let tasks ← Conc.TaskSet.new tasks.spawn <| loop { host, config, subs, events } { router := initialState config.params now + streams := [] pending := [] nextTick := now + config.params.heartbeatInterval } return { diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index c47714bf..d603acaf 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -104,8 +104,12 @@ in the codec group wrapping upstream `google/snappy`. the negative record. - A bad frame fails its handler and resets its stream. A body that does not decode leaves the loop running. - - The handler serves one frame in any chunking. Trailing bytes fail the - handler, and the buffer cap is checked before an append. + - The handler serves consecutive frames in any chunking. It checks the + length before it appends body bytes, and EOF between frames is clean. + - The node reuses one outbound stream per peer and retires failed streams. + A peer leave resets its stream, and stop closes the outbound streams. + - The receipt observer sees duplicates before validation and delivery. + - The default gossip delay is zero. Tests supply an explicit spread. - A heartbeat sends the graft, and a later publish reaches the mesh. - The `i`-th IHAVE of a heartbeat leaves at `trickle i`, and no earlier. - A publish with no peers opens no stream and delivers nothing locally. From 406d60a0b18d28e0bb1f5346fa83c6da289f1636 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:25:22 +0800 Subject: [PATCH 032/125] fix(protocols): retry one failed stream write and close departing peers --- Tests/Gossipsub/Node.lean | 41 ++++++++-- .../Libp2p/Protocols/Gossipsub/Node.lean | 79 +++++++++++++------ packages/libp2p/protocols/docs/PLAN.md | 5 +- 3 files changed, 93 insertions(+), 32 deletions(-) diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index 53526809..a7a74fd9 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -412,17 +412,39 @@ private def sendChecks (r : Tests.Runner) : Async Unit := do r.check "node.send.second-frame-on-the-same-stream" (next.any (fun s => s.rpc == subOptRpc ta false)) r.check "node.send.reuses-the-peer-stream" ((← (opened.get : BaseIO Nat)) == 2) - -- End the pipe behind the cached stream. Its next write fails, so the send - -- removes the stream. A later send opens a fresh one. + -- The cached pipe fails its next write. The same action opens a fresh + -- stream and retries its frame once. for (_, stream) in streams do stream.reset let streams ← sendRpc counting streams pa (subOptRpc ta true) - r.check "node.send.failed-write-retires-the-stream" streams.isEmpty - let streams ← sendRpc counting streams pa (subOptRpc ta true) let reopened ← within seen - r.check "node.send.reopens-after-failure" + r.check "node.send.failed-write-retries-on-a-fresh-stream" ((← (opened.get : BaseIO Nat)) == 3 && reopened.any (fun s => s.rpc == subOptRpc ta true)) + r.check "node.send.retry-keeps-one-entry" (streams.length == 1) + discard <| probe sw pb seen + let streams ← sendRpc counting streams pb (subOptRpc ta true) + discard <| within seen + r.check "node.send.two-peers-have-two-streams" + (streams.length == 2 && (← (opened.get : BaseIO Nat)) == 4) for (_, stream) in streams do stream.close sw.close + -- Both attempts fail. The retry bound prevents a third open, and both + -- failed streams are reset and removed. + let attempts : OnceRef Nat ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) + let resets : OnceRef Nat ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) + let failing : HostSeam := + { setHandler := fun _ => pure () + newStream := fun _ _ => do + (attempts.update (· + 1) : BaseIO Unit) + return .ok (Constants.meshsubId, + { read := pure (.ok none) + write := fun _ => pure (.error .closed) + close := pure () + reset := (resets.update (· + 1) : BaseIO Unit) }) } + let failed ← sendRpc failing [] pa (subOptRpc ta true) + r.check "node.send.retry-is-bounded-to-two-attempts" + ((← (attempts.get : BaseIO Nat)) == 2) + r.check "node.send.failed-attempts-reset-and-drop-their-streams" + (failed.isEmpty && (← (resets.get : BaseIO Nat)) == 2) /-! ## Receipt observation and stream ownership -/ @@ -469,9 +491,14 @@ private def streamOwnership (r : Tests.Runner) : Async Unit := do discard <| probe sw pc seen let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) let opens : OnceRef Nat ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) + let closes : OnceRef Nat ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) let counting : HostSeam := { seam with newStream := fun peer protocols => do (opens.update (· + 1) : BaseIO Unit) - seam.newStream peer protocols } + match ← seam.newStream peer protocols with + | .error e => return .error e + | .ok (id, stream) => return .ok (id, { stream with close := do + (closes.update (· + 1) : BaseIO Unit) + stream.close }) } let n ← (start counting (configOn tc) : BaseIO Node) discard (n.peerJoined pc .meshsub : BaseIO Bool) discard <| within seen @@ -484,7 +511,9 @@ private def streamOwnership (r : Tests.Runner) : Async Unit := do discard (n.peerJoined pc .meshsub : BaseIO Bool) discard <| within seen r.check "node.stream.peer-leave-retires-the-stream" ((← (opens.get : BaseIO Nat)) == 2) + r.check "node.stream.peer-leave-closes-the-stream" ((← (closes.get : BaseIO Nat)) == 1) n.stop + r.check "node.stream.stop-closes-the-cached-stream" ((← (closes.get : BaseIO Nat)) == 2) sw.close r.check "node.stream.stop-closes-outbound-before-the-host-joins" true (tc.close : BaseIO Unit) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index a254e06e..af01a6c3 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -62,13 +62,20 @@ through `OnceRef.update` (root ARCHITECTURE §4.1). - **The default gossip delay is zero.** The pinned go implementation flushes heartbeat gossip at once. Config.trickle keeps an explicit delay for tests and callers that need one, as section 12 of the brief records. -- **The loop keeps one outbound stream per peer.** Each action returns the - updated stream table. The loop closes the streams when it ends, and resets - a stream when its peer leaves or a write fails. The next send can reopen it. - Opening and writing run on the loop task, so either call can park the loop. -- **The inbound handler serves consecutive frames.** Go and rust keep their - outbound stream open. The handler bounds each frame before appending bytes, - and serves several frames from one chunk in order. +- **The loop keeps one outbound stream per peer.** The pinned go pubsub + version a01777894f4c resets the previous inbound stream on a duplicate + open (comm.go, lines 55 to 65). Its sender keeps one stream per peer + (lines 181 to 233). Each action returns the updated stream table. A failed + write resets and removes its stream, then retries once on a fresh stream. + A peer leave closes its stream. The loop closes all streams on exit. +- **A slow peer can park the loop.** A peer that stops reading parks the loop + inside write until it reads or its session ends. Go writes from a per-peer + goroutine with a bounded queue and drops on a full queue. This loop has no + such queue yet. Opening a stream can also park the loop. +- **The inbound handler serves consecutive frames.** Go keeps one reader + loop per stream (comm.go, lines 75 to 120). Go and rust write many RPCs on + one stream. A one-frame rule would break interop on the second RPC. The + handler bounds each frame before appending bytes and preserves frame order. - **The receipt observer runs before the core step.** Config.onRpc receives the sender and payload bytes of every RPC, including duplicates and bad protobuf bodies. Frame errors fail the handler before they reach it. @@ -152,29 +159,53 @@ private def retire (streams : Outbound) (peer : Peer) : Conc.Async Outbound := d if p = peer then stream.reset return streams.filter (fun e => e.1 != peer) -/-- Write one frame and return the stream table. A successful write keeps -its stream open. A failed write resets and removes it. -/ +/-- Close and drop a peer's stream when the peer leaves. The half-close +wakes the peer's reader with EOF after its queued bytes. -/ +private def closePeer (streams : Outbound) (peer : Peer) : Conc.Async Outbound := do + for (p, stream) in streams do + if p = peer then stream.close + return streams.filter (fun e => e.1 != peer) + +/-- Write one frame and return the stream table and success flag. A failed +write resets and removes its stream before the caller can retry. -/ private def writeFrame (streams : Outbound) (peer : Peer) (stream : Streams.Stream) - (frame : List Nat) : Conc.Async Outbound := do - try + (frame : List Nat) : Conc.Async (Outbound × Bool) := do + let sent ← try match ← stream.write (Bytes.ofNatList frame) with - | .ok () => return streams - | .error _ => retire streams peer - catch _ => retire streams peer + | .ok () => pure true + | .error _ => pure false + catch _ => pure false + if sent then return (streams, true) + return (← retire streams peer, false) + +/-- Try at most attempts writes. A failed open ends the action. A failed +write removes its stream, so the next attempt opens a fresh stream. -/ +private def sendFrame (host : HostSeam) (streams : Outbound) (to : Peer) + (frame : List Nat) : Nat → Conc.Async Outbound + | 0 => pure streams + | attempts + 1 => do + let stream? ← match streams.find? (fun e => e.1 == to) with + | some (_, stream) => pure (some stream) + | none => + try + match ← host.newStream to [Constants.meshsubId] with + | .ok (_, stream) => pure (some stream) + | .error _ => pure none + catch _ => pure none + let some stream := stream? | return streams + let streams := (to, stream) :: streams.filter (fun e => e.1 != to) + let (streams, sent) ← writeFrame streams to stream frame + if sent then return streams + sendFrame host streams to frame attempts /-- Send one RPC through the peer's stream and return the next stream table. -An RPC over Constants.rpcCap opens no stream. A failed open leaves the table -unchanged. The host reports send failures at integration. -/ +An RPC over Constants.rpcCap opens no stream. A failed write retries once +on a fresh stream. A failed open drops the action. The host reports send +failures at integration. -/ def sendRpc (host : HostSeam) (streams : Outbound) (to : Peer) (rpc : Rpc.RPC) : Conc.Async Outbound := do let some frame := Rpc.encodeFrame? rpc | return streams - if let some (_, stream) := streams.find? (fun e => e.1 == to) then - return ← writeFrame streams to stream frame - try - match ← host.newStream to [Constants.meshsubId] with - | .error _ => return streams - | .ok (_, stream) => writeFrame ((to, stream) :: streams) to stream frame - catch _ => return streams + sendFrame host streams to frame 2 /-- Half-close each outbound stream when the loop ends. The peer's read wakes at EOF, and its host joins the inbound handler. -/ @@ -340,7 +371,7 @@ private def stepAndAct (env : Env) (st : LoopState) (event : Core.Event) : Conc. (env.config.onRpc peer bytes : BaseIO Unit) let st ← match event with | .peerLeft peer => do - let streams ← retire st.streams peer + let streams ← closePeer st.streams peer pure { st with streams, pending := st.pending.filter (fun p => p.peer != peer) } | _ => pure st match Core.step env.config.params st.router event with diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index d603acaf..998b9d7e 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -106,8 +106,9 @@ in the codec group wrapping upstream `google/snappy`. not decode leaves the loop running. - The handler serves consecutive frames in any chunking. It checks the length before it appends body bytes, and EOF between frames is clean. - - The node reuses one outbound stream per peer and retires failed streams. - A peer leave resets its stream, and stop closes the outbound streams. + - The node reuses one outbound stream per peer. A failed write resets its + stream and retries once on a fresh stream. Two peers use two streams. + A peer leave closes its stream, and stop closes the outbound streams. - The receipt observer sees duplicates before validation and delivery. - The default gossip delay is zero. Tests supply an explicit spread. - A heartbeat sends the graft, and a later publish reaches the mesh. From 618bcd9552d14b211520d3fbe1a7ac361751f1b1 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:26:25 +0800 Subject: [PATCH 033/125] docs(protocols): cite the harness receipt contract before deduplication --- .../Libp2p/Protocols/Gossipsub/Node.lean | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index af01a6c3..365d7ae2 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -76,9 +76,14 @@ through `OnceRef.update` (root ARCHITECTURE §4.1). loop per stream (comm.go, lines 75 to 120). Go and rust write many RPCs on one stream. A one-frame rule would break interop on the second RPC. The handler bounds each frame before appending bytes and preserves frame order. -- **The receipt observer runs before the core step.** Config.onRpc receives - the sender and payload bytes of every RPC, including duplicates and bad - protobuf bodies. Frame errors fail the handler before they reach it. +- **The receipt observer runs before the core step.** The interop harness + logs every received message, including duplicates (gossipsub-interop, + test-specs/implementation.md, lines 71 to 78). Its go participant logs + messages on RECV_RPC (go-libp2p/tracer.go, lines 75 to 88). Core.handlePubs + deduplicates before delivery (Core.lean, lines 603 and 614). Config.onRpc + exposes the sender and unframed body before that step. The observer can + decode the body to log every message. The default hook decodes nothing. + Frame errors fail the handler before they reach the observer. - **The host posts `peerJoined` before it serves a stream of that peer.** The core ignores an RPC from an unknown peer (`Core.rpcStep`), so an earlier stream loses its RPC. @@ -106,8 +111,9 @@ structure Config where /-- The delay of the `i`-th gossip send of one heartbeat, in milliseconds. The caller fixes it, so a test gets exact times. -/ trickle : Nat → Millis := defaultTrickle - /-- Observe each inbound RPC before validation and deduplication. The callback - runs on the loop task and must not park. The bytes are the unframed body. -/ + /-- Called for every inbound RPC with its sender and its bytes, before the + core sees them. A harness reads it to log each received message, including + a duplicate. It runs on the loop task, so it must not park. -/ onRpc : Peer → List Nat → BaseIO Unit := fun _ _ => pure () /-- A running node. Every field is a seam over the loop task, which is the From 3d5e842ea306f660720e29c96e6e3609aaf225a7 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:28:29 +0800 Subject: [PATCH 034/125] fix(protocols): expose optional gossip spread with a zero default --- Tests/Gossipsub/Node.lean | 9 ++++++-- .../Libp2p/Protocols/Gossipsub/Node.lean | 21 ++++++++++++------- packages/libp2p/protocols/docs/PLAN.md | 3 ++- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index a7a74fd9..3c2ceb93 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -61,7 +61,7 @@ private def badBody : List Nat := [255] /-- The configuration of a node on a test clock. -/ private def configOn (tc : TestClock) (params : Core.Params := Core.defaults) - (trickle : Nat → Millis := defaultTrickle) : Config := + (trickle : Nat → Millis := fun _ => 0) : Config := { params, clock := tc.clock, trickle } /-- A delivery callback that keeps the message for the check. -/ @@ -580,7 +580,12 @@ private def gossipParams : Core.Params := the time of the heartbeat plus `trickle i`, and no earlier. -/ private def trickleSpread (r : Tests.Runner) : Async Unit := do r.check "node.trickle.default-is-zero" - ((List.range 64).all (fun i => defaultTrickle i == 0)) + ((List.range 64).all (fun i => ({} : Config).trickle i == 0)) + r.check "node.trickle.public-spread-is-below-one-second" + ((List.range 1000).all (fun i => Libp2p.Protocols.Gossipsub.Node.spread i < 1000)) + r.check "node.trickle.public-spread-formula" + (Libp2p.Protocols.Gossipsub.Node.spread 0 == 1 && + Libp2p.Protocols.Gossipsub.Node.spread 1 == 920) let sw ← (Switch.new : BaseIO Switch) let tc ← (TestClock.new : BaseIO TestClock) let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index 365d7ae2..acee133d 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -59,9 +59,15 @@ through `OnceRef.update` (root ARCHITECTURE §4.1). join, so that peer would never learn the topic. The go implementation announces a change in its host registry, outside the router. The loop does the same with `subOptRpc`, after the core step. -- **The default gossip delay is zero.** The pinned go implementation flushes - heartbeat gossip at once. Config.trickle keeps an explicit delay for tests - and callers that need one, as section 12 of the brief records. +- **The default gossip delay is zero.** The pinned go router enqueues IHAVEs + in emitGossip (gossipsub.go, lines 2007 to 2076). Its flush sends them at + once (lines 2078 to 2092). The random sleep in announceRetry belongs to a + subscription retry (pubsub.go, lines 1234 to 1235). The loop owns the + emission schedule, so a caller can spread heartbeat gossip across the + interval. The default spreads nothing and matches go. Tests configure an + explicit spread because issue #23 names the trickle regressions. The delay + applies only to heartbeat IHAVEs. Publish, forward, graft and hello sends + leave at once. - **The loop keeps one outbound stream per peer.** The pinned go pubsub version a01777894f4c resets the previous inbound stream on a duplicate open (comm.go, lines 55 to 65). Its sender keeps one stream per peer @@ -97,9 +103,10 @@ namespace Libp2p.Protocols.Gossipsub.Node open Mock.Host (HostSeam) -/-- The default sends heartbeat gossip at once, as the pinned go router does. -Callers can provide a delay through Config.trickle. -/ -def defaultTrickle (_i : Nat) : Millis := 0 +/-- A spread a caller may choose for heartbeat gossip. Each delay stays below +one second. The multiplier is prime, so the delays do not repeat with a short +period. Config.trickle defaults to zero. -/ +def spread (i : Nat) : Millis := (i * 7919 + 1) % 1000 /-- The configuration of one node: the router parameters, the clock the loop reads, and the spread of the heartbeat gossip. -/ @@ -110,7 +117,7 @@ structure Config where clock : Conc.Clock := Conc.Clock.system /-- The delay of the `i`-th gossip send of one heartbeat, in milliseconds. The caller fixes it, so a test gets exact times. -/ - trickle : Nat → Millis := defaultTrickle + trickle : Nat → Millis := fun _ => 0 /-- Called for every inbound RPC with its sender and its bytes, before the core sees them. A harness reads it to log each received message, including a duplicate. It runs on the loop task, so it must not park. -/ diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index 998b9d7e..5bb83358 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -112,7 +112,8 @@ in the codec group wrapping upstream `google/snappy`. - The receipt observer sees duplicates before validation and delivery. - The default gossip delay is zero. Tests supply an explicit spread. - A heartbeat sends the graft, and a later publish reaches the mesh. - - The `i`-th IHAVE of a heartbeat leaves at `trickle i`, and no earlier. + - With a configured spread, the i-th IHAVE of a heartbeat leaves at its + delay and no earlier. - A publish with no peers opens no stream and delivers nothing locally. - `stop` joins the loop, a later post answers `false`, and the switch closes. From aaec6180380d39c796a21b440e1bbd50e74930ad Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:30:25 +0800 Subject: [PATCH 035/125] fix(protocols): drive the node through one queue and an alarm task --- .../Libp2p/Protocols/Gossipsub/Node.lean | 109 +++++++++--------- 1 file changed, 55 insertions(+), 54 deletions(-) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index acee133d..fa00c949 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -15,14 +15,14 @@ level (ARCHITECTURE, "Modules"; root ARCHITECTURE §3.3). ## The loop -One task owns the router state. A bounded `Chan` carries the events of the -application and the host. The clock seam carries the heartbeat and the -delayed gossip. The loop parks in `select` over the two, so the timer costs -no task, and a `TestClock` drives every time decision of a test. +One task owns the router state. A bounded channel carries application and +host events and the wakes of Conc.Alarm. The alarm has its own task. The +loop uses repeat and parks only on the channel receive. Ready events use +constant stack space. The loop asks the alarm for the next due time after +each input. A TestClock drives the alarm in tests. -`loop` and `readFrames` are `partial`. A peer controls the number of events -and chunks. No measure bounds the wait for a complete frame or a close -(root CLAUDE.md, "`partial def`"). +The inbound readFrames is partial because a peer controls the number of +frames and chunks before EOF (root CLAUDE.md, "partial def"). ## Ownership @@ -226,6 +226,11 @@ private def closeOutbound (streams : Outbound) : Conc.Async Unit := do for (_, stream) in streams do stream.close +/-- The one input queue carries core events and alarm wakes. -/ +inductive Input where + | event (e : Core.Event) + | wake (now : Millis) + /-! ## The inbound handler -/ /-- The total frame size once its prefix is complete. A prefix under the @@ -245,7 +250,7 @@ A chunk may hold several frames, so pos marks the bytes already consumed. The handler appends only the bytes needed by the prefix or current body. Its buffer stays under the RPC cap plus Varint.maxBytes, even when one chunk holds many frames. EOF between frames is clean. -/ -private partial def readFrames (events : Conc.Chan Core.Event) (peer : Peer) +private partial def readFrames (events : Conc.Chan Input) (peer : Peer) (stream : Streams.Stream) (buf chunk : ByteArray) (pos : Nat) : Conc.Async (Except Mock.Core.Error Unit) := do match frameSize buf with @@ -256,7 +261,7 @@ private partial def readFrames (events : Conc.Chan Core.Event) (peer : Peer) match Rpc.decodeFrame? (Bytes.toNatList buf) with | .ok (some (payload, [])) => -- Park: the loop frees a slot, or stop closes the event queue. - match ← events.send (.rpc peer payload) with + match ← events.send (.event (.rpc peer payload)) with | .error _ => return .ok () | .ok () => return ← readFrames events peer stream ByteArray.empty chunk pos | _ => return .error (.protocol "the RPC frame is malformed") @@ -278,7 +283,7 @@ private partial def readFrames (events : Conc.Chan Core.Event) (peer : Peer) /-- Serve every RPC of a peer's stream in arrival order. Frame errors reset both directions. A clean EOF closes our write direction. The host owns and joins this handler task. -/ -def handler (events : Conc.Chan Core.Event) : Mock.Core.StreamHandler where +def handler (events : Conc.Chan Input) : Mock.Core.StreamHandler where id := Constants.meshsubId handle peer stream := ExceptT.mk do let answer ← readFrames events peer stream ByteArray.empty ByteArray.empty 0 @@ -322,7 +327,9 @@ private structure Env where /-- The subscription table. -/ subs : Conc.OnceRef Subscriptions /-- The event queue. -/ - events : Conc.Chan Core.Event + events : Conc.Chan Input + /-- The owned alarm task posts wakes on events. -/ + alarm : Conc.Alarm /-- One gossip send that waits for its due time. -/ private structure Pending where @@ -344,12 +351,6 @@ private structure LoopState where /-- The time of the next heartbeat. -/ nextTick : Millis -/-- What wakes the loop: the timer, or the event queue. `none` is a closed -and empty queue. -/ -private inductive Wake where - | timer - | event (e : Option Core.Event) - /-- Run the actions of one core step, in order, and answer the delayed sends. `gossipAt` is the time of the tick when a tick made the actions. The `i`-th IHAVE send of that tick waits for `trickle i` milliseconds, and every @@ -409,40 +410,38 @@ private def announce (env : Env) (st : LoopState) (event : Core.Event) : streams ← sendRpc env.host streams peer rpc return { st with streams } -/-- The loop. Each pass sends the gossip that came due, runs the heartbeat -when it came due, arms one timer for the next due time, and parks. - -A due time in the past gives a timer of 0 ms: the `Nat` subtraction stops at -0, and such a timer completes at once. The tick runs at the top of the pass, -so an event queue that stays full cannot hold a heartbeat back. The peer -order of a tick comes from the state of the loop, its only writer. -/ -private partial def loop (env : Env) (st : LoopState) : Conc.Async Unit := do - let clock := env.config.clock - -- The ascription marks the action `BaseIO`, so the lift into `Async` applies - -- (see `Conc.TaskSet.joinAll`). - let now ← (clock.nowMs : BaseIO Nat) +/-- The next wake is the first due send or the next heartbeat. -/ +private def dueOf (st : LoopState) : Millis := + st.pending.foldl (fun due p => Nat.min due p.due) st.nextTick + +/-- Send due gossip before the heartbeat shifts the message cache. -/ +private def onWake (env : Env) (st : LoopState) (now : Millis) : Conc.Async LoopState := do let (due, later) := st.pending.partition (fun p => decide (p.due ≤ now)) let mut streams := st.streams for p in due.mergeSort (fun a b => decide (a.due ≤ b.due)) do streams ← sendRpc env.host streams p.peer p.rpc - let st : LoopState := { st with streams, pending := later } - let st ← - if st.nextTick ≤ now then do - let order := st.router.peers.map (·.1) - let st ← stepAndAct env st (.tick now order) - pure { st with nextTick := now + env.config.params.heartbeatInterval } - else - pure st - let wake := st.pending.foldl (fun m p => Nat.min m p.due) st.nextTick - let timer ← (clock.after (wake - now) : BaseIO (Conc.Park Unit)) - -- Park: a send or a close of the queue wakes it, and so does the timer. - match ← Conc.select #[Conc.Park.recv env.events Wake.event, timer.map fun () => Wake.timer] with - | .timer => loop env st - | .event none => closeOutbound st.streams - | .event (some e) => - let st ← stepAndAct env st e - let st ← announce env st e - loop env st + let st := { st with streams, pending := later } + if st.nextTick ≤ now then + let st ← stepAndAct env st (.tick now (st.router.peers.map (·.1))) + return { st with nextTick := now + env.config.params.heartbeatInterval } + return st + +/-- Run the core through one receive park. Each action returns its state. +The closed channel serves queued inputs before it returns none. The tail +closes the alarm and every cached outbound stream. -/ +private def loop (env : Env) (st0 : LoopState) : Conc.Async Unit := do + let mut st := st0 + (env.alarm.set (dueOf st) : BaseIO Unit) + repeat + match ← env.events.recv with + | none => break + | some (.wake now) => st ← onWake env st now + | some (.event e) => + st ← stepAndAct env st e + st ← announce env st e + (env.alarm.set (dueOf st) : BaseIO Unit) + (env.alarm.close : BaseIO Unit) + closeOutbound st.streams /-- The router state of a node with no peers at time `now`. -/ private def initialState (p : Core.Params) (now : Millis) : Core.State := @@ -474,16 +473,18 @@ application. -/ def start (host : HostSeam) (config : Config := {}) : BaseIO Node := do let now ← config.clock.nowMs let subs : Conc.OnceRef Subscriptions ← Conc.OnceRef.new { next := 0, entries := [] } - let events : Conc.Chan Core.Event ← Conc.Chan.new 64 + let events : Conc.Chan Input ← Conc.Chan.new 64 host.setHandler (handler events) let tasks ← Conc.TaskSet.new - tasks.spawn <| loop { host, config, subs, events } + let alarm ← Conc.Alarm.new + tasks.spawn <| alarm.run config.clock (fun now => events.send (.wake now)) + tasks.spawn <| loop { host, config, subs, events, alarm } { router := initialState config.params now streams := [] pending := [] nextTick := now + config.params.heartbeatInterval } return { - publish := fun topic payload => events.trySend (.publish (messageOf topic payload)) + publish := fun topic payload => events.trySend (.event (.publish (messageOf topic payload))) subscribe := fun topic callback => do -- The cell changes first, so a delivery after the event finds the -- callback. A refused event takes this one registration back. @@ -491,15 +492,15 @@ def start (host : HostSeam) (config : Config := {}) : BaseIO Node := do (s.next, { next := s.next + 1 entries := s.entries ++ [{ id := s.next, topic, callback }] }) - let sent ← events.trySend (.subscribe topic) + let sent ← events.trySend (.event (.subscribe topic)) if !sent then subs.update fun s => { s with entries := s.entries.filter (fun e => e.id != id) } return sent unsubscribe := fun topic => do subs.update fun s => { s with entries := s.entries.filter (fun e => e.topic != topic) } - events.trySend (.unsubscribe topic) - peerJoined := fun peer proto => events.trySend (.peerJoined peer proto) - peerLeft := fun peer => events.trySend (.peerLeft peer) + events.trySend (.event (.unsubscribe topic)) + peerJoined := fun peer proto => events.trySend (.event (.peerJoined peer proto)) + peerLeft := fun peer => events.trySend (.event (.peerLeft peer)) stop := do (events.close : BaseIO Unit) tasks.joinAll } From ef0805cb1e6b995a31a69e0ba328e494e5f27ead Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:31:47 +0800 Subject: [PATCH 036/125] test(protocols): pin the gossipsub RPC bytes against the go encoder --- Tests/Gossipsub/Wire.lean | 168 ++++++ Tests/Main.lean | 2 + Tests/fixtures/gossipsub-rpc-go.json | 168 ++++++ .../protocols/tools/go-rpc-fixtures/go.mod | 8 + .../protocols/tools/go-rpc-fixtures/go.sum | 33 ++ .../protocols/tools/go-rpc-fixtures/main.go | 486 ++++++++++++++++++ 6 files changed, 865 insertions(+) create mode 100644 Tests/Gossipsub/Wire.lean create mode 100644 Tests/fixtures/gossipsub-rpc-go.json create mode 100644 packages/libp2p/protocols/tools/go-rpc-fixtures/go.mod create mode 100644 packages/libp2p/protocols/tools/go-rpc-fixtures/go.sum create mode 100644 packages/libp2p/protocols/tools/go-rpc-fixtures/main.go diff --git a/Tests/Gossipsub/Wire.lean b/Tests/Gossipsub/Wire.lean new file mode 100644 index 00000000..849e9cb8 --- /dev/null +++ b/Tests/Gossipsub/Wire.lean @@ -0,0 +1,168 @@ +import Tests.Support +import Libp2p.Protocols.Gossipsub.Rpc + +/-! +# Gossipsub suite: the wire bytes of the reference implementation + +`Tests/fixtures/gossipsub-rpc-go.json` records nine gossipsub RPC values that the pinned go +implementation marshals with its own protobuf encoder. Each case carries the marshaled payload as +hex, the semantic values the generator set, and a `roundtrip` flag that the generator confirmed +with `proto.Unmarshal` and `proto.Equal`. The generator is +`packages/libp2p/protocols/tools/go-rpc-fixtures/main.go`, and the module version comes from the +interop harness pin (`vendor/test-plans/gossipsub-interop/go-libp2p/go.mod:8`). + +This suite decodes each case with this package's codec, checks the decoded value against the +recorded fields, and re-encodes it. The re-encoding decodes to the same value, and for every case +whose payload carries no field that this codec leaves out, the re-encoding is the recorded bytes. +The suite reads one fixture and needs no network, no clock and no task. + +The plan records that gossipsub has no vectors (`docs/PLAN.md`, "Vector stance"). This fixture is +the first one, and its home is `Tests/fixtures` rather than `Tests/vectors`, because a random +draw does not reproduce it; the generator records where every byte came from. +-/ + +set_option autoImplicit false + +namespace Tests.Gossipsub.Wire + +open Libp2p.Protocols +open Libp2p.Protocols.Gossipsub +open Libp2p.Protocols.Gossipsub.Rpc +open Tests (Runner loadFixture jArr jObj jStr unhex) + +open Lean (Json) + +private def file : String := "gossipsub-rpc-go.json" + +/-- The recorded provenance names this repository's generator, the go module, and the pinned +version, so a fixture that arrives from somewhere else fails its own check. -/ +private def checkProvenance (r : Runner) (j : Json) : IO Unit := do + let p ← jObj file j "_provenance" + let expect (field got : String) (want : String) : IO Unit := + r.check s!"wire provenance {field}" (got == want) + expect "generatedBy" (← jStr file p "generatedBy") + "packages/libp2p/protocols/tools/go-rpc-fixtures/main.go" + expect "module" (← jStr file p "module") "github.com/libp2p/go-libp2p-pubsub, pb package" + expect "version" (← jStr file p "version") "v0.15.1-0.20260305223556-a01777894f4c" + +/-- The bytes of a hex string in the fixture. -/ +private def bytesOf (what hex : String) : IO (List Nat) := do + return Bytes.toNatList (← unhex what hex) + +/-- Decode one payload. The answer is `none` when the codec rejects bytes that the go encoder +produced, which is the failure this suite exists to find. -/ +private def decodePayload (r : Runner) (what : String) (payload : ByteArray) : + IO (Option Rpc.RPC) := + match Pb.decodeMessage? (α := Rpc.RPC) (Bytes.toNatList payload) with + | .ok (rpc, rest) => do + r.check s!"{what} consumes every byte" rest.isEmpty + return some rpc + | .error _ => do + r.check s!"{what} decodes" false + return none + +/-- The value survives one re-encoding: this codec's encoder output decodes to the same value. -/ +private def isStable (rpc : Rpc.RPC) : Bool := + match Pb.decodeMessage? (α := Rpc.RPC) (Pb.encodeMessage rpc) with + | .ok (again, rest) => (again == rpc) && rest.isEmpty + | .error _ => false + +/-- The `fields` object of one case, when the case carries one. -/ +private def fieldsOf (_r : Runner) (_name : String) (c : Json) : IO (Option Json) := + match c.getObjVal? "fields" with + | .ok v => return some v + | .error _ => return none + +/-- The subscription records the generator set, checked against the decoded value. -/ +private def checkSubs (r : Runner) (name : String) (f : Json) (rpc : Rpc.RPC) : IO Unit := do + let arr := match (f.getObjVal? "subscriptions") with + | .ok v => (v.getArr?.toOption.getD #[]).toList + | .error _ => [] + r.check s!"{name} subscription count" (arr.length == rpc.subscriptions.length) + for (rec, got) in arr.zip rpc.subscriptions do + let topicid := (rec.getObjVal? "topicid").toOption.bind (·.getStr?.toOption) + let sub := (rec.getObjVal? "subscribe").toOption.bind (·.getBool?.toOption) + r.check s!"{name} subscription topic" (topicid == some got.topicid) + r.check s!"{name} subscription flag" (sub == some got.subscribe) + +/-- The published messages the generator set, checked against the decoded value. -/ +private def checkPubs (r : Runner) (name : String) (f : Json) (rpc : Rpc.RPC) : IO Unit := do + let arr := match (f.getObjVal? "publish") with + | .ok v => (v.getArr?.toOption.getD #[]).toList + | .error _ => [] + r.check s!"{name} publish count" (arr.length == rpc.publish.length) + for (rec, got) in arr.zip rpc.publish do + let topic := (rec.getObjVal? "topic_str").toOption.bind (·.getStr?.toOption) + r.check s!"{name} publish topic" (topic == some got.topic.name) + let dataHex : Option String := (rec.getObjVal? "data_hex").toOption.bind (·.getStr?.toOption) + match dataHex with + | some h => r.check s!"{name} publish data" (some (Bytes.toNatList (Bytes.ofHex? h |>.getD .empty)) == got.data) + | none => r.check s!"{name} publish data absent" got.data.isNone + +/-- The control counts and the prune record of the topic, which is the only control message that +carries a peer list and a backoff here. -/ +private def checkControl (r : Runner) (name : String) (f : Json) (rpc : Rpc.RPC) : IO Unit := do + match f.getObjVal? "control" with + | .error _ => r.check s!"{name} no control when none was set" rpc.control.isNone + | .ok c => + match rpc.control with + | none => r.check s!"{name} control decodes" false + | some msg => + let count (k : String) : IO Nat := do + match c.getObjVal? k with + | .ok v => return (v.getArr?.toOption.getD #[]).size + | .error _ => return 0 + let ngraft ← count "graft" + let nprune ← count "prune" + let nihave ← count "ihave" + let niwant ← count "iwant" + r.check s!"{name} graft count" (ngraft == msg.graft.length) + r.check s!"{name} prune count" (nprune == msg.prune.length) + r.check s!"{name} ihave count" (nihave == msg.ihave.length) + r.check s!"{name} iwant count" (niwant == msg.iwant.length) + if let some p := msg.prune.head? then + r.check s!"{name} prune topic" (p.topicId == "test-topic") + r.check s!"{name} prune backoff" (p.backoff == some 60) + r.check s!"{name} prune peers" (p.peers.map (·.peerId) == [some [1, 2, 3]]) + if let some i := msg.ihave.head? then + r.check s!"{name} ihave topic" (i.topicId == "test-topic") + r.check s!"{name} ihave ids" + (i.messageIds.map (·.bytes) == [[1, 0, 0, 0, 0, 0, 0, 0], [2, 0, 0, 0, 0, 0, 0, 0]]) + if let some i := msg.iwant.head? then + r.check s!"{name} iwant ids" + (i.messageIds.map (·.bytes) == [[1, 0, 0, 0, 0, 0, 0, 0], [2, 0, 0, 0, 0, 0, 0, 0]]) + +/-- One case: decode, check the recorded fields, re-encode, and compare the bytes. The +`unknown-fields` case carries a field this codec leaves out on the way back, so its re-encoding is +a prefix of the recorded bytes, and that is the recorded behaviour rather than a surprise. -/ +private def checkCase (r : Runner) (c : Json) : IO Unit := do + let name ← jStr file c "name" + let payload ← bytesOf s!"{file} {name}" (← jStr file c "hex") + let rtRecorded := (c.getObjVal? "roundtrip").toOption.bind (·.getBool?.toOption) + r.check s!"{name} generator roundtrip" (rtRecorded == some true) + match ← decodePayload r name (Bytes.ofNatList payload) with + | none => pure () + | some rpc => + r.check s!"{name} re-encodes stably" (isStable rpc) + if let some f ← fieldsOf r name c then + checkSubs r name f rpc + checkPubs r name f rpc + checkControl r name f rpc + let encoded := Pb.encodeMessage rpc + if name == "unknown-fields" then + r.check s!"{name} re-encoding is the recorded prefix" + (encoded.length < payload.length && payload.take encoded.length == encoded) + else + r.check s!"{name} re-encoding is the recorded bytes" (encoded == payload) + +/-- The suite. -/ +def run (r : Runner) : IO Unit := do + r.section "gossipsub wire" "tests/fixtures/gossipsub-rpc-go.json" + let j ← loadFixture file + checkProvenance r j + let cases ← jArr file j "cases" + r.check "wire case count" (cases.length == 9) + for c in cases do + checkCase r c + +end Tests.Gossipsub.Wire \ No newline at end of file diff --git a/Tests/Main.lean b/Tests/Main.lean index 9b54a108..3efe1de4 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -6,6 +6,7 @@ import Tests.Gossipsub.Cache import Tests.Gossipsub.Score import Tests.Gossipsub.Rpc import Tests.Gossipsub.Node +import Tests.Gossipsub.Wire import Tests.Concurrency import Tests.Protobuf import Tests.Bytes @@ -43,6 +44,7 @@ def main : IO UInt32 := do Tests.Gossipsub.Score.run r Tests.Gossipsub.Rpc.run r Tests.Gossipsub.Node.run r + Tests.Gossipsub.Wire.run r Tests.Concurrency.run r Tests.Protobuf.run r Bytes.runTests r diff --git a/Tests/fixtures/gossipsub-rpc-go.json b/Tests/fixtures/gossipsub-rpc-go.json new file mode 100644 index 00000000..44cc9de7 --- /dev/null +++ b/Tests/fixtures/gossipsub-rpc-go.json @@ -0,0 +1,168 @@ +{ + "_provenance": { + "generatedBy": "packages/libp2p/protocols/tools/go-rpc-fixtures/main.go", + "module": "github.com/libp2p/go-libp2p-pubsub, pb package", + "version": "v0.15.1-0.20260305223556-a01777894f4c", + "api": "proto.Marshal of pb.RPC values, then proto.Unmarshal and proto.Equal for the roundtrip field", + "format": "the RPC payload of one gossipsub frame, as lowercase hex; the uvarint prefix is Streams.Framing", + "source": "vendor/test-plans/gossipsub-interop/go-libp2p/go.mod:8 pins the module version" + }, + "cases": [ + { + "name": "sub-opt-subscribe", + "hex": "0a0e0801120a746573742d746f7069630a050800120174", + "fields": { + "subscriptions": [ + { + "topicid": "test-topic", + "subscribe": true + }, + { + "topicid": "t", + "subscribe": false + } + ] + }, + "roundtrip": true + }, + { + "name": "publish-basic", + "hex": "121612080001020304050607220a746573742d746f706963", + "fields": { + "publish": [ + { + "topic_str": "test-topic", + "topic_bytes_hex": "746573742d746f706963", + "data_hex": "0001020304050607" + } + ] + }, + "roundtrip": true + }, + { + "name": "publish-empty-data", + "hex": "120c220a746573742d746f706963", + "fields": { + "publish": [ + { + "topic_str": "test-topic", + "topic_bytes_hex": "746573742d746f706963" + } + ] + }, + "roundtrip": true + }, + { + "name": "control-graft", + "hex": "1a0e1a0c0a0a746573742d746f706963", + "fields": { + "control": { + "graft": [ + { + "topic_bytes_hex": "746573742d746f706963" + } + ] + } + }, + "roundtrip": true + }, + { + "name": "control-prune", + "hex": "1a1722150a0a746573742d746f70696312050a03010203183c", + "fields": { + "control": { + "prune": [ + { + "topic_bytes_hex": "746573742d746f706963", + "peers_hex_list": [ + "010203" + ], + "backoff": 60 + } + ] + } + }, + "roundtrip": true + }, + { + "name": "control-ihave", + "hex": "1a220a200a0a746573742d746f7069631208010000000000000012080200000000000000", + "fields": { + "control": { + "ihave": [ + { + "topic_bytes_hex": "746573742d746f706963", + "message_ids_hex": [ + "0100000000000000", + "0200000000000000" + ] + } + ] + } + }, + "roundtrip": true + }, + { + "name": "control-iwant", + "hex": "1a1612140a0801000000000000000a080200000000000000", + "fields": { + "control": { + "iwant": [ + { + "message_ids_hex": [ + "0100000000000000", + "0200000000000000" + ] + } + ] + } + }, + "roundtrip": true + }, + { + "name": "mixed", + "hex": "0a0e0801120a746573742d746f7069630a050800120174121612080001020304050607220a746573742d746f7069631a0e1a0c0a0a746573742d746f706963", + "fields": { + "subscriptions": [ + { + "topicid": "test-topic", + "subscribe": true + }, + { + "topicid": "t", + "subscribe": false + } + ], + "publish": [ + { + "topic_str": "test-topic", + "topic_bytes_hex": "746573742d746f706963", + "data_hex": "0001020304050607" + } + ], + "control": { + "graft": [ + { + "topic_bytes_hex": "746573742d746f706963" + } + ] + } + }, + "roundtrip": true + }, + { + "name": "unknown-fields", + "hex": "121612080001020304050607220a746573742d746f7069634a04deadbeef", + "fields": { + "publish": [ + { + "topic_str": "test-topic", + "topic_bytes_hex": "746573742d746f706963", + "data_hex": "0001020304050607" + } + ] + }, + "roundtrip": true + } + ] +} diff --git a/packages/libp2p/protocols/tools/go-rpc-fixtures/go.mod b/packages/libp2p/protocols/tools/go-rpc-fixtures/go.mod new file mode 100644 index 00000000..75837f52 --- /dev/null +++ b/packages/libp2p/protocols/tools/go-rpc-fixtures/go.mod @@ -0,0 +1,8 @@ +module leanp2p-protocols-rpc-fixtures + +go 1.24.1 + +require ( + github.com/gogo/protobuf v1.3.2 + github.com/libp2p/go-libp2p-pubsub v0.15.1-0.20260305223556-a01777894f4c +) \ No newline at end of file diff --git a/packages/libp2p/protocols/tools/go-rpc-fixtures/go.sum b/packages/libp2p/protocols/tools/go-rpc-fixtures/go.sum new file mode 100644 index 00000000..c668fd58 --- /dev/null +++ b/packages/libp2p/protocols/tools/go-rpc-fixtures/go.sum @@ -0,0 +1,33 @@ +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/libp2p/go-libp2p-pubsub v0.15.1-0.20260305223556-a01777894f4c h1:rcinqzooEODok/JNrp5xpgHbqre3qR3ww7u0fDMkTVw= +github.com/libp2p/go-libp2p-pubsub v0.15.1-0.20260305223556-a01777894f4c/go.mod h1:lr4oE8bFgQaifRcoc2uWhWWiK6tPdOEKpUuR408GFN4= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/packages/libp2p/protocols/tools/go-rpc-fixtures/main.go b/packages/libp2p/protocols/tools/go-rpc-fixtures/main.go new file mode 100644 index 00000000..7c7be6c9 --- /dev/null +++ b/packages/libp2p/protocols/tools/go-rpc-fixtures/main.go @@ -0,0 +1,486 @@ +package main + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "os" + + "github.com/gogo/protobuf/proto" + pb "github.com/libp2p/go-libp2p-pubsub/pb" +) + +func strPtr(s string) *string { return &s } +func boolPtr(b bool) *bool { return &b } +func uint64Ptr(u uint64) *uint64 { return &u } + +type CaseJSON struct { + Name string `json:"name"` + Hex string `json:"hex"` + Fields FieldsJSON `json:"fields"` + Roundtrip bool `json:"roundtrip"` +} + +type FieldsJSON struct { + Subscriptions []SubscriptionFields `json:"subscriptions,omitempty"` + Publish []PublishFields `json:"publish,omitempty"` + Control *ControlFields `json:"control,omitempty"` +} + +type SubscriptionFields struct { + Topicid string `json:"topicid"` + Subscribe bool `json:"subscribe"` +} + +type PublishFields struct { + TopicStr *string `json:"topic_str,omitempty"` + TopicBytesHex *string `json:"topic_bytes_hex,omitempty"` + DataHex *string `json:"data_hex,omitempty"` + SeqnoHex *string `json:"seqno_hex,omitempty"` + SignatureHex *string `json:"signature_hex,omitempty"` + KeyHex *string `json:"key_hex,omitempty"` +} + +type ControlFields struct { + Graft []GraftFields `json:"graft,omitempty"` + Prune []PruneFields `json:"prune,omitempty"` + Ihave []IhaveFields `json:"ihave,omitempty"` + Iwant []IwantFields `json:"iwant,omitempty"` +} + +type GraftFields struct { + TopicBytesHex string `json:"topic_bytes_hex"` +} + +type PruneFields struct { + TopicBytesHex string `json:"topic_bytes_hex"` + PeersHexList []string `json:"peers_hex_list,omitempty"` + Backoff *uint64 `json:"backoff,omitempty"` +} + +type IhaveFields struct { + TopicBytesHex string `json:"topic_bytes_hex"` + MessageIDsHex []string `json:"message_ids_hex"` +} + +type IwantFields struct { + MessageIDsHex []string `json:"message_ids_hex"` +} + +// Command go-rpc-fixtures writes Tests/fixtures/gossipsub-rpc-go.json. +// +// The gossipsub RPC bytes of this tree are pinned against the protobuf +// marshaler of the go implementation that the interop gate runs +// (vendor/test-plans/gossipsub-interop/go-libp2p/go.mod). Each case records the +// marshaled payload, the semantic values the program set, and a roundtrip flag +// that proto.Unmarshal and proto.Equal confirm before the file is written. +// +// Run from this directory with the Go toolchain the gate image carries: +// +// go run . ../../../../Tests/fixtures/gossipsub-rpc-go.json +func main() { + var cases []CaseJSON + + topicTest := "test-topic" + topicTestHex := hex.EncodeToString([]byte(topicTest)) + dataBasic := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07} + dataBasicHex := hex.EncodeToString(dataBasic) + + msgId1 := []byte{1, 0, 0, 0, 0, 0, 0, 0} + msgId2 := []byte{2, 0, 0, 0, 0, 0, 0, 0} + msgId1Hex := hex.EncodeToString(msgId1) + msgId2Hex := hex.EncodeToString(msgId2) + + // 1. sub-opt-subscribe: two SubOpts, one true, one false, topics "test-topic" and "t" + { + rpc := &pb.RPC{ + Subscriptions: []*pb.RPC_SubOpts{ + {Topicid: strPtr("test-topic"), Subscribe: boolPtr(true)}, + {Topicid: strPtr("t"), Subscribe: boolPtr(false)}, + }, + } + raw, err := proto.Marshal(rpc) + if err != nil { + panic(fmt.Sprintf("case 1 marshal failed: %v", err)) + } + var decoded pb.RPC + if err := proto.Unmarshal(raw, &decoded); err != nil { + panic(fmt.Sprintf("case 1 unmarshal failed: %v", err)) + } + rt := proto.Equal(rpc, &decoded) + if !rt { + panic("case 1 roundtrip failed") + } + cases = append(cases, CaseJSON{ + Name: "sub-opt-subscribe", + Hex: hex.EncodeToString(raw), + Fields: FieldsJSON{ + Subscriptions: []SubscriptionFields{ + {Topicid: "test-topic", Subscribe: true}, + {Topicid: "t", Subscribe: false}, + }, + }, + Roundtrip: rt, + }) + } + + // 2. publish-basic: one Message with data 8 bytes 00..07, topic "test-topic", from/seqno/signature/key absent + var bPublishBasic []byte + var fieldsPublishBasic PublishFields + { + rpc := &pb.RPC{ + Publish: []*pb.Message{ + { + Topic: strPtr(topicTest), + Data: dataBasic, + }, + }, + } + raw, err := proto.Marshal(rpc) + if err != nil { + panic(fmt.Sprintf("case 2 marshal failed: %v", err)) + } + bPublishBasic = raw + var decoded pb.RPC + if err := proto.Unmarshal(raw, &decoded); err != nil { + panic(fmt.Sprintf("case 2 unmarshal failed: %v", err)) + } + rt := proto.Equal(rpc, &decoded) + if !rt { + panic("case 2 roundtrip failed") + } + fieldsPublishBasic = PublishFields{ + TopicStr: strPtr(topicTest), + TopicBytesHex: strPtr(topicTestHex), + DataHex: strPtr(dataBasicHex), + } + cases = append(cases, CaseJSON{ + Name: "publish-basic", + Hex: hex.EncodeToString(raw), + Fields: FieldsJSON{ + Publish: []PublishFields{fieldsPublishBasic}, + }, + Roundtrip: rt, + }) + } + + // 3. publish-empty-data: one Message with data absent, topic "test-topic" + { + rpc := &pb.RPC{ + Publish: []*pb.Message{ + { + Topic: strPtr(topicTest), + Data: nil, + }, + }, + } + raw, err := proto.Marshal(rpc) + if err != nil { + panic(fmt.Sprintf("case 3 marshal failed: %v", err)) + } + var decoded pb.RPC + if err := proto.Unmarshal(raw, &decoded); err != nil { + panic(fmt.Sprintf("case 3 unmarshal failed: %v", err)) + } + rt := proto.Equal(rpc, &decoded) + if !rt { + panic("case 3 roundtrip failed") + } + cases = append(cases, CaseJSON{ + Name: "publish-empty-data", + Hex: hex.EncodeToString(raw), + Fields: FieldsJSON{ + Publish: []PublishFields{ + { + TopicStr: strPtr(topicTest), + TopicBytesHex: strPtr(topicTestHex), + }, + }, + }, + Roundtrip: rt, + }) + } + + // 4. control-graft: control with one Graft for "test-topic" + { + rpc := &pb.RPC{ + Control: &pb.ControlMessage{ + Graft: []*pb.ControlGraft{ + {TopicID: strPtr(topicTest)}, + }, + }, + } + raw, err := proto.Marshal(rpc) + if err != nil { + panic(fmt.Sprintf("case 4 marshal failed: %v", err)) + } + var decoded pb.RPC + if err := proto.Unmarshal(raw, &decoded); err != nil { + panic(fmt.Sprintf("case 4 unmarshal failed: %v", err)) + } + rt := proto.Equal(rpc, &decoded) + if !rt { + panic("case 4 roundtrip failed") + } + cases = append(cases, CaseJSON{ + Name: "control-graft", + Hex: hex.EncodeToString(raw), + Fields: FieldsJSON{ + Control: &ControlFields{ + Graft: []GraftFields{ + {TopicBytesHex: topicTestHex}, + }, + }, + }, + Roundtrip: rt, + }) + } + + // 5. control-prune: control with one Prune for "test-topic", backoff 60, one peer id bytes [1,2,3] + { + peerBytes := []byte{1, 2, 3} + rpc := &pb.RPC{ + Control: &pb.ControlMessage{ + Prune: []*pb.ControlPrune{ + { + TopicID: strPtr(topicTest), + Backoff: uint64Ptr(60), + Peers: []*pb.PeerInfo{ + {PeerID: peerBytes}, + }, + }, + }, + }, + } + raw, err := proto.Marshal(rpc) + if err != nil { + panic(fmt.Sprintf("case 5 marshal failed: %v", err)) + } + var decoded pb.RPC + if err := proto.Unmarshal(raw, &decoded); err != nil { + panic(fmt.Sprintf("case 5 unmarshal failed: %v", err)) + } + rt := proto.Equal(rpc, &decoded) + if !rt { + panic("case 5 roundtrip failed") + } + cases = append(cases, CaseJSON{ + Name: "control-prune", + Hex: hex.EncodeToString(raw), + Fields: FieldsJSON{ + Control: &ControlFields{ + Prune: []PruneFields{ + { + TopicBytesHex: topicTestHex, + Backoff: uint64Ptr(60), + PeersHexList: []string{hex.EncodeToString(peerBytes)}, + }, + }, + }, + }, + Roundtrip: rt, + }) + } + + // 6. control-ihave: control with one IHave for "test-topic" and two message ids of 8 bytes each + { + rpc := &pb.RPC{ + Control: &pb.ControlMessage{ + Ihave: []*pb.ControlIHave{ + { + TopicID: strPtr(topicTest), + MessageIDs: []string{string(msgId1), string(msgId2)}, + }, + }, + }, + } + raw, err := proto.Marshal(rpc) + if err != nil { + panic(fmt.Sprintf("case 6 marshal failed: %v", err)) + } + var decoded pb.RPC + if err := proto.Unmarshal(raw, &decoded); err != nil { + panic(fmt.Sprintf("case 6 unmarshal failed: %v", err)) + } + rt := proto.Equal(rpc, &decoded) + if !rt { + panic("case 6 roundtrip failed") + } + cases = append(cases, CaseJSON{ + Name: "control-ihave", + Hex: hex.EncodeToString(raw), + Fields: FieldsJSON{ + Control: &ControlFields{ + Ihave: []IhaveFields{ + { + TopicBytesHex: topicTestHex, + MessageIDsHex: []string{msgId1Hex, msgId2Hex}, + }, + }, + }, + }, + Roundtrip: rt, + }) + } + + // 7. control-iwant: control with one IWant holding the same two ids + { + rpc := &pb.RPC{ + Control: &pb.ControlMessage{ + Iwant: []*pb.ControlIWant{ + { + MessageIDs: []string{string(msgId1), string(msgId2)}, + }, + }, + }, + } + raw, err := proto.Marshal(rpc) + if err != nil { + panic(fmt.Sprintf("case 7 marshal failed: %v", err)) + } + var decoded pb.RPC + if err := proto.Unmarshal(raw, &decoded); err != nil { + panic(fmt.Sprintf("case 7 unmarshal failed: %v", err)) + } + rt := proto.Equal(rpc, &decoded) + if !rt { + panic("case 7 roundtrip failed") + } + cases = append(cases, CaseJSON{ + Name: "control-iwant", + Hex: hex.EncodeToString(raw), + Fields: FieldsJSON{ + Control: &ControlFields{ + Iwant: []IwantFields{ + { + MessageIDsHex: []string{msgId1Hex, msgId2Hex}, + }, + }, + }, + }, + Roundtrip: rt, + }) + } + + // 8. mixed: subscriptions plus one message plus a control with one Graft, to pin field order + { + rpc := &pb.RPC{ + Subscriptions: []*pb.RPC_SubOpts{ + {Topicid: strPtr("test-topic"), Subscribe: boolPtr(true)}, + {Topicid: strPtr("t"), Subscribe: boolPtr(false)}, + }, + Publish: []*pb.Message{ + { + Topic: strPtr(topicTest), + Data: dataBasic, + }, + }, + Control: &pb.ControlMessage{ + Graft: []*pb.ControlGraft{ + {TopicID: strPtr(topicTest)}, + }, + }, + } + raw, err := proto.Marshal(rpc) + if err != nil { + panic(fmt.Sprintf("case 8 marshal failed: %v", err)) + } + var decoded pb.RPC + if err := proto.Unmarshal(raw, &decoded); err != nil { + panic(fmt.Sprintf("case 8 unmarshal failed: %v", err)) + } + rt := proto.Equal(rpc, &decoded) + if !rt { + panic("case 8 roundtrip failed") + } + cases = append(cases, CaseJSON{ + Name: "mixed", + Hex: hex.EncodeToString(raw), + Fields: FieldsJSON{ + Subscriptions: []SubscriptionFields{ + {Topicid: "test-topic", Subscribe: true}, + {Topicid: "t", Subscribe: false}, + }, + Publish: []PublishFields{fieldsPublishBasic}, + Control: &ControlFields{ + Graft: []GraftFields{ + {TopicBytesHex: topicTestHex}, + }, + }, + }, + Roundtrip: rt, + }) + } + + // 9. unknown-fields: "publish-basic" hex with an extra appended field + // Field number 9, wire type 2 (length-delimited), payload 4 bytes (0xde, 0xad, 0xbe, 0xef) + // Tag = (9 << 3) | 2 = 74 = 0x4a, Length = 0x04 + { + extraField := []byte{0x4a, 0x04, 0xde, 0xad, 0xbe, 0xef} + raw := append(bytes.Clone(bPublishBasic), extraField...) + + var decoded1 pb.RPC + if err := proto.Unmarshal(raw, &decoded1); err != nil { + panic(fmt.Sprintf("case 9 unmarshal failed: %v", err)) + } + var decoded2 pb.RPC + if err := proto.Unmarshal(raw, &decoded2); err != nil { + panic(fmt.Sprintf("case 9 second unmarshal failed: %v", err)) + } + rt := proto.Equal(&decoded1, &decoded2) + if !rt { + panic("case 9 roundtrip failed") + } + // Confirm re-marshaling produces exact original bytes including unknown field + reEncoded, err := proto.Marshal(&decoded1) + if err != nil || !bytes.Equal(reEncoded, raw) { + panic("case 9 re-encoding did not match raw bytes") + } + + cases = append(cases, CaseJSON{ + Name: "unknown-fields", + Hex: hex.EncodeToString(raw), + Fields: FieldsJSON{ + Publish: []PublishFields{fieldsPublishBasic}, + }, + Roundtrip: rt, + }) + } + + // Verify all roundtrips + for _, c := range cases { + if !c.Roundtrip { + fmt.Fprintf(os.Stderr, "ERROR: case %s roundtrip failed\n", c.Name) + os.Exit(1) + } + } + + outPath := "../../../../Tests/fixtures/gossipsub-rpc-go.json" + if len(os.Args) > 1 { + outPath = os.Args[1] + } + + provenance := map[string]string{ + "generatedBy": "packages/libp2p/protocols/tools/go-rpc-fixtures/main.go", + "module": "github.com/libp2p/go-libp2p-pubsub, pb package", + "version": "v0.15.1-0.20260305223556-a01777894f4c", + "api": "proto.Marshal of pb.RPC values, then proto.Unmarshal and proto.Equal for the roundtrip field", + "format": "the RPC payload of one gossipsub frame, as lowercase hex; the uvarint prefix is Streams.Framing", + "source": "vendor/test-plans/gossipsub-interop/go-libp2p/go.mod:8 pins the module version", + } + out := struct { + Provenance map[string]string `json:"_provenance"` + Cases []CaseJSON `json:"cases"` + }{provenance, cases} + buf, err := json.MarshalIndent(out, "", " ") + if err != nil { + panic(err) + } + buf = append(buf, '\n') + + if err := os.WriteFile(outPath, buf, 0644); err != nil { + panic(err) + } + fmt.Printf("Generated %d cases to %s\n", len(cases), outPath) +} \ No newline at end of file From b7795ba256254cc4119d9783bd95e5e9c3148476 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:33:26 +0800 Subject: [PATCH 037/125] fix(protocols): enqueue peer writes and wake writers before stop joins --- .../Libp2p/Protocols/Gossipsub/Node.lean | 246 +++++++++--------- 1 file changed, 130 insertions(+), 116 deletions(-) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index fa00c949..07a2b8c6 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -26,21 +26,22 @@ frames and chunks before EOF (root CLAUDE.md, "partial def"). ## Ownership -The task set of the node owns the loop task, and `stop` joins it. The host +The task set of the node owns the loop, alarm and writer tasks. Stop joins them. The host owns the handler tasks of the inbound streams. Nothing cancels a task: `stop` closes the event queue, and the close wakes the parked loop. The -router state and the pending gossip are local to the loop, so no cell -guards them. The subscription table is the one shared cell, and it changes -through `OnceRef.update` (root ARCHITECTURE §4.1). +router state and pending gossip are local to the loop. Shared registries, +subscriptions and the closing flag change through OnceRef.update or +updateGet (root ARCHITECTURE §4.1). Only the first stop caller joins. ## Recorded decisions - **The stop field runs in Async.** The brief gives it a BaseIO type. TaskSet.joinAll runs in Async, because joining can park. This field keeps that type and leaves the blocking run to the application edge. -- **Actions return the next stream table and pending sends.** This follows - the yamux performAll discipline. These actions use Async because opening - and writing streams can park. The loop assigns each returned state. +- **Actions never park.** Each send offers a frame to a peer queue with 32 + slots. One writer task owns that peer's stream. A full queue drops the + frame, as go does for a full peer queue. Node.dropped reports these drops. + Actions return the next pending list, and the loop assigns that answer. - **The spread index counts sends within each heartbeat.** Config describes this index, while the brief's loop sketch increments it once per tick. Each delayed send gets its own index, and the next heartbeat starts at 0. @@ -68,16 +69,12 @@ through `OnceRef.update` (root ARCHITECTURE §4.1). explicit spread because issue #23 names the trickle regressions. The delay applies only to heartbeat IHAVEs. Publish, forward, graft and hello sends leave at once. -- **The loop keeps one outbound stream per peer.** The pinned go pubsub - version a01777894f4c resets the previous inbound stream on a duplicate - open (comm.go, lines 55 to 65). Its sender keeps one stream per peer - (lines 181 to 233). Each action returns the updated stream table. A failed - write resets and removes its stream, then retries once on a fresh stream. - A peer leave closes its stream. The loop closes all streams on exit. -- **A slow peer can park the loop.** A peer that stops reading parks the loop - inside write until it reads or its session ends. Go writes from a per-peer - goroutine with a bounded queue and drops on a full queue. This loop has no - such queue yet. Opening a stream can also park the loop. +- **Each peer has one writer task.** The pinned go pubsub version a01777894f4c + resets duplicate inbound streams (comm.go, lines 55 to 65). Its sender uses + one stream and a bounded queue per peer (lines 181 to 233). Our writer + writes frames in queue order. A failed write closes the queue and posts + peerLeft. Stop resets installed streams to wake parked writes and joins + the writer tasks. The host must end a newStream call when its session ends. - **The inbound handler serves consecutive frames.** Go keeps one reader loop per stream (comm.go, lines 75 to 120). Go and rust write many RPCs on one stream. A one-frame rule would break interop on the second RPC. The @@ -146,6 +143,8 @@ structure Node where field runs in `Async` because a join parks, and a parked `BaseIO` action would hold a thread. A second call does nothing. -/ stop : Conc.Async Unit + /-- The number of refused outbound frames. -/ + dropped : BaseIO Nat /-! ## One outbound RPC -/ @@ -162,75 +161,48 @@ beside the code that sends it. -/ def subOptRpc (t : TopicId) (on : Bool) : Rpc.RPC := { subscriptions := [{ subscribe := on, topicid := t.name }], publish := [], control := none } -/-- The outbound streams owned by the loop, one per peer. Each send returns -its next table, so the loop assigns from the answer. -/ -abbrev Outbound := List (Peer × Streams.Stream) - -/-- Retire a peer's outbound stream. Reset wakes both directions. -/ -private def retire (streams : Outbound) (peer : Peer) : Conc.Async Outbound := do - for (p, stream) in streams do - if p = peer then stream.reset - return streams.filter (fun e => e.1 != peer) - -/-- Close and drop a peer's stream when the peer leaves. The half-close -wakes the peer's reader with EOF after its queued bytes. -/ -private def closePeer (streams : Outbound) (peer : Peer) : Conc.Async Outbound := do - for (p, stream) in streams do - if p = peer then stream.close - return streams.filter (fun e => e.1 != peer) - -/-- Write one frame and return the stream table and success flag. A failed -write resets and removes its stream before the caller can retry. -/ -private def writeFrame (streams : Outbound) (peer : Peer) (stream : Streams.Stream) - (frame : List Nat) : Conc.Async (Outbound × Bool) := do - let sent ← try - match ← stream.write (Bytes.ofNatList frame) with - | .ok () => pure true - | .error _ => pure false - catch _ => pure false - if sent then return (streams, true) - return (← retire streams peer, false) - -/-- Try at most attempts writes. A failed open ends the action. A failed -write removes its stream, so the next attempt opens a fresh stream. -/ -private def sendFrame (host : HostSeam) (streams : Outbound) (to : Peer) - (frame : List Nat) : Nat → Conc.Async Outbound - | 0 => pure streams - | attempts + 1 => do - let stream? ← match streams.find? (fun e => e.1 == to) with - | some (_, stream) => pure (some stream) - | none => - try - match ← host.newStream to [Constants.meshsubId] with - | .ok (_, stream) => pure (some stream) - | .error _ => pure none - catch _ => pure none - let some stream := stream? | return streams - let streams := (to, stream) :: streams.filter (fun e => e.1 != to) - let (streams, sent) ← writeFrame streams to stream frame - if sent then return streams - sendFrame host streams to frame attempts - -/-- Send one RPC through the peer's stream and return the next stream table. -An RPC over Constants.rpcCap opens no stream. A failed write retries once -on a fresh stream. A failed open drops the action. The host reports send -failures at integration. -/ -def sendRpc (host : HostSeam) (streams : Outbound) (to : Peer) (rpc : Rpc.RPC) : - Conc.Async Outbound := do - let some frame := Rpc.encodeFrame? rpc | return streams - sendFrame host streams to frame 2 - -/-- Half-close each outbound stream when the loop ends. The peer's read -wakes at EOF, and its host joins the inbound handler. -/ -private def closeOutbound (streams : Outbound) : Conc.Async Unit := do - for (_, stream) in streams do - stream.close - /-- The one input queue carries core events and alarm wakes. -/ inductive Input where | event (e : Core.Event) | wake (now : Millis) +/-- One peer's bounded writer queue and its currently open stream. -/ +structure Outbound where + /-- At most 32 encoded frames wait for this peer. -/ + queue : Conc.Chan ByteArray + /-- Stop resets this stream to wake a parked write. -/ + stream : Conc.OnceRef (Option Streams.Stream) + +/-- Close a queue and reset its stream to wake its writer. -/ +private def endOutbound (ob : Outbound) : Conc.Async Unit := do + (ob.queue.close : BaseIO Unit) + if let some stream ← (ob.stream.get : BaseIO (Option Streams.Stream)) then + stream.reset + +/-- One writer owns the peer stream. It writes queued frames in order and +closes the stream at the end. A failed write closes the queue and reports +peerLeft. Stop resets an installed stream; a late open checks closing too. -/ +private def writer (host : HostSeam) (closing : Conc.OnceRef Bool) + (events : Conc.Chan Input) (peer : Peer) (ob : Outbound) : Conc.Async Unit := do + let fail : Conc.Async Unit := do + (ob.queue.close : BaseIO Unit) + discard <| events.send (.event (.peerLeft peer)) + try + match ← host.newStream peer [Constants.meshsubId] with + | .error _ => fail + | .ok (_, stream) => + (ob.stream.set (some stream) : BaseIO Unit) + if ← (closing.get : BaseIO Bool) then stream.reset + repeat + match ← ob.queue.recv with + | none => break + | some frame => + if let .error _ ← stream.write frame then + fail + break + stream.close + catch _ => fail + /-! ## The inbound handler -/ /-- The total frame size once its prefix is complete. A prefix under the @@ -330,6 +302,40 @@ private structure Env where events : Conc.Chan Input /-- The owned alarm task posts wakes on events. -/ alarm : Conc.Alarm + /-- The owner of the loop, alarm and writers. -/ + tasks : Conc.TaskSet + /-- The peer writer registry. Only the loop adds entries. -/ + outbound : Conc.OnceRef (List (Peer × Outbound)) + /-- The first stop caller sets this flag. -/ + closing : Conc.OnceRef Bool + /-- Frames refused by a full or closed outbound queue. -/ + drops : Conc.OnceRef Nat + +/-- Encode and offer one RPC to a peer's queue. This operation never parks. +A full queue drops its frame. Each peer has one writer and one stream. -/ +private def enqueue (env : Env) (peer : Peer) (rpc : Rpc.RPC) : BaseIO Unit := do + if ← env.closing.get then return () + let some frame := Rpc.encodeFrame? rpc | return () + let table ← env.outbound.get + let ob ← match table.find? (fun e => e.1 == peer) with + | some (_, ob) => pure ob + | none => do + let ob : Outbound := { queue := ← Conc.Chan.new 32, stream := ← Conc.OnceRef.new none } + env.outbound.update (fun table => (peer, ob) :: table) + env.tasks.spawn (writer env.host env.closing env.events peer ob) + pure ob + if ← env.closing.get then + ob.queue.close + return () + if !(← ob.queue.trySend (Bytes.ofNatList frame)) then env.drops.update (· + 1) + +/-- Retire a peer writer after its disconnect event. -/ +private def removePeer (env : Env) (peer : Peer) : BaseIO Unit := do + let removed ← env.outbound.updateGet fun table => + (table.filter (fun e => e.1 == peer), table.filter (fun e => e.1 != peer)) + for (_, ob) in removed do + ob.queue.close + env.tasks.spawn (endOutbound ob) /-- One gossip send that waits for its due time. -/ private structure Pending where @@ -344,8 +350,6 @@ private structure Pending where private structure LoopState where /-- The router state. -/ router : Core.State - /-- The outbound streams owned by this loop. -/ - streams : Outbound /-- The gossip sends that wait for their due time. -/ pending : List Pending /-- The time of the next heartbeat. -/ @@ -355,9 +359,8 @@ private structure LoopState where sends. `gossipAt` is the time of the tick when a tick made the actions. The `i`-th IHAVE send of that tick waits for `trickle i` milliseconds, and every other action runs now. -/ -private def perform (env : Env) (streams : Outbound) (gossipAt : Option Millis) - (actions : List Core.Action) : Conc.Async (Outbound × List Pending) := do - let mut streams := streams +private def perform (env : Env) (gossipAt : Option Millis) + (actions : List Core.Action) : BaseIO (List Pending) := do let mut delayed : Array Pending := #[] let mut gossipIndex := 0 for action in actions do @@ -370,23 +373,23 @@ private def perform (env : Env) (streams : Outbound) (gossipAt : Option Millis) let delay := env.config.trickle gossipIndex gossipIndex := gossipIndex + 1 if delay = 0 then - streams ← sendRpc env.host streams to rpc + enqueue env to rpc else delayed := delayed.push { due := now + delay, peer := to, rpc := rpc } else - streams ← sendRpc env.host streams to rpc - | none => streams ← sendRpc env.host streams to rpc - return (streams, delayed.toList) + enqueue env to rpc + | none => enqueue env to rpc + return delayed.toList /-- One core step, then its actions. Only an RPC that does not decode makes the core answer `.error`. That answer keeps the state, and the loop runs on. -/ -private def stepAndAct (env : Env) (st : LoopState) (event : Core.Event) : Conc.Async LoopState := do +private def stepAndAct (env : Env) (st : LoopState) (event : Core.Event) : BaseIO LoopState := do if let .rpc peer bytes := event then (env.config.onRpc peer bytes : BaseIO Unit) let st ← match event with | .peerLeft peer => do - let streams ← closePeer st.streams peer - pure { st with streams, pending := st.pending.filter (fun p => p.peer != peer) } + removePeer env peer + pure { st with pending := st.pending.filter (fun p => p.peer != peer) } | _ => pure st match Core.step env.config.params st.router event with | .error (.decode _) => return st @@ -394,33 +397,31 @@ private def stepAndAct (env : Env) (st : LoopState) (event : Core.Event) : Conc. let gossipAt := match event with | .tick now _ => some now | _ => none - let (streams, delayed) ← perform env st.streams gossipAt actions - return { st with router, streams, pending := st.pending ++ delayed } + let delayed ← perform env gossipAt actions + return { st with router, pending := st.pending ++ delayed } /-- Tell every known peer about a subscription change (module doc). -/ private def announce (env : Env) (st : LoopState) (event : Core.Event) : - Conc.Async LoopState := do + BaseIO LoopState := do let record? := match event with | .subscribe t => some (subOptRpc t true) | .unsubscribe t => some (subOptRpc t false) | _ => none - let mut streams := st.streams if let some rpc := record? then for (peer, _) in st.router.peers do - streams ← sendRpc env.host streams peer rpc - return { st with streams } + enqueue env peer rpc + return st /-- The next wake is the first due send or the next heartbeat. -/ private def dueOf (st : LoopState) : Millis := st.pending.foldl (fun due p => Nat.min due p.due) st.nextTick /-- Send due gossip before the heartbeat shifts the message cache. -/ -private def onWake (env : Env) (st : LoopState) (now : Millis) : Conc.Async LoopState := do +private def onWake (env : Env) (st : LoopState) (now : Millis) : BaseIO LoopState := do let (due, later) := st.pending.partition (fun p => decide (p.due ≤ now)) - let mut streams := st.streams for p in due.mergeSort (fun a b => decide (a.due ≤ b.due)) do - streams ← sendRpc env.host streams p.peer p.rpc - let st := { st with streams, pending := later } + enqueue env p.peer p.rpc + let st := { st with pending := later } if st.nextTick ≤ now then let st ← stepAndAct env st (.tick now (st.router.peers.map (·.1))) return { st with nextTick := now + env.config.params.heartbeatInterval } @@ -435,13 +436,14 @@ private def loop (env : Env) (st0 : LoopState) : Conc.Async Unit := do repeat match ← env.events.recv with | none => break - | some (.wake now) => st ← onWake env st now + | some (.wake now) => st ← (onWake env st now : BaseIO LoopState) | some (.event e) => - st ← stepAndAct env st e - st ← announce env st e + st ← (stepAndAct env st e : BaseIO LoopState) + st ← (announce env st e : BaseIO LoopState) (env.alarm.set (dueOf st) : BaseIO Unit) (env.alarm.close : BaseIO Unit) - closeOutbound st.streams + for (_, ob) in ← (env.outbound.get : BaseIO (List (Peer × Outbound))) do + (ob.queue.close : BaseIO Unit) /-- The router state of a node with no peers at time `now`. -/ private def initialState (p : Core.Params) (now : Millis) : Core.State := @@ -477,32 +479,44 @@ def start (host : HostSeam) (config : Config := {}) : BaseIO Node := do host.setHandler (handler events) let tasks ← Conc.TaskSet.new let alarm ← Conc.Alarm.new + let outbound ← Conc.OnceRef.new ([] : List (Peer × Outbound)) + let closing ← Conc.OnceRef.new false + let drops ← Conc.OnceRef.new 0 tasks.spawn <| alarm.run config.clock (fun now => events.send (.wake now)) - tasks.spawn <| loop { host, config, subs, events, alarm } + tasks.spawn <| loop { host, config, subs, events, alarm, tasks, outbound, closing, drops } { router := initialState config.params now - streams := [] pending := [] nextTick := now + config.params.heartbeatInterval } + let post : Core.Event → BaseIO Bool := fun e => do + if ← closing.get then return false + events.trySend (.event e) return { - publish := fun topic payload => events.trySend (.event (.publish (messageOf topic payload))) + publish := fun topic payload => post (.publish (messageOf topic payload)) subscribe := fun topic callback => do + if ← closing.get then return false -- The cell changes first, so a delivery after the event finds the -- callback. A refused event takes this one registration back. let id ← subs.updateGet fun s => (s.next, { next := s.next + 1 entries := s.entries ++ [{ id := s.next, topic, callback }] }) - let sent ← events.trySend (.event (.subscribe topic)) + let sent ← post (.subscribe topic) if !sent then subs.update fun s => { s with entries := s.entries.filter (fun e => e.id != id) } return sent unsubscribe := fun topic => do + if ← closing.get then return false subs.update fun s => { s with entries := s.entries.filter (fun e => e.topic != topic) } - events.trySend (.event (.unsubscribe topic)) - peerJoined := fun peer proto => events.trySend (.event (.peerJoined peer proto)) - peerLeft := fun peer => events.trySend (.event (.peerLeft peer)) + post (.unsubscribe topic) + peerJoined := fun peer proto => post (.peerJoined peer proto) + peerLeft := fun peer => post (.peerLeft peer) + dropped := drops.get stop := do - (events.close : BaseIO Unit) - tasks.joinAll } + let first ← (closing.updateGet (fun was => (!was, true)) : BaseIO Bool) + if first then + (events.close : BaseIO Unit) + for (_, ob) in ← (outbound.get : BaseIO (List (Peer × Outbound))) do + endOutbound ob + tasks.joinAll } end Libp2p.Protocols.Gossipsub.Node From be0e8709dd95e1be497c32f60df627b13a082aba Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:34:40 +0800 Subject: [PATCH 038/125] fix(protocols): bound gossip and register inbound peers before their RPCs --- .../Libp2p/Protocols/Gossipsub/Node.lean | 108 ++++++++++-------- 1 file changed, 60 insertions(+), 48 deletions(-) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index 07a2b8c6..f805d74b 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -42,13 +42,15 @@ updateGet (root ARCHITECTURE §4.1). Only the first stop caller joins. slots. One writer task owns that peer's stream. A full queue drops the frame, as go does for a full peer queue. Node.dropped reports these drops. Actions return the next pending list, and the loop assigns that answer. -- **The spread index counts sends within each heartbeat.** Config describes - this index, while the brief's loop sketch increments it once per tick. - Each delayed send gets its own index, and the next heartbeat starts at 0. -- **A refused subscribe removes its callback.** The cell changes before the - post, so a refused post removes that registration by its unique id. -- **The queue carries Core.Event directly.** The loop owns all delayed sends, - so no external sender needs the brief's unused send message constructor. +- **Delayed gossip has a bound.** At most 256 sends wait in the pending list. + Overflow drops the newest excess sends and adds to Node.dropped. Due sends + leave before the tick shifts the message cache. The delay index increases + across the run. Config.order hashes the heartbeat count with peer bytes. +- **A successful subscribe registers its callback.** The event post comes + first, and a refused post leaves no callback. Subscriptions use atomic + updates and preserve registration order. +- **The handler announces its opener.** It posts peerJoined before reading + frames, so the first RPC reaches a known peer. Repeat joins are no-ops. - **No signer.** The core validates with `strictNoSign` (`Core.validationOk`), which requires `from` and `seqno` to be absent. So `publish` signs nothing and never writes the peer id of the node. @@ -59,7 +61,7 @@ updateGet (root ARCHITECTURE §4.1). Only the first stop caller joins. only when a peer joins. The core emits nothing for a subscribe after the join, so that peer would never learn the topic. The go implementation announces a change in its host registry, outside the router. The loop - does the same with `subOptRpc`, after the core step. + does the same with subOptRpc, before the core step. - **The default gossip delay is zero.** The pinned go router enqueues IHAVEs in emitGossip (gossipsub.go, lines 2007 to 2076). Its flush sends them at once (lines 2078 to 2092). The random sleep in announceRetry belongs to a @@ -87,9 +89,6 @@ updateGet (root ARCHITECTURE §4.1). Only the first stop caller joins. exposes the sender and unframed body before that step. The observer can decode the body to log every message. The default hook decodes nothing. Frame errors fail the handler before they reach the observer. -- **The host posts `peerJoined` before it serves a stream of that peer.** The - core ignores an RPC from an unknown peer (`Core.rpcStep`), so an earlier - stream loses its RPC. - **A send failure has no sink.** The send retires the stream, and the mock host has no failure log. The host reports stream failures at integration. -/ @@ -105,6 +104,12 @@ one second. The multiplier is prime, so the delays do not repeat with a short period. Config.trickle defaults to zero. -/ def spread (i : Nat) : Millis := (i * 7919 + 1) % 1000 +/-- Replayable peer order for a heartbeat. The hash combines its counter +with each peer's identity bytes. The loop supplies only known peers. -/ +def defaultOrder (tick : Nat) (peers : List Peer) : List Peer := + let key (peer : Peer) : UInt64 := mixHash (hash tick) (hash peer.bytes) + peers.mergeSort (fun a b => key a ≤ key b) + /-- The configuration of one node: the router parameters, the clock the loop reads, and the spread of the heartbeat gossip. -/ structure Config where @@ -115,6 +120,8 @@ structure Config where /-- The delay of the `i`-th gossip send of one heartbeat, in milliseconds. The caller fixes it, so a test gets exact times. -/ trickle : Nat → Millis := fun _ => 0 + /-- Replayable peer order for the numbered heartbeat. -/ + order : Nat → List Peer → List Peer := defaultOrder /-- Called for every inbound RPC with its sender and its bytes, before the core sees them. A harness reads it to log each received message, including a duplicate. It runs on the loop task, so it must not park. -/ @@ -143,7 +150,7 @@ structure Node where field runs in `Async` because a join parks, and a parked `BaseIO` action would hold a thread. A second call does nothing. -/ stop : Conc.Async Unit - /-- The number of refused outbound frames. -/ + /-- Frames dropped at the peer queue or pending-gossip bound. -/ dropped : BaseIO Nat /-! ## One outbound RPC -/ @@ -258,6 +265,10 @@ joins this handler task. -/ def handler (events : Conc.Chan Input) : Mock.Core.StreamHandler where id := Constants.meshsubId handle peer stream := ExceptT.mk do + -- The opener joins before its first RPC. Repeat joins are loop no-ops. + if let .error _ ← events.send (.event (.peerJoined peer .meshsub)) then + stream.close + return .ok () let answer ← readFrames events peer stream ByteArray.empty ByteArray.empty 0 match answer with | .ok () => stream.close @@ -266,10 +277,8 @@ def handler (events : Conc.Chan Input) : Mock.Core.StreamHandler where /-! ## The subscription table -/ -/-- One delivery callback, with the id that lets `subscribe` take it back. -/ +/-- One delivery callback. -/ private structure Subscription where - /-- The id of this registration. -/ - id : Nat /-- The topic. -/ topic : TopicId /-- The callback. -/ @@ -277,8 +286,6 @@ private structure Subscription where /-- The shared table of the callbacks, in registration order. -/ private structure Subscriptions where - /-- The next free id. -/ - next : Nat /-- The callbacks. -/ entries : List Subscription @@ -354,15 +361,19 @@ private structure LoopState where pending : List Pending /-- The time of the next heartbeat. -/ nextTick : Millis + /-- The heartbeat count used for the peer order. -/ + ticks : Nat + /-- The next run-wide delayed gossip index. -/ + gossipIndex : Nat /-- Run the actions of one core step, in order, and answer the delayed sends. `gossipAt` is the time of the tick when a tick made the actions. The `i`-th IHAVE send of that tick waits for `trickle i` milliseconds, and every other action runs now. -/ -private def perform (env : Env) (gossipAt : Option Millis) - (actions : List Core.Action) : BaseIO (List Pending) := do +private def perform (env : Env) (gossipAt : Option Millis) (index : Nat) + (actions : List Core.Action) : BaseIO (List Pending × Nat) := do let mut delayed : Array Pending := #[] - let mut gossipIndex := 0 + let mut gossipIndex := index for action in actions do match action with | .deliver m => (deliver env.subs m : BaseIO Unit) @@ -379,11 +390,26 @@ private def perform (env : Env) (gossipAt : Option Millis) else enqueue env to rpc | none => enqueue env to rpc - return delayed.toList + return (delayed.toList, gossipIndex) + +/-- Tell every known peer about a subscription change (module doc). -/ +private def announce (env : Env) (st : LoopState) (event : Core.Event) : + BaseIO LoopState := do + let record? := match event with + | .subscribe t => some (subOptRpc t true) + | .unsubscribe t => some (subOptRpc t false) + | _ => none + if let some rpc := record? then + for (peer, _) in st.router.peers do + enqueue env peer rpc + return st /-- One core step, then its actions. Only an RPC that does not decode makes the core answer `.error`. That answer keeps the state, and the loop runs on. -/ private def stepAndAct (env : Env) (st : LoopState) (event : Core.Event) : BaseIO LoopState := do + if let .peerJoined peer _ := event then + if (Core.peerOf st.router peer).isSome then return st + let st ← announce env st event if let .rpc peer bytes := event then (env.config.onRpc peer bytes : BaseIO Unit) let st ← match event with @@ -397,20 +423,11 @@ private def stepAndAct (env : Env) (st : LoopState) (event : Core.Event) : BaseI let gossipAt := match event with | .tick now _ => some now | _ => none - let delayed ← perform env gossipAt actions - return { st with router, pending := st.pending ++ delayed } - -/-- Tell every known peer about a subscription change (module doc). -/ -private def announce (env : Env) (st : LoopState) (event : Core.Event) : - BaseIO LoopState := do - let record? := match event with - | .subscribe t => some (subOptRpc t true) - | .unsubscribe t => some (subOptRpc t false) - | _ => none - if let some rpc := record? then - for (peer, _) in st.router.peers do - enqueue env peer rpc - return st + let (delayed, gossipIndex) ← perform env gossipAt st.gossipIndex actions + let all := st.pending ++ delayed + let overflow := all.length - 256 + if 0 < overflow then env.drops.update (· + overflow) + return { st with router, gossipIndex, pending := all.take 256 } /-- The next wake is the first due send or the next heartbeat. -/ private def dueOf (st : LoopState) : Millis := @@ -423,8 +440,9 @@ private def onWake (env : Env) (st : LoopState) (now : Millis) : BaseIO LoopStat enqueue env p.peer p.rpc let st := { st with pending := later } if st.nextTick ≤ now then - let st ← stepAndAct env st (.tick now (st.router.peers.map (·.1))) - return { st with nextTick := now + env.config.params.heartbeatInterval } + let order := env.config.order st.ticks (st.router.peers.map (·.1)) + let st ← stepAndAct env st (.tick now order) + return { st with nextTick := now + env.config.params.heartbeatInterval, ticks := st.ticks + 1 } return st /-- Run the core through one receive park. Each action returns its state. @@ -439,7 +457,6 @@ private def loop (env : Env) (st0 : LoopState) : Conc.Async Unit := do | some (.wake now) => st ← (onWake env st now : BaseIO LoopState) | some (.event e) => st ← (stepAndAct env st e : BaseIO LoopState) - st ← (announce env st e : BaseIO LoopState) (env.alarm.set (dueOf st) : BaseIO Unit) (env.alarm.close : BaseIO Unit) for (_, ob) in ← (env.outbound.get : BaseIO (List (Peer × Outbound))) do @@ -474,7 +491,7 @@ the back-pressure to the peer, and it refuses a `BaseIO` post of the application. -/ def start (host : HostSeam) (config : Config := {}) : BaseIO Node := do let now ← config.clock.nowMs - let subs : Conc.OnceRef Subscriptions ← Conc.OnceRef.new { next := 0, entries := [] } + let subs : Conc.OnceRef Subscriptions ← Conc.OnceRef.new { entries := [] } let events : Conc.Chan Input ← Conc.Chan.new 64 host.setHandler (handler events) let tasks ← Conc.TaskSet.new @@ -486,7 +503,9 @@ def start (host : HostSeam) (config : Config := {}) : BaseIO Node := do tasks.spawn <| loop { host, config, subs, events, alarm, tasks, outbound, closing, drops } { router := initialState config.params now pending := [] - nextTick := now + config.params.heartbeatInterval } + nextTick := now + config.params.heartbeatInterval + ticks := 0 + gossipIndex := 0 } let post : Core.Event → BaseIO Bool := fun e => do if ← closing.get then return false events.trySend (.event e) @@ -494,15 +513,8 @@ def start (host : HostSeam) (config : Config := {}) : BaseIO Node := do publish := fun topic payload => post (.publish (messageOf topic payload)) subscribe := fun topic callback => do if ← closing.get then return false - -- The cell changes first, so a delivery after the event finds the - -- callback. A refused event takes this one registration back. - let id ← subs.updateGet fun s => - (s.next, - { next := s.next + 1 - entries := s.entries ++ [{ id := s.next, topic, callback }] }) let sent ← post (.subscribe topic) - if !sent then - subs.update fun s => { s with entries := s.entries.filter (fun e => e.id != id) } + if sent then subs.update fun s => { entries := s.entries ++ [{ topic, callback }] } return sent unsubscribe := fun topic => do if ← closing.get then return false From 18c99225cc929e70a0cc0f0e86086af533e9fa3b Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:39:26 +0800 Subject: [PATCH 039/125] test(protocols): regenerate the RPC fixture from its committed generator --- Tests/fixtures/gossipsub-rpc-go.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/fixtures/gossipsub-rpc-go.json b/Tests/fixtures/gossipsub-rpc-go.json index 44cc9de7..9eb12660 100644 --- a/Tests/fixtures/gossipsub-rpc-go.json +++ b/Tests/fixtures/gossipsub-rpc-go.json @@ -1,11 +1,11 @@ { "_provenance": { - "generatedBy": "packages/libp2p/protocols/tools/go-rpc-fixtures/main.go", - "module": "github.com/libp2p/go-libp2p-pubsub, pb package", - "version": "v0.15.1-0.20260305223556-a01777894f4c", "api": "proto.Marshal of pb.RPC values, then proto.Unmarshal and proto.Equal for the roundtrip field", "format": "the RPC payload of one gossipsub frame, as lowercase hex; the uvarint prefix is Streams.Framing", - "source": "vendor/test-plans/gossipsub-interop/go-libp2p/go.mod:8 pins the module version" + "generatedBy": "packages/libp2p/protocols/tools/go-rpc-fixtures/main.go", + "module": "github.com/libp2p/go-libp2p-pubsub, pb package", + "source": "vendor/test-plans/gossipsub-interop/go-libp2p/go.mod:8 pins the module version", + "version": "v0.15.1-0.20260305223556-a01777894f4c" }, "cases": [ { From c31794cd3beceab7ec27bda5e9d3d7661c945e13 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:43:41 +0800 Subject: [PATCH 040/125] test(protocols): pin alarm deadlines and bounded peer writer behavior --- Tests/Gossipsub/Node.lean | 1307 ++++++++++++++----------------------- 1 file changed, 508 insertions(+), 799 deletions(-) diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index 3c2ceb93..982d2f8d 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -2,24 +2,13 @@ import Tests.Support import Libp2p.Protocols.Gossipsub.Node /-! -# Gossipsub suite: the node loop +# Gossipsub node loop checks -The checks of unit 3b at the seam of the loop: two nodes over the in-memory -switch, the inbound handler, the heartbeat, the gossip spread, and the stop. -No check reads the router state. A check watches what the seam shows: a -delivery callback, or the RPCs that a probe peer decodes. - -## How a check waits - -Each node takes its own `TestClock`. The loop arms one timer in each pass, so -`settle tc n` returns after the loop has served `n - 1` events since its start -and parked again. A pass holds no other park, so the count gives an exact -order with no sleep. A wait for a timer or a delivery has a real deadline of -`patience` milliseconds, so a missing event fails its check. `stop` and -`Switch.close` have no deadline. Each one wakes what it joins with a close. - -A node posts an event per inbound RPC, so each count below names its events -in a comment. +The suite observes RPCs, deliveries, writer streams and close signals. A +receipt marker provides a queue barrier. TestClock timers belong to the +alarm and track deadlines. The suite never counts timer arms as core events. +Every receive has a real deadline. Stop checks reset their test stream on +expiry, so a failed check can still release its owned tasks. -/ set_option autoImplicit false @@ -32,831 +21,551 @@ open Libp2p.Protocols.Gossipsub open Libp2p.Protocols.Gossipsub.Node open Libp2p.Protocols.Mock.Host (Switch HostSeam) -/-! ## Fixtures -/ - -private def ta : TopicId := ⟨"t"⟩ - private def pa : Peer := ⟨[1], "a"⟩ private def pb : Peer := ⟨[2], "b"⟩ private def pc : Peer := ⟨[3], "c"⟩ private def pd : Peer := ⟨[4], "d"⟩ -private def px : Peer := ⟨[9], "x"⟩ - -/-- The real deadline of every wait of this suite, in milliseconds. A -passing run never reaches it. -/ +private def px : Peer := ⟨[9], "control"⟩ +private def topic : TopicId := ⟨"t"⟩ private def patience : Nat := 5000 -private def hello (t : TopicId) : Rpc.RPC := Core.helloRpc [t] - -private def msgOf (topic : TopicId) (payload : List Nat) : Message := - { «from» := none, data := some payload, seqno := none, topic := topic, +private def message (data : List Nat) : Message := + { «from» := none, data := some data, seqno := none, topic, signature := none, key := none } -/-- The bytes of one RPC frame. -/ -private def frameOf (rpc : Rpc.RPC) : List Nat := - (Rpc.encodeFrame? rpc).getD [] - -/-- A frame around a body that does not decode as an RPC. -/ -private def badBody : List Nat := [255] - -/-- The configuration of a node on a test clock. -/ -private def configOn (tc : TestClock) (params : Core.Params := Core.defaults) - (trickle : Nat → Millis := fun _ => 0) : Config := - { params, clock := tc.clock, trickle } - -/-- A delivery callback that keeps the message for the check. -/ -private def keep (inbox : Chan Message) : Message → BaseIO Unit := - fun m => discard (inbox.trySend m) - -/-- Wait until the loop has armed `n` more timers. `false` at the deadline. The -expiry closes the clock, and the close wakes `nextTimer`. -/ -private def settle (tc : TestClock) (n : Nat) : Async Bool := do - for _ in [0:n] do - match ← withinDeadline Clock.system patience tc.close tc.nextTimer with - | .ok true => pure () - | _ => return false - return true - -/-- The next value of the channel, or `none` at the deadline. -/ -private def within {α : Type} (c : Chan α) : Async (Option α) := do +private def frame (rpc : Rpc.RPC) : ByteArray := + Bytes.ofNatList ((Rpc.encodeFrame? rpc).getD []) + +/-- A receive that fails at a real deadline instead of hanging the suite. -/ +private def receive {α : Type} (c : Chan α) : Async (Option α) := do let timer ← (Park.sleep patience none : BaseIO (Park (Option α))) select #[Park.recv c id, timer] -/-- True when the channel holds no value now. -/ -private def quiet {α : Type} (c : Chan α) : Async Bool := do - return (← (c.tryRecv : BaseIO (Option α))).isNone - -/-- True when the task set fails: its handler answered an error. -/ -private def joinFails (ts : TaskSet) : Async Bool := do - try - ts.joinAll - return false - catch _ => - return true - -/-- Stop the nodes, close the switch, and close the clocks. -/ -private def teardown (sw : Switch) (nodes : List Node) (clocks : List TestClock) : Async Unit := do - for n in nodes do - n.stop - sw.close - for c in clocks do - (c.close : BaseIO Unit) +private def marker : Rpc.RPC := subOptRpc ⟨"node-suite-barrier"⟩ false + +/-- A clock with a signal for the absolute time of each armed deadline. -/ +private structure ClockProbe where + test : TestClock + armed : Chan Nat + +private def ClockProbe.new : BaseIO ClockProbe := + return ⟨← TestClock.new, ← Chan.new 512⟩ -/-! ## The probe peer -/ +private def ClockProbe.clock (c : ClockProbe) : Clock where + nowMs := c.test.clock.nowMs + after ms := do + let timer ← c.test.clock.after ms + discard <| c.armed.trySend ((← c.test.clock.nowMs) + ms) + return timer + +/-- Wait for one exact deadline. Earlier recorded arms may still be queued. -/ +private def armedAt (c : ClockProbe) (due : Nat) : Async Bool := do + for _ in [0:512] do + match ← receive c.armed with + | some t => if t = due then return true + | none => return false + return false -/-- One RPC that a probe decoded. -/ private structure Seen where - /-- The probe that decoded it. -/ receiver : Peer - /-- The opener of the stream. -/ - sender : Peer - /-- The RPC. -/ + peer : Peer rpc : Rpc.RPC -/-- Every byte of a stream. -/ -private partial def readAll (s : Streams.Stream) (acc : ByteArray) : Async ByteArray := do - match ← s.read with - | .ok (some chunk) => readAll s (acc ++ chunk) - | .ok none => return acc - | .error _ => return acc - -/-- Decode each frame as soon as its bytes arrive. The probe reads until EOF, -so the node can reuse one stream for many RPCs. -/ -private partial def probeRead (seen : Chan Seen) (me sender : Peer) - (stream : Streams.Stream) (buf : List Nat) : Async Unit := do - match Rpc.decodeFrame? buf with - | .ok (some (payload, rest)) => - if let .ok rpc := Core.decodeRpc payload then - discard (seen.trySend { receiver := me, sender, rpc } : BaseIO Bool) - probeRead seen me sender stream rest - | .error _ => stream.reset - | .ok none => - match ← stream.read with - | .ok (some bytes) => probeRead seen me sender stream (buf ++ Bytes.toNatList bytes) - | _ => stream.close - -/-- A raw peer whose handler records each RPC before the stream closes. -/ -private def probe (sw : Switch) (me : Peer) (seen : Chan Seen) : Async HostSeam := do - let (seam, _) ← (sw.host me : BaseIO (HostSeam × TaskSet)) - (seam.setHandler - { id := Constants.meshsubId - handle := fun sender stream => ExceptT.lift (probeRead seen me sender stream []) } : BaseIO Unit) - return seam - -/-- Open a stream, and write the chunks one by one. The stream stays open. -/ -private def sendOpen (seam : HostSeam) (to : Peer) (chunks : List (List Nat)) : - Async (Option Streams.Stream) := do - match ← seam.newStream to [Constants.meshsubId] with - | .error _ => return none - | .ok (_, stream) => - for chunk in chunks do - discard <| stream.write (Bytes.ofNatList chunk) - return some stream - -/-- Open a stream, write the chunks one by one, and half-close it. -/ -private def send (seam : HostSeam) (to : Peer) (chunks : List (List Nat)) : - Async (Option Streams.Stream) := do - let stream? ← sendOpen seam to chunks - if let some stream := stream? then - stream.close - return stream? - -/-- True when the handlers of the task set fail while the stream stays open, -so the bytes alone made them fail. The expiry resets the stream, and the reset -wakes a handler that still reads. -/ -private def failsOnOpenStream (ts : TaskSet) (stream? : Option Streams.Stream) : Async Bool := do - let some stream := stream? | return false - -- The expiry step of `withinDeadline` is `BaseIO` and the reset is `Async`, so - -- the reset runs as a task with an owner of its own. - let waker ← (TaskSet.new : BaseIO TaskSet) - let answer ← withinDeadline Clock.system patience (waker.spawn stream.reset) (joinFails ts) - waker.joinAll - return match answer with - | .ok failed => failed - | .error _ => false - -private def isGraftOf (t : TopicId) (rpc : Rpc.RPC) : Bool := - rpc.control.any (fun c => c.graft.any (fun g => g.topicId == t.name)) - -/-! ## The switch -/ - -/-- The switch contract: the handler table of each peer, the protocol choice, -the half-close, the reset, and the close. -/ +/-- A probe reads many frames on one persistent inbound stream. It also +records the end of each handler, so tests can distinguish close from open. -/ +private def probe (sw : Switch) (me : Peer) (seen : Chan Seen) + (ended : Chan Unit) : BaseIO HostSeam := do + let (host, _) ← sw.host me + host.setHandler { + id := Constants.meshsubId + handle := fun sender stream => ExceptT.lift do + let mut buf : List Nat := [] + let mut done := false + repeat + match Rpc.decodeFrame? buf with + | .ok (some (body, rest)) => + buf := rest + if let .ok rpc := Core.decodeRpc body then + discard (seen.trySend { receiver := me, peer := sender, rpc } : BaseIO Bool) + | .error _ => break + | .ok none => + match ← stream.read with + | .ok (some bytes) => buf := buf ++ Bytes.toNatList bytes + | _ => done := true + if done then break + stream.close + discard (ended.trySend () : BaseIO Bool) } + return host + +private def openStream (host : HostSeam) (peer : Peer) : Async Streams.Stream := do + match ← host.newStream peer [Constants.meshsubId] with + | .ok (_, stream) => return stream + | .error e => throw <| IO.userError s!"open: {repr e}" + +/-- Each fixture has a raw control stream and a receipt marker. Sending the +marker after seam posts puts its receipt behind those posts in the queue. -/ +private structure Fixture where + node : Node + clock : ClockProbe + control : Streams.Stream + barriers : Chan Unit + receipts : Chan (Peer × List Nat) + deliveries : Chan Message + hostTasks : TaskSet + opens : OnceRef Nat + +private def fixture (sw : Switch) (me : Peer) (config : Config := {}) : Async Fixture := do + let clock ← (ClockProbe.new : BaseIO ClockProbe) + let (host, hostTasks) ← (sw.host me : BaseIO (HostSeam × TaskSet)) + let opens ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) + let barriers ← (Chan.new 64 : BaseIO (Chan Unit)) + let receipts ← (Chan.new 2048 : BaseIO (Chan (Peer × List Nat))) + let deliveries ← (Chan.new 2048 : BaseIO (Chan Message)) + let controlPeer : Peer := ⟨me.bytes ++ [99], me.text ++ "-control"⟩ + let cfg := { config with + clock := clock.clock + order := fun tick peers => config.order tick (peers.filter (· != controlPeer)) + onRpc := fun p bytes => do + config.onRpc p bytes + if bytes == Pb.encodeMessage marker then discard <| barriers.trySend () + else discard <| receipts.trySend (p, bytes) } + let counted : HostSeam := { host with newStream := fun p ids => do + if p != controlPeer then (opens.update (· + 1) : BaseIO Unit) + host.newStream p ids } + let node ← (start counted cfg : BaseIO Node) + -- A raw control peer consumes its hello and has no router of its own. + let seen ← (Chan.new 2048 : BaseIO (Chan Seen)) + let ended ← (Chan.new 64 : BaseIO (Chan Unit)) + let controlHost ← (probe sw controlPeer seen ended : BaseIO HostSeam) + let control ← openStream controlHost me + return { node, clock, control, barriers, receipts, deliveries, hostTasks, opens } + +private def barrier (f : Fixture) : Async Bool := do + discard <| f.control.write (frame marker) + return (← receive f.barriers).isSome + +private def subscribe (f : Fixture) : BaseIO Bool := + f.node.subscribe topic (fun m => discard <| f.deliveries.trySend m) + +private def finish (sw : Switch) (fs : List Fixture) : Async Unit := do + for f in fs do + f.control.close + f.node.stop + (f.clock.test.close : BaseIO Unit) + sw.close + +private def delivered (f : Fixture) (data : List Nat) : Async Bool := do + return (← receive f.deliveries) == some (message data) + +/-- The switch serves handlers by peer and protocol. Half-close preserves the +read direction, and reset wakes a parked read. -/ private def switchChecks (r : Tests.Runner) : Async Unit := do let sw ← (Switch.new : BaseIO Switch) - let (seamA, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) - let (seamB, tasksB) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) - let echoId : Mock.Core.ProtocolId := ⟨"/echo"⟩ - let sender : OnceRef (Option Peer) ← (OnceRef.new none : BaseIO (OnceRef (Option Peer))) - (seamB.setHandler - { id := echoId - handle := fun from_ stream => ExceptT.lift do - (sender.set (some from_) : BaseIO Unit) - let bytes ← readAll stream ByteArray.empty - discard <| stream.write bytes - stream.close } : BaseIO Unit) - let unknownPeer ← seamA.newStream px [echoId] - r.check "switch.unknown-peer" (match unknownPeer with | .error (.protocol _) => true | _ => false) - let noProtocol ← seamA.newStream pb [⟨"/other"⟩] + let (a, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let (b, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) + let sender ← (Chan.new 8 : BaseIO (Chan Peer)) + let id : Mock.Core.ProtocolId := ⟨"/node-test-echo"⟩ + (b.setHandler { id, handle := fun peer s => ExceptT.lift do + discard (sender.trySend peer : BaseIO Bool) + repeat + match ← s.read with + | .ok (some bytes) => discard <| s.write bytes + | _ => break + s.close } : BaseIO Unit) + r.check "switch.unknown-peer" + (match ← a.newStream pc [id] with | .error (.protocol _) => true | _ => false) r.check "switch.no-common-protocol" - (match noProtocol with | .error (.protocol _) => true | _ => false) - let otherSide ← seamB.newStream pa [echoId] - r.check "switch.a-handler-serves-its-own-peer" - (match otherSide with | .error (.protocol _) => true | _ => false) - match ← seamA.newStream pb [⟨"/other"⟩, echoId] with - | .error _ => r.check "switch.first-served-protocol" false - | .ok (id, stream) => - r.check "switch.first-served-protocol" (id == echoId) - discard <| stream.write (Bytes.ofNatList [1, 2, 3]) - stream.close - let echoed ← readAll stream ByteArray.empty - r.check "switch.half-close-keeps-the-read-direction" (Bytes.toNatList echoed == [1, 2, 3]) - tasksB.joinAll - r.check "switch.handler-gets-the-opener" ((← (sender.get : BaseIO (Option Peer))) == some pa) - let afterClose ← stream.write (Bytes.ofNatList [4]) + (match ← a.newStream pb [⟨"/missing"⟩] with | .error (.protocol _) => true | _ => false) + r.check "switch.handler-belongs-to-its-peer" + (match ← b.newStream pa [id] with | .error (.protocol _) => true | _ => false) + match ← a.newStream pb [⟨"/missing"⟩, id] with + | .error _ => r.check "switch.negotiates-first-served-protocol" false + | .ok (chosen, s) => + r.check "switch.negotiates-first-served-protocol" (chosen == id) + discard <| s.write (Bytes.ofNatList [1,2,3]) + s.close + r.check "switch.handler-gets-sender" ((← receive sender) == some pa) + r.check "switch.half-close-keeps-read" + (match ← s.read with | .ok (some bytes) => Bytes.toNatList bytes == [1,2,3] | _ => false) r.check "switch.write-after-close-fails" - (match afterClose with | .error .closed => true | _ => false) - -- A handler that parks on a read ends when the switch closes. - (seamA.setHandler - { id := echoId - handle := fun _ stream => ExceptT.lift do - discard <| readAll stream ByteArray.empty } : BaseIO Unit) - discard <| seamB.newStream pa [echoId] + (match ← s.write (Bytes.ofNatList [4]) with | .error _ => true | _ => false) sw.close - r.check "switch.close-ends-a-parked-handler" true - let afterSwitchClose ← seamB.newStream pa [echoId] - r.check "switch.no-stream-after-close" - (match afterSwitchClose with | .error (.closed _) => true | _ => false) sw.close - r.check "switch.second-close-does-nothing" true - -/-! ## Two nodes -/ + r.check "switch.repeated-close-returns" true + r.check "switch.no-open-after-close" + (match ← a.newStream pb [id] with | .error (.closed _) => true | _ => false) -/-- Test 1: the whole path of one publish. The path covers `newStream`, the -handler, the frame decode, `Event.rpc`, the acceptance of the core, and the -delivery. -/ -private def twoNodes (r : Tests.Runner) : Async Unit := do - let sw ← (Switch.new : BaseIO Switch) - let tcA ← (TestClock.new : BaseIO TestClock) - let tcB ← (TestClock.new : BaseIO TestClock) - let (seamA, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) - let (seamB, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) - let a ← (start seamA (configOn tcA) : BaseIO Node) - let b ← (start seamB (configOn tcB) : BaseIO Node) - let inbox : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) - r.check "node.two.join-b" (← (b.peerJoined pa .meshsub : BaseIO Bool)) - -- B: the start, and the join. B knows A before the hello of A arrives. - r.check "node.two.b-knows-a" (← settle tcB 2) - r.check "node.two.subscribe" (← (a.subscribe ta (keep inbox) : BaseIO Bool)) - r.check "node.two.join-a" (← (a.peerJoined pb .meshsub : BaseIO Bool)) - -- A: the start, the hello of B, the subscribe, and the join. - r.check "node.two.a-settles" (← settle tcA 4) - -- B: the hello of A, which names the topic. - r.check "node.two.b-reads-the-hello" (← settle tcB 1) - r.check "node.two.publish" (← (b.publish ta (Bytes.ofNatList [7, 8, 9]) : BaseIO Bool)) - let got ← within inbox - r.check "node.two.delivery-has-the-payload" (got == some (msgOf ta [7, 8, 9])) - r.check "node.two.one-delivery" (← quiet inbox) - teardown sw [a, b] [tcA, tcB] - -/-- Test 2: a subscribe after the join reaches the peer, and a later -unsubscribe sends the negative record. -/ -private def announcement (r : Tests.Runner) : Async Unit := do - let sw ← (Switch.new : BaseIO Switch) - let tcA ← (TestClock.new : BaseIO TestClock) - let tcB ← (TestClock.new : BaseIO TestClock) - let (seamA, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) - let (seamB, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) - let a ← (start seamA (configOn tcA) : BaseIO Node) - let b ← (start seamB (configOn tcB) : BaseIO Node) - let inbox : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) - discard (b.peerJoined pa .meshsub : BaseIO Bool) - -- B: the start, and the join. - r.check "node.announce.b-knows-a" (← settle tcB 2) - discard (a.peerJoined pb .meshsub : BaseIO Bool) - -- A: the start, the hello of B, and the join. - r.check "node.announce.a-knows-b" (← settle tcA 3) - -- B: the empty hello of A. - r.check "node.announce.b-reads-the-empty-hello" (← settle tcB 1) - discard (a.subscribe ta (keep inbox) : BaseIO Bool) - -- B: the record of the subscribe. The hello carried no topic. - r.check "node.announce.b-reads-the-record" (← settle tcB 1) - discard (b.publish ta (Bytes.ofNatList [1]) : BaseIO Bool) - let got ← within inbox - r.check "node.announce.subscribe-after-join-reaches-the-peer" (got == some (msgOf ta [1])) - -- The fanout of B keeps A, so a later publish still reaches A. The callback - -- is gone. - discard (a.unsubscribe ta : BaseIO Bool) - discard (b.publish ta (Bytes.ofNatList [2]) : BaseIO Bool) - -- A: the subscribe, the first publish, the unsubscribe, and the second - -- publish. - r.check "node.announce.a-serves-the-leave" (← settle tcA 4) - r.check "node.announce.unsubscribe-drops-the-callback" (← quiet inbox) - teardown sw [a, b] [tcA, tcB] - -- The records at the seam: one positive, one negative. - let sw ← (Switch.new : BaseIO Switch) - let tc ← (TestClock.new : BaseIO TestClock) - let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) - discard <| probe sw pc seen - let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) - let n ← (start seam (configOn tc) : BaseIO Node) - discard (n.peerJoined pc .meshsub : BaseIO Bool) - let first ← within seen - r.check "node.announce.hello-has-no-topic" - (first.any (fun s => s.rpc.subscriptions.isEmpty)) - discard (n.subscribe ta (keep inbox) : BaseIO Bool) - let on ← within seen - r.check "node.announce.positive-record" (on.any (fun s => s.rpc == subOptRpc ta true)) - discard (n.unsubscribe ta : BaseIO Bool) - let off ← within seen - r.check "node.announce.negative-record" (off.any (fun s => s.rpc == subOptRpc ta false)) - r.check "node.announce.opener-is-the-node" (off.any (fun s => s.sender == pa && s.receiver == pc)) - teardown sw [n] [tc] - -/-! ## The inbound handler -/ - -/-- Test 3: a bad frame fails its handler and resets its stream, and a body -that does not decode leaves the loop running. The other checks pin the read -loop of the handler. -/ -private def handlerChecks (r : Tests.Runner) : Async Unit := do +/-- A local publish has no deliver action in the reviewed core. The loop +serves queued inputs before stop, without a separate drain loop. -/ +private def localChecks (r : Tests.Runner) : Async Unit := do let sw ← (Switch.new : BaseIO Switch) - let tc ← (TestClock.new : BaseIO TestClock) - let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) - let seamA ← probe sw pa seen - let (seamB, tasksB) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) - let b ← (start seamB (configOn tc) : BaseIO Node) - let inbox : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) - discard (b.subscribe ta (keep inbox) : BaseIO Bool) - discard (b.peerJoined pa .meshsub : BaseIO Bool) - -- B: the start, the subscribe, and the join. - r.check "node.handler.setup" (← settle tc 3) - -- A length over the cap: the handler fails before any body arrives. - let overCap := Streams.Framing.encodeLength .uvarint (Constants.rpcCap + 1) - let stream? ← sendOpen seamA pb [overCap] - r.check "node.handler.over-cap-fails-on-the-prefix" (← failsOnOpenStream tasksB stream?) - match stream? with - | none => r.check "node.handler.over-cap-resets" false - | some stream => - let written ← stream.write (Bytes.ofNatList [1]) - r.check "node.handler.over-cap-resets" - (match written with | .error .closed => true | _ => false) - -- A prefix that never ends. - let longPrefix? ← sendOpen seamA pb [List.replicate 12 0x80] - r.check "node.handler.long-prefix-fails" (← failsOnOpenStream tasksB longPrefix?) - -- A large chunk with an invalid prefix fails without an unbounded append. - let tooLarge? ← sendOpen seamA pb [List.replicate (Constants.rpcCap + 6) 0x80] - r.check "node.handler.large-malformed-chunk-fails" (← failsOnOpenStream tasksB tooLarge?) - -- The stream ends inside a frame. - discard <| send seamA pb [Streams.Framing.encodeLength .uvarint 5 ++ [1]] - r.check "node.handler.end-inside-a-frame-fails" (← joinFails tasksB) - -- The stream ends inside a prefix. - discard <| send seamA pb [[0x80]] - r.check "node.handler.end-inside-a-prefix-fails" (← joinFails tasksB) - -- EOF between frames is clean. - discard <| send seamA pb [] - r.check "node.handler.empty-stream-is-clean" (!(← joinFails tasksB)) - -- A frame whose body does not decode: the handler is clean, and the core - -- answers the error to the loop. - r.check "node.handler.body-does-not-decode" - (match Core.decodeRpc badBody with | .error _ => true | .ok _ => false) - discard <| send seamA pb [Streams.Framing.encodeLength .uvarint badBody.length ++ badBody] - r.check "node.handler.bad-body-reaches-the-loop" (← settle tc 1) - r.check "node.handler.bad-body-is-clean" (!(← joinFails tasksB)) - -- The loop survives: a later RPC gets delivered. - discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [1]))] - let got ← within inbox - r.check "node.handler.loop-survives-a-bad-body" (got == some (msgOf ta [1])) - -- Two frames in one chunk are processed in arrival order. - discard <| send seamA pb - [frameOf (Core.publishRpc (msgOf ta [2])) ++ frameOf (Core.publishRpc (msgOf ta [3]))] - let two ← within inbox - let three ← within inbox - r.check "node.handler.two-frames-in-one-chunk" - (two == some (msgOf ta [2]) && three == some (msgOf ta [3])) - -- One frame in single bytes is one RPC. - discard <| send seamA pb ((frameOf (Core.publishRpc (msgOf ta [4]))).map (fun x => [x])) - let four ← within inbox - r.check "node.handler.one-frame-in-single-bytes" (four == some (msgOf ta [4])) - r.check "node.handler.split-frame-is-clean" (!(← joinFails tasksB)) - -- A frame of a peer that left is ignored. The event of the leave comes - -- first, so the order holds. - discard (b.peerLeft pa : BaseIO Bool) - -- B: the four RPC frames since the bad body, and the leave. - r.check "node.handler.peer-left" (← settle tc 5) - discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [5]))] - r.check "node.handler.unknown-peer-reaches-the-loop" (← settle tc 1) - r.check "node.handler.unknown-peer-delivers-nothing" (← quiet inbox) - teardown sw [b] [tc] - -/-- The send path: an RPC over the cap opens no stream, and a peer with no -handler does not fault the caller. -/ -private def sendChecks (r : Tests.Runner) : Async Unit := do + let f ← fixture sw pa + discard (subscribe f : BaseIO Bool) + r.check "node.local.publish-accepted" (← (f.node.publish topic (Bytes.ofNatList [1]) : BaseIO Bool)) + r.check "node.local.publish-unsubscribed-accepted" + (← (f.node.publish ⟨"other"⟩ (Bytes.ofNatList [2]) : BaseIO Bool)) + r.check "node.local.barrier" (← barrier f) + r.check "node.local.no-delivery" ((← (f.deliveries.tryRecv : BaseIO (Option Message))).isNone) + r.check "node.local.no-stream" ((← (f.opens.get : BaseIO Nat)) == 0) + finish sw [f] + +/-- The system-clock alarm ends when the node stops, without an advance of a +test clock. A real heartbeat grafts its known peer. -/ +private def systemClockChecks (r : Tests.Runner) : Async Unit := do let sw ← (Switch.new : BaseIO Switch) - let seen : Chan Seen ← (Chan.new 4 : BaseIO (Chan Seen)) - let seam ← probe sw pa seen - let opened : OnceRef Nat ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) - let counting : HostSeam := - { seam with - newStream := fun peer protocols => do - (opened.update (· + 1) : BaseIO Unit) - seam.newStream peer protocols } - let streams ← sendRpc counting [] pa (Core.publishRpc (msgOf ta (List.replicate Constants.rpcCap 7))) - r.check "node.send.over-cap-opens-no-stream" ((← (opened.get : BaseIO Nat)) == 0) - let streams ← sendRpc counting streams px (subOptRpc ta true) - r.check "node.send.no-peer-is-no-fault" ((← (opened.get : BaseIO Nat)) == 1) - let streams ← sendRpc counting streams pa (subOptRpc ta true) - let got ← within seen - r.check "node.send.one-frame-round-trips" (got.any (fun s => s.rpc == subOptRpc ta true)) - let streams ← sendRpc counting streams pa (subOptRpc ta false) - let next ← within seen - r.check "node.send.second-frame-on-the-same-stream" - (next.any (fun s => s.rpc == subOptRpc ta false)) - r.check "node.send.reuses-the-peer-stream" ((← (opened.get : BaseIO Nat)) == 2) - -- The cached pipe fails its next write. The same action opens a fresh - -- stream and retries its frame once. - for (_, stream) in streams do stream.reset - let streams ← sendRpc counting streams pa (subOptRpc ta true) - let reopened ← within seen - r.check "node.send.failed-write-retries-on-a-fresh-stream" - ((← (opened.get : BaseIO Nat)) == 3 && reopened.any (fun s => s.rpc == subOptRpc ta true)) - r.check "node.send.retry-keeps-one-entry" (streams.length == 1) - discard <| probe sw pb seen - let streams ← sendRpc counting streams pb (subOptRpc ta true) - discard <| within seen - r.check "node.send.two-peers-have-two-streams" - (streams.length == 2 && (← (opened.get : BaseIO Nat)) == 4) - for (_, stream) in streams do stream.close + let seen ← (Chan.new 64 : BaseIO (Chan Seen)) + let ended ← (Chan.new 16 : BaseIO (Chan Unit)) + discard (probe sw pb seen ended : BaseIO HostSeam) + let (host, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let n ← (start host { params := { Core.defaults with heartbeatInterval := 20 } } : BaseIO Node) + discard (n.subscribe topic (fun _ => pure ()) : BaseIO Bool) + discard (n.peerJoined pb .meshsub : BaseIO Bool) + discard <| receive seen + let graft ← receive seen + r.check "node.system.alarm-sends-graft" + (graft.any (fun s => s.rpc.control.any (fun c => !c.graft.isEmpty))) + let result ← withinDeadline Clock.system patience (pure ()) n.stop + r.check "node.system.stop-closes-alarm" (match result with | .ok () => true | _ => false) sw.close - -- Both attempts fail. The retry bound prevents a third open, and both - -- failed streams are reset and removed. - let attempts : OnceRef Nat ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) - let resets : OnceRef Nat ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) - let failing : HostSeam := - { setHandler := fun _ => pure () - newStream := fun _ _ => do - (attempts.update (· + 1) : BaseIO Unit) - return .ok (Constants.meshsubId, - { read := pure (.ok none) - write := fun _ => pure (.error .closed) - close := pure () - reset := (resets.update (· + 1) : BaseIO Unit) }) } - let failed ← sendRpc failing [] pa (subOptRpc ta true) - r.check "node.send.retry-is-bounded-to-two-attempts" - ((← (attempts.get : BaseIO Nat)) == 2) - r.check "node.send.failed-attempts-reset-and-drop-their-streams" - (failed.isEmpty && (← (resets.get : BaseIO Nat)) == 2) - -/-! ## Receipt observation and stream ownership -/ - -/-- The receipt observer sees duplicate bodies before deduplication. Its -callback runs before the delivery callback of the accepted message. -/ -private def receiptChecks (r : Tests.Runner) : Async Unit := do + +/-- A sender's first frame registers its peer before the core sees the RPC. +The same bytes twice reach the observer twice and deliver once. -/ +private def inboundChecks (r : Tests.Runner) : Async Unit := do let sw ← (Switch.new : BaseIO Switch) - let tc ← (TestClock.new : BaseIO TestClock) - let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) - let source ← probe sw pa seen - let (seam, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) - let receipts : Chan (Peer × List Nat) ← (Chan.new 8 : BaseIO (Chan (Peer × List Nat))) - let order : OnceRef (List Nat) ← (OnceRef.new [] : BaseIO (OnceRef (List Nat))) - let cfg : Config := { configOn tc with onRpc := fun peer bytes => do - order.update (· ++ [1]) - discard <| receipts.trySend (peer, bytes) } - let n ← (start seam cfg : BaseIO Node) - discard (n.subscribe ta (fun _ => order.update (· ++ [2])) : BaseIO Bool) - discard (n.peerJoined pa .meshsub : BaseIO Bool) - r.check "node.receipt.setup" (← settle tc 3) - let rpc := Core.publishRpc (msgOf ta [7]) - let body := Pb.encodeMessage rpc - let frame := frameOf rpc - let bad := Streams.Framing.encodeLength .uvarint badBody.length ++ badBody - discard <| send source pb [frame ++ frame ++ bad] - -- The accepted RPC, its duplicate, and the bad protobuf body. - r.check "node.receipt.three-events" (← settle tc 3) - let first ← within receipts - let duplicate ← within receipts - let invalid ← within receipts - r.check "node.receipt.sender-and-body" - (first == some (pa, body) && duplicate == some (pa, body)) - r.check "node.receipt.bad-protobuf-is-observed" (invalid == some (pa, badBody)) - r.check "node.receipt.before-deduplication-and-delivery" - ((← (order.get : BaseIO (List Nat))) == [1, 2, 1, 1]) - teardown sw [n] [tc] - -/-- A node keeps its stream across announcements, then retires it when the -peer leaves. Stop half-closes the replacement stream, so its handler joins. -/ -private def streamOwnership (r : Tests.Runner) : Async Unit := do + let f ← fixture sw pb + r.check "node.inbound.subscribe" (← (subscribe f : BaseIO Bool)) + r.check "node.inbound.setup-barrier" (← barrier f) + let seen ← (Chan.new 32 : BaseIO (Chan Seen)) + let ended ← (Chan.new 32 : BaseIO (Chan Unit)) + let source ← (probe sw pa seen ended : BaseIO HostSeam) + let stream ← openStream source pb + let bytes := frame (Core.publishRpc (message [7])) + discard <| stream.write (bytes ++ bytes ++ frame marker) + r.check "node.inbound.two-frames-with-one-id" (← delivered f [7]) + r.check "node.inbound.frames-before-marker" ((← receive f.barriers).isSome) + let first ← receive f.receipts + let second ← receive f.receipts + r.check "node.receipt.sees-both-identical-rpcs" + (first == some (pa, Pb.encodeMessage (Core.publishRpc (message [7]))) && first == second) + r.check "node.receipt.delivery-deduplicates" ((← (f.deliveries.tryRecv : BaseIO (Option Message))).isNone) + r.check "node.inbound.first-rpc-needs-no-notifiee" first.isSome + -- A split frame stays one RPC. The stream remains open for more frames. + for byte in Bytes.toNatList (frame (Core.publishRpc (message [8]))) do + discard <| stream.write (Bytes.ofNatList [byte]) + r.check "node.inbound.single-byte-chunks" (← delivered f [8]) + discard <| stream.write (Bytes.ofNatList [1, 255] ++ frame (Core.publishRpc (message [9]))) + r.check "node.inbound.bad-protobuf-keeps-the-loop" (← delivered f [9]) + stream.close + -- Drain the successful handler before testing a framing fault. + r.check "node.inbound.barrier-before-fault" (← barrier f) + let bad ← openStream source pb + discard <| bad.write (Bytes.ofNatList (Streams.Framing.encodeLength .uvarint (Constants.rpcCap + 1))) + -- Closing the successful streams lets the host join report this fault. + f.control.close + let failed ← try + f.hostTasks.joinAll + pure false + catch _ => pure true + r.check "node.inbound.over-cap-fails-handler" failed + r.check "node.inbound.over-cap-reset" (match ← bad.write (Bytes.ofNatList [1]) with + | .error _ => true | .ok _ => false) + f.control.close + f.node.stop + -- A bad handler error is reported by the host owner. + try sw.close catch _ => pure () + (f.clock.test.close : BaseIO Unit) + +/-- Two real nodes learn subscriptions and deliver a publish without a +heartbeat or an explicit peerJoined call at the receiver. -/ +private def pairChecks (r : Tests.Runner) : Async Unit := do let sw ← (Switch.new : BaseIO Switch) - let tc ← (TestClock.new : BaseIO TestClock) - let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) - discard <| probe sw pc seen - let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) - let opens : OnceRef Nat ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) - let closes : OnceRef Nat ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) - let counting : HostSeam := { seam with newStream := fun peer protocols => do - (opens.update (· + 1) : BaseIO Unit) - match ← seam.newStream peer protocols with - | .error e => return .error e - | .ok (id, stream) => return .ok (id, { stream with close := do - (closes.update (· + 1) : BaseIO Unit) - stream.close }) } - let n ← (start counting (configOn tc) : BaseIO Node) - discard (n.peerJoined pc .meshsub : BaseIO Bool) - discard <| within seen - discard (n.subscribe ta (fun _ => pure ()) : BaseIO Bool) - discard <| within seen - discard (n.unsubscribe ta : BaseIO Bool) - discard <| within seen - r.check "node.stream.one-stream-for-three-rpcs" ((← (opens.get : BaseIO Nat)) == 1) - discard (n.peerLeft pc : BaseIO Bool) - discard (n.peerJoined pc .meshsub : BaseIO Bool) - discard <| within seen - r.check "node.stream.peer-leave-retires-the-stream" ((← (opens.get : BaseIO Nat)) == 2) - r.check "node.stream.peer-leave-closes-the-stream" ((← (closes.get : BaseIO Nat)) == 1) - n.stop - r.check "node.stream.stop-closes-the-cached-stream" ((← (closes.get : BaseIO Nat)) == 2) - sw.close - r.check "node.stream.stop-closes-outbound-before-the-host-joins" true - (tc.close : BaseIO Unit) + let a ← fixture sw pa + let b ← fixture sw pb + discard (subscribe a : BaseIO Bool) + r.check "node.pair.subscribe-barrier" (← barrier a) + discard (b.node.peerJoined pa .meshsub : BaseIO Bool) + -- Wait for A's hello at B through its receipt observer. + r.check "node.pair.hello-reaches-publisher" ((← receive b.receipts).isSome) + discard (b.node.publish topic (Bytes.ofNatList [1]) : BaseIO Bool) + r.check "node.pair.publish-delivers" (← delivered a [1]) + discard (a.node.unsubscribe topic : BaseIO Bool) + r.check "node.pair.unsubscribe-barrier" (← barrier a) + discard (b.node.publish topic (Bytes.ofNatList [2]) : BaseIO Bool) + r.check "node.pair.publisher-barrier" (← barrier b) + r.check "node.pair.no-local-delivery" ((← (b.deliveries.tryRecv : BaseIO (Option Message))).isNone) + finish sw [a, b] + +/-- The loop announces the topic before grafting its already announced peer. +Repeat joins emit no hello. Multiple callbacks preserve registration order. -/ +private def surfaceChecks (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let f ← fixture sw pa + let seen ← (Chan.new 32 : BaseIO (Chan Seen)) + let ended ← (Chan.new 16 : BaseIO (Chan Unit)) + let source ← (probe sw pb seen ended : BaseIO HostSeam) + let stream ← openStream source pa + discard <| stream.write (frame (Core.helloRpc [topic]) ++ frame marker) + r.check "node.surface.remote-topic-ready" ((← receive f.barriers).isSome) + let hello ← receive seen + r.check "node.surface.initial-hello" (hello.any (fun s => s.rpc.subscriptions.isEmpty)) + discard (f.node.peerJoined pb .meshsub : BaseIO Bool) + r.check "node.surface.repeat-join-barrier" (← barrier f) + r.check "node.surface.repeat-join-emits-no-hello" ((← (seen.tryRecv : BaseIO (Option Seen))).isNone) + let tags ← (Chan.new 16 : BaseIO (Chan Nat)) + discard (f.node.subscribe topic (fun _ => discard <| tags.trySend 1) : BaseIO Bool) + let record ← receive seen + let graft ← receive seen + r.check "node.surface.announcement-precedes-graft" + (record.any (fun s => s.rpc == subOptRpc topic true) && + graft.any (fun s => s.rpc.control.any (fun c => !c.graft.isEmpty))) + discard (f.node.subscribe topic (fun _ => discard <| tags.trySend 2) : BaseIO Bool) + discard (f.node.subscribe ⟨"other"⟩ (fun _ => discard <| tags.trySend 9) : BaseIO Bool) + r.check "node.surface.callback-registration-barrier" (← barrier f) + discard <| stream.write (frame (Core.publishRpc (message [20])) ++ frame marker) + r.check "node.surface.callback-rpc-barrier" ((← receive f.barriers).isSome) + let first ← receive tags + let second ← receive tags + r.check "node.surface.callbacks-in-registration-order" (first == some 1 && second == some 2) + r.check "node.surface.other-topic-callback-silent" ((← (tags.tryRecv : BaseIO (Option Nat))).isNone) + discard (seen.drain : BaseIO (List Seen)) + discard (f.node.unsubscribe topic : BaseIO Bool) + r.check "node.surface.unsubscribe-barrier" (← barrier f) + let off ← receive seen + r.check "node.surface.negative-subscription-record" (off.any (fun s => s.rpc == subOptRpc topic false)) + discard <| stream.write (frame (Core.publishRpc (message [21])) ++ frame marker) + r.check "node.surface.post-unsubscribe-barrier" ((← receive f.barriers).isSome) + r.check "node.surface.unsubscribe-removes-callbacks" ((← (tags.tryRecv : BaseIO (Option Nat))).isNone) + stream.close + discard (f.node.peerLeft pb : BaseIO Bool) + r.check "node.surface.leave-barrier" (← barrier f) + r.check "node.surface.leave-ends-peer-handler" ((← receive ended).isSome) + finish sw [f] -/-! ## The heartbeat -/ +private def gossipParams : Core.Params := + { Core.defaults with + heartbeatInterval := 100 + mesh := { d := 0, dLow := 0, dHigh := 0, dScore := 0, dLazy := 2 } } -/-- Test 4: a heartbeat fills the mesh. The probe sees the graft at the -seam, and a later publish of a real pair reaches the peer through the mesh. -/ -private def tickGrafts (r : Tests.Runner) : Async Unit := do - let sw ← (Switch.new : BaseIO Switch) - let tc ← (TestClock.new : BaseIO TestClock) - let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) - discard <| probe sw pc seen - let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) - let n ← (start seam (configOn tc) : BaseIO Node) - let inbox : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) - discard (n.subscribe ta (keep inbox) : BaseIO Bool) - discard (n.peerJoined pc .meshsub : BaseIO Bool) - -- The start, the subscribe, and the join. - r.check "node.tick.setup" (← settle tc 3) - let first ← within seen - r.check "node.tick.hello-names-the-topic" (first.any (fun s => s.rpc == hello ta)) - r.check "node.tick.no-graft-before-the-heartbeat" (← quiet seen) - (tc.advance Core.defaults.heartbeatInterval : BaseIO Unit) - let graft ← within seen - r.check "node.tick.heartbeat-sends-the-graft" (graft.any (fun s => isGraftOf ta s.rpc)) - -- The next heartbeat comes one interval later. The mesh is full, so it - -- sends nothing new. - r.check "node.tick.loop-rearms" (← settle tc 1) - teardown sw [n] [tc] - -- A real pair. +private def ihave (s : Seen) : Bool := hasIhave s.rpc + +/-- Explicit delays pin both sides of a deadline and cross a heartbeat. +The run-wide index makes the second heartbeat's first delay 30 ms. -/ +private def trickleChecks (r : Tests.Runner) : Async Unit := do let sw ← (Switch.new : BaseIO Switch) - let tcA ← (TestClock.new : BaseIO TestClock) - let tcB ← (TestClock.new : BaseIO TestClock) - let (seamA, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) - let (seamB, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) - let a ← (start seamA (configOn tcA) : BaseIO Node) - let b ← (start seamB (configOn tcB) : BaseIO Node) - let inbox : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) - discard (b.subscribe ta (keep inbox) : BaseIO Bool) - discard (b.peerJoined pa .meshsub : BaseIO Bool) - -- B: the start, the subscribe, and the join. - r.check "node.tick.pair-b" (← settle tcB 3) - discard (a.subscribe ta (fun _ => pure ()) : BaseIO Bool) - discard (a.peerJoined pb .meshsub : BaseIO Bool) - discard (a.publish ta (Bytes.ofNatList [1]) : BaseIO Bool) - -- A: the start, the hello of B, the subscribe, the join, and the publish. - r.check "node.tick.pair-a" (← settle tcA 5) - r.check "node.tick.empty-mesh-sends-nothing" (← quiet inbox) - (tcA.advance Core.defaults.heartbeatInterval : BaseIO Unit) - -- The pass that runs the heartbeat arms the next timer after it. - r.check "node.tick.pair-heartbeat" (← settle tcA 1) - discard (a.publish ta (Bytes.ofNatList [2]) : BaseIO Bool) - let got ← within inbox - r.check "node.tick.publish-reaches-the-mesh" (got == some (msgOf ta [2])) - teardown sw [a, b] [tcA, tcB] - -/-- The parameters of the spread check: an empty mesh, so each peer takes the -gossip of the topic. -/ -private def gossipParams : Core.Params := - { Core.defaults with mesh := { d := 0, dLow := 0, dHigh := 0, dScore := 0, dLazy := 2 } } - -/-- Test 5: the trickle regression. The `i`-th IHAVE of one heartbeat leaves at -the time of the heartbeat plus `trickle i`, and no earlier. -/ -private def trickleSpread (r : Tests.Runner) : Async Unit := do - r.check "node.trickle.default-is-zero" - ((List.range 64).all (fun i => ({} : Config).trickle i == 0)) - r.check "node.trickle.public-spread-is-below-one-second" - ((List.range 1000).all (fun i => Libp2p.Protocols.Gossipsub.Node.spread i < 1000)) - r.check "node.trickle.public-spread-formula" - (Libp2p.Protocols.Gossipsub.Node.spread 0 == 1 && - Libp2p.Protocols.Gossipsub.Node.spread 1 == 920) + let seen ← (Chan.new 64 : BaseIO (Chan Seen)) + let ended ← (Chan.new 16 : BaseIO (Chan Unit)) + discard (probe sw pc seen ended : BaseIO HostSeam) + discard (probe sw pd seen ended : BaseIO HostSeam) + let f ← fixture sw pa { params := gossipParams, trickle := fun i => (i + 1) * 10 } + discard (subscribe f : BaseIO Bool) + discard (f.node.peerJoined pc .meshsub : BaseIO Bool) + discard (f.node.peerJoined pd .meshsub : BaseIO Bool) + discard (f.node.publish topic (Bytes.ofNatList [7]) : BaseIO Bool) + r.check "node.trickle.setup" (← barrier f) + discard <| receive seen + discard <| receive seen + r.check "node.trickle.initial-deadline" (← armedAt f.clock 100) + (f.clock.test.advance 100 : BaseIO Unit) + r.check "node.trickle.first-deadline" (← armedAt f.clock 110) + r.check "node.trickle.no-send-at-tick" ((← (seen.tryRecv : BaseIO (Option Seen))).isNone) + (f.clock.test.advance 9 : BaseIO Unit) + r.check "node.trickle.no-send-at-9" ((← (seen.tryRecv : BaseIO (Option Seen))).isNone) + (f.clock.test.advance 1 : BaseIO Unit) + let one ← receive seen + r.check "node.trickle.first-at-10" (one.any ihave) + r.check "node.trickle.second-deadline" (← armedAt f.clock 120) + (f.clock.test.advance 11 : BaseIO Unit) + let two ← receive seen + r.check "node.trickle.advance-11-serves-second" (two.any ihave) + r.check "node.trickle.two-distinct-receivers" + (match one, two with | some x, some y => x.receiver != y.receiver | _, _ => false) + r.check "node.trickle.next-heartbeat" (← armedAt f.clock 200) + (f.clock.test.advance 79 : BaseIO Unit) + r.check "node.trickle.run-wide-first-index" (← armedAt f.clock 230) + r.check "node.trickle.second-heartbeat-waits" ((← (seen.tryRecv : BaseIO (Option Seen))).isNone) + (f.clock.test.advance 29 : BaseIO Unit) + r.check "node.trickle.no-send-at-29" ((← (seen.tryRecv : BaseIO (Option Seen))).isNone) + (f.clock.test.advance 1 : BaseIO Unit) + r.check "node.trickle.second-heartbeat-first-at-30" ((← receive seen).any ihave) + r.check "node.trickle.second-heartbeat-second-deadline" (← armedAt f.clock 240) + (f.clock.test.advance 10 : BaseIO Unit) + r.check "node.trickle.cross-heartbeat-order" ((← receive seen).any ihave) + finish sw [f] + +private def defaultAndOrderChecks (r : Tests.Runner) : Async Unit := do + r.check "node.trickle.default-zero" ((List.range 16).all (fun i => ({} : Config).trickle i == 0)) + r.check "node.trickle.spread-below-one-second" ((List.range 1000).all (fun i => spread i < 1000)) + r.check "node.tick.order-changes" (defaultOrder 0 [pa,pb,pc,pd] != defaultOrder 1 [pa,pb,pc,pd]) + r.check "node.tick.order-replays" (defaultOrder 7 [pa,pb,pc,pd] == defaultOrder 7 [pa,pb,pc,pd]) let sw ← (Switch.new : BaseIO Switch) - let tc ← (TestClock.new : BaseIO TestClock) - let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) - discard <| probe sw pc seen - discard <| probe sw pd seen - let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) - let spread : Nat → Millis := fun i => (i + 1) * 10 - let n ← (start seam (configOn tc gossipParams spread) : BaseIO Node) - discard (n.subscribe ta (fun _ => pure ()) : BaseIO Bool) - discard (n.peerJoined pc .meshsub : BaseIO Bool) - discard (n.peerJoined pd .meshsub : BaseIO Bool) - discard (n.publish ta (Bytes.ofNatList [1]) : BaseIO Bool) - -- The start, the subscribe, two joins, and the publish. - r.check "node.trickle.setup" (← settle tc 5) - let hello1 ← within seen - let hello2 ← within seen - r.check "node.trickle.hellos" - (hello1.any (fun s => !hasIhave s.rpc) && hello2.any (fun s => !hasIhave s.rpc)) - (tc.advance gossipParams.heartbeatInterval : BaseIO Unit) - -- The heartbeat ran, and the loop armed the timer of the first send. - r.check "node.trickle.heartbeat" (← settle tc 1) - r.check "node.trickle.no-send-at-the-heartbeat" (← quiet seen) - (tc.advance 9 : BaseIO Unit) - r.check "node.trickle.no-send-before-its-time" (← quiet seen) - (tc.advance 1 : BaseIO Unit) - let first ← within seen - r.check "node.trickle.first-send-at-10-ms" (first.any (fun s => hasIhave s.rpc)) - -- The loop sent the first, and armed the timer of the second. - r.check "node.trickle.first-pass" (← settle tc 1) - r.check "node.trickle.second-waits-for-the-clock" (← quiet seen) - (tc.advance 9 : BaseIO Unit) - r.check "node.trickle.second-not-before-its-time" (← quiet seen) - (tc.advance 1 : BaseIO Unit) - let second ← within seen - r.check "node.trickle.second-send-at-20-ms" (second.any (fun s => hasIhave s.rpc)) - r.check "node.trickle.two-receivers" - (match first, second with - | some x, some y => x.receiver != y.receiver - | _, _ => false) - teardown sw [n] [tc] - -/-- With the default zero delay, both IHAVEs reach their peers on the -heartbeat. The test clock needs no second advance. -/ -private def defaultGossip (r : Tests.Runner) : Async Unit := do + let seen ← (Chan.new 64 : BaseIO (Chan Seen)) + let ended ← (Chan.new 16 : BaseIO (Chan Unit)) + discard (probe sw pc seen ended : BaseIO HostSeam) + let f ← fixture sw pa { params := gossipParams } + discard (subscribe f : BaseIO Bool) + discard (f.node.peerJoined pc .meshsub : BaseIO Bool) + discard (f.node.publish topic (Bytes.ofNatList [4]) : BaseIO Bool) + r.check "node.default.setup" (← barrier f) + discard <| receive seen + r.check "node.default.timer" (← armedAt f.clock 100) + (f.clock.test.advance 100 : BaseIO Unit) + r.check "node.default.gossip-at-tick" ((← receive seen).any ihave) + finish sw [f] + +/-- Long delays reach the pending bound across heartbeats. Excess entries +increase the same drop counter as full writer queues. -/ +private def pendingBound (r : Tests.Runner) : Async Unit := do let sw ← (Switch.new : BaseIO Switch) - let tc ← (TestClock.new : BaseIO TestClock) - let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) - discard <| probe sw pc seen - discard <| probe sw pd seen - let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) - let n ← (start seam (configOn tc gossipParams) : BaseIO Node) - discard (n.subscribe ta (fun _ => pure ()) : BaseIO Bool) - discard (n.peerJoined pc .meshsub : BaseIO Bool) - discard (n.peerJoined pd .meshsub : BaseIO Bool) - discard (n.publish ta (Bytes.ofNatList [1]) : BaseIO Bool) - r.check "node.trickle.zero-default-setup" (← settle tc 5) - discard <| within seen - discard <| within seen - (tc.advance gossipParams.heartbeatInterval : BaseIO Unit) - let first ← within seen - let second ← within seen - r.check "node.trickle.zero-default-sends-at-the-heartbeat" - (first.any (fun s => hasIhave s.rpc) && second.any (fun s => hasIhave s.rpc)) - teardown sw [n] [tc] - -/-- The receivers of the IHAVE sends of one heartbeat, in send order, when both -sends come due in one pass of the loop. -/ -private def gossipOrder (spread : Nat → Millis) : Async (List Peer) := do + let seen ← (Chan.new 64 : BaseIO (Chan Seen)) + let ended ← (Chan.new 16 : BaseIO (Chan Unit)) + discard (probe sw pc seen ended : BaseIO HostSeam) + discard (probe sw pd seen ended : BaseIO HostSeam) + let params := { gossipParams with window := { historyLength := 300, historyGossip := 300, valid := by decide } } + let f ← fixture sw pa { params, trickle := fun _ => 1000000 } + discard (subscribe f : BaseIO Bool) + discard (f.node.peerJoined pc .meshsub : BaseIO Bool) + discard (f.node.peerJoined pd .meshsub : BaseIO Bool) + discard (f.node.publish topic (Bytes.ofNatList [5]) : BaseIO Bool) + r.check "node.pending.setup" (← barrier f) + r.check "node.pending.initial-alarm" (← armedAt f.clock 100) + let mut armed := true + for i in [0:130] do + (f.clock.test.advance 100 : BaseIO Unit) + if !(← armedAt f.clock ((i + 2) * 100)) then armed := false + r.check "node.pending.every-heartbeat-ran" armed + r.check "node.pending.bound-256-drops-four" ((← (f.node.dropped : BaseIO Nat)) == 4) + finish sw [f] + +/-- A parked writer leaves the loop free. Sixty offers fill the remaining +slots of its 32-frame queue. Stop resets the stream and returns on deadline. -/ +private def stalledAndDrops (r : Tests.Runner) : Async Unit := do let sw ← (Switch.new : BaseIO Switch) + let reached ← (Chan.new 1 : BaseIO (Chan Unit)) + let parked ← (Chan.new 1 : BaseIO (Chan Unit)) + let opened ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) + let resets ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) + let receipts ← (Chan.new 128 : BaseIO (Chan Unit)) + let (_, controlTasks) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) + let (base, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) + let host : HostSeam := { base with newStream := fun peer ids => do + if peer = pc then + (opened.update (· + 1) : BaseIO Unit) + return .ok (Constants.meshsubId, { + read := pure (.ok none) + write := fun _ => do + discard (reached.trySend () : BaseIO Bool) + discard parked.recv + return .error .closed + close := pure () + reset := do + (resets.update (· + 1) : BaseIO Unit) + (parked.close : BaseIO Unit) }) + base.newStream peer ids } let tc ← (TestClock.new : BaseIO TestClock) - let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) - discard <| probe sw pc seen - discard <| probe sw pd seen - let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) - let opened : OnceRef (List Peer) ← (OnceRef.new [] : BaseIO (OnceRef (List Peer))) - let recording : HostSeam := - { seam with - newStream := fun peer protocols => do - match ← seam.newStream peer protocols with - | .error err => return .error err - | .ok (id, stream) => - return .ok (id, { stream with write := fun bytes => do - if let .ok (some (payload, _)) := Rpc.decodeFrame? (Bytes.toNatList bytes) then - if let .ok rpc := Core.decodeRpc payload then - if hasIhave rpc then (opened.update (· ++ [peer]) : BaseIO Unit) - stream.write bytes }) } - let n ← (start recording (configOn tc gossipParams spread) : BaseIO Node) - discard (n.subscribe ta (fun _ => pure ()) : BaseIO Bool) - discard (n.peerJoined pc .meshsub : BaseIO Bool) - discard (n.peerJoined pd .meshsub : BaseIO Bool) - discard (n.publish ta (Bytes.ofNatList [1]) : BaseIO Bool) - discard <| settle tc 5 - (tc.advance gossipParams.heartbeatInterval : BaseIO Unit) - discard <| settle tc 1 - (tc.advance 20 : BaseIO Unit) - -- The pass that sends both arms the timer of the next heartbeat after them. - discard <| settle tc 1 - let order ← (opened.get : BaseIO (List Peer)) - teardown sw [n] [tc] - return order - -/-- Two sends that come due in one pass leave in the order of their due times, -whatever the order of the heartbeat that made them. -/ -private def trickleOrder (r : Tests.Runner) : Async Unit := do - let rising ← gossipOrder (fun i => (i + 1) * 10) - let falling ← gossipOrder (fun i => 20 - i * 10) - r.check "node.trickle.two-sends-in-one-pass" (rising.length == 2) - r.check "node.trickle.due-time-orders-the-sends" (rising == falling.reverse) - -/-- The production clock: a real heartbeat sends the graft, and `stop` wakes a -loop that parks on a real timer. `Config` defaults to this clock. -/ -private def systemClock (r : Tests.Runner) : Async Unit := do - let sw ← (Switch.new : BaseIO Switch) - let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) - discard <| probe sw pc seen - let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) - let n ← (start seam { params := { Core.defaults with heartbeatInterval := 20 } } : BaseIO Node) - discard (n.subscribe ta (fun _ => pure ()) : BaseIO Bool) + let n ← (start host { clock := tc.clock, onRpc := fun _ _ => discard (receipts.trySend ()) } : BaseIO Node) discard (n.peerJoined pc .meshsub : BaseIO Bool) - let first ← within seen - r.check "node.system.hello" (first.any (fun s => s.rpc == hello ta)) - let graft ← within seen - r.check "node.system.real-heartbeat-sends-the-graft" (graft.any (fun s => isGraftOf ta s.rpc)) + r.check "node.stall.writer-reached-write" ((← receive reached).isSome) + -- The first hello is parked in write. Each new subscription adds one frame. + for i in [0:60] do + discard (n.subscribe ⟨s!"topic-{i}"⟩ (fun _ => pure ()) : BaseIO Bool) + -- A raw marker is ordered after the 60 offers. + let seen ← (Chan.new 16 : BaseIO (Chan Seen)) + let ended ← (Chan.new 16 : BaseIO (Chan Unit)) + let control ← (probe sw pa seen ended : BaseIO HostSeam) + let stream ← openStream control pb + discard <| stream.write (frame marker) + r.check "node.stall.loop-serves-marker" ((← receive receipts).isSome) + let drops ← (n.dropped : BaseIO Nat) + r.check "node.queue.capacity-32-drops-28" (drops == 28) + IO.println s!"node.measure.full-queue drops={drops}" + let stopped ← withinDeadline Clock.system patience parked.close n.stop + r.check "node.stall.stop-returns-before-deadline" (match stopped with | .ok () => true | _ => false) + r.check "node.stall.stop-reset-the-stream" (0 < (← (resets.get : BaseIO Nat))) + n.stop n.stop - r.check "node.system.stop-wakes-a-timer-park" true + n.stop + r.check "node.stop.repeated-calls-return" true + r.check "node.stop.publish-refused" (!(← (n.publish topic (Bytes.ofNatList [1]) : BaseIO Bool))) + r.check "node.stop.subscribe-refused" (!(← (n.subscribe topic (fun _ => pure ()) : BaseIO Bool))) + r.check "node.stop.unsubscribe-refused" (!(← (n.unsubscribe topic : BaseIO Bool))) + r.check "node.stop.join-refused" (!(← (n.peerJoined pc .meshsub : BaseIO Bool))) + r.check "node.stop.leave-refused" (!(← (n.peerLeft pc : BaseIO Bool))) + stream.close sw.close + controlTasks.joinAll + (tc.close : BaseIO Unit) -/-! ## The callbacks and the queue -/ +/-- Count adjacent sequence inversions in one publisher's received messages. -/ +private def inversions : List Nat → Nat + | a :: b :: rest => (if b < a then 1 else 0) + inversions (b :: rest) + | _ => 0 -/-- The callbacks of a topic run in registration order, and the callback of -another topic stays silent. -/ -private def callbackOrder (r : Tests.Runner) : Async Unit := do - let sw ← (Switch.new : BaseIO Switch) - let tc ← (TestClock.new : BaseIO TestClock) - let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) - let seamA ← probe sw pa seen - let (seamB, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) - let b ← (start seamB (configOn tc) : BaseIO Node) - let order : Chan Nat ← (Chan.new 8 : BaseIO (Chan Nat)) - let tag : Nat → Message → BaseIO Unit := fun n _ => discard (order.trySend n) - discard (b.subscribe ta (tag 1) : BaseIO Bool) - discard (b.subscribe ⟨"u"⟩ (tag 9) : BaseIO Bool) - discard (b.subscribe ta (tag 2) : BaseIO Bool) - discard (b.peerJoined pa .meshsub : BaseIO Bool) - -- The start, three subscribes, and the join. - r.check "node.callbacks.setup" (← settle tc 5) - discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [1]))] - let one ← within order - let two ← within order - r.check "node.callbacks.registration-order" (one == some 1 && two == some 2) - -- One more pass, for the RPC. - r.check "node.callbacks.served" (← settle tc 1) - r.check "node.callbacks.other-topic-stays-silent" (← quiet order) - teardown sw [b] [tc] - -/-- A full queue refuses a post, and a refused `subscribe` leaves no callback. -The first send of the loop parks on a gate, so the loop reads no event, and -the queue fills to its bound. -/ -private def fullQueue (r : Tests.Runner) : Async Unit := do - let sw ← (Switch.new : BaseIO Switch) - let tc ← (TestClock.new : BaseIO TestClock) - let seen : Chan Seen ← (Chan.new 16 : BaseIO (Chan Seen)) - let seamA ← probe sw pa seen - let (seamB, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) - let gate : Chan Unit ← (Chan.new 1 : BaseIO (Chan Unit)) - let reached : Chan Unit ← (Chan.new 1 : BaseIO (Chan Unit)) - let gated : HostSeam := - { seamB with - newStream := fun peer protocols => do - discard (reached.trySend () : BaseIO Bool) - -- Park: the close of the gate wakes it, and later sends pass at once. - discard gate.recv - seamB.newStream peer protocols } - let b ← (start gated (configOn tc) : BaseIO Node) - let kept : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) - let refused : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) - discard (b.peerJoined pa .meshsub : BaseIO Bool) - r.check "node.queue.loop-parks-on-the-gate" ((← within reached).isSome) - r.check "node.queue.subscribe-takes-a-slot" (← (b.subscribe ta (keep kept) : BaseIO Bool)) - -- 63 slots remain of the 64. - let mut taken := 0 - for i in [0:70] do - if ← (b.publish ⟨"u"⟩ (Bytes.ofNatList [i]) : BaseIO Bool) then - taken := taken + 1 - r.check "node.queue.bound-is-64" (taken == 63) - r.check "node.queue.full-refuses-subscribe" - (!(← (b.subscribe ta (keep refused) : BaseIO Bool))) - (gate.close : BaseIO Unit) - -- The start, the join, the subscribe, and 63 publishes. - r.check "node.queue.loop-drains" (← settle tc 66) - -- The default message id is the payload, so this payload differs from the - -- 70 above. - discard <| send seamA pb [frameOf (Core.publishRpc (msgOf ta [200]))] - let got ← within kept - r.check "node.queue.accepted-callback-runs" (got == some (msgOf ta [200])) - r.check "node.queue.refused-callback-stays-silent" (← quiet refused) - teardown sw [b] [tc] - -/-! ## Local publish and stop -/ - -/-- Test 6: a publish with no peers opens no stream, and the core emits no -deliver action for a local publish, so the callback of the publisher stays -silent (`Core.publishLocal`). The loop keeps running. -/ -private def localPublish (r : Tests.Runner) : Async Unit := do - let sw ← (Switch.new : BaseIO Switch) - let tc ← (TestClock.new : BaseIO TestClock) - let (seam, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) - let opened : OnceRef Nat ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) - let counting : HostSeam := - { seam with - newStream := fun peer protocols => do - (opened.update (· + 1) : BaseIO Unit) - seam.newStream peer protocols } - let n ← (start counting (configOn tc) : BaseIO Node) - let inbox : Chan Message ← (Chan.new 8 : BaseIO (Chan Message)) - discard (n.subscribe ta (keep inbox) : BaseIO Bool) - r.check "node.local.publish-subscribed" (← (n.publish ta (Bytes.ofNatList [1]) : BaseIO Bool)) - r.check "node.local.publish-unsubscribed" (← (n.publish ⟨"u"⟩ (Bytes.ofNatList [2]) : BaseIO Bool)) - -- The start, the subscribe, and two publishes. - r.check "node.local.loop-serves-the-publishes" (← settle tc 4) - r.check "node.local.no-stream" ((← (opened.get : BaseIO Nat)) == 0) - r.check "node.local.no-local-delivery" (← quiet inbox) - teardown sw [n] [tc] - -/-- Test 7: `stop` joins the loop, every later post answers `false`, an inbound -stream of a stopped node ends clean, and the switch closes without a hang. -/ -private def stopChecks (r : Tests.Runner) : Async Unit := do +/-- Two publishers run at once in bursts of 16 messages. Each waits for the +burst's callbacks before its next burst, so the 32-slot peer queue has room. +Both writers keep one stream and preserve their peer order. -/ +private def publisherOrder (r : Tests.Runner) : Async Unit := do let sw ← (Switch.new : BaseIO Switch) - let tc ← (TestClock.new : BaseIO TestClock) - let seen : Chan Seen ← (Chan.new 4 : BaseIO (Chan Seen)) - let seamA ← probe sw pa seen - let (seamB, tasksB) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) - let b ← (start seamB (configOn tc) : BaseIO Node) - discard (b.peerJoined pa .meshsub : BaseIO Bool) - -- A park on the queue wakes by the close, with no help from the clock. - r.check "node.stop.setup" (← settle tc 2) - b.stop - r.check "node.stop.returns" true - b.stop - r.check "node.stop.second-stop-does-nothing" true - r.check "node.stop.publish-answers-false" (!(← (b.publish ta (Bytes.ofNatList [1]) : BaseIO Bool))) - r.check "node.stop.subscribe-answers-false" (!(← (b.subscribe ta (fun _ => pure ()) : BaseIO Bool))) - r.check "node.stop.unsubscribe-answers-false" (!(← (b.unsubscribe ta : BaseIO Bool))) - r.check "node.stop.join-answers-false" (!(← (b.peerJoined pa .meshsub : BaseIO Bool))) - r.check "node.stop.leave-answers-false" (!(← (b.peerLeft pa : BaseIO Bool))) - discard <| send seamA pb [frameOf (subOptRpc ta true)] - r.check "node.stop.inbound-frame-is-clean" (!(← joinFails tasksB)) - sw.close - r.check "node.stop.switch-closes" true - (tc.close : BaseIO Unit) - -/-- Run the checks of this module. -/ + let a ← fixture sw pa + let b ← fixture sw pb + let sink ← fixture sw pc + let ackA ← (Chan.new 32 : BaseIO (Chan Unit)) + let ackB ← (Chan.new 32 : BaseIO (Chan Unit)) + let rows ← (OnceRef.new ([] : List (Nat × Nat)) : BaseIO (OnceRef (List (Nat × Nat)))) + discard (sink.node.subscribe topic (fun m => do + let data := m.data.getD [] + let from_ := data[0]?.getD 0 + let seq := data[1]?.getD 0 + 256 * data[2]?.getD 0 + rows.update (· ++ [(from_, seq)]) + discard <| (if from_ = 1 then ackA else ackB).trySend ()) : BaseIO Bool) + r.check "node.order.sink-ready" (← barrier sink) + discard (a.node.peerJoined pc .meshsub : BaseIO Bool) + discard (b.node.peerJoined pc .meshsub : BaseIO Bool) + -- Each publisher must see the sink's hello before its first publish. + r.check "node.order.a-has-subscription" ((← receive a.receipts).isSome) + r.check "node.order.b-has-subscription" ((← receive b.receipts).isSome) + let owner ← (TaskSet.new : BaseIO TaskSet) + let produce (f : Fixture) (id : Nat) (ack : Chan Unit) : Async Unit := do + for batch in [0:32] do + let count := Nat.min 16 (500 - batch * 16) + for j in [0:count] do + let i := batch * 16 + j + if !(← (f.node.publish topic (Bytes.ofNatList [id, i % 256, i / 256]) : BaseIO Bool)) then + throw <| IO.userError "publisher event refused" + for _ in [0:count] do + if (← receive ack).isNone then throw <| IO.userError "publisher delivery timeout" + owner.spawn (produce a 1 ackA) + owner.spawn (produce b 2 ackB) + owner.joinAll + let data ← (rows.get : BaseIO (List (Nat × Nat))) + let seqA := (data.filter (·.1 == 1)).map (·.2) + let seqB := (data.filter (·.1 == 2)).map (·.2) + let ia := inversions seqA + let ib := inversions seqB + let oa ← (a.opens.get : BaseIO Nat) + let ob ← (b.opens.get : BaseIO Nat) + r.check "node.order.both-publishers-deliver-500" (seqA.length == 500 && seqB.length == 500) + r.check "node.order.zero-adjacent-inversions-per-peer" (ia == 0 && ib == 0) + r.check "node.order.one-stream-per-publisher" (oa == 1 && ob == 1) + r.check "node.order.bursts-drop-no-frames" + ((← (a.node.dropped : BaseIO Nat)) == 0 && (← (b.node.dropped : BaseIO Nat)) == 0) + IO.println s!"node.measure.publishers countA={seqA.length} countB={seqB.length} streamsA={oa} streamsB={ob} inversionsA={ia} inversionsB={ib}" + finish sw [a,b,sink] + +/-- Run the node regressions and print the measured queue and ordering counts. -/ def run (r : Tests.Runner) : IO Unit := do - r.section "gossipsub Node" - "packages/libp2p/protocols/docs/PLAN.md, unit 3 test milestones" - Conc.Async.run do + r.section "gossipsub Node" "packages/libp2p/protocols/docs/PLAN.md, unit 3" + Async.run do + -- Inbound reset checks use a separate host task owner below. switchChecks r - twoNodes r - announcement r - handlerChecks r - sendChecks r - receiptChecks r - streamOwnership r - tickGrafts r - trickleSpread r - defaultGossip r - trickleOrder r - systemClock r - callbackOrder r - fullQueue r - localPublish r - stopChecks r + localChecks r + systemClockChecks r + inboundChecks r + pairChecks r + surfaceChecks r + trickleChecks r + defaultAndOrderChecks r + pendingBound r + stalledAndDrops r + publisherOrder r end Tests.Gossipsub.Node From 779b9f8a9fa533eff56b85686e2f1cfb36581983 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:47:17 +0800 Subject: [PATCH 041/125] docs(protocols): state the writer and alarm regression contracts --- .../Libp2p/Protocols/Gossipsub/Node.lean | 9 ++++--- .../protocols/Libp2p/Protocols/Mock/Host.lean | 13 ++++++++-- packages/libp2p/protocols/docs/PLAN.md | 25 ++++++++++++------- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index f805d74b..d13ff423 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -26,8 +26,8 @@ frames and chunks before EOF (root CLAUDE.md, "partial def"). ## Ownership -The task set of the node owns the loop, alarm and writer tasks. Stop joins them. The host -owns the handler tasks of the inbound streams. Nothing cancels a task: +The task set of the node owns the loop, alarm and writer tasks. Stop joins +them. The host owns the handler tasks of the inbound streams. Nothing cancels a task: `stop` closes the event queue, and the close wakes the parked loop. The router state and pending gossip are local to the loop. Shared registries, subscriptions and the closing flag change through OnceRef.update or @@ -81,6 +81,7 @@ updateGet (root ARCHITECTURE §4.1). Only the first stop caller joins. loop per stream (comm.go, lines 75 to 120). Go and rust write many RPCs on one stream. A one-frame rule would break interop on the second RPC. The handler bounds each frame before appending bytes and preserves frame order. + A zero-length frame decodes as an empty RPC and reaches the core. - **The receipt observer runs before the core step.** The interop harness logs every received message, including duplicates (gossipsub-interop, test-specs/implementation.md, lines 71 to 78). Its go participant logs @@ -368,8 +369,8 @@ private structure LoopState where /-- Run the actions of one core step, in order, and answer the delayed sends. `gossipAt` is the time of the tick when a tick made the actions. The -`i`-th IHAVE send of that tick waits for `trickle i` milliseconds, and every -other action runs now. -/ +run-wide i-th IHAVE waits for trickle i milliseconds. Every other action +runs now. -/ private def perform (env : Env) (gossipAt : Option Millis) (index : Nat) (actions : List Core.Action) : BaseIO (List Pending × Nat) := do let mut delayed : Array Pending := #[] diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean index be4cb610..4e885a03 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean @@ -16,8 +16,17 @@ signatures. The real host package replaces this module at integration. The module also declares `HostSeam`, the two calls the gossipsub loop makes, and `Switch`, an in-memory double of that seam. The field names -follow PR #61 (`Host.newStream`, `Host.setHandler`), so the swap at -integration touches one file. +follow PR #61 (Host.newStream, Host.setHandler). The consumer builds the +seam from a host h with these closures: + +```lean +let seam : HostSeam := + { newStream := fun peer ids => h.newStream peer ids + setHandler := fun handler => h.setHandler handler } +``` + +The switch retains its pipes until close. It serves finite test runs. A +long-running stress test needs a double that retires closed streams. -/ set_option autoImplicit false diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index 5bb83358..a98eea48 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -90,13 +90,13 @@ in the codec group wrapping upstream `google/snappy`. field tables of the vendored gossipsub spec, both directions. - The router core as transcript tests, `Event.tick` advancing the heartbeat: mesh graft and prune transitions, and iwant/ihave - bookkeeping, at the level the root names (root PLAN W6.4). + bookkeeping (root PLAN W6.4). - The signing-policy tests, once unit 3b records the choice against the two axes of the pubsub spec. - Unit 3b owns the trickle regressions that issue #23's done-when names. - The pure `step` decides which peer receives which gossip on a tick. The - loop owns the delay that spreads those messages across the heartbeat - interval. `Tests/Gossipsub/Node.lean` pins that delay on a test clock. + Tests/Gossipsub/Node.lean pins the IHAVE deadlines on a test clock. + The checks cover configured delays across heartbeats and the default + zero-delay schedule. - The loop suite, `Tests/Gossipsub/Node.lean`, runs over the in-memory switch of `Mock/Host.lean`: - One publish crosses two nodes and reaches the callback. @@ -106,14 +106,21 @@ in the codec group wrapping upstream `google/snappy`. not decode leaves the loop running. - The handler serves consecutive frames in any chunking. It checks the length before it appends body bytes, and EOF between frames is clean. - - The node reuses one outbound stream per peer. A failed write resets its - stream and retries once on a fresh stream. Two peers use two streams. - A peer leave closes its stream, and stop closes the outbound streams. + - Each peer has a writer task and a 32-slot queue. Two publishers run in + parallel with one stream each and zero adjacent inversions per peer. + A full queue drops frames, and the suite reports the drop count. + - A parked peer writer leaves the loop free. Stop resets its stream, + closes the peer queues and alarm, and joins the owned tasks. + - The handler registers its opener before the first RPC. Repeat joins + emit no hello. Subscription records precede grafts. + - The replayable hash order changes across heartbeat counters. + - Pending gossip holds at most 256 sends. Excess sends increment the + drop count. Due sends leave before the heartbeat shifts the cache. - The receipt observer sees duplicates before validation and delivery. - The default gossip delay is zero. Tests supply an explicit spread. - A heartbeat sends the graft, and a later publish reaches the mesh. - - With a configured spread, the i-th IHAVE of a heartbeat leaves at its - delay and no earlier. + - With a configured spread, each IHAVE leaves at its run-wide indexed + delay and no earlier. The check crosses two heartbeat intervals. - A publish with no peers opens no stream and delivers nothing locally. - `stop` joins the loop, a later post answers `false`, and the switch closes. From 480b86e92fed9ba197445eb2c07a6d26a9353b0a Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:49:59 +0800 Subject: [PATCH 042/125] test(protocols): pin consecutive RPC frames and framing failures --- Tests/Gossipsub/Node.lean | 33 +++++++++++++++++++ .../Libp2p/Protocols/Gossipsub/Node.lean | 14 +++++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index 982d2f8d..5f9a1a7c 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -255,6 +255,14 @@ private def inboundChecks (r : Tests.Runner) : Async Unit := do r.check "node.inbound.single-byte-chunks" (← delivered f [8]) discard <| stream.write (Bytes.ofNatList [1, 255] ++ frame (Core.publishRpc (message [9]))) r.check "node.inbound.bad-protobuf-keeps-the-loop" (← delivered f [9]) + -- Distinct messages in one chunk both deliver, in order. + discard <| stream.write (frame (Core.publishRpc (message [10])) ++ + frame (Core.publishRpc (message [11]))) + r.check "node.inbound.distinct-frames-first" (← delivered f [10]) + r.check "node.inbound.distinct-frames-second" (← delivered f [11]) + -- The empty frame is legal and does not end the stream. + discard <| stream.write (Bytes.ofNatList [0] ++ frame (Core.publishRpc (message [12]))) + r.check "node.inbound.zero-length-frame-keeps-stream-open" (← delivered f [12]) stream.close -- Drain the successful handler before testing a framing fault. r.check "node.inbound.barrier-before-fault" (← barrier f) @@ -275,6 +283,30 @@ private def inboundChecks (r : Tests.Runner) : Async Unit := do try sw.close catch _ => pure () (f.clock.test.close : BaseIO Unit) +/-- Direct handler boundary cases preserve the framing error channel. The +fake stream returns finite chunks and records reset without a parked task. -/ +private def frameBoundaryChecks (r : Tests.Runner) : Async Unit := do + let cases : List (String × List ByteArray) := + [ ("bad-prefix", [Bytes.ofNatList (List.replicate 12 128)]) + , ("eof-inside-prefix", [Bytes.ofNatList [128]]) + , ("eof-inside-body", [Bytes.ofNatList [5,1]]) + , ("large-malformed-chunk", [Bytes.ofNatList (List.replicate (Constants.rpcCap + 6) 128)]) ] + for (name, chunks) in cases do + let remaining ← (OnceRef.new chunks : BaseIO (OnceRef (List ByteArray))) + let reset ← (OnceRef.new false : BaseIO (OnceRef Bool)) + let events ← (Chan.new 16 : BaseIO (Chan Input)) + let stream : Streams.Stream := + { read := do + let next ← (remaining.updateGet (fun xs => (xs.head?, xs.drop 1)) : BaseIO (Option ByteArray)) + return .ok next + write := fun _ => pure (.ok ()) + close := pure () + reset := (reset.set true : BaseIO Unit) } + let result ← ((handler events).handle pa stream).run + r.check s!"node.framing.{name}" + ((match result with | .error (.protocol _) => true | _ => false) && + (← (reset.get : BaseIO Bool))) + /-- Two real nodes learn subscriptions and deliver a publish without a heartbeat or an explicit peerJoined call at the receiver. -/ private def pairChecks (r : Tests.Runner) : Async Unit := do @@ -560,6 +592,7 @@ def run (r : Tests.Runner) : IO Unit := do localChecks r systemClockChecks r inboundChecks r + frameBoundaryChecks r pairChecks r surfaceChecks r trickleChecks r diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index d13ff423..bd4e0299 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -77,11 +77,15 @@ updateGet (root ARCHITECTURE §4.1). Only the first stop caller joins. writes frames in queue order. A failed write closes the queue and posts peerLeft. Stop resets installed streams to wake parked writes and joins the writer tasks. The host must end a newStream call when its session ends. -- **The inbound handler serves consecutive frames.** Go keeps one reader - loop per stream (comm.go, lines 75 to 120). Go and rust write many RPCs on - one stream. A one-frame rule would break interop on the second RPC. The - handler bounds each frame before appending bytes and preserves frame order. - A zero-length frame decodes as an empty RPC and reaches the core. +- **The inbound handler serves every frame of a stream.** The pubsub spec + uses separate long-lived inbound and outbound streams (pubsub/README.md, + lines 78 to 83). Go reads messages in a loop (comm.go, lines 75 to 120). + Rust keeps one inbound substream (protocols/gossipsub/src/handler.rs, + lines 78 to 82). Both peers write many RPCs on one stream. The handler + preserves frame order and checks each length before appending body bytes. + A bad prefix, an over-cap frame or EOF inside a frame is a protocol error. + A zero-length frame is legal (Rpc.lean, line 867). Go skips it; this handler + decodes it as an empty RPC that the core accepts. - **The receipt observer runs before the core step.** The interop harness logs every received message, including duplicates (gossipsub-interop, test-specs/implementation.md, lines 71 to 78). Its go participant logs From 26127b503af817a959155d60a02a020013c3aba9 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:50:28 +0800 Subject: [PATCH 043/125] docs(protocols): state the multi-frame handler milestone --- packages/libp2p/protocols/docs/PLAN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index a98eea48..c051a659 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -104,8 +104,8 @@ in the codec group wrapping upstream `google/snappy`. the negative record. - A bad frame fails its handler and resets its stream. A body that does not decode leaves the loop running. - - The handler serves consecutive frames in any chunking. It checks the - length before it appends body bytes, and EOF between frames is clean. + - The handler serves every frame of a stream, in any chunking, and resets + the stream on a bad frame. - Each peer has a writer task and a 32-slot queue. Two publishers run in parallel with one stream each and zero adjacent inversions per peer. A full queue drops frames, and the suite reports the drop count. From 864e7df68c7e648fc1b9d8fe8e9e15cea33613c9 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:17:40 +0800 Subject: [PATCH 044/125] fix(protocols): correct the trickle index contract and the generator path --- Tests/Gossipsub/Wire.lean | 4 ++-- .../protocols/Libp2p/Protocols/Gossipsub/Node.lean | 2 +- .../libp2p/protocols/tools/go-rpc-fixtures/main.go | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Tests/Gossipsub/Wire.lean b/Tests/Gossipsub/Wire.lean index 849e9cb8..9a244b10 100644 --- a/Tests/Gossipsub/Wire.lean +++ b/Tests/Gossipsub/Wire.lean @@ -68,7 +68,7 @@ private def isStable (rpc : Rpc.RPC) : Bool := | .error _ => false /-- The `fields` object of one case, when the case carries one. -/ -private def fieldsOf (_r : Runner) (_name : String) (c : Json) : IO (Option Json) := +private def fieldsOf (c : Json) : IO (Option Json) := match c.getObjVal? "fields" with | .ok v => return some v | .error _ => return none @@ -144,7 +144,7 @@ private def checkCase (r : Runner) (c : Json) : IO Unit := do | none => pure () | some rpc => r.check s!"{name} re-encodes stably" (isStable rpc) - if let some f ← fieldsOf r name c then + if let some f ← fieldsOf c then checkSubs r name f rpc checkPubs r name f rpc checkControl r name f rpc diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index bd4e0299..c8833795 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -122,7 +122,7 @@ structure Config where params : Core.Params := Core.defaults /-- The clock of the loop's timer. -/ clock : Conc.Clock := Conc.Clock.system - /-- The delay of the `i`-th gossip send of one heartbeat, in milliseconds. + /-- The delay of the run-wide `i`-th delayed gossip send, in milliseconds. The caller fixes it, so a test gets exact times. -/ trickle : Nat → Millis := fun _ => 0 /-- Replayable peer order for the numbered heartbeat. -/ diff --git a/packages/libp2p/protocols/tools/go-rpc-fixtures/main.go b/packages/libp2p/protocols/tools/go-rpc-fixtures/main.go index 7c7be6c9..9c15f3b6 100644 --- a/packages/libp2p/protocols/tools/go-rpc-fixtures/main.go +++ b/packages/libp2p/protocols/tools/go-rpc-fixtures/main.go @@ -11,9 +11,9 @@ import ( pb "github.com/libp2p/go-libp2p-pubsub/pb" ) -func strPtr(s string) *string { return &s } -func boolPtr(b bool) *bool { return &b } -func uint64Ptr(u uint64) *uint64 { return &u } +func strPtr(s string) *string { return &s } +func boolPtr(b bool) *bool { return &b } +func uint64Ptr(u uint64) *uint64 { return &u } type CaseJSON struct { Name string `json:"name"` @@ -78,7 +78,7 @@ type IwantFields struct { // // Run from this directory with the Go toolchain the gate image carries: // -// go run . ../../../../Tests/fixtures/gossipsub-rpc-go.json +// go run . ../../../../../Tests/fixtures/gossipsub-rpc-go.json func main() { var cases []CaseJSON @@ -456,7 +456,7 @@ func main() { } } - outPath := "../../../../Tests/fixtures/gossipsub-rpc-go.json" + outPath := "../../../../../Tests/fixtures/gossipsub-rpc-go.json" if len(os.Args) > 1 { outPath = os.Args[1] } @@ -483,4 +483,4 @@ func main() { panic(err) } fmt.Printf("Generated %d cases to %s\n", len(cases), outPath) -} \ No newline at end of file +} From e96e60d50d16167632312e80675b56d2324591a8 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:18:12 +0800 Subject: [PATCH 045/125] docs(protocols): state why the wire fixture lives under fixtures --- Tests/Gossipsub/Wire.lean | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/Gossipsub/Wire.lean b/Tests/Gossipsub/Wire.lean index 9a244b10..33d77695 100644 --- a/Tests/Gossipsub/Wire.lean +++ b/Tests/Gossipsub/Wire.lean @@ -17,8 +17,10 @@ whose payload carries no field that this codec leaves out, the re-encoding is th The suite reads one fixture and needs no network, no clock and no task. The plan records that gossipsub has no vectors (`docs/PLAN.md`, "Vector stance"). This fixture is -the first one, and its home is `Tests/fixtures` rather than `Tests/vectors`, because a random -draw does not reproduce it; the generator records where every byte came from. +the first one. It lives in `Tests/fixtures` because the CI provenance step regenerates +`Tests/vectors` with `scripts/extract-vectors.py` and diffs the result, while this file comes from +its own committed generator. The fixture records the generator, the module and the version, so a +stale file fails its own provenance check. -/ set_option autoImplicit false From c72c65211f29e2fa9464745a946d46da25c76021 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:38:18 +0800 Subject: [PATCH 046/125] fix(protocols): register subscriptions before posting their events --- Tests/Gossipsub/Node.lean | 24 +++++++++++++++++++ .../Libp2p/Protocols/Gossipsub/Node.lean | 21 ++++++++++++---- packages/libp2p/protocols/docs/PLAN.md | 2 ++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index 5f9a1a7c..7df86db3 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -283,6 +283,29 @@ private def inboundChecks (r : Tests.Runner) : Async Unit := do try sw.close catch _ => pure () (f.clock.test.close : BaseIO Unit) +/-- The reviewed core emits no local delivery for publish. These 20 trials +use a peer RPC immediately after subscribe, so they exercise callback +registration against a real delivery without changing that core policy. -/ +private def subscribeRace (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let f ← fixture sw pb + let seen ← (Chan.new 64 : BaseIO (Chan Seen)) + let ended ← (Chan.new 16 : BaseIO (Chan Unit)) + let source ← (probe sw pa seen ended : BaseIO HostSeam) + let stream ← openStream source pb + let deliveries ← (Chan.new 32 : BaseIO (Chan Message)) + let mut successes := 0 + for i in [0:20] do + let t : TopicId := ⟨s!"subscribe-race-{i}"⟩ + let accepted ← (f.node.subscribe t (fun m => discard <| deliveries.trySend m) : BaseIO Bool) + let m := { message [i] with topic := t } + discard <| stream.write (frame (Core.publishRpc m)) + if accepted && (← receive deliveries) == some m then + successes := successes + 1 + r.check "node.surface.subscribe-then-publish-reaches-callback" (successes == 20) + stream.close + finish sw [f] + /-- Direct handler boundary cases preserve the framing error channel. The fake stream returns finite chunks and records reset without a parked task. -/ private def frameBoundaryChecks (r : Tests.Runner) : Async Unit := do @@ -595,6 +618,7 @@ def run (r : Tests.Runner) : IO Unit := do frameBoundaryChecks r pairChecks r surfaceChecks r + subscribeRace r trickleChecks r defaultAndOrderChecks r pendingBound r diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index c8833795..2e989459 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -46,9 +46,11 @@ updateGet (root ARCHITECTURE §4.1). Only the first stop caller joins. Overflow drops the newest excess sends and adds to Node.dropped. Due sends leave before the tick shifts the message cache. The delay index increases across the run. Config.order hashes the heartbeat count with peer bytes. -- **A successful subscribe registers its callback.** The event post comes - first, and a refused post leaves no callback. Subscriptions use atomic - updates and preserve registration order. +- **Subscribe registers its callback before the post.** A delivery handled + between the post and registration would miss its callback. The second + review measured 11 lost deliveries in 60 trials with that order. A refused + post takes back its own registration by id. Concurrent callers remove + their own entries through atomic updates. - **The handler announces its opener.** It posts peerJoined before reading frames, so the first RPC reaches a known peer. Repeat joins are no-ops. - **No signer.** The core validates with `strictNoSign` (`Core.validationOk`), @@ -284,6 +286,8 @@ def handler (events : Conc.Chan Input) : Mock.Core.StreamHandler where /-- One delivery callback. -/ private structure Subscription where + /-- The unique registration id used by a refused post. -/ + id : Nat /-- The topic. -/ topic : TopicId /-- The callback. -/ @@ -291,6 +295,8 @@ private structure Subscription where /-- The shared table of the callbacks, in registration order. -/ private structure Subscriptions where + /-- The next unused registration id. -/ + next : Nat /-- The callbacks. -/ entries : List Subscription @@ -496,7 +502,7 @@ the back-pressure to the peer, and it refuses a `BaseIO` post of the application. -/ def start (host : HostSeam) (config : Config := {}) : BaseIO Node := do let now ← config.clock.nowMs - let subs : Conc.OnceRef Subscriptions ← Conc.OnceRef.new { entries := [] } + let subs : Conc.OnceRef Subscriptions ← Conc.OnceRef.new { next := 0, entries := [] } let events : Conc.Chan Input ← Conc.Chan.new 64 host.setHandler (handler events) let tasks ← Conc.TaskSet.new @@ -518,8 +524,13 @@ def start (host : HostSeam) (config : Config := {}) : BaseIO Node := do publish := fun topic payload => post (.publish (messageOf topic payload)) subscribe := fun topic callback => do if ← closing.get then return false + let id ← subs.updateGet fun s => + (s.next, + { next := s.next + 1 + entries := s.entries ++ [{ id := s.next, topic, callback }] }) let sent ← post (.subscribe topic) - if sent then subs.update fun s => { entries := s.entries ++ [{ topic, callback }] } + if !sent then + subs.update fun s => { s with entries := s.entries.filter (fun entry => entry.id != id) } return sent unsubscribe := fun topic => do if ← closing.get then return false diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index c051a659..eec0f40f 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -100,6 +100,8 @@ in the codec group wrapping upstream `google/snappy`. - The loop suite, `Tests/Gossipsub/Node.lean`, runs over the in-memory switch of `Mock/Host.lean`: - One publish crosses two nodes and reaches the callback. + - Subscribe registers the callback before posting its event. A refused + post takes back its own registration by id. - A subscribe after the join reaches the peer, and an unsubscribe sends the negative record. - A bad frame fails its handler and resets its stream. A body that does From 6ac101b738eae63e9221de2b97aaf3f66e8c183a Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:46:54 +0800 Subject: [PATCH 047/125] fix(protocols): retain inbound peers and read framed bodies with one allocation --- Tests/Gossipsub/Node.lean | 90 +++++++++++ Tests/Gossipsub/Wire.lean | 10 +- .../Libp2p/Protocols/Gossipsub/Node.lean | 142 +++++++++++------- 3 files changed, 179 insertions(+), 63 deletions(-) diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index 7df86db3..7c86ee0e 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -330,6 +330,94 @@ private def frameBoundaryChecks (r : Tests.Runner) : Async Unit := do ((match result with | .error (.protocol _) => true | _ => false) && (← (reset.get : BaseIO Bool))) +/-- A failed outbound write retires only its writer. The existing inbound +stream can still publish, and a later send opens a new outbound writer. -/ +private def outboundFailureChecks (r : Tests.Runner) : Async Unit := do + let deliveries ← (Chan.new 8 : BaseIO (Chan Message)) + let receipts ← (Chan.new 8 : BaseIO (Chan Unit)) + let closed ← (Chan.new 8 : BaseIO (Chan Unit)) + let inbound ← (Pipe.new 16 : BaseIO Pipe) + let opens ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) + let saved ← (OnceRef.new (none : Option Mock.Core.StreamHandler) : BaseIO (OnceRef (Option Mock.Core.StreamHandler))) + let host : HostSeam := { + setHandler := fun h => saved.set (some h) + newStream := fun _ _ => do + (opens.update (· + 1) : BaseIO Unit) + return .ok (Constants.meshsubId, { + read := pure (.ok none) + write := fun _ => pure (.error .closed) + close := discard (closed.trySend () : BaseIO Bool) + reset := pure () }) } + let tc ← (TestClock.new : BaseIO TestClock) + let n ← (start host { clock := tc.clock, onRpc := fun _ _ => discard (receipts.trySend ()) } : BaseIO Node) + discard (n.subscribe topic (fun m => discard <| deliveries.trySend m) : BaseIO Bool) + let some h ← (saved.get : BaseIO (Option Mock.Core.StreamHandler)) + | throw <| IO.userError "missing node handler" + let owner ← (TaskSet.new : BaseIO TaskSet) + let stream : Streams.Stream := { + read := return .ok (← inbound.read) + write := fun _ => pure (.ok ()) + close := pure () + reset := (inbound.close : BaseIO Unit) } + owner.spawn do + discard <| (h.handle pa stream).run + discard <| inbound.write (frame (Core.publishRpc (message [31]))) + r.check "node.failure.first-inbound-publish-delivers" + ((← receive deliveries) == some (message [31])) + r.check "node.failure.outbound-write-failed" ((← receive closed).isSome) + -- The close signal follows retirement, so the second publish sees the + -- post-failure peer state while using the same inbound stream. + discard <| inbound.write (frame (Core.publishRpc (message [32]))) + r.check "node.failure.second-inbound-publish-survives-outbound-failure" + ((← receive deliveries) == some (message [32])) + discard (n.subscribe ⟨"reopen"⟩ (fun _ => pure ()) : BaseIO Bool) + r.check "node.failure.next-send-opens-fresh-writer" ((← receive closed).isSome) + r.check "node.failure.two-outbound-opens" ((← (opens.get : BaseIO Nat)) == 2) + (inbound.close : BaseIO Unit) + owner.joinAll + n.stop + (tc.close : BaseIO Unit) + +/-- A legal body arrives one byte per read. The direct handler queue records +all frame bodies in order, without a router changing or deduplicating them. -/ +private def readerCopyChecks (r : Tests.Runner) : Async Unit := do + let first := Core.publishRpc (message (List.replicate 8192 17)) + let second := Core.publishRpc (message [18]) + let third := Core.publishRpc (message [19]) + let all := frame first ++ frame second ++ frame third + let cursor ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) + let events ← (Chan.new 16 : BaseIO (Chan Input)) + let dribble : Streams.Stream := { + read := do + let i ← (cursor.updateGet (fun i => (i, i + 1)) : BaseIO Nat) + return .ok (if i < all.size then some (all.extract i (i + 1)) else none) + write := fun _ => pure (.ok ()) + close := pure () + reset := pure () } + let result ← ((handler events).handle pa dribble).run + let inputs ← (events.drain : BaseIO (List Input)) + let bodies := inputs.filterMap fun + | .event (.rpc _ bytes) => some bytes + | _ => none + r.check "node.reader.many-one-byte-chunks-preserve-all-frames" + ((match result with | .ok () => true | _ => false) && + bodies == [Pb.encodeMessage first, Pb.encodeMessage second, Pb.encodeMessage third]) + let split := frame third + let chunks := [frame first ++ frame second ++ split.extract 0 1, + split.extract 1 3, split.extract 3 split.size] + let pending ← (OnceRef.new chunks : BaseIO (OnceRef (List ByteArray))) + let mixedEvents ← (Chan.new 16 : BaseIO (Chan Input)) + let mixed := { dribble with read := do + let bytes ← (pending.updateGet (fun xs => (xs.head?, xs.drop 1)) : BaseIO (Option ByteArray)) + return .ok bytes } + let mixedResult ← ((handler mixedEvents).handle pa mixed).run + let mixedInputs ← (mixedEvents.drain : BaseIO (List Input)) + let mixedBodies := mixedInputs.filterMap fun + | .event (.rpc _ bytes) => some bytes + | _ => none + r.check "node.reader.two-frames-then-split-frame-preserve-order" + ((match mixedResult with | .ok () => true | _ => false) && mixedBodies == bodies) + /-- Two real nodes learn subscriptions and deliver a publish without a heartbeat or an explicit peerJoined call at the receiver. -/ private def pairChecks (r : Tests.Runner) : Async Unit := do @@ -616,6 +704,8 @@ def run (r : Tests.Runner) : IO Unit := do systemClockChecks r inboundChecks r frameBoundaryChecks r + outboundFailureChecks r + readerCopyChecks r pairChecks r surfaceChecks r subscribeRace r diff --git a/Tests/Gossipsub/Wire.lean b/Tests/Gossipsub/Wire.lean index 33d77695..7d4da56e 100644 --- a/Tests/Gossipsub/Wire.lean +++ b/Tests/Gossipsub/Wire.lean @@ -16,11 +16,11 @@ recorded fields, and re-encodes it. The re-encoding decodes to the same value, a whose payload carries no field that this codec leaves out, the re-encoding is the recorded bytes. The suite reads one fixture and needs no network, no clock and no task. -The plan records that gossipsub has no vectors (`docs/PLAN.md`, "Vector stance"). This fixture is -the first one. It lives in `Tests/fixtures` because the CI provenance step regenerates -`Tests/vectors` with `scripts/extract-vectors.py` and diffs the result, while this file comes from -its own committed generator. The fixture records the generator, the module and the version, so a -stale file fails its own provenance check. +The CI provenance step covers Tests/vectors only. It regenerates that corpus +with scripts/extract-vectors.py. This fixture lives in Tests/fixtures and +checks its own generator, module and version provenance fields. A maintainer +regenerates it by hand with go run from packages/libp2p/protocols/tools/go-rpc-fixtures. +The command is go run . with the generator's default output path. -/ set_option autoImplicit false diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index 2e989459..5258a4ce 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -21,8 +21,10 @@ loop uses repeat and parks only on the channel receive. Ready events use constant stack space. The loop asks the alarm for the next due time after each input. A TestClock drives the alarm in tests. -The inbound readFrames is partial because a peer controls the number of -frames and chunks before EOF (root CLAUDE.md, "partial def"). +The inbound reader uses repeat with a mutable header, body cursor and chunk +position. Each bounded body allocation follows its decoded length. Each +input byte enters the body once, so small reads use constant stack space +and linear copying. ## Ownership @@ -76,8 +78,9 @@ updateGet (root ARCHITECTURE §4.1). Only the first stop caller joins. - **Each peer has one writer task.** The pinned go pubsub version a01777894f4c resets duplicate inbound streams (comm.go, lines 55 to 65). Its sender uses one stream and a bounded queue per peer (lines 181 to 233). Our writer - writes frames in queue order. A failed write closes the queue and posts - peerLeft. Stop resets installed streams to wake parked writes and joins + writes frames in queue order. A failed write retires its outbound entry + and closes its queue. Transport notifications own peerLeft. Stop resets + installed streams to wake parked writes and joins the writer tasks. The host must end a newStream call when its session ends. - **The inbound handler serves every frame of a stream.** The pubsub spec uses separate long-lived inbound and outbound streams (pubsub/README.md, @@ -96,8 +99,10 @@ updateGet (root ARCHITECTURE §4.1). Only the first stop caller joins. exposes the sender and unframed body before that step. The observer can decode the body to log every message. The default hook decodes nothing. Frame errors fail the handler before they reach the observer. -- **A send failure has no sink.** The send retires the stream, and the mock host has - no failure log. The host reports stream failures at integration. +- **A send failure retires its outbound entry.** The next send starts a fresh + writer and stream. The core keeps the peer and accepts further RPCs on its + live inbound stream. Retirement checks the writer id, so an old writer + cannot remove a newer entry for the same peer. -/ set_option autoImplicit false @@ -108,7 +113,8 @@ open Mock.Host (HostSeam) /-- A spread a caller may choose for heartbeat gossip. Each delay stays below one second. The multiplier is prime, so the delays do not repeat with a short -period. Config.trickle defaults to zero. -/ +period. Config.trickle defaults to zero. A caller who needs gossip inside +each heartbeat interval sets heartbeatInterval above the largest delay. -/ def spread (i : Nat) : Millis := (i * 7919 + 1) % 1000 /-- Replayable peer order for a heartbeat. The hash combines its counter @@ -157,7 +163,8 @@ structure Node where field runs in `Async` because a join parks, and a parked `BaseIO` action would hold a thread. A second call does nothing. -/ stop : Conc.Async Unit - /-- Frames dropped at the peer queue or pending-gossip bound. -/ + /-- Frames dropped by a full or closed peer queue, or by the 256-send + pending-gossip bound. -/ dropped : BaseIO Nat /-! ## One outbound RPC -/ @@ -182,6 +189,8 @@ inductive Input where /-- One peer's bounded writer queue and its currently open stream. -/ structure Outbound where + /-- The writer generation used for exact retirement. -/ + id : Nat /-- At most 32 encoded frames wait for this peer. -/ queue : Conc.Chan ByteArray /-- Stop resets this stream to wake a parked write. -/ @@ -194,13 +203,15 @@ private def endOutbound (ob : Outbound) : Conc.Async Unit := do stream.reset /-- One writer owns the peer stream. It writes queued frames in order and -closes the stream at the end. A failed write closes the queue and reports -peerLeft. Stop resets an installed stream; a late open checks closing too. -/ +closes the stream at the end. A failed open or write retires only this writer +entry and closes its queue. The core peer stays connected. Stop resets an +installed stream; a late open checks closing too. -/ private def writer (host : HostSeam) (closing : Conc.OnceRef Bool) - (events : Conc.Chan Input) (peer : Peer) (ob : Outbound) : Conc.Async Unit := do + (outbound : Conc.OnceRef (List (Peer × Outbound))) (peer : Peer) (ob : Outbound) : Conc.Async Unit := do let fail : Conc.Async Unit := do (ob.queue.close : BaseIO Unit) - discard <| events.send (.event (.peerLeft peer)) + (outbound.update (fun table => table.filter (fun e => e.2.id != ob.id)) : BaseIO Unit) + if let some stream ← (ob.stream.get : BaseIO (Option Streams.Stream)) then stream.reset try match ← host.newStream peer [Constants.meshsubId] with | .error _ => fail @@ -219,52 +230,63 @@ private def writer (host : HostSeam) (closing : Conc.OnceRef Bool) /-! ## The inbound handler -/ -/-- The total frame size once its prefix is complete. A prefix under the -RPC cap can use up to Varint.maxBytes bytes. The decoder checks the length -before the handler appends any body bytes. -/ -private def frameSize (buf : ByteArray) : Except Mock.Core.Error (Option Nat) := - let head := Bytes.toNatList (buf.extract 0 Varint.maxBytes) - match Streams.Framing.decodeLength? .uvarint head with - | .error .truncated => .ok none - | .error .malformed => .error (.protocol "the RPC prefix is malformed") - | .ok (size, rest) => - if Constants.rpcCap < size then .error (.protocol "the RPC exceeds the cap") - else .ok (some (head.length - rest.length + size)) - -/-- Read consecutive frames. The buffer holds only the current frame. -A chunk may hold several frames, so pos marks the bytes already consumed. -The handler appends only the bytes needed by the prefix or current body. -Its buffer stays under the RPC cap plus Varint.maxBytes, even when one -chunk holds many frames. EOF between frames is clean. -/ -private partial def readFrames (events : Conc.Chan Input) (peer : Peer) - (stream : Streams.Stream) (buf chunk : ByteArray) (pos : Nat) : - Conc.Async (Except Mock.Core.Error Unit) := do - match frameSize buf with - | .error e => return .error e - | .ok size? => - if let some size := size? then - if size ≤ buf.size then - match Rpc.decodeFrame? (Bytes.toNatList buf) with - | .ok (some (payload, [])) => - -- Park: the loop frees a slot, or stop closes the event queue. - match ← events.send (.event (.rpc peer payload)) with - | .error _ => return .ok () - | .ok () => return ← readFrames events peer stream ByteArray.empty chunk pos - | _ => return .error (.protocol "the RPC frame is malformed") - if pos < chunk.size then - -- An incomplete prefix needs one byte. A complete prefix fixes the - -- exact body size, so no append can exceed that frame's bound. - let need := size?.map (fun size => size - buf.size) |>.getD 1 - let take := Nat.min need (chunk.size - pos) - readFrames events peer stream (buf ++ chunk.extract pos (pos + take)) chunk (pos + take) - else +/-- Read consecutive frames with a bounded header and one body allocation. +The header is at most Varint.maxBytes bytes. Its decoded length is capped +before emptyWithCapacity allocates the body. This is the pinned Lean name +for the capacity constructor. Each copySlice writes only the next slice at +cursor, without retaining an earlier body value. EOF between frames is clean. +The repeat loop uses constant stack space for any number of chunks or frames. -/ +private def readFrames (events : Conc.Chan Input) (peer : Peer) + (stream : Streams.Stream) : Conc.Async (Except Mock.Core.Error Unit) := do + let mut header := ByteArray.emptyWithCapacity Varint.maxBytes + let mut body := ByteArray.empty + let mut expected : Option Nat := none + let mut cursor := 0 + let mut chunk := ByteArray.empty + let mut pos := 0 + repeat + if let some size := expected then + if cursor = size then + -- Park: the loop frees a slot, or stop closes the event queue. + if let .error _ ← events.send (.event (.rpc peer (Bytes.toNatList body))) then + return .ok () + body := ByteArray.empty + expected := none + cursor := 0 + if pos = chunk.size then -- Park: the peer writes, half-closes, or resets. match ← stream.read with | .error e => return .error (Mock.Core.mapStreamError e) | .ok none => - return if buf.isEmpty then .ok () + return if header.isEmpty && expected.isNone then .ok () else .error (.protocol "the stream ended inside an RPC frame") - | .ok (some bytes) => readFrames events peer stream buf bytes 0 + | .ok (some bytes) => + chunk := bytes + pos := 0 + else + match expected with + | some size => + let count := Nat.min (size - cursor) (chunk.size - pos) + body := chunk.copySlice pos body cursor count + cursor := cursor + count + pos := pos + count + | none => + if Varint.maxBytes ≤ header.size then + return .error (.protocol "the RPC header is malformed") + header := header.push (chunk.get! pos) + pos := pos + 1 + match Streams.Framing.decodeLength? .uvarint (Bytes.toNatList header) with + | .error .truncated => pure () + | .error .malformed => return .error (.protocol "the RPC header is malformed") + | .ok (size, _) => + if Constants.rpcCap < size then + return .error (.protocol "the RPC exceeds the cap") + -- The allocation runs only after the length passes the cap. + body := ByteArray.emptyWithCapacity size + expected := some size + cursor := 0 + header := ByteArray.emptyWithCapacity Varint.maxBytes + return .ok () /-- Serve every RPC of a peer's stream in arrival order. Frame errors reset both directions. A clean EOF closes our write direction. The host owns and @@ -276,7 +298,7 @@ def handler (events : Conc.Chan Input) : Mock.Core.StreamHandler where if let .error _ ← events.send (.event (.peerJoined peer .meshsub)) then stream.close return .ok () - let answer ← readFrames events peer stream ByteArray.empty ByteArray.empty 0 + let answer ← readFrames events peer stream match answer with | .ok () => stream.close | .error _ => stream.reset @@ -322,8 +344,10 @@ private structure Env where alarm : Conc.Alarm /-- The owner of the loop, alarm and writers. -/ tasks : Conc.TaskSet - /-- The peer writer registry. Only the loop adds entries. -/ + /-- The peer writer registry. The loop inserts and writers retire by id. -/ outbound : Conc.OnceRef (List (Peer × Outbound)) + /-- The next writer generation, allocated atomically. -/ + nextOutbound : Conc.OnceRef Nat /-- The first stop caller sets this flag. -/ closing : Conc.OnceRef Bool /-- Frames refused by a full or closed outbound queue. -/ @@ -338,9 +362,10 @@ private def enqueue (env : Env) (peer : Peer) (rpc : Rpc.RPC) : BaseIO Unit := d let ob ← match table.find? (fun e => e.1 == peer) with | some (_, ob) => pure ob | none => do - let ob : Outbound := { queue := ← Conc.Chan.new 32, stream := ← Conc.OnceRef.new none } + let id ← env.nextOutbound.updateGet (fun n => (n, n + 1)) + let ob : Outbound := { id, queue := ← Conc.Chan.new 32, stream := ← Conc.OnceRef.new none } env.outbound.update (fun table => (peer, ob) :: table) - env.tasks.spawn (writer env.host env.closing env.events peer ob) + env.tasks.spawn (writer env.host env.closing env.outbound peer ob) pure ob if ← env.closing.get then ob.queue.close @@ -508,10 +533,11 @@ def start (host : HostSeam) (config : Config := {}) : BaseIO Node := do let tasks ← Conc.TaskSet.new let alarm ← Conc.Alarm.new let outbound ← Conc.OnceRef.new ([] : List (Peer × Outbound)) + let nextOutbound ← Conc.OnceRef.new 0 let closing ← Conc.OnceRef.new false let drops ← Conc.OnceRef.new 0 tasks.spawn <| alarm.run config.clock (fun now => events.send (.wake now)) - tasks.spawn <| loop { host, config, subs, events, alarm, tasks, outbound, closing, drops } + tasks.spawn <| loop { host, config, subs, events, alarm, tasks, outbound, nextOutbound, closing, drops } { router := initialState config.params now pending := [] nextTick := now + config.params.heartbeatInterval From eb40c4013701fe3396a60e0f38860b1dd9a6b434 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:07:07 +0800 Subject: [PATCH 048/125] docs(protocols): drop process references from the shipped docstrings --- Tests/Gossipsub/Node.lean | 10 +++++----- .../Libp2p/Protocols/Gossipsub/Node.lean | 15 +++++++-------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index 7c86ee0e..3bff0f21 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -195,8 +195,8 @@ private def switchChecks (r : Tests.Runner) : Async Unit := do r.check "switch.no-open-after-close" (match ← a.newStream pb [id] with | .error (.closed _) => true | _ => false) -/-- A local publish has no deliver action in the reviewed core. The loop -serves queued inputs before stop, without a separate drain loop. -/ +/-- A local publish has no deliver action in the core. The loop serves queued +inputs before stop, without a separate drain loop. -/ private def localChecks (r : Tests.Runner) : Async Unit := do let sw ← (Switch.new : BaseIO Switch) let f ← fixture sw pa @@ -283,9 +283,9 @@ private def inboundChecks (r : Tests.Runner) : Async Unit := do try sw.close catch _ => pure () (f.clock.test.close : BaseIO Unit) -/-- The reviewed core emits no local delivery for publish. These 20 trials -use a peer RPC immediately after subscribe, so they exercise callback -registration against a real delivery without changing that core policy. -/ +/-- The core emits no local delivery for publish. These 20 trials use a peer +RPC immediately after subscribe, so they exercise callback registration +against a real delivery. -/ private def subscribeRace (r : Tests.Runner) : Async Unit := do let sw ← (Switch.new : BaseIO Switch) let f ← fixture sw pb diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index 5258a4ce..916195f8 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -37,9 +37,9 @@ updateGet (root ARCHITECTURE §4.1). Only the first stop caller joins. ## Recorded decisions -- **The stop field runs in Async.** The brief gives it a BaseIO type. - TaskSet.joinAll runs in Async, because joining can park. This field keeps - that type and leaves the blocking run to the application edge. +- **The stop field runs in Async.** Joining parks, and TaskSet.joinAll runs + in Async for that reason. So the field keeps that type and leaves the + blocking run to the application edge. - **Actions never park.** Each send offers a frame to a peer queue with 32 slots. One writer task owns that peer's stream. A full queue drops the frame, as go does for a full peer queue. Node.dropped reports these drops. @@ -48,11 +48,10 @@ updateGet (root ARCHITECTURE §4.1). Only the first stop caller joins. Overflow drops the newest excess sends and adds to Node.dropped. Due sends leave before the tick shifts the message cache. The delay index increases across the run. Config.order hashes the heartbeat count with peer bytes. -- **Subscribe registers its callback before the post.** A delivery handled - between the post and registration would miss its callback. The second - review measured 11 lost deliveries in 60 trials with that order. A refused - post takes back its own registration by id. Concurrent callers remove - their own entries through atomic updates. +- **Subscribe registers its callback before the post.** The loop can serve a + delivery between the post and the registration, and that delivery would + miss the callback. A refused post takes back its own registration by id. + Concurrent callers remove their own entries through atomic updates. - **The handler announces its opener.** It posts peerJoined before reading frames, so the first RPC reaches a known peer. Repeat joins are no-ops. - **No signer.** The core validates with `strictNoSign` (`Core.validationOk`), From 0f25e63bf00355b843f9bed2e1c497d5c858042f Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Fri, 6 Nov 2026 08:01:44 +0700 Subject: [PATCH 049/125] docs(protocols): describe the shipped tree in the root docstring --- packages/libp2p/protocols/Libp2p/Protocols.lean | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/libp2p/protocols/Libp2p/Protocols.lean b/packages/libp2p/protocols/Libp2p/Protocols.lean index d2c870ca..26444ac5 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols.lean @@ -10,7 +10,7 @@ import Libp2p.Protocols.Gossipsub.Node /-! # Libp2p.Protocols -The protocols subpackage (ARCHITECTURE §4.9). This pull request carries -the gossipsub modules. Identify, ping and req/resp arrive with their own -units. The package docs live under `docs/` beside this file. +The protocols subpackage (ARCHITECTURE §4.9). The gossipsub modules +live here. Identify, ping and req/resp arrive with their own units. +The package docs live under `docs/` beside this file. -/ From d712257dfa552e4eb629f73d849669ef8780fcfa Mon Sep 17 00:00:00 2001 From: Ivan Anishchuk Date: Fri, 6 Nov 2026 09:37:49 +0530 Subject: [PATCH 050/125] feat(security): W5.3, Noise XX and the secure transport The XX handshake is a pure core over opaque payloads: State, Event, Action, and step, and it takes its inputs the way flynn's HandshakeState does. The libp2p payload check sits above it. It refuses a payload over 8 KiB before the protobuf decode, then checks the identity key and the signature over the static key. The payload codec proves its round trip over the byte model. The transport is a second core under one loop, with a reader and a writer beside it. Noise has no window, so read credits bound the inbox, and stepBytesSettles proves that each read settles to one delivery or one returned credit. A failed open or seal tears the connection down before a read sees the end. Noise.securityProtocol and the fixed-key test value fill the SecurityProtocol seam. The leftover runs both ways: the negotiation's bytes seed the framed reader, and the bytes past the last handshake message seed the transport. The suite replays the four flynn vectors in both roles and the go transcript as the responder, and runs the seam values over the sim. scripts/noise-interop.sh runs the package against go-libp2p v0.50.0's Noise transport over loopback TCP in both directions. W5.1 and W5.2 have not merged, so core and identity come from mocks under Libp2p.Security.Mock. The mocks state no axiom. The branch sits on etheorem/leanp2p#62 for Bytes.Blob and Streams.readLoop. VECTORS.lock vendors the Noise files of js, nim, and rust that the docs cite. Closes #16 --- Tests/Interop/Noise.lean | 142 +++ Tests/Main.lean | 2 + Tests/Noise.lean | 813 ++++++++++++++++++ VECTORS.lock | 16 +- lakefile.toml | 8 + packages/libp2p/security/Libp2p/Security.lean | 13 +- .../security/Libp2p/Security/Constants.lean | 85 ++ .../security/Libp2p/Security/Handshake.lean | 546 ++++++++++++ .../security/Libp2p/Security/Mock/Core.lean | 84 ++ .../Libp2p/Security/Mock/Identity.lean | 154 ++++ .../security/Libp2p/Security/Noise.lean | 221 +++++ .../security/Libp2p/Security/Payload.lean | 198 +++++ .../security/Libp2p/Security/Transport.lean | 444 ++++++++++ packages/libp2p/security/docs/ARCHITECTURE.md | 176 +++- packages/libp2p/security/docs/PLAN.md | 11 +- scripts/noise-interop.sh | 53 ++ scripts/noise-interop/go.mod | 30 + scripts/noise-interop/go.sum | 63 ++ scripts/noise-interop/main.go | 154 ++++ .../src/performHandshake.ts | 93 ++ .../connection-encrypter-noise/src/utils.ts | 226 +++++ .../libp2p/protocols/secure/noise.nim | 669 ++++++++++++++ vendor/rust-libp2p/.pin | 1 + vendor/rust-libp2p/LICENSE | 18 + vendor/rust-libp2p/transports/noise/src/io.rs | 151 ++++ .../transports/noise/src/io/framed.rs | 240 ++++++ .../transports/noise/src/io/handshake.rs | 272 ++++++ 27 files changed, 4842 insertions(+), 41 deletions(-) create mode 100644 Tests/Interop/Noise.lean create mode 100644 Tests/Noise.lean create mode 100644 packages/libp2p/security/Libp2p/Security/Constants.lean create mode 100644 packages/libp2p/security/Libp2p/Security/Handshake.lean create mode 100644 packages/libp2p/security/Libp2p/Security/Mock/Core.lean create mode 100644 packages/libp2p/security/Libp2p/Security/Mock/Identity.lean create mode 100644 packages/libp2p/security/Libp2p/Security/Noise.lean create mode 100644 packages/libp2p/security/Libp2p/Security/Payload.lean create mode 100644 packages/libp2p/security/Libp2p/Security/Transport.lean create mode 100755 scripts/noise-interop.sh create mode 100644 scripts/noise-interop/go.mod create mode 100644 scripts/noise-interop/go.sum create mode 100644 scripts/noise-interop/main.go create mode 100644 vendor/js-libp2p/packages/connection-encrypter-noise/src/performHandshake.ts create mode 100644 vendor/js-libp2p/packages/connection-encrypter-noise/src/utils.ts create mode 100644 vendor/nim-libp2p/libp2p/protocols/secure/noise.nim create mode 100644 vendor/rust-libp2p/.pin create mode 100644 vendor/rust-libp2p/LICENSE create mode 100644 vendor/rust-libp2p/transports/noise/src/io.rs create mode 100644 vendor/rust-libp2p/transports/noise/src/io/framed.rs create mode 100644 vendor/rust-libp2p/transports/noise/src/io/handshake.rs diff --git a/Tests/Interop/Noise.lean b/Tests/Interop/Noise.lean new file mode 100644 index 00000000..74bf3eac --- /dev/null +++ b/Tests/Interop/Noise.lean @@ -0,0 +1,142 @@ +import Streams +import Libp2p.Security + +/-! +# The Noise interop driver (unit W5.3) + +The Lean side of the Noise interop check against go-libp2p v0.50.0's +Noise transport, with flynn/noise v1.1.0 under it. +`scripts/noise-interop.sh` builds the go side (`scripts/noise-interop/`) +and runs both directions over loopback TCP, with no multistream-select, +so the check sees the Noise handshake and transport frames alone. The +full stack meets go-libp2p at W5.9. + +* `noise-interop listen`: listen, print the port, accept one connection + as the responder, read `size` bytes, echo them, and then read the + peer's orderly end. +* `noise-interop dial PORT`: connect as the initiator, write `size` + bytes, check the echo, and close. + +Both sides take fixed Ed25519 identities from 32-byte seeds, so each +side checks the peer id the other proves. The Noise static key comes +from `Config.generate`, as in production. + +The driver lives outside `lake test`, which stays hermetic: Go tooling +is test-only and confined to the harness (CLAUDE.md, "Dependencies"). +The payload pattern matches the go side. +-/ + +set_option autoImplicit false + +namespace Tests.Interop.Noise + +open Conc (Async) +-- The keys and the peer id come from the identity mock of the package +-- until W5.1 merges (RULES.md, "Working ahead of the tree"). +open Libp2p.Security.Mock.Identity +open Libp2p.Security + +/-- 32 bytes of one value, the form of both identity seeds. -/ +def seed (v : UInt8) : ByteArray := ⟨Array.replicate 32 v⟩ + +/-- Our identity seed: 32 bytes of `L`. The go side holds the same. -/ +def leanSeed : ByteArray := seed 'L'.toNat.toUInt8 + +/-- The go side's identity seed: 32 bytes of `G`. -/ +def goSeed : ByteArray := seed 'G'.toNat.toUInt8 + +/-- The bytes each way: past three full frames of `maxPlaintext`, so a +write goes out as four frames. -/ +def size : Nat := 200000 + +/-- The pattern both sides build. -/ +def payload (n : Nat) : ByteArray := + ⟨(Array.range n).map fun k => UInt8.ofNat ((k * 7 + 3) % 251)⟩ + +/-- Fail with a message. -/ +def fail {α : Type} (msg : String) : Async α := + throw <| IO.userError s!"lean: {msg}" + +/-- The value of an `Except`, or a failure that names the error. -/ +def orFail {ε α : Type} [ToString ε] (what : String) : Except ε α → Async α + | .ok a => return a + | .error e => fail s!"{what}: {e}" + +/-- A multiaddr from its text form. -/ +def addr (s : String) : Async Multiformats.Multiaddr := do + orFail s!"address {s}" (Multiformats.Multiaddr.parse? s |>.mapError fun e => repr e |>.pretty) + +/-- The Ed25519 key of a seed. -/ +def keyOf (s : ByteArray) : Async PrivKey := + orFail "identity" (PrivKey.ofData? .ed25519 s |>.mapError fun e => (repr e).pretty) + +/-- Our seam value, and the peer id the go side must prove. -/ +def setup : Async (Mock.Core.SecurityProtocol × PeerId) := do + let cfg ← orFail "static key" + ((← (Noise.Config.generate (← keyOf leanSeed)).run).mapError fun e => (repr e).pretty) + let goKey ← keyOf goSeed + let want ← orFail "go peer id" + ((do PeerId.ofPubKey (← goKey.toPublic) : Except Error PeerId) |>.mapError fun e => (repr e).pretty) + return (Noise.securityProtocol cfg, want) + +/-- Read until `n` bytes have arrived, or the end. -/ +def readN (sc : Mock.Core.SecureConn) (n : Nat) : Async ByteArray := do + let mut acc := ByteArray.empty + while acc.size < n do + match ← sc.read with + | some b => acc := acc ++ b + | none => break + return acc + +/-- The secure connection's answer, as a failure when it is an error. -/ +def joinOk (sc : Mock.Core.SecureConn) : Async Unit := do + orFail "join" (← sc.join.run) + +/-- The listener side: the responder, then the echo, then the peer's +orderly end. -/ +def listenMode : Async Unit := do + let (proto, want) ← setup + let l ← Streams.Tcp.transport.listen (← addr "/ip4/127.0.0.1/tcp/0") + let some port := Streams.Tcp.socketAddress? l.addr |>.map (·.port) | fail "no port" + IO.println port + (← IO.getStdout).flush + let some conn ← l.accept | fail "accept failed" + l.close + let sc ← orFail "handshake" (← (proto.handshake .responder conn .empty).run) + unless sc.remotePeer == want do fail s!"remote peer {sc.remotePeer}, want {want}" + let got ← readN sc size + unless got.size == size do fail s!"read {got.size} bytes, want {size}" + sc.write got + -- The dialer closes after it has read the echo: an orderly end. + if (← sc.read).isSome then fail "bytes after the echo, want the end" + sc.close + joinOk sc + l.join + +/-- The dialer side: the initiator, then the write and the echo. -/ +def dialMode (port : String) : Async Unit := do + let (proto, want) ← setup + let some conn ← Streams.Tcp.transport.dial (← addr s!"/ip4/127.0.0.1/tcp/{port}") | fail "dial failed" + let sc ← orFail "handshake" (← (proto.handshake .initiator conn .empty).run) + unless sc.remotePeer == want do fail s!"remote peer {sc.remotePeer}, want {want}" + let data := payload size + sc.write data + let got ← readN sc size + unless got == data do fail s!"the echo of {size} bytes differs, got {got.size}" + sc.close + joinOk sc + +end Tests.Interop.Noise + +def main (args : List String) : IO UInt32 := do + let run : Conc.Async Unit := match args with + | ["listen"] => Tests.Interop.Noise.listenMode + | ["dial", port] => Tests.Interop.Noise.dialMode port + | _ => Tests.Interop.Noise.fail "usage: noise-interop listen | dial PORT" + try + run.run + IO.eprintln "lean: ok" + return 0 + catch e => + IO.eprintln s!"{e}" + return 1 diff --git a/Tests/Main.lean b/Tests/Main.lean index b43e7275..8c942412 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -18,6 +18,7 @@ import Tests.Facade import Tests.CryptoCross import Tests.Yamux import Tests.Identity +import Tests.Noise /-! # Test entry point @@ -51,4 +52,5 @@ def main : IO UInt32 := do Tests.CryptoCross.run r Tests.Yamux.run r Tests.Identity.run r + Tests.Noise.run r r.summary diff --git a/Tests/Noise.lean b/Tests/Noise.lean new file mode 100644 index 00000000..76ae299e --- /dev/null +++ b/Tests/Noise.lean @@ -0,0 +1,813 @@ +import Tests.Support +import Bytes +import Proto +import Streams +import Libp2p.Security + +/-! +# Noise suite (unit W5.3, 1a) + +The unit 1a milestones of `packages/libp2p/security/docs/PLAN.md`, +"Test milestones (unit 1a)": + +* The four flynn XX cases of `noise-vectors.json` replay through the + core in both roles, as events in and actions out: three handshake + messages, then two transport messages under the cipher states of + `split`. +* The go transcript of `noise-go-transcript.json` replays the + responder: message 2 is byte-exact, message 3 decrypts and its + payload verifies to the captured peer id, and the derived cipher + states round-trip the recorded transport frames. +* The 8 KiB gate refuses a payload one byte over before the decode. +* The static-key check: a signature over another static key, a + changed signature, and a missing signature fail; a good payload + gives the peer id of its identity key. + +Beyond the milestones: a libp2p handshake between two cores, with +Ed25519 identities on both sides, and the hostile cases (a changed +ciphertext, a short message, a long message, a message out of turn, an +input twice, an event after the completion). + +The unit 1b milestones, "Test milestones (unit 1b)", run the seam +values over the in-memory sim: + +* The handshake completes in both roles, with and without split writes, + and each side proves the other's peer id. +* A bit-flipped transport frame is a protocol error: the reader sees the + end after the teardown, and the join answers `protocol`. A clean close + reads the end, and the join answers `.ok`. +* A write over `maxPlaintext` goes out as frames at the cap, and the + peer reads it back whole. +* The fixed-key value gives the same bytes on the wire in two runs. +* The leftover in both directions: message 0 reaches the responder + through the `ByteArray` argument, and a transport frame that the + handshake over-read reaches the reader through the `SecureConn`. + +Beyond the milestones: a payload signed over another static key fails +the initiator with `authentication` before message 2 goes out, an end +inside the handshake is `closed`, an end inside a transport frame is +`truncatedFrame`, and a cipher state at the reserved nonce fails the +step (owned contract 6). +-/ + +set_option autoImplicit false + +namespace Tests + +-- The keys and the peer id come from the identity mock of the package +-- until W5.1 merges (RULES.md, "Working ahead of the tree"). +open Bytes Libp2p.Security.Mock.Identity Libp2p.Security + +/-- 32 bytes from hex. -/ +private def key32 (where_ hex : String) : IO Noise.Bytes32 := do + match Noise.Bytes32.ofByteArray? (← unhex where_ hex) with + | some k => pure k + | none => throw <| IO.userError s!"{where_}: not 32 bytes: {hex}" + +/-- A value of an `Except`, or a failed run that names the error. -/ +private def expectOk {ε α : Type} [Repr ε] (where_ : String) : Except ε α → IO α + | .ok a => pure a + | .error e => throw <| IO.userError s!"{where_}: {repr e}" + +/-- The X25519 public key of a private key. -/ +private def x25519Pub (where_ : String) (k : Noise.Bytes32) : IO Noise.Bytes32 := do + let p ← expectOk where_ (Crypto.x25519PublicKey k.key) + pure ⟨p⟩ + +/-- A Noise message with its 2-byte length prefix. -/ +private def frame (b : ByteArray) : ByteArray := + (Streams.Framing.encodeB? .u16be Noise.maxMessage b).getD .empty + +/-- The message inside a framed write: the bytes after the 2-byte +prefix, as the shell's framed read hands them to the peer's core. -/ +private def body (f : Bytes.Blob) : Bytes.Blob := ⟨f.bytes.extract 2 f.bytes.size⟩ + +/-- Post the events in order, and gather the actions. -/ +private def stepAll (st : Noise.State) (evs : List Noise.Event) : + Except Noise.Error (Noise.State × List Noise.Action) := + evs.foldlM (init := (st, [])) fun (st, acts) ev => do + let (st, a) ← Noise.step st ev + return (st, acts ++ a) + +/-- The input with the bit 0 of byte `i` flipped. -/ +private def flip (b : ByteArray) (i : Nat) : ByteArray := + if h : i < b.size then b.set i (b[i] ^^^ 1) h else b + +/-- `n` bytes of value `v`. -/ +private def fill (n : Nat) (v : UInt8) : ByteArray := ⟨Array.replicate n v⟩ + +/-! ## The flynn vectors -/ + +/-- One flynn case: both roles, three handshake messages, then the two +transport messages. flynn's runner sends the first transport message +with the initiator's cipher state and the second with the +responder's (`vendor/flynn-noise/vector_test.go`). -/ +private def flynnCase (r : Runner) (c : Lean.Json) : IO Unit := do + let file := "noise-vectors.json" + let note ← jStr file c "note" + let name := s!"flynn {note}" + let prologue ← unhex file (← jStr file c "prologue") + let iS ← key32 file (← jStr file c "initStatic") + let rS ← key32 file (← jStr file c "respStatic") + let iE ← key32 file (← jStr file c "initEphemeral") + let rE ← key32 file (← jStr file c "respEphemeral") + let mut ps : Array ByteArray := #[] + let mut cts : Array ByteArray := #[] + for m in ← jArr file c "messages" do + ps := ps.push (← unhex file (← jStr file m "payload")) + cts := cts.push (← unhex file (← jStr file m "ciphertext")) + unless ps.size = 5 do + r.check s!"{name}: five messages" false + return + -- `ps` and `cts` grow together, so both hold five entries, and every + -- index below is in range. + let i0 ← expectOk name (Noise.State.init .initiator prologue iS) + let r0 ← expectOk name (Noise.State.init .responder prologue rS) + -- Message 0, initiator to responder: `e`. The vector holds the + -- message; the write action holds it framed. + let (i1, a) ← expectOk name (stepAll i0 [.entropy iE, .«local» ⟨ps[0]!⟩]) + r.check s!"{name}: the initiator writes message 0" (a == [.write ⟨frame cts[0]!⟩]) + -- Message 1, responder to initiator: `e, ee, s, es`. + let (r1, a) ← expectOk name + (stepAll r0 [.entropy rE, .«local» ⟨ps[1]!⟩, .bytes ⟨cts[0]!⟩]) + r.check s!"{name}: the responder reads message 0 and writes message 1" + (a == [.payload ⟨ps[0]!⟩ none, .write ⟨frame cts[1]!⟩]) + -- Message 2, initiator to responder: `s, se`, then `split`. + let (_, a) ← expectOk name (stepAll i1 [.«local» ⟨ps[2]!⟩, .bytes ⟨cts[1]!⟩]) + let [.payload q1 k1, .write f2, .complete ci] := a + | r.check s!"{name}: the initiator reads message 1, writes message 2, and completes" false + r.check s!"{name}: the initiator reads payload 1" (q1 == ⟨ps[1]!⟩) + r.check s!"{name}: payload 1 carries the responder's static key, ahead of the write" + (k1 == some (← x25519Pub name rS)) + r.check s!"{name}: the initiator writes message 2" (f2 == ⟨frame cts[2]!⟩) + let (_, a) ← expectOk name (stepAll r1 [.bytes ⟨cts[2]!⟩]) + let [.payload q2 k2, .complete cr] := a + | r.check s!"{name}: the responder reads message 2 and completes" false + r.check s!"{name}: the responder reads payload 2" (q2 == ⟨ps[2]!⟩) + r.check s!"{name}: payload 2 carries the initiator's static key" + (k2 == some (← x25519Pub name iS)) + r.check s!"{name}: the cipher states pair up" (ci.send == cr.recv && ci.recv == cr.send) + r.check s!"{name}: the initiator learned the responder's static key" + (ci.remoteStatic == (← x25519Pub name rS)) + r.check s!"{name}: the responder learned the initiator's static key" + (cr.remoteStatic == (← x25519Pub name iS)) + -- The transport messages carry an empty associated data. + let (_, ct3) ← expectOk name (ci.send.encryptWithAd .empty ps[3]!) + r.check s!"{name}: transport message 0 under the initiator's state" (ct3 == cts[3]!) + let (_, pt3) ← expectOk name (cr.recv.decryptWithAd .empty cts[3]!) + r.check s!"{name}: the responder opens transport message 0" (pt3 == ps[3]!) + let (_, ct4) ← expectOk name (cr.send.encryptWithAd .empty ps[4]!) + r.check s!"{name}: transport message 1 under the responder's state" (ct4 == cts[4]!) + let (_, pt4) ← expectOk name (ci.recv.decryptWithAd .empty cts[4]!) + r.check s!"{name}: the initiator opens transport message 1" (pt4 == ps[4]!) + +private def flynn (r : Runner) : IO Unit := do + let file := "noise-vectors.json" + let j ← loadJson file + let cases ← jArr file j "cases" + r.check "flynn: four cases" (cases.length == 4) + for c in cases do + try flynnCase r c + catch e => r.check s!"flynn case: runs ({e})" false + +/-! ## The go transcript -/ + +/-- The responder against go-libp2p v0.50.0 as initiator. -/ +private def goTranscriptRun (r : Runner) : IO Unit := do + let file := "noise-go-transcript.json" + let name := "go transcript" + let j ← loadJson file + let rS ← key32 file (← jStr file j "respStaticNoiseHex") + let rE ← key32 file (← jStr file j "respEphemeralPrivHex") + let rPayload ← unhex file (← jStr file j "respPayloadHex") + let iPayload ← unhex file (← jStr file j "initPayloadHex") + let mut msgs : Array (String × ByteArray) := #[] + for m in ← jArr file j "messages" do + msgs := msgs.push (← jStr file m "dir", ← unhex file (← jStr file m "hex")) + -- The indexes below read the three handshake messages. + unless 3 ≤ msgs.size do + r.check s!"{name}: three handshake messages" false + return + r.check s!"{name}: the ephemeral private key gives the captured public key" + ((← x25519Pub name rE).bytes == (← unhex file (← jStr file j "respEphemeralNoiseHex"))) + -- The captured messages carry their prefix; the core takes them + -- without it, as the shell's framed read hands them on. + let st ← expectOk name (Noise.State.init .responder .empty rS) + let (st, a) ← expectOk name + (stepAll st [.entropy rE, .«local» ⟨rPayload⟩, .bytes (body ⟨msgs[0]!.2⟩)]) + r.check s!"{name}: message 1 carries an empty payload, and message 2 is byte-exact" + (a == [.payload ⟨.empty⟩ none, .write ⟨msgs[1]!.2⟩]) + let (_, a) ← expectOk name (stepAll st [.bytes (body ⟨msgs[2]!.2⟩)]) + let [.payload q (some k), .complete c] := a + | r.check s!"{name}: the responder reads message 3 with the static key, and completes" false + r.check s!"{name}: message 3 decrypts to the captured payload" (q == ⟨iPayload⟩) + r.check s!"{name}: the payload's key is the completion's key" (k == c.remoteStatic) + -- The payload checks, both directions. The shell checks with the key + -- of the payload action. + let v ← expectOk name (Noise.verify k iPayload) + r.check s!"{name}: the initiator payload verifies to the captured peer id" + (v.peer.toString == (← jStr file j "initPeerId")) + r.check s!"{name}: the initiator's identity key is the captured key" + (v.key.data == (← unhex file (← jStr file j "initIdentityPubHex"))) + let vr ← expectOk name (Noise.verify (← x25519Pub name rS) rPayload) + r.check s!"{name}: the responder payload verifies to the captured peer id" + (vr.peer.toString == (← jStr file j "respPeerId")) + r.check s!"{name}: the responder payload keeps its extensions as bytes" vr.extensions.isSome + -- The transport frames: open each one, then seal the plaintext again + -- with a copy of the state from before the open. The copy must give + -- the recorded bytes back. + let mut recv := c.recv + let mut send := c.send + for (dir, wire) in msgs.toList.drop 3 do + let body := wire.extract 2 wire.size + let label := s!"{name}: transport frame {dir} {toHex (wire.extract 0 4)}… round-trips" + match dir with + | "i2r" => + let (next, pt) ← expectOk name (recv.decryptWithAd .empty body) + let (_, again) ← expectOk name (recv.encryptWithAd .empty pt) + recv := next + r.check label (again == body) + | _ => + let (_, pt) ← expectOk name (send.decryptWithAd .empty body) + let (next, again) ← expectOk name (send.encryptWithAd .empty pt) + send := next + r.check label (again == body) + +private def goTranscript (r : Runner) : IO Unit := do + try goTranscriptRun r + catch e => r.check s!"go transcript: runs ({e})" false + +/-! ## The payload check -/ + +/-- An Ed25519 identity from a fill byte. -/ +private def identity (where_ : String) (v : UInt8) : IO PrivKey := + expectOk where_ (PrivKey.ofData? .ed25519 (fill 32 v)) + +/-- The payload of the identity over the static key. -/ +private def payloadOf (where_ : String) (k : PrivKey) (staticPub : Noise.Bytes32) : + IO ByteArray := do + expectOk where_ (← (Noise.build k staticPub).run) + +private def payloadChecksRun (r : Runner) : IO Unit := do + let name := "payload" + let k ← identity name 7 + let peer ← expectOk name (do PeerId.ofPubKey (← k.toPublic)) + let sA ← x25519Pub name (← key32 name (toHex (fill 32 1))) + let sB ← x25519Pub name (← key32 name (toHex (fill 32 2))) + let p ← payloadOf name k sA + r.check s!"{name}: a good payload gives the peer id of its key" + (match Noise.verify sA p with | .ok v => v.peer == peer | .error _ => false) + r.check s!"{name}: a good payload carries no extensions" + (match Noise.verify sA p with | .ok v => v.extensions.isNone | .error _ => false) + r.check s!"{name}: a signature over another static key fails" + (failsWith .badSignature (Noise.verify sB p)) + -- The signature is the last field, so the last byte is a signature + -- byte. + r.check s!"{name}: a changed signature fails" + (failsWith .badSignature (Noise.verify sA (flip p (p.size - 1)))) + let pub ← expectOk name k.toPublic + let some kf := Noise.Field.ofByteArray? pub.marshal + | r.check s!"{name}: the key fits a field" false + let noSig := Pb.encodeMessageB + ({ identityKey := some kf, identitySig := none, extensions := none } : Noise.HandshakePayload) + r.check s!"{name}: a payload with no signature fails" + (failsWith .missingIdentity (Noise.verify sA noSig)) + r.check s!"{name}: an empty payload fails" (failsWith .missingIdentity (Noise.verify sA .empty)) + -- The gate: one byte over is refused before the decode. At the cap + -- the decode runs, and the zero tag is a protobuf error. + let over := Noise.maxPayload + 1 + r.check s!"{name}: {over} bytes are refused before the decode" + (failsWith (.payloadTooLarge over) (Noise.verify sA (fill over 0))) + r.check s!"{name}: {Noise.maxPayload} bytes reach the decode" + (failsWith (.payload .badField) (Noise.verify sA (fill Noise.maxPayload 0))) + +private def payloadChecks (r : Runner) : IO Unit := do + try payloadChecksRun r + catch e => r.check s!"payload: runs ({e})" false + +/-! ## A libp2p handshake between two cores -/ + +/-- Both roles with Ed25519 identities, each side checking the other's +payload as the shell does, before its next action. -/ +private def libp2pRun (r : Runner) : IO Unit := do + let name := "libp2p handshake" + let kI ← identity name 3 + let kR ← identity name 4 + let peerI ← expectOk name (do PeerId.ofPubKey (← kI.toPublic)) + let peerR ← expectOk name (do PeerId.ofPubKey (← kR.toPublic)) + let sI ← key32 name (toHex (fill 32 5)) + let sR ← key32 name (toHex (fill 32 6)) + let pI ← payloadOf name kI (← x25519Pub name sI) + let pR ← payloadOf name kR (← x25519Pub name sR) + let i0 ← expectOk name (Noise.State.init .initiator .empty sI) + let r0 ← expectOk name (Noise.State.init .responder .empty sR) + let (i1, a) ← expectOk name (stepAll i0 [.entropy (← key32 name (toHex (fill 32 8))), + .«local» ⟨.empty⟩]) + let [.write m0] := a | r.check s!"{name}: message 0" false + let (r1, a) ← expectOk name (stepAll r0 [.entropy (← key32 name (toHex (fill 32 9))), + .«local» ⟨pR⟩, .bytes (body m0)]) + let [.payload _ none, .write m1] := a | r.check s!"{name}: message 1" false + let (_, a) ← expectOk name (stepAll i1 [.«local» ⟨pI⟩, .bytes (body m1)]) + -- The payload comes before the write of message 2, with the key its + -- check needs, so the shell can refuse before message 2 goes out. + let [.payload q1 (some k1), .write m2, .complete ci] := a | r.check s!"{name}: message 2" false + r.check s!"{name}: the initiator proves the responder's peer id" + (match Noise.verify k1 q1.bytes with | .ok v => v.peer == peerR | .error _ => false) + let (_, a) ← expectOk name (stepAll r1 [.bytes (body m2)]) + let [.payload q2 (some k2), .complete cr] := a | r.check s!"{name}: completion" false + r.check s!"{name}: the responder proves the initiator's peer id" + (match Noise.verify k2 q2.bytes with | .ok v => v.peer == peerI | .error _ => false) + r.check s!"{name}: the cipher states pair up" (ci.send == cr.recv && ci.recv == cr.send) + +private def libp2p (r : Runner) : IO Unit := do + try libp2pRun r + catch e => r.check s!"libp2p handshake: runs ({e})" false + +/-! ## Hostile input -/ + +private def hostileRun (r : Runner) : IO Unit := do + let file := "noise-vectors.json" + let name := "noise hostile" + let j ← loadJson file + let some c := (← jArr file j "cases").head? | r.check s!"{name}: a flynn case" false + let iS ← key32 file (← jStr file c "initStatic") + let rS ← key32 file (← jStr file c "respStatic") + let iE ← key32 file (← jStr file c "initEphemeral") + let cts ← (← jArr file c "messages").mapM fun m => do unhex file (← jStr file m "ciphertext") + -- The indexes below read the three handshake messages. + unless 3 ≤ cts.length do + r.check s!"{name}: three handshake messages" false + return + let m1 := cts[1]! + -- The initiator before and after message 0. + let i0 ← expectOk name (Noise.State.init .initiator .empty iS) + r.check s!"{name}: a message before the initiator's first write is out of turn" + (failsWith .unexpectedEvent (Noise.step i0 (.bytes ⟨m1⟩))) + let (i1, _) ← expectOk name (stepAll i0 [.entropy iE, .«local» ⟨.empty⟩]) + r.check s!"{name}: a changed payload tag fails to decrypt" + (failsWith .decrypt (stepAll i1 [.bytes ⟨flip m1 (m1.size - 1)⟩])) + r.check s!"{name}: a changed static key fails to decrypt" + (failsWith .decrypt (stepAll i1 [.bytes ⟨flip m1 32⟩])) + r.check s!"{name}: a 10-byte message 1 is short" + (failsWith (.shortMessage 10 32) (stepAll i1 [.bytes ⟨fill 10 0⟩])) + r.check s!"{name}: message 1 without its payload tag is short" + (failsWith (.shortMessage 40 80) (stepAll i1 [.bytes ⟨m1.extract 0 40⟩])) + let long := Noise.maxMessage + 1 + r.check s!"{name}: a {long}-byte message is refused" + (failsWith (.messageTooLarge long) (stepAll i1 [.bytes ⟨fill long 0⟩])) + r.check s!"{name}: an entropy the phase does not need is refused" + (failsWith .unexpectedEvent (stepAll i1 [.entropy iE])) + r.check s!"{name}: a second waiting payload is refused" + (failsWith .unexpectedEvent (stepAll i1 [.«local» ⟨.empty⟩, .«local» ⟨.empty⟩])) + let (i2, a) ← expectOk name (stepAll i1 [.«local» ⟨.empty⟩, .bytes ⟨m1⟩]) + let [_, .write w2, .complete _] := a | r.check s!"{name}: the initiator completes" false + r.check s!"{name}: message 2 matches the vector" (w2 == ⟨frame cts[2]!⟩) + r.check s!"{name}: an event after the completion is refused" + (failsWith .finished (Noise.step i2 (.bytes ⟨.empty⟩))) + -- The responder after message 1 has no write left. + let r0 ← expectOk name (Noise.State.init .responder .empty rS) + let rE ← key32 file (← jStr file c "respEphemeral") + let (r1, _) ← expectOk name (stepAll r0 [.entropy rE, .«local» ⟨.empty⟩, .bytes ⟨cts[0]!⟩]) + r.check s!"{name}: a payload after the responder's last write is refused" + (failsWith .unexpectedEvent (Noise.step r1 (.«local» ⟨.empty⟩))) + +private def hostile (r : Runner) : IO Unit := do + try hostileRun r + catch e => r.check s!"noise hostile: runs ({e})" false + +/-! ## The seam values over the sim (unit 1b) -/ + +section Sim + +open Conc (Async OnceRef) +open Streams (Connection) +open Libp2p.Security.Mock.Core (SecureConn SecurityProtocol) + +/-- An address, or a failed run. -/ +private def ma (s : String) : Async Multiformats.Multiaddr := do + match Multiformats.Multiaddr.parse? s with + | .ok a => return a + | .error _ => throw <| IO.userError s!"test address {s} does not parse" + +/-- A connected pair over a fresh sim net: the dialer's end and the +listener's end. -/ +private def pair (split : Bool := false) : Async (Connection × Connection) := do + let net ← Streams.Sim.Net.new split + let l ← net.transport.listen (← ma "/memory/7") + let some c ← net.transport.dial l.addr | throw <| IO.userError "dial failed" + let some s ← l.accept | throw <| IO.userError "accept failed" + l.close + return (c, s) + +/-- A Noise configuration from an Ed25519 identity byte and a static key +byte. -/ +private def cfgOf (where_ : String) (idByte staticByte : UInt8) : IO Noise.Config := do + return { identity := ← identity where_ idByte, static := ← key32 where_ (toHex (fill 32 staticByte)) } + +/-- The peer id of a configuration's identity. -/ +private def peerOf (where_ : String) (cfg : Noise.Config) : IO PeerId := + expectOk where_ (do PeerId.ofPubKey (← cfg.identity.toPublic)) + +/-- The connection with each write passed through `f`, which sees the +number of the write, from 0, and a cell of its own. `f` answers the +bytes to write now. -/ +private def mapWrites {σ : Type} (c : Connection) (init : σ) + (f : Nat → ByteArray → σ → ByteArray × σ) : BaseIO Connection := do + let cell : OnceRef (Nat × σ) ← OnceRef.new (0, init) + return { c with + write := fun b => do + -- The ascription marks the step `BaseIO`, so the lift into `Async` + -- applies (see `Conc.TaskSet.joinAll`). + let out ← (cell.updateGet fun (n, s) => + let (out, s') := f n b s + (out, (n + 1, s')) : BaseIO ByteArray) + if !out.isEmpty then c.write out } + +/-- Run both handshakes: the responder as a task, the initiator here. +The await joins the task (§4.1, rule 2). -/ +private def both (pI pR : SecurityProtocol) (c s : Connection) (leftR : ByteArray := .empty) : + Async (Except Mock.Core.Error SecureConn × Except Mock.Core.Error SecureConn) := do + let t ← (Conc.Wrap.spawn (pR.handshake .responder s leftR).run : + BaseIO (Task (Except IO.Error (Except Mock.Core.Error SecureConn)))) + let ri ← (pI.handshake .initiator c .empty).run + match ← Conc.Wrap.await t with + | .ok rr => return (ri, rr) + | .error e => throw e + +/-- Read until `n` bytes have arrived, or the end. -/ +private def readN (sc : SecureConn) (n : Nat) : Async ByteArray := do + let mut acc := ByteArray.empty + while acc.size < n do + match ← sc.read with + | some b => acc := acc ++ b + | none => break + return acc + +/-- The secure connection of a result, or a failed run. -/ +private def secured (where_ : String) : Except Mock.Core.Error SecureConn → Async SecureConn + | .ok sc => return sc + | .error e => throw <| IO.userError s!"{where_}: {e}" + +/-- True when the join answers an orderly end. -/ +private def joinsOk (sc : SecureConn) : Async Bool := do + match ← sc.join.run with + | .ok () => return true + | .error _ => return false + +/-- The handshake end-to-end in both roles, data both ways, and the +orderly close. -/ +private def simRoundTrip (r : Runner) (split : Bool) : Async Unit := do + let name := s!"noise sim{if split then " (split writes)" else ""}" + let cfgI ← cfgOf name 3 5 + let cfgR ← cfgOf name 4 6 + let (c, s) ← pair split + let (ri, rr) ← both (Noise.securityProtocol cfgI) (Noise.securityProtocol cfgR) c s + let ci ← secured name ri + let cr ← secured name rr + r.check s!"{name}: the initiator proves the responder's peer id" + (ci.remotePeer == (← peerOf name cfgR)) + r.check s!"{name}: the responder proves the initiator's peer id" + (cr.remotePeer == (← peerOf name cfgI)) + r.check s!"{name}: each side names its own peer id" + (ci.localPeer == (← peerOf name cfgI) && cr.localPeer == (← peerOf name cfgR)) + ci.write "hello".toUTF8 + r.check s!"{name}: the responder reads the initiator's bytes" + ((← readN cr 5) == "hello".toUTF8) + cr.write "world!".toUTF8 + r.check s!"{name}: the initiator reads the responder's bytes" + ((← readN ci 6) == "world!".toUTF8) + -- The orderly close: the peer reads the end, and both joins answer + -- `.ok`. + ci.close + r.check s!"{name}: after the close the peer reads the end" ((← cr.read).isNone) + cr.close + r.check s!"{name}: both joins answer an orderly end" ((← joinsOk ci) && (← joinsOk cr)) + +/-- A changed transport frame is a protocol error: the responder's read +sees the end after the teardown, its join answers `protocol`, and the +initiator sees the connection go. -/ +private def simCorrupt (r : Runner) : Async Unit := do + let name := "noise sim corrupt frame" + let cfgI ← cfgOf name 3 5 + let cfgR ← cfgOf name 4 6 + let (c, s) ← pair + -- Writes 0 and 1 are handshake messages 0 and 2; write 2 is the first + -- transport frame, and its last byte is a tag byte. + let c' ← mapWrites c () fun n b _ => (if n == 2 then flip b (b.size - 1) else b, ()) + let (ri, rr) ← both (Noise.securityProtocol cfgI) (Noise.securityProtocol cfgR) c' s + let ci ← secured name ri + let cr ← secured name rr + ci.write "tampered".toUTF8 + r.check s!"{name}: the reader sees the end, not the bytes" ((← cr.read).isNone) + r.check s!"{name}: the join answers a protocol error" + (match ← cr.join.run with | .error (.protocol _) => true | _ => false) + r.check s!"{name}: the teardown closes the connection, so the peer reads the end" + ((← ci.read).isNone) + ci.close + r.check s!"{name}: the initiator's join answers an orderly end" (← joinsOk ci) + +/-- A write over `maxPlaintext` goes out in chunks, and the peer reads +it back whole. -/ +private def simLargeWrite (r : Runner) : Async Unit := do + let name := "noise sim large write" + let cfgI ← cfgOf name 3 5 + let cfgR ← cfgOf name 4 6 + let (c, s) ← pair + let (ri, rr) ← both (Noise.securityProtocol cfgI) (Noise.securityProtocol cfgR) c s + let ci ← secured name ri + let cr ← secured name rr + let size := 2 * Noise.maxPlaintext + 5 + let big : ByteArray := ⟨(Array.range size).map (·.toUInt8)⟩ + -- The write parks until the writer has sent its frames, and the + -- writer parks on the sim's pipe, so the read runs beside it. + let t ← (Conc.Wrap.spawn (ci.write big) : BaseIO (Task (Except IO.Error Unit))) + r.check s!"{name}: {size} bytes read back whole" ((← readN cr size) == big) + match ← Conc.Wrap.await t with + | .ok () => r.check s!"{name}: the write returns" true + | .error e => r.check s!"{name}: the write returns ({e})" false + ci.close + cr.close + r.check s!"{name}: both joins answer an orderly end" ((← joinsOk ci) && (← joinsOk cr)) + +/-- The fixed-key value: both roles over the sim, twice, give the same +bytes on the wire. -/ +private def simFixedKey (r : Runner) : Async Unit := do + let name := "noise fixed keys" + let cfgI ← cfgOf name 3 5 + let cfgR ← cfgOf name 4 6 + let pI := Noise.fixedKeyProtocol cfgI (fill 32 11) + let pR := Noise.fixedKeyProtocol cfgR (fill 32 12) + let mut runs : Array (Array ByteArray × Array ByteArray) := #[] + for _ in [0:2] do + let (c, s) ← pair + let logI : OnceRef (Array ByteArray) ← OnceRef.new #[] + let logR : OnceRef (Array ByteArray) ← OnceRef.new #[] + let c' := { c with write := fun b => do logI.update (·.push b); c.write b } + let s' := { s with write := fun b => do logR.update (·.push b); s.write b } + let (ri, rr) ← both pI pR c' s' + let ci ← secured name ri + let cr ← secured name rr + ci.write "fixed".toUTF8 + r.check s!"{name}: the bytes arrive" ((← readN cr 5) == "fixed".toUTF8) + ci.close + cr.close + discard <| joinsOk ci + discard <| joinsOk cr + runs := runs.push ((← (logI.get : BaseIO _)), (← (logR.get : BaseIO _))) + -- The loop pushed two runs, so indexes 0 and 1 are in range. + r.check s!"{name}: two runs write the same initiator bytes" (runs[0]!.1 == runs[1]!.1) + r.check s!"{name}: two runs write the same responder bytes" (runs[0]!.2 == runs[1]!.2) + r.check s!"{name}: the initiator wrote three messages and one frame" (runs[0]!.1.size == 3) + +/-- The leftover in both directions. In: the responder's handshake +takes message 0 as its `ByteArray` argument. Out: message 2 and the +first transport frame arrive in one chunk, and the frame reaches the +reader through the `SecureConn`. -/ +private def simLeftover (r : Runner) : Async Unit := do + let name := "noise leftover" + let cfgI ← cfgOf name 3 5 + let cfgR ← cfgOf name 4 6 + let (c, s) ← pair + -- Write 1 is message 2: hold it, and send it with write 2, the first + -- transport frame, as one chunk. + let c' ← mapWrites c ByteArray.empty fun n b held => + if n == 1 then (.empty, b) else if n == 2 then (held ++ b, .empty) else (b, held) + let pI := Noise.securityProtocol cfgI + let pR := Noise.securityProtocol cfgR + let t ← (Conc.Wrap.spawn (do + -- The negotiation's stand-in: it reads message 0 and hands it on. + let some m0 ← s.read | throw <| IO.userError s!"{name}: no message 0" + (pR.handshake .responder s m0).run) : + BaseIO (Task (Except IO.Error (Except Mock.Core.Error SecureConn)))) + let ci ← secured name (← (pI.handshake .initiator c' .empty).run) + ci.write "early".toUTF8 + let cr ← match ← Conc.Wrap.await t with + | .ok rr => secured name rr + | .error e => throw e + r.check s!"{name}: the responder's handshake over-read the frame" (!cr.leftover.isEmpty) + r.check s!"{name}: the frame reaches the reader, decrypted" ((← readN cr 5) == "early".toUTF8) + ci.close + cr.close + r.check s!"{name}: both joins answer an orderly end" ((← joinsOk ci) && (← joinsOk cr)) + +/-- Start one handshake as a task. The caller awaits it, which joins it +(§4.1, rule 2). -/ +private def spawnHandshake (p : SecurityProtocol) (role : Mock.Core.Role) (c : Connection) + (left : ByteArray := .empty) : + BaseIO (Task (Except IO.Error (Except Mock.Core.Error SecureConn))) := + Conc.Wrap.spawn (p.handshake role c left).run + +/-- The answer of a handshake task, or a failed run. -/ +private def awaitHandshake (t : Task (Except IO.Error (Except Mock.Core.Error SecureConn))) : + Async (Except Mock.Core.Error SecureConn) := do + match ← Conc.Wrap.await t with + | .ok rr => return rr + | .error e => throw e + +/-- A responder whose payload signs another static key: the initiator +fails with `authentication`, and message 2 never reaches the wire. The +responder runs the core directly, since a seam value always signs its +own static key. -/ +private def simBadSignature (r : Runner) : Async Unit := do + let name := "noise sim bad signature" + let cfgI ← cfgOf name 3 5 + let sR ← key32 name (toHex (fill 32 6)) + let other ← x25519Pub name (← key32 name (toHex (fill 32 9))) + let bad ← payloadOf name (← identity name 4) other + let (c, s) ← pair + let t ← spawnHandshake (Noise.securityProtocol cfgI) .initiator c + let some m0 ← s.read | throw <| IO.userError s!"{name}: no message 0" + let st ← expectOk name (Noise.State.init .responder .empty sR) + let (_, a) ← expectOk name + (stepAll st [.entropy (← key32 name (toHex (fill 32 8))), .«local» ⟨bad⟩, .bytes (body ⟨m0⟩)]) + let [.payload _ none, .write m1] := a | throw <| IO.userError s!"{name}: message 1" + s.write m1.bytes + r.check s!"{name}: the initiator fails with an authentication error" + (match ← awaitHandshake t with | .error (.authentication _) => true | _ => false) + r.check s!"{name}: message 2 never reaches the wire, and the connection ends" + ((← s.read).isNone) + +/-- A write after the peer has closed throws, as `Connection.write` +promises, and the join still answers an orderly end. -/ +private def simWriteAfterClose (r : Runner) : Async Unit := do + let name := "noise sim write after close" + let cfgI ← cfgOf name 3 5 + let cfgR ← cfgOf name 4 6 + let (c, s) ← pair + let (ri, rr) ← both (Noise.securityProtocol cfgI) (Noise.securityProtocol cfgR) c s + let ci ← secured name ri + let cr ← secured name rr + cr.close + r.check s!"{name}: the initiator reads the end" ((← ci.read).isNone) + let threw ← try ci.write "late".toUTF8; pure false catch _ => pure true + r.check s!"{name}: the write throws" threw + r.check s!"{name}: both joins answer an orderly end" ((← joinsOk ci) && (← joinsOk cr)) + +/-- A fault of a connection read after the handshake: the reader sees +the end, and the join throws the fault (§3.5). The initiator's read 0 +is message 1; every read after it throws. -/ +private def simReadFault (r : Runner) : Async Unit := do + let name := "noise sim read fault" + let cfgI ← cfgOf name 3 5 + let cfgR ← cfgOf name 4 6 + let (c, s) ← pair + let reads : OnceRef Nat ← OnceRef.new 0 + let c' : Connection := { c with + read := do + let n ← (reads.updateGet fun n => (n, n + 1) : BaseIO Nat) + if n == 0 then c.read else throw <| IO.userError "injected read fault" } + let (ri, rr) ← both (Noise.securityProtocol cfgI) (Noise.securityProtocol cfgR) c' s + let ci ← secured name ri + let cr ← secured name rr + r.check s!"{name}: the reader sees the end" ((← ci.read).isNone) + ci.close + let thrown ← try discard ci.join.run; pure false catch _ => pure true + r.check s!"{name}: the join throws the fault" thrown + cr.close + discard <| joinsOk cr + +/-- A write whose frames go out after the loop has ended answers `.ok`. +The initiator's reads end once the test closes `fin`, as after a peer +FIN, while its writes still reach the peer. The write's frames wait at +`gate` until the teardown has run, and then go out. -/ +private def simWriteAcrossEnd (r : Runner) : Async Unit := do + let name := "noise sim write across the end" + let cfgI ← cfgOf name 3 5 + let cfgR ← cfgOf name 4 6 + let (c, s) ← pair + let fin : Conc.Chan Unit ← Conc.Chan.new 1 + let gate : Conc.Chan Unit ← Conc.Chan.new 1 + -- One slot per late write: the write says it reached the gate. + let atGate : Conc.Chan Unit ← Conc.Chan.new 4 + let reads : OnceRef Nat ← OnceRef.new 0 + let writes : OnceRef Nat ← OnceRef.new 0 + let c' : Connection := { c with + read := do + let n ← (reads.updateGet fun n => (n, n + 1) : BaseIO Nat) + -- Read 0 is message 1. After it, park until `fin` closes: the end. + if n == 0 then c.read else do discard fin.recv; return none + write := fun b => do + let n ← (writes.updateGet fun n => (n, n + 1) : BaseIO Nat) + -- Writes 0 and 1 are handshake messages; later ones wait at the gate. + if n ≥ 2 then + discard (atGate.trySend () : BaseIO Bool) + discard gate.recv + c.write b } + let (ri, rr) ← both (Noise.securityProtocol cfgI) (Noise.securityProtocol cfgR) c' s + let ci ← secured name ri + let cr ← secured name rr + let t ← (Conc.Wrap.spawn (ci.write "late".toUTF8) : BaseIO (Task (Except IO.Error Unit))) + -- The write's frames reach the gate before the reads end, so the + -- loop took the write before it shut down. + discard atGate.recv + -- The end of the reads: the loop shuts down, and the teardown closes + -- the inbox, so this read answers `none` only after the teardown. + fin.close + r.check s!"{name}: the reader sees the end" ((← ci.read).isNone) + gate.close + r.check s!"{name}: the write answers .ok, since its frames went out" + (match ← Conc.Wrap.await t with | .ok () => true | .error _ => false) + r.check s!"{name}: the peer reads the bytes" ((← readN cr 4) == "late".toUTF8) + ci.close + cr.close + r.check s!"{name}: both joins answer an orderly end" ((← joinsOk ci) && (← joinsOk cr)) + +/-- The peer ends the connection inside the handshake: the initiator +fails with `closed`. -/ +private def simHandshakeEof (r : Runner) : Async Unit := do + let name := "noise sim handshake eof" + let cfgI ← cfgOf name 3 5 + let (c, s) ← pair + let t ← spawnHandshake (Noise.securityProtocol cfgI) .initiator c + let some _ ← s.read | throw <| IO.userError s!"{name}: no message 0" + s.close + r.check s!"{name}: the initiator fails with closed" + (match ← awaitHandshake t with | .error (.closed _) => true | _ => false) + +/-- The transport core at its edges: the end of the connection, a read +that completes no frame, and a cipher state at the reserved nonce +(owned contract 6). -/ +private def transportEdges (r : Runner) : IO Unit := do + let name := "noise transport core" + let k ← key32 name (toHex (fill 32 1)) + let st : Noise.Transport.State := ⟨.ofKey k, .ofKey k, ⟨.empty⟩⟩ + r.check s!"{name}: an end between frames shuts down" + (match Noise.Transport.step st .eof with | .ok (_, [.shutdown]) => true | _ => false) + -- A prefix of 5 and one body byte: no frame is whole yet. + let short : ByteArray := ⟨#[0, 5, 1]⟩ + let (st', a) ← expectOk name (Noise.Transport.step st (.bytes ⟨short⟩)) + r.check s!"{name}: a read that completes no frame returns its credit" (a == [.credit]) + r.check s!"{name}: an end inside a frame is truncatedFrame" + (failsWith (.truncatedFrame 3) (Noise.Transport.step st' .eof)) + let spent : Noise.CipherState := ⟨some k, Noise.reservedNonce⟩ + r.check s!"{name}: a seal at the reserved nonce fails" + (failsWith .nonceExhausted (Noise.Transport.sealFrames spent (fill 3 0))) + r.check s!"{name}: a write at the reserved nonce fails the step, so the loop tears down" + (failsWith .nonceExhausted (Noise.Transport.step { st with send := spent } (.write 0 ⟨fill 3 0⟩))) + -- The seam arms of the payload check and of the end (owned contract 4). + let authArms := [Noise.Error.badSignature, .missingIdentity, .identity .publicMismatch, + .payloadTooLarge (Noise.maxPayload + 1), .payload .badField] + r.check s!"{name}: every failure of the payload check maps to authentication" + (authArms.all fun e => match e.toCore with | .authentication _ => true | _ => false) + r.check s!"{name}: an open failure maps to protocol" + (match Noise.Error.decrypt.toCore with | .protocol _ => true | _ => false) + +/-- The pure chunking: three frames, at the cap, at the cap, and the +rest. -/ +private def chunking (r : Runner) : IO Unit := do + let name := "noise chunking" + let k ← key32 name (toHex (fill 32 1)) + let size := 2 * Noise.maxPlaintext + 5 + let (_, wire) ← expectOk name (Noise.Transport.sealFrames (.ofKey k) (fill size 7)) + let mut lens : Array Nat := #[] + let mut rest := wire + while !rest.isEmpty do + match Streams.Framing.headerB? .u16be Noise.maxMessage rest with + | .ok (len, hsize) => + lens := lens.push len + rest := rest.extract (hsize + len) rest.size + | .error _ => break + r.check s!"{name}: {size} bytes go out as frames of 65535, 65535, and 21 bytes" + (lens == #[Noise.maxMessage, Noise.maxMessage, 5 + Crypto.aeadTagLen]) + let (_, empty) ← expectOk name (Noise.Transport.sealFrames (.ofKey k) .empty) + r.check s!"{name}: an empty write takes no frame" empty.isEmpty + +private def sim (r : Runner) : IO Unit := do + chunking r + transportEdges r + for (label, act) in [("round trip", simRoundTrip r false), ("split round trip", simRoundTrip r true), + ("corrupt", simCorrupt r), ("large write", simLargeWrite r), ("fixed keys", simFixedKey r), + ("leftover", simLeftover r), ("bad signature", simBadSignature r), + ("handshake eof", simHandshakeEof r), ("write after close", simWriteAfterClose r), ("read fault", simReadFault r), + ("write across the end", simWriteAcrossEnd r)] do + try Conc.Async.run act + catch e => r.check s!"noise sim {label}: runs ({e})" false + +end Sim + +/-! ## The constants -/ + +-- The frame cap is the largest length the 2-byte prefix carries, so +-- every frame header a read takes is within the cap before the payload +-- allocates (owned contract 8). +example : Noise.maxMessage = Streams.Framing.Prefix.maxLen .u16be := rfl + +-- The protocol name is exactly 32 bytes, so `InitializeSymmetric` +-- takes it as `h` with no padding and no hash. +#guard Noise.protocolName.toUTF8.size = 32 +def Noise.run (r : Runner) : IO Unit := do + r.section "noise" "flynn/noise vectors.txt; go-libp2p p2p/security/noise" + -- The plaintext cap leaves room for the tag inside the frame cap. The + -- tag length reads the shim through `@[extern]`, so neither the kernel + -- nor the build-time interpreter can evaluate it; the test binary, + -- which links the shim, checks it. + r.check "the plaintext cap and the tag fill the frame cap" + (Noise.maxPlaintext + Crypto.aeadTagLen == Noise.maxMessage) + flynn r + goTranscript r + payloadChecks r + libp2p r + hostile r + sim r + +end Tests diff --git a/VECTORS.lock b/VECTORS.lock index d29b6362..6b8b24ec 100644 --- a/VECTORS.lock +++ b/VECTORS.lock @@ -105,8 +105,8 @@ dir = "vendor/nim-libp2p" repo = "https://github.com/vacp2p/nim-libp2p" commit = "3dce083d1c9900d4d98878faaffe5c872b65ec48" branch = "master" -paths = ["/libp2p/stream/chronosstream.nim", "/libp2p/stream/lpstream.nim", "/libp2p/muxers/yamux/yamux.nim", "/tests/libp2p/test_peer_id.nim", "/LICENSE*"] -used-for = "chronosstream.nim and lpstream.nim: a TCP read error as EOF, and the remote reset as a kind of EOF (net/streams, the peer's reset); yamux.nim: the reset that clears the unread bytes (net/yamux); test_peer_id.nim: the peer id texts of peerid-text-nim.json (libp2p/identity)" +paths = ["/libp2p/stream/chronosstream.nim", "/libp2p/stream/lpstream.nim", "/libp2p/muxers/yamux/yamux.nim", "/libp2p/protocols/secure/noise.nim", "/tests/libp2p/test_peer_id.nim", "/LICENSE*"] +used-for = "chronosstream.nim and lpstream.nim: a TCP read error as EOF, and the remote reset as a kind of EOF (net/streams, the peer's reset); yamux.nim: the reset that clears the unread bytes (net/yamux); noise.nim: the static key set before message 0, the dropped payload of message 0, the payload decode, the reads on demand, and the joined frames of one write (libp2p/security); test_peer_id.nim: the peer id texts of peerid-text-nim.json (libp2p/identity)" [[vendor]] dir = "vendor/rust-yamux" @@ -121,8 +121,16 @@ dir = "vendor/js-libp2p" repo = "https://github.com/libp2p/js-libp2p" commit = "fab409544d968c90184f515c842755a0e819a372" branch = "main" -paths = ["/packages/stream-multiplexer-yamux/src/stream.ts", "/LICENSE*"] -used-for = "the yamux stream's data after the peer's FIN (net/yamux)" +paths = ["/packages/stream-multiplexer-yamux/src/stream.ts", "/packages/connection-encrypter-noise/src/performHandshake.ts", "/packages/connection-encrypter-noise/src/utils.ts", "/LICENSE*"] +used-for = "stream.ts: the yamux stream's data after the peer's FIN (net/yamux); performHandshake.ts and utils.ts: the payload check below the Noise state, its order before message 3, the dropped payload of message 0, and the payload decode (libp2p/security)" + +[[vendor]] +dir = "vendor/rust-libp2p" +repo = "https://github.com/libp2p/rust-libp2p" +commit = "c449090f49b5ae2338e8f0727be98e0a739ba135" +branch = "master" +paths = ["/transports/noise/src/io.rs", "/transports/noise/src/io/handshake.rs", "/transports/noise/src/io/framed.rs", "/LICENSE*"] +used-for = "io.rs: the one decrypted frame held ahead of the reader; io/handshake.rs: the read buffer that the handshake hands to the transport, and the refused payload of message 0; io/framed.rs: the payload decode with no limit below the frame (libp2p/security)" [[vendor]] dir = "vendor/lean4" diff --git a/lakefile.toml b/lakefile.toml index 507a62f1..ff4e9584 100644 --- a/lakefile.toml +++ b/lakefile.toml @@ -156,6 +156,7 @@ roots = ["Libp2p.Core"] name = "Libp2pSecurity" srcDir = "packages/libp2p/security" roots = ["Libp2p.Security"] +globs = ["Libp2p.Security.*"] [[lean_lib]] name = "Libp2pMux" @@ -201,3 +202,10 @@ root = "Tests.Main" [[lean_exe]] name = "yamux-interop" root = "Tests.Interop.Yamux" + +# The Lean side of the Noise interop check against go-libp2p's Noise +# transport; run it through `scripts/noise-interop.sh`. Like +# `yamux-interop`, it is not part of `lake test`. +[[lean_exe]] +name = "noise-interop" +root = "Tests.Interop.Noise" diff --git a/packages/libp2p/security/Libp2p/Security.lean b/packages/libp2p/security/Libp2p/Security.lean index 8d8c7d2f..21037854 100644 --- a/packages/libp2p/security/Libp2p/Security.lean +++ b/packages/libp2p/security/Libp2p/Security.lean @@ -1,7 +1,14 @@ +import Libp2p.Security.Constants +import Libp2p.Security.Handshake +import Libp2p.Security.Payload +import Libp2p.Security.Transport +import Libp2p.Security.Noise + /-! # Libp2p.Security -The security subpackage: Noise XX as a `SecurityProtocol` value (ARCHITECTURE §4.9). Scaffold root. -The modules arrive with unit W5.3 of `docs/PLAN.md`. The package docs -live under `docs/` beside this file. +The security subpackage: Noise XX as a `SecurityProtocol` value +(ARCHITECTURE §4.9). Unit 1a of W5.3 holds the constants, the XX core, +and the payload check; unit 1b adds the transport and the seam values. +The package docs live under `docs/` beside this file. -/ diff --git a/packages/libp2p/security/Libp2p/Security/Constants.lean b/packages/libp2p/security/Libp2p/Security/Constants.lean new file mode 100644 index 00000000..0e7a6a2d --- /dev/null +++ b/packages/libp2p/security/Libp2p/Security/Constants.lean @@ -0,0 +1,85 @@ +import Crypto +import Libp2p.Security.Mock.Core + +/-! +# The Noise constants + +The numbers and strings of the noise-libp2p spec (`vendor/specs/noise`) +that this package uses, each with one home (PRINCIPLES 1.5). The +spec fixes the protocol id, the Noise protocol name, the 2-byte frame +length, the signature prefix, and the payload field numbers. The 8 KiB +payload cap comes from the package ARCHITECTURE, owned contract 2. The +two queue sizes of the transport come from the rust and nim read paths +and from the count of the posters; each docstring says which. +-/ + +set_option autoImplicit false + +namespace Libp2p.Security.Noise + +/-- The id that multistream-select negotiates for Noise. -/ +def protocolId : Mock.Core.ProtocolId := ⟨"/noise"⟩ + +/-- The Noise protocol name, the one cipher suite of the spec ("Noise +Protocol Name"). It is exactly 32 bytes long, so the symmetric state +takes it as `h` with no hash and no padding. -/ +def protocolName : String := "Noise_XX_25519_ChaChaPoly_SHA256" + +/-- The largest Noise message: the 2-byte big-endian length prefix +carries at most 65535 ("Wire Format"). -/ +def maxMessage : Nat := 65535 + +/-- The largest plaintext of one transport message. The Poly1305 tag +counts against `maxMessage`, so a writer chunks plaintext at this size +(owned contract 3). -/ +def maxPlaintext : Nat := maxMessage - Crypto.aeadTagLen + +/-- The largest handshake payload the decoder accepts: 8 KiB (root +§5.1). The spec and the references set no limit below `maxMessage` +(package ARCHITECTURE, owned contract 2, note). -/ +def maxPayload : Nat := 8192 + +/-- The length of an X25519 public key, the `e` and `s` tokens on the +wire (RFC 7748 §5). -/ +def dhLen : Nat := 32 + +/-- The nonce the Noise spec reserves: a cipher state with this nonce +refuses to encrypt or decrypt (Noise spec §5.1). -/ +def reservedNonce : Nat := 2 ^ 64 - 1 + +/-- The prefix of the signed data: the UTF-8 string, then the static +Noise public key ("The libp2p Handshake Payload"). -/ +def staticKeyPrefix : String := "noise-libp2p-static-key:" + +/-! ## The transport queues -/ + +/-- The decrypted chunks that wait for the reader of a secure +connection: one. rust-libp2p holds one decrypted frame in +`Output.recv_buffer` and decrypts the next one only when the reader has +drained it (`vendor/rust-libp2p/transports/noise/src/io.rs`, lines 73 +to 94). nim-libp2p decrypts in `readMessage`, on the reader's demand +(`vendor/nim-libp2p/libp2p/protocols/secure/noise.nim`, line 450). Here +one chunk is the plaintext of one connection read, so the transport +reads no further ahead than one read. -/ +def inboxChunks : Nat := 1 + +/-- The events that wait for the transport loop. The posters are the +reader, which holds at most `inboxChunks` credits, so it posts at most +one read at a time, and the writers of the secure connection, each +parked on its own write. The one muxer of the stack writes from one +task (the yamux ARCHITECTURE, "Three tasks, and one writer"). So the +queue holds both posters, and a third poster parks. -/ +def eventQueueCap : Nat := inboxChunks + 1 + +/-! ## The `NoiseHandshakePayload` message -/ + +/-- `optional bytes identity_key = 1`. -/ +def identityKeyField : Nat := 1 + +/-- `optional bytes identity_sig = 2`. -/ +def identitySigField : Nat := 2 + +/-- `optional NoiseExtensions extensions = 4`. -/ +def extensionsField : Nat := 4 + +end Libp2p.Security.Noise diff --git a/packages/libp2p/security/Libp2p/Security/Handshake.lean b/packages/libp2p/security/Libp2p/Security/Handshake.lean new file mode 100644 index 00000000..aae9f0c4 --- /dev/null +++ b/packages/libp2p/security/Libp2p/Security/Handshake.lean @@ -0,0 +1,546 @@ +import Crypto +import Bytes +import Proto +import Streams.Framing +import Libp2p.Security.Mock.Identity +import Libp2p.Security.Mock.Core +import Libp2p.Security.Constants + +/-! +# The Noise XX core + +The XX handshake of the Noise spec, as a pure state machine (root +ARCHITECTURE §3.3): + +``` +step : State → Event → Except Error (State × List Action) +``` + +``` +XX: + -> e + <- e, ee, s, es + -> s, se +``` + +## Two layers, as the specs split them + +The Noise spec defines the handshake over opaque payloads and a +prologue. The noise-libp2p spec puts the libp2p payload inside the +second and third messages, and says: "Upon receiving the handshake +payload, peers MUST decode the public key … The key MUST then be used +to validate the `identity_sig` field." This module is the Noise layer. +It hands each decrypted payload out as `Action.payload`, and +`Libp2p.Security.Payload` checks it. go-libp2p and js-libp2p split the +work the same way (the package ARCHITECTURE, "The core shape"). The +flynn vectors carry raw payloads and a prologue, so they test this +layer alone. + +The shell performs the actions in order. On the initiator, the payload +of message 2 comes out before the write of message 3, so the shell +checks it and tears the connection down before message 3 reaches the +wire, as go and js do (the same section). + +## The interface follows flynn's + +flynn's `HandshakeState` (`vendor/flynn-noise/state.go`) takes the +prologue and the static keypair in `NewHandshakeState`, one payload per +`WriteMessage`, and one whole message per `ReadMessage`. A call out of +turn is an error there. js and nim also set the static key when they +build the handshake state (the package ARCHITECTURE, "The core shape"). +So `State.init` takes the role, the prologue +(empty for libp2p), and the static key. The events are: + +* `bytes`: one whole Noise message, which the shell read through the + 2-byte length prefix. A message out of turn is `unexpectedEvent`. +* `entropy`: the 32 bytes of the ephemeral private key, from the `Rng` + seam of the facade (§3.6, §3.3: randomness is an input). +* `local`: the marshaled payload of our next handshake message. + +The bytes the shell read past the last message stay in its framed +reader, and the transport reads them next (§4.7). rust-libp2p keeps +them the same way: `map_into_transport` hands the read buffer of the +handshake's `Framed` to the transport +(`vendor/rust-libp2p/transports/noise/src/io/handshake.rs`, line 157). + +The actions: `write` a framed message, hand out a decrypted remote +`payload` with the remote static key once it is known, and `complete` +the handshake with the two cipher states of `split` and the remote +static key. The shell learns of the state only from the actions (§3.3), +so the payload carries the key that its check needs. + +The core has no tick. The upgrader owns the deadline (owned contract +7). + +## The keys by phase + +`Phase` names the next step of each role and holds the keys that the +step needs. A DH of the pattern therefore never looks up a key that may +be absent: the type of the phase carries it. +-/ + +set_option autoImplicit false + +namespace Libp2p.Security.Noise + +-- `Role` and the identity error come from the mocks of W5.2 and W5.1 +-- (RULES.md, "Working ahead of the tree"). +open Bytes Libp2p.Security.Mock.Core + +/-! ## Data types + +The core's bytes sit in `Bytes.Blob`, the shared wrapper with a `Repr` +(§3.2). -/ + +/-- Exactly 32 bytes, for core data: a key, the chaining key, or the +handshake hash. The backend type `Crypto.Key32` has no `Repr`, and the +instance belongs with that type (§3.2), so the core wraps it. -/ +structure Bytes32 where + /-- The bytes, with their size. -/ + key : Crypto.Key32 + +namespace Bytes32 + +/-- The bytes. -/ +def bytes (k : Bytes32) : ByteArray := k.key.bytes + +/-- The bytes as a `Bytes32`. `none` when the size is not 32. -/ +def ofByteArray? (b : ByteArray) : Option Bytes32 := + (Crypto.Key32.ofByteArray? b).map Bytes32.mk + +instance : BEq Bytes32 := ⟨fun a b => a.bytes == b.bytes⟩ + +/-- The `Repr` that §3.3 asks of core data. It prints the bytes, keys +included. -/ +instance : Repr Bytes32 where + reprPrec k _ := s!"Noise.Bytes32 {toHex k.bytes}" + +end Bytes32 + +/-- How a Noise handshake or payload check fails: the one error +inductive of the package (§3.5). The seam value maps each arm into +`Core.Error` (owned contract 1, contract 4). -/ +inductive Error where + /-- An AEAD open failed: the message was changed, or it was sealed + under another key. A protocol error, distinct from EOF (owned + contract 1). -/ + | decrypt + /-- Another crypto failure: an X25519 peer key of small order + (`invalidKey`), or a library fault. -/ + | crypto (e : Crypto.CryptoError) + /-- A handshake message shorter than its tokens need: the size, and + the size the next token needs. -/ + | shortMessage (size need : Nat) + /-- A message longer than `maxMessage`. -/ + | messageTooLarge (size : Nat) + /-- A cipher state reached the reserved nonce `2 ^ 64 - 1`. -/ + | nonceExhausted + /-- An event the phase does not take: a message out of turn, an + entropy the phase does not need, or a payload when no write of ours + is left or one already waits. -/ + | unexpectedEvent + /-- An event arrived after the handshake completed. -/ + | finished + /-- The connection ended before the handshake completed. The shell + reports it; the core never sees the end of the connection. -/ + | closed + /-- The connection ended inside a transport frame: the bytes of the + frame that had arrived. go-libp2p reads each frame with `io.ReadFull`, + which fails with `io.ErrUnexpectedEOF` here + (`vendor/go-libp2p/p2p/security/noise/rw.go`, lines 130 and 144), so only an end + between frames is orderly. -/ + | truncatedFrame (size : Nat) + /-- A handshake payload over `maxPayload`, refused before the decode + (owned contract 2). -/ + | payloadTooLarge (size : Nat) + /-- The `NoiseHandshakePayload` protobuf did not decode. -/ + | payload (e : Pb.Error) + /-- The payload holds no `identity_key` or no `identity_sig`. -/ + | missingIdentity + /-- The identity key did not unmarshal, or no peer id derives from + it. -/ + | identity (e : Mock.Identity.Error) + /-- The signature does not verify over the static key of the + handshake (owned contract 4). -/ + | badSignature + deriving BEq, Repr + +/-! ## The cipher state -/ + +/-- The Noise `CipherState`: a key, once one is mixed in, and the nonce +of the next message. -/ +structure CipherState where + /-- The key. `none` before the first `MixKey`: then encryption is the + identity function. -/ + key : Option Bytes32 + /-- The nonce of the next message. -/ + nonce : Nat + deriving BEq, Repr + +namespace CipherState + +/-- The cipher state with no key. -/ +def empty : CipherState := ⟨none, 0⟩ + +/-- A fresh cipher state under a key. -/ +def ofKey (k : Bytes32) : CipherState := ⟨some k, 0⟩ + +/-- The ChaChaPoly nonce of the Noise spec: 4 zero bytes, then the +counter as 8 bytes little-endian. `Nat.toUInt8` keeps the low 8 bits, +so each entry is one byte of the counter. The array literal has 12 +entries, so `rfl` proves the size. -/ +def nonceBytes (n : Nat) : Crypto.Nonce12 := + let b (i : Nat) : UInt8 := (n >>> (8 * i)).toUInt8 + ⟨⟨#[0, 0, 0, 0, b 0, b 1, b 2, b 3, b 4, b 5, b 6, b 7]⟩, rfl⟩ + +-- `ByteArray.toList` recurses by well-founded recursion, which the +-- kernel does not unfold, so the check runs through the evaluator. +#guard (nonceBytes 258).bytes.toList = [0, 0, 0, 0, 2, 1, 0, 0, 0, 0, 0, 0] + +/-- `EncryptWithAd`: seal under the key and the nonce, then step the +nonce. With no key, the plaintext passes unchanged. -/ +def encryptWithAd (cs : CipherState) (ad pt : ByteArray) : Except Error (CipherState × ByteArray) := + match cs.key with + | none => .ok (cs, pt) + | some k => + if cs.nonce ≥ reservedNonce then .error .nonceExhausted + else do + let ct ← (Crypto.chachaSeal k.key (nonceBytes cs.nonce) ad pt).mapError .crypto + return ({ cs with nonce := cs.nonce + 1 }, ct) + +/-- `DecryptWithAd`: open under the key and the nonce, then step the +nonce. A tag that does not verify is `decrypt`. -/ +def decryptWithAd (cs : CipherState) (ad ct : ByteArray) : Except Error (CipherState × ByteArray) := + match cs.key with + | none => .ok (cs, ct) + | some k => + if cs.nonce ≥ reservedNonce then .error .nonceExhausted + else do + let pt ← (Crypto.chachaOpen k.key (nonceBytes cs.nonce) ad ct).mapError fun + | .authFailed => .decrypt + | e => .crypto e + return ({ cs with nonce := cs.nonce + 1 }, pt) + +end CipherState + +/-! ## The symmetric state -/ + +/-- The Noise `SymmetricState`: the cipher state, the chaining key +`ck`, and the handshake hash `h`. -/ +structure SymmetricState where + /-- The cipher state of the handshake messages. -/ + cs : CipherState + /-- The chaining key. -/ + ck : Bytes32 + /-- The handshake hash. -/ + h : Bytes32 + deriving BEq, Repr + +/-- The 32 bytes that a facade call answered. The facade types the +answer as `ByteArray`, so the size is checked once here, where the +bytes cross the FFI (§3.7). A wrong size is a library fault. -/ +private def toBytes32 (b : ByteArray) : Except Error Bytes32 := + match Bytes32.ofByteArray? b with + | some k => .ok k + | none => .error (.crypto .fault) + +/-- SHA-256, the `HASH` of the cipher suite. -/ +private def hash (b : ByteArray) : Except Error Bytes32 := do + toBytes32 (← (Crypto.sha256 b).mapError .crypto) + +/-- The Noise `HKDF` with two outputs. The Noise spec defines it as +HMAC-SHA-256 steps over the chaining key, and they are RFC 5869 with +`ck` as the salt, an empty info, and 64 bytes of output. -/ +private def hkdf2 (ck : Bytes32) (ikm : ByteArray) : Except Error (Bytes32 × Bytes32) := do + let out ← (Crypto.hkdfSha256 ck.bytes ikm .empty 64).mapError .crypto + return (← toBytes32 (out.extract 0 32), ← toBytes32 (out.extract 32 64)) + +/-- The X25519 shared secret of a private and a public key. -/ +private def dh (priv pub : Bytes32) : Except Error ByteArray := + (Crypto.x25519 priv.key pub.key).map (·.bytes) |>.mapError .crypto + +namespace SymmetricState + +/-- `InitializeSymmetric`: `h` is the protocol name, zero-padded when +shorter than 32 bytes and hashed when longer, and `ck` is `h`. -/ +def init (name : ByteArray) : Except Error SymmetricState := do + let h ← if name.size ≤ 32 then + toBytes32 (name ++ ⟨Array.replicate (32 - name.size) 0⟩) + else hash name + return ⟨.empty, h, h⟩ + +/-- `MixHash`: `h` becomes `HASH(h ‖ data)`. -/ +def mixHash (ss : SymmetricState) (data : ByteArray) : Except Error SymmetricState := do + return { ss with h := ← hash (ss.h.bytes ++ data) } + +/-- `MixKey`: the KDF of `ck` and the input gives the new `ck` and a +fresh cipher key. -/ +def mixKey (ss : SymmetricState) (ikm : ByteArray) : Except Error SymmetricState := do + let (ck, k) ← hkdf2 ss.ck ikm + return { ss with ck, cs := .ofKey k } + +/-- A DH token (`ee`, `es`, `se`): `MixKey` of the shared secret. -/ +def mixDh (ss : SymmetricState) (priv pub : Bytes32) : Except Error SymmetricState := do + ss.mixKey (← dh priv pub) + +/-- `EncryptAndHash`: encrypt with `h` as the associated data, then mix +the ciphertext into `h`. -/ +def encryptAndHash (ss : SymmetricState) (pt : ByteArray) : + Except Error (SymmetricState × ByteArray) := do + let (cs, ct) ← ss.cs.encryptWithAd ss.h.bytes pt + let ss ← { ss with cs }.mixHash ct + return (ss, ct) + +/-- `DecryptAndHash`: decrypt with `h` as the associated data, then mix +the ciphertext into `h`. -/ +def decryptAndHash (ss : SymmetricState) (ct : ByteArray) : + Except Error (SymmetricState × ByteArray) := do + let (cs, pt) ← ss.cs.decryptWithAd ss.h.bytes ct + let ss ← { ss with cs }.mixHash ct + return (ss, pt) + +/-- `Split`: the KDF of `ck` and an empty input gives the two transport +keys. The first one protects the initiator's messages. -/ +def split (ss : SymmetricState) : Except Error (CipherState × CipherState) := do + let (k1, k2) ← hkdf2 ss.ck .empty + return (.ofKey k1, .ofKey k2) + +end SymmetricState + +/-! ## The handshake state -/ + +/-- The next step of the handshake, by role, with the keys it needs. +The initiator writes messages 0 and 2, and the responder writes +message 1. -/ +inductive Phase where + /-- The initiator writes message 0 (`e`). -/ + | initStart + /-- The initiator wrote message 0 with its ephemeral key, and reads + message 1. -/ + | initSentA (e : Bytes32) + /-- The initiator read message 1: the peer's ephemeral and static + keys. It writes message 2 (`s, se`). -/ + | initGotB (re rs : Bytes32) + /-- The responder reads message 0. -/ + | respStart + /-- The responder read message 0: the peer's ephemeral key. It writes + message 1 (`e, ee, s, es`). -/ + | respGotA (re : Bytes32) + /-- The responder wrote message 1 with its ephemeral key, and reads + message 2. -/ + | respSentB (e : Bytes32) + /-- The handshake completed. -/ + | done + deriving BEq, Repr + +namespace Phase + +/-- The phase still needs the ephemeral private key: before our `e` +token. -/ +def needsEntropy : Phase → Bool + | .initStart | .respStart | .respGotA _ => true + | _ => false + +/-- A write of ours is still ahead, so a payload may wait for it. -/ +def writesLeft : Phase → Bool + | .initStart | .initSentA _ | .initGotB _ _ | .respStart | .respGotA _ => true + | _ => false + +end Phase + +/-- The state of one handshake: the phase, the symmetric state, our +static keys, and the inputs that wait to be used. -/ +structure State where + /-- The next step, with the keys it needs. -/ + phase : Phase + /-- The symmetric state. -/ + sym : SymmetricState + /-- Our static private key. -/ + s : Bytes32 + /-- Our static public key. -/ + sPub : Bytes32 + /-- Our ephemeral private key, once the entropy arrived and before + the `e` token used it. -/ + entropy : Option Bytes32 + /-- The payload of our next message, once the shell posted it. -/ + payload : Option Blob + deriving BEq, Repr + +/-- An input of the core. -/ +inductive Event where + /-- One whole Noise message, without its length prefix. -/ + | bytes (msg : Blob) + /-- The ephemeral private key, 32 bytes from the `Rng` seam. -/ + | entropy (e : Bytes32) + /-- The marshaled payload of our next handshake message. `local` is a + Lean keyword, so the name takes guillemets, as `Muxer.«open»` + does. -/ + | «local» (p : Blob) + deriving BEq, Repr + +/-- The end of a handshake. -/ +structure Completion where + /-- The cipher state of our transport messages. -/ + send : CipherState + /-- The cipher state of the peer's transport messages. -/ + recv : CipherState + /-- The peer's static Noise key. The payload check verifies the + signature over it. -/ + remoteStatic : Bytes32 + deriving BEq, Repr + +/-- An output of the core. The shell performs the actions in order. -/ +inductive Action where + /-- Write the framed message to the connection. -/ + | write (frame : Blob) + /-- The peer's payload of one handshake message, decrypted, with the + peer's static key once the handshake has learned it. The shell checks + the payloads of messages 1 and 2 against that key before it performs + the next action; it learns of the state only from the actions (§3.3), + so the key travels here. Message 0 arrives before the peer's `s` + token, so its key is `none`. -/ + | payload (p : Blob) (remoteStatic : Option Bytes32) + /-- The handshake completed. -/ + | complete (c : Completion) + deriving BEq, Repr + +namespace State + +/-- The state before the first message: the protocol name and the +prologue mixed in. libp2p uses an empty prologue; the flynn vectors set +one. -/ +def init (role : Role) (prologue : ByteArray) (s : Bytes32) : Except Error State := do + let sym ← SymmetricState.init protocolName.toUTF8 + let sym ← sym.mixHash prologue + let sPub ← (Crypto.x25519PublicKey s.key).mapError .crypto + let phase := match role with + | .initiator => .initStart + | .responder => .respStart + return { phase, sym, s, sPub := ⟨sPub⟩, entropy := none, payload := none } + +/-! ### Tokens -/ + +/-- Write `e`: the public key of the ephemeral private key, in clear, +mixed into `h`. -/ +private def writeE (sym : SymmetricState) (e : Bytes32) : + Except Error (SymmetricState × ByteArray) := do + let pub ← (Crypto.x25519PublicKey e.key).mapError .crypto + return (← sym.mixHash pub.bytes, pub.bytes) + +/-- Read `e` at `pos`: the peer's ephemeral key, mixed into `h`. -/ +private def readE (sym : SymmetricState) (msg : ByteArray) (pos : Nat) : + Except Error (SymmetricState × Bytes32 × Nat) := do + let some re := Bytes32.ofByteArray? (msg.extract pos (pos + dhLen)) + | throw (.shortMessage msg.size (pos + dhLen)) + return (← sym.mixHash re.bytes, re, pos + dhLen) + +/-- Read `s` at `pos`. In XX, `s` always follows a `MixKey`, so it +arrives encrypted: the key and the tag. -/ +private def readS (sym : SymmetricState) (msg : ByteArray) (pos : Nat) : + Except Error (SymmetricState × Bytes32 × Nat) := do + let len := dhLen + Crypto.aeadTagLen + if msg.size < pos + len then throw (.shortMessage msg.size (pos + len)) + let (sym, pt) ← sym.decryptAndHash (msg.extract pos (pos + len)) + return (sym, ← toBytes32 pt, pos + len) + +/-- End a message we write: the encrypted payload after the token +bytes, then the 2-byte length prefix. -/ +private def finishWrite (sym : SymmetricState) (out payload : ByteArray) : + Except Error (SymmetricState × Blob) := do + let (sym, ct) ← sym.encryptAndHash payload + let msg := out ++ ct + let some frame := Streams.Framing.encodeB? .u16be maxMessage msg + | throw (.messageTooLarge msg.size) + return (sym, ⟨frame⟩) + +/-- End a message we read: the payload is the rest after `pos`. -/ +private def finishRead (sym : SymmetricState) (msg : ByteArray) (pos : Nat) : + Except Error (SymmetricState × Blob) := do + let (sym, pt) ← sym.decryptAndHash (msg.extract pos msg.size) + return (sym, ⟨pt⟩) + +/-- `Split`, and the completion in our role. The initiator sends with +the first cipher state. -/ +private def complete (sym : SymmetricState) (initiator : Bool) (rs : Bytes32) : + Except Error Completion := do + let (c1, c2) ← sym.split + let (send, recv) := if initiator then (c1, c2) else (c2, c1) + return { send, recv, remoteStatic := rs } + +/-! ### Messages -/ + +/-- Write the message the phase writes, when its inputs are present. +No action while one is missing. -/ +private def tryWrite (st : State) : Except Error (State × List Action) := do + match st.phase, st.payload, st.entropy with + | .initStart, some p, some e => + -- Message 0: `e`. + let (sym, out) ← writeE st.sym e + let (sym, frame) ← finishWrite sym out p.bytes + return ({ st with phase := .initSentA e, sym, payload := none, entropy := none }, + [.write frame]) + | .respGotA re, some p, some e => + -- Message 1: `e, ee, s, es`. The responder's `es` is DH(s, re). + let (sym, out) ← writeE st.sym e + let sym ← sym.mixDh e re + let (sym, sCt) ← sym.encryptAndHash st.sPub.bytes + let sym ← sym.mixDh st.s re + let (sym, frame) ← finishWrite sym (out ++ sCt) p.bytes + return ({ st with phase := .respSentB e, sym, payload := none, entropy := none }, + [.write frame]) + | .initGotB re rs, some p, _ => + -- Message 2: `s, se`. The initiator's `se` is DH(s, re). + let (sym, sCt) ← st.sym.encryptAndHash st.sPub.bytes + let sym ← sym.mixDh st.s re + let (sym, frame) ← finishWrite sym sCt p.bytes + let c ← complete sym true rs + return ({ st with phase := .done, sym, payload := none }, [.write frame, .complete c]) + | _, _, _ => return (st, []) + +/-- Read the message the phase reads. A message in a phase that writes, +or after the end, is `unexpectedEvent`. -/ +private def read (st : State) (msg : ByteArray) : Except Error (State × List Action) := do + if msg.size > maxMessage then throw (.messageTooLarge msg.size) + match st.phase with + | .respStart => + -- Message 0: `e`, and a payload in clear. + let (sym, re, pos) ← readE st.sym msg 0 + let (sym, p) ← finishRead sym msg pos + return ({ st with phase := .respGotA re, sym }, [.payload p none]) + | .initSentA e => + -- Message 1: `e, ee, s, es`. The initiator's `es` is DH(e, rs). + let (sym, re, pos) ← readE st.sym msg 0 + let sym ← sym.mixDh e re + let (sym, rs, pos) ← readS sym msg pos + let sym ← sym.mixDh e rs + let (sym, p) ← finishRead sym msg pos + return ({ st with phase := .initGotB re rs, sym }, [.payload p (some rs)]) + | .respSentB e => + -- Message 2: `s, se`. The responder's `se` is DH(e, rs). + let (sym, rs, pos) ← readS st.sym msg 0 + let sym ← sym.mixDh e rs + let (sym, p) ← finishRead sym msg pos + let c ← complete sym false rs + return ({ st with phase := .done, sym }, [.payload p (some rs), .complete c]) + | _ => throw .unexpectedEvent + +end State + +/-- The transition function. An input is taken when the phase can use +it; then the core writes whatever its inputs allow. An event after the +completion is `finished`: the transport takes over then. -/ +def step (st : State) (ev : Event) : Except Error (State × List Action) := do + if st.phase == .done then throw .finished + match ev with + | .bytes m => + let (st, acts) ← st.read m.bytes + let (st, more) ← st.tryWrite + return (st, acts ++ more) + | .entropy e => + unless st.phase.needsEntropy && st.entropy.isNone do throw .unexpectedEvent + State.tryWrite { st with entropy := some e } + | .«local» p => + unless st.phase.writesLeft && st.payload.isNone do throw .unexpectedEvent + State.tryWrite { st with payload := some p } + +end Libp2p.Security.Noise diff --git a/packages/libp2p/security/Libp2p/Security/Mock/Core.lean b/packages/libp2p/security/Libp2p/Security/Mock/Core.lean new file mode 100644 index 00000000..0c516340 --- /dev/null +++ b/packages/libp2p/security/Libp2p/Security/Mock/Core.lean @@ -0,0 +1,84 @@ +import Conc +import Streams +import Libp2p.Security.Mock.Identity + +/-! +# Mock: the surface of `libp2p/core` that Noise uses + +W5.2 (`libp2p/core`, etheorem/leanp2p#15) has not merged, so this +package builds against a mock of its frozen surface (RULES.md, "Working +ahead of the tree"). The types are copies of the core ARCHITECTURE, +"Public surface": `ProtocolId`, `Role`, `Core.Error` with its four arms, +and the security seam, `SecureConn` and `SecurityProtocol`, with the +handshake signature of root §4.9. The mock imports nothing from the +real package, and it states no axiom. `PeerId` comes from the identity +mock beside it. + +At the integration, the package imports `Libp2p.Core`, drops +`open Libp2p.Security.Mock`, and the module goes. +-/ + +set_option autoImplicit false + +namespace Libp2p.Security.Mock.Core + +open Conc Streams Libp2p.Security.Mock.Identity + +/-- A protocol id: the exact text that multistream-select carries +(§3.4). -/ +structure ProtocolId where + /-- The id text, exactly as it travels on the wire. -/ + toString : String + deriving DecidableEq, Hashable, Ord, Repr + +instance : ToString ProtocolId := ⟨ProtocolId.toString⟩ + +/-- The two directions of a connection (§3.4). The initiator dialed, +and the responder accepted. -/ +inductive Role where + | initiator + | responder + deriving DecidableEq, Repr, Hashable + +/-- The coarse seam error (§3.5, §4.9). Each arm carries the rendered +error of the implementation. -/ +inductive Error where + | protocol (detail : String) + | authentication (detail : String) + | timeout (detail : String) + | closed (detail : String) + deriving DecidableEq, Repr + +/-- The log line of the error: the kind, then the rendered detail. -/ +def Error.render : Error → String + | .protocol d => s!"protocol error: {d}" + | .authentication d => s!"authentication failure: {d}" + | .timeout d => s!"timeout: {d}" + | .closed d => s!"closed: {d}" + +instance : ToString Error := ⟨Error.render⟩ + +/-- A connection after a security handshake: the secure `read`, +`write`, and `close`, the two peer ids the handshake proved, the +ciphertext the handshake over-read (§4.7), and the completion. -/ +structure SecureConn extends Connection where + /-- The peer id of our identity key. -/ + localPeer : PeerId + /-- The peer id the peer proved in the handshake. -/ + remotePeer : PeerId + /-- The ciphertext the handshake read past its last message. -/ + leftover : ByteArray + /-- Wait until the secure connection has ended, and answer how it + ended. -/ + join : ExceptT Error Async Unit + +/-- The security seam: one security protocol that multistream-select +can negotiate. -/ +structure SecurityProtocol where + /-- The id that multistream-select negotiates, such as `/noise`. -/ + id : ProtocolId + /-- Run the handshake in the given role over the connection. The + `ByteArray` holds the bytes the negotiation over-read. -/ + handshake : Role → Connection → ByteArray → ExceptT Error Async SecureConn + +end Libp2p.Security.Mock.Core diff --git a/packages/libp2p/security/Libp2p/Security/Mock/Identity.lean b/packages/libp2p/security/Libp2p/Security/Mock/Identity.lean new file mode 100644 index 00000000..44384861 --- /dev/null +++ b/packages/libp2p/security/Libp2p/Security/Mock/Identity.lean @@ -0,0 +1,154 @@ +import Bytes +import Proto +import Multiformats +import Crypto + +/-! +# Mock: the surface of `libp2p/identity` that Noise uses + +W5.1 (`libp2p/identity`, etheorem/leanp2p#11) has not merged, so this +package builds against a mock of its frozen surface (RULES.md, "Working +ahead of the tree"; the identity ARCHITECTURE, "Public surface"). The +mock imports nothing from the real package, and it states no axiom. + +The names and the shapes follow the identity ARCHITECTURE: `KeyType`, +`PubKey` with `unmarshal?`, `marshal`, `data`, and `verify`, `PrivKey` +with `ofData?`, `toPublic`, and `sign`, `PeerId` with `ofPubKey` and its +text form, and `Identity.Error`, a copy of the real error inductive. + +**Why the opaques have values.** The Noise suites sign and verify real +handshake payloads, and the go transcript checks the peer ids that +go-libp2p printed, so the mock computes. It covers Ed25519 alone, the +key type of every Noise suite and of go-libp2p's default identity: + +* the `Keys` protobuf of the peer-ids spec, field 1 the varint `Type` + and field 2 the `Data` bytes, with an Ed25519 `Data` of 32 bytes; +* the peer id: the marshaled key is 36 bytes, at most 42, so it + inlines as an identity multihash, and its text form is base58btc; +* signing and verification through the Ed25519 entries of the facade. + +Every other key type fails with `keyData`. The kernel sees no +definition, so no proof can unfold one. At the integration, the +package imports `Libp2p.Identity`, drops `open Libp2p.Security.Mock`, +and the module goes. +-/ + +set_option autoImplicit false + +namespace Libp2p.Security.Mock.Identity + +open Bytes + +/-- The key types of the peer-ids spec. -/ +inductive KeyType where + | rsa + | ed25519 + | secp256k1 + | ecdsa + deriving DecidableEq, Repr + +/-- The protobuf code of a key type (the peer-ids spec, `KeyType`). -/ +def KeyType.toCode : KeyType → Nat + | .rsa => 0 + | .ed25519 => 1 + | .secp256k1 => 2 + | .ecdsa => 3 + +/-- A failure of the identity package: a copy of the real arms. -/ +inductive Error where + | message (e : Pb.Error) + | missingField (field : Nat) + | badKeyType (code : Nat) + | keyData (t : KeyType) (size : Nat) + | publicMismatch + | crypto (e : Crypto.CryptoError) + | peerIdCodec (e : Multiformats.Error) + | notPeerShape + deriving Repr, BEq + +/-- A public key: its type and its `Data` bytes. -/ +structure PubKey where + keyType : KeyType + data : ByteArray + +instance : Repr PubKey where + reprPrec k _ := s!"PubKey {repr k.keyType} {toHex k.data}" + +/-- A private key: its type and the Ed25519 seed. -/ +structure PrivKey where + keyType : KeyType + seed : Crypto.Key32 + +/-- A peer id: the bytes of its multihash. -/ +structure PeerId where + bytes : ByteArray + +instance : BEq PeerId := ⟨fun a b => a.bytes == b.bytes⟩ + +instance : Repr PeerId where + reprPrec p _ := s!"PeerId {toHex p.bytes}" + +namespace Impl + +/-- The `Keys` protobuf of an Ed25519 key: field 1 (`Type`, varint 1), +then field 2 (`Data`, 32 bytes). -/ +def ed25519Header : ByteArray := ⟨#[0x08, 0x01, 0x12, 0x20]⟩ + +def marshal (k : PubKey) : ByteArray := ed25519Header ++ k.data + +def unmarshal? (b : ByteArray) : Except Error PubKey := + if b.size == 36 && b.extract 0 4 == ed25519Header then .ok ⟨.ed25519, b.extract 4 36⟩ + else .error (.keyData .ed25519 b.size) + +def verify (k : PubKey) (msg sig : ByteArray) : Bool := + match k.keyType with + | .ed25519 => Crypto.ed25519Verify k.data msg sig + | _ => false + +def privOfData? (t : KeyType) (d : ByteArray) : Except Error PrivKey := + match t, Crypto.Key32.ofByteArray? d with + | .ed25519, some seed => .ok ⟨.ed25519, seed⟩ + | _, _ => .error (.keyData t d.size) + +def toPublic (k : PrivKey) : Except Error PubKey := do + let pub ← (Crypto.ed25519PublicKey k.seed).mapError .crypto + return ⟨.ed25519, pub.bytes⟩ + +def sign (k : PrivKey) (msg : ByteArray) : ExceptT Error IO ByteArray := + ExceptT.mk (pure ((Crypto.ed25519Sign k.seed msg).mapError .crypto)) + +/-- The identity multihash of the marshaled key: code `0x00`, then the +length, then the bytes. -/ +def ofPubKey (k : PubKey) : Except Error PeerId := + let m := marshal k + .ok ⟨ByteArray.mk #[0x00, m.size.toUInt8] ++ m⟩ + +end Impl + +/-- The strict unmarshal of a marshaled `PublicKey`. -/ +opaque PubKey.unmarshal? : ByteArray → Except Error PubKey := Impl.unmarshal? + +/-- The deterministic marshaling of a public key. -/ +opaque PubKey.marshal : PubKey → ByteArray := Impl.marshal + +/-- Verify a signature of `msg` under the key. -/ +opaque PubKey.verify : PubKey → ByteArray → ByteArray → Bool := Impl.verify + +/-- The private key of a key type and its `Data` bytes. -/ +opaque PrivKey.ofData? : KeyType → ByteArray → Except Error PrivKey := Impl.privOfData? + +/-- The public key of a private key. -/ +opaque PrivKey.toPublic : PrivKey → Except Error PubKey := Impl.toPublic + +/-- Sign `msg` with the key. -/ +opaque PrivKey.sign : PrivKey → ByteArray → ExceptT Error IO ByteArray := Impl.sign + +/-- The peer id of a public key. -/ +opaque PeerId.ofPubKey : PubKey → Except Error PeerId := Impl.ofPubKey + +/-- The legacy base58btc text form of a peer id. -/ +opaque PeerId.toString : PeerId → String := fun p => Multiformats.Base58.encode p.bytes + +instance : ToString PeerId := ⟨PeerId.toString⟩ + +end Libp2p.Security.Mock.Identity diff --git a/packages/libp2p/security/Libp2p/Security/Noise.lean b/packages/libp2p/security/Libp2p/Security/Noise.lean new file mode 100644 index 00000000..a5f5a0b5 --- /dev/null +++ b/packages/libp2p/security/Libp2p/Security/Noise.lean @@ -0,0 +1,221 @@ +import Crypto +import Streams +import Libp2p.Security.Mock.Identity +import Libp2p.Security.Mock.Core +import Libp2p.Security.Constants +import Libp2p.Security.Handshake +import Libp2p.Security.Payload +import Libp2p.Security.Transport + +/-! +# The Noise seam values + +`securityProtocol` fills the `SecurityProtocol` seam of `libp2p/core` +with Noise XX (root ARCHITECTURE §4.9), and `fixedKeyProtocol` is the +test value beside it (§3.1). Both run one handshake shell over the core +of `Libp2p.Security.Handshake`, and they differ only in the `Rng` the +shell draws the ephemeral key from. + +## The handshake shell + +The shell builds our payload once, through `Noise.build` and the sign +dispatch of `libp2p/identity`. It posts the entropy and the payloads as +events, and reads each peer message through the `u16be` framed reader +of `Streams.Framing`, capped at `maxMessage` (owned contract 8). The +reader starts from the bytes the negotiation over-read (owned contract +5). The shell performs the actions in order, and checks each payload +that carries the peer's static key with `Noise.verify` before the next +action. So on the initiator a bad payload of message 1 ends the +handshake before message 2 reaches the wire, as go and js order it. + +The noise-libp2p spec puts the libp2p payload in the second and third +messages ("XX": "The second and third handshake messages include a +handshake payload"), so the initiator posts an empty payload for +message 0. The initiator's own payload goes in message 2, and the +responder's in message 1. The responder does not check what message 0 +carries. go-libp2p, js-libp2p, and nim-libp2p drop it the same way; +rust-libp2p refuses a payload that decodes to any field (the package +ARCHITECTURE, "The responder drops the payload of message 0"). + +## The end of the handshake + +The bytes the framed reader holds past the last message are transport +ciphertext. They seed the transport (`Transport.Session.new`), and the +`SecureConn` records them in `leftover`. A failed handshake closes the +connection, and the error reaches the caller mapped into `Core.Error` +(`Error.toCore`, owned contract 6 of `libp2p/core`). + +## The static key + +The Noise static key belongs to the seam value, so all handshakes of a +host share it. `Config.generate` draws it +from `Rng.system`; the test value takes a fixed one. +-/ + +set_option autoImplicit false + +namespace Libp2p.Security.Noise + +-- The keys, the peer id, and the seam types come from the mocks of +-- W5.1 and W5.2 (RULES.md, "Working ahead of the tree"). At the +-- integration, `Mock.Core` reads `Libp2p.Core` again. +open Libp2p.Security.Mock.Identity +open Conc (Async) +open Streams (Connection BufReader) + +/-- The seam error of a Noise error (root §3.5). Every failure of +`Noise.verify` is a failure of the static-key check, and owned contract +4 maps it to `authentication`: the spec's check decodes the identity key +and then validates the signature ("peers MUST decode the public key … +The key MUST then be used to validate"), and `verify` gates and decodes +the payload on the way. The end of the connection inside the handshake +is `closed`; every other failure breaks the spec, and is `protocol`. +The detail is the rendered Noise error. -/ +def Error.toCore (e : Error) : Mock.Core.Error := + let detail := s!"noise: {repr e}" + match e with + | .payloadTooLarge _ | .payload _ | .missingIdentity | .identity _ | .badSignature => + .authentication detail + | .closed => .closed detail + | _ => .protocol detail + +/-- What a Noise seam value holds: the identity key, which signs the +payload, and the Noise static private key. -/ +structure Config where + /-- The identity key of the host. -/ + identity : PrivKey + /-- The X25519 static private key of the handshakes. -/ + static : Bytes32 + +/-- 32 bytes from the `Rng`, as a key. -/ +private def draw32 (rng : Crypto.Rng) : ExceptT Error IO Bytes32 := do + let b ← (rng.bytes 32).adapt .crypto + let some k := Bytes32.ofByteArray? b | throw (.crypto .fault) + return k + +/-- A configuration with a fresh static key from `Rng.system`. -/ +def Config.generate (identity : PrivKey) : ExceptT Error IO Config := do + return { identity, static := ← draw32 Crypto.Rng.system } + +/-- An `ExceptT Error IO` step inside the shell. `Async` lifts `IO`, so +the bind inside the `do` block takes the step as it is. -/ +private def ofIO {α : Type} (x : ExceptT Error IO α) : ExceptT Error Async α := + ExceptT.mk do return (← x.run) + +/-- An `Except Error` result inside the shell. -/ +private def ofExcept {α : Type} (x : Except Error α) : ExceptT Error Async α := + ExceptT.mk (pure x) + +/-- What the shell has learned so far: the checked payload, and the +completion. -/ +private structure Learned where + verified : Option Verified := none + completion : Option Completion := none + +/-- Perform the actions of one step, in order. A payload that carries +the peer's static key is checked before the next action. -/ +private def performAll (conn : Connection) (l : Learned) (acts : List Action) : + ExceptT Error Async Learned := do + let mut l := l + for a in acts do + match a with + | .write f => conn.write f.bytes + | .payload p (some k) => l := { l with verified := some (← ofExcept (verify k p.bytes)) } + -- Message 0 carries no libp2p payload (module doc). + | .payload _ none => pure () + | .complete c => l := { l with completion := some c } + return l + +/-- The handshake shell. The answer holds the completion, the checked +payload, and the reader that holds the bytes past the last message. -/ +private def runHandshake (cfg : Config) (rng : Crypto.Rng) (role : Mock.Core.Role) + (conn : Connection) (leftover : ByteArray) : + ExceptT Error Async (Completion × Verified × BufReader) := do + let st0 ← ofExcept (State.init role .empty cfg.static) + let sPub ← ofExcept ((Crypto.x25519PublicKey cfg.static.key).mapError .crypto) + let payload ← ofIO (build cfg.identity ⟨sPub⟩) + let e ← ofIO (draw32 rng) + -- The local events: the entropy, then the payload of each message we + -- write (module doc). + let locals : List Event := match role with + | .initiator => [.entropy e, .«local» ⟨.empty⟩, .«local» ⟨payload⟩] + | .responder => [.entropy e, .«local» ⟨payload⟩] + let mut st := st0 + let mut l : Learned := {} + for ev in locals do + let (st', acts) ← ofExcept (step st ev) + st := st' + l ← performAll conn l acts + -- The peer's messages. XX reads one on the initiator and two on the + -- responder; the core refuses any other order. + let mut r := BufReader.new conn leftover + while l.completion.isNone do + -- Park: the peer's bytes wake the read, and so does the close of the + -- connection by the deadline of the caller (owned contract 7). + match ← Streams.Framing.read r .u16be maxMessage with + | .ok (some (msg, r')) => + r := r' + let (st', acts) ← ofExcept (step st (.bytes ⟨msg⟩)) + st := st' + l ← performAll conn l acts + | .ok none => throw .closed + -- Unreachable: `maxMessage` is the largest length two bytes carry, so + -- no header is over the cap, and the IO read never answers + -- `truncated` (`Streams.Framing.read`). Reaching the arm means that + -- assumption broke, which is a fault of ours, not a protocol + -- condition (root §3.5). + | .error e => + ofIO (ExceptT.lift (throw (IO.userError + s!"noise: Framing.read failed on a u16be prefix at cap {maxMessage}: {repr e}"))) + let some c := l.completion | throw .closed + -- Each role reads one message that carries the peer's static key. + let some v := l.verified | throw .missingIdentity + return (c, v, r) + +/-- The handshake of a seam value: the shell, then the transport over +the cipher states. A failure closes the connection before the answer +(root §3.5). -/ +private def handshakeWith (cfg : Config) (rng : Crypto.Rng) (role : Mock.Core.Role) + (conn : Connection) (leftover : ByteArray) : ExceptT Mock.Core.Error Async Mock.Core.SecureConn := + ExceptT.mk do + let shaken ← try (runHandshake cfg rng role conn leftover).run + catch e => do conn.closeQuietly; throw e + match shaken with + | .error e => + conn.closeQuietly + return .error e.toCore + | .ok (c, v, r) => + let localPeer ← match cfg.identity.toPublic.bind PeerId.ofPubKey with + | .ok p => pure p + | .error e => + conn.closeQuietly + return .error (Error.identity e).toCore + let s ← Transport.Session.new conn c r.leftover + return .ok { + read := s.read + write := s.write + close := s.close + localAddr := conn.localAddr + remoteAddr := conn.remoteAddr + localPeer + remotePeer := v.peer + leftover := r.leftover + join := ExceptT.mk do return (← s.join).mapError Error.toCore } + +/-- The production value: Noise with the configured keys, and the +ephemeral keys from `Rng.system`. -/ +def securityProtocol (cfg : Config) : Mock.Core.SecurityProtocol where + id := protocolId + handshake := handshakeWith cfg Crypto.Rng.system + +/-- The test value (§3.1): Noise with fixed keys. Each handshake draws +its ephemeral key from a fresh `Rng.seeded seed`, so with an Ed25519 +identity, whose signature is deterministic (§3.6), the same peer +behavior gives the same bytes on the wire. -/ +def fixedKeyProtocol (cfg : Config) (seed : ByteArray) : Mock.Core.SecurityProtocol where + id := protocolId + handshake role conn leftover := ExceptT.mk do + let rng ← Crypto.Rng.seeded seed + (handshakeWith cfg rng role conn leftover).run + +end Libp2p.Security.Noise diff --git a/packages/libp2p/security/Libp2p/Security/Payload.lean b/packages/libp2p/security/Libp2p/Security/Payload.lean new file mode 100644 index 00000000..9d0bd07d --- /dev/null +++ b/packages/libp2p/security/Libp2p/Security/Payload.lean @@ -0,0 +1,198 @@ +import Proto +import Libp2p.Security.Mock.Identity +import Libp2p.Security.Constants +import Libp2p.Security.Handshake + +/-! +# The libp2p handshake payload + +The second and third Noise messages carry a `NoiseHandshakePayload` +(the noise-libp2p spec, "The libp2p Handshake Payload"): + +```protobuf +message NoiseHandshakePayload { + optional bytes identity_key = 1; + optional bytes identity_sig = 2; + optional NoiseExtensions extensions = 4; +} +``` + +`identity_key` is a marshaled `PublicKey` (the peer-ids spec), and +`identity_sig` signs the string `noise-libp2p-static-key:` followed by +the static Noise public key. The spec says: "Upon receiving the +handshake payload, peers MUST decode the public key … The key MUST then +be used to validate the `identity_sig` field against the static Noise +key received in the handshake." `verify` does both, and gives the +`PeerId` the key derives. + +**The 8 KiB gate.** `decode?` refuses a payload over `maxPayload` +before the protobuf decode runs (owned contract 2, §5.1). + +**The extensions stay opaque** (the package ARCHITECTURE, "The +handshake extensions"). The decode keeps field 4 as bytes, and the +payload we build carries none. +-/ + +set_option autoImplicit false + +namespace Libp2p.Security.Noise + +-- The keys and the peer id come from the mock of W5.1 (RULES.md, +-- "Working ahead of the tree"). +open Bytes Libp2p.Security.Mock.Identity + +/-- The bytes of one present payload field, with the varint budget of +its length prefix (`Pb.Value.Valid`), so `toFields` needs no check. -/ +structure Field where + /-- The bytes. -/ + bytes : ByteArray + /-- The length fits its varint prefix. -/ + lt : bytes.size < Varint.limit + +/-- The field of the bytes. `none` only past the varint budget, which no +`ByteArray` in memory reaches. -/ +def Field.ofByteArray? (b : ByteArray) : Option Field := + if h : b.size < Varint.limit then some ⟨b, h⟩ else none + +instance : Repr Field where + reprPrec f _ := s!"Noise.Field {toHex f.bytes}" + +/-- One `NoiseHandshakePayload`. Every field is `optional` in the spec +schema, so each one may be absent. -/ +structure HandshakePayload where + /-- `identity_key`: the marshaled identity `PublicKey`. -/ + identityKey : Option Field + /-- `identity_sig`: the signature over the static Noise key. -/ + identitySig : Option Field + /-- `extensions`, kept as the bytes of the submessage. -/ + extensions : Option Field + deriving Repr + +namespace HandshakePayload + +/-- The entry of one optional field: one entry when present, none when +absent. `codec/proto` carries a payload in the `List Nat` byte model, so +the bytes enter through `toNatList`. -/ +private def entry (n : Nat) : Option Field → List Pb.Entry + | some f => [(n, .bytes (toNatList f.bytes))] + | none => [] + +private theorem entryValid (n : Nat) (h0 : 0 < n) (h1 : n < Pb.fieldNumberLimit) + (x : Option Field) : ∀ e ∈ entry n x, e.Valid := by + cases x with + | none => simp [entry] + | some f => + simp only [entry, List.mem_cons, List.not_mem_nil, or_false] + rintro e rfl + refine ⟨h0, h1, ?_, fun _ hx => ltOfMemToNatList hx⟩ + rw [lengthToNatList] + exact f.lt + +/-- The entries in field order. -/ +def toFields (p : HandshakePayload) : Pb.Fields where + entries := entry identityKeyField p.identityKey ++ entry identitySigField p.identitySig ++ + entry extensionsField p.extensions + valid := by + intro e he + simp only [List.mem_append] at he + -- The field numbers 1, 2, and 4 are in the spec range; `decide` + -- unfolds the constants and compares them with `2 ^ 29`. + rcases he with (he | he) | he + · exact entryValid _ (by decide) (by decide) _ e he + · exact entryValid _ (by decide) (by decide) _ e he + · exact entryValid _ (by decide) (by decide) _ e he + +/-- A present field of the decoded entries. A field of the wrong wire +type reads as absent, as `Pb.getBytes?` reads it. -/ +private def field? (fs : Pb.Fields) (n : Nat) : Option Field := + (Pb.getBytes? n fs).bind fun l => Field.ofByteArray? (ofNatList l) + +/-- Every field is optional, so the table rejects no entry list. -/ +def ofFields (fs : Pb.Fields) : HandshakePayload where + identityKey := field? fs identityKeyField + identitySig := field? fs identitySigField + extensions := field? fs extensionsField + +end HandshakePayload + +instance : Pb.ProtoMessage HandshakePayload where + toFields := HandshakePayload.toFields + ofFields? fs := .ok (HandshakePayload.ofFields fs) + +/-- A field reads back from its own bytes: the size check passes on +`f.lt`, and structure eta rebuilds `f`. -/ +private theorem Field.ofByteArray?Bytes (f : Field) : Field.ofByteArray? f.bytes = some f := by + simp [Field.ofByteArray?, f.lt] + +/-- The table reads back its own entries. Each field is absent or +present, so eight cases; in each one the lookup of a field number finds +the one entry `toFields` wrote for it. -/ +theorem HandshakePayload.ofFieldsToFields (p : HandshakePayload) : + HandshakePayload.ofFields (HandshakePayload.toFields p) = p := by + obtain ⟨k, s, x⟩ := p + cases k <;> cases s <;> cases x <;> + simp [HandshakePayload.ofFields, HandshakePayload.toFields, HandshakePayload.field?, + HandshakePayload.entry, Pb.getBytes?, Pb.lookupLast, List.lookup, identityKeyField, + identitySigField, extensionsField, ofNatListToNatList, Field.ofByteArray?Bytes] + +/-- A payload decodes back to itself over the byte model, with no input +left: the wire round trip of `codec/proto` (`Pb.decodeEncode`), then the +table's own. -/ +theorem HandshakePayload.decodeEncode (p : HandshakePayload) : + Pb.decodeMessage? (Pb.encodeMessage p) = .ok (p, []) := by + -- The instance's `ofFields?` is `.ok ∘ ofFields`, so after the wire + -- round trip the table's own round trip closes the goal. + simp only [Pb.decodeMessage?, Pb.encodeMessage, Pb.ProtoMessage.toFields, + Pb.ProtoMessage.ofFields?, Pb.decodeEncode, bind, Except.bind, + HandshakePayload.ofFieldsToFields] + +/-- The signed data: `noise-libp2p-static-key:`, then the static Noise +public key. -/ +def signatureInput (staticPub : Bytes32) : ByteArray := + staticKeyPrefix.toUTF8 ++ staticPub.bytes + +/-- The payload of the bytes. A payload over `maxPayload` is refused +before the decode runs (owned contract 2). -/ +def HandshakePayload.decode? (b : ByteArray) : Except Error HandshakePayload := + if b.size > maxPayload then .error (.payloadTooLarge b.size) + -- `decodeMessageB?` reads the whole buffer, so the count it answers + -- is `b.size`, and only the message stays. + else ((Pb.decodeMessageB? b).map Prod.fst).mapError .payload + +/-- What a payload proved: the identity key, the peer id it derives, +and the opaque extensions. -/ +structure Verified where + /-- The identity key of the peer. -/ + key : PubKey + /-- The peer id of the identity key. -/ + peer : PeerId + /-- The bytes of the `extensions` submessage, when present. -/ + extensions : Option Blob + deriving Repr + +/-- Check the peer's payload against the static key of the handshake: +the gate and the decode, then the identity key, then the signature. A +failure ends the connection (the spec: "If the signature is invalid, +the connection MUST be terminated immediately"). -/ +def verify (remoteStatic : Bytes32) (b : ByteArray) : Except Error Verified := do + let p ← HandshakePayload.decode? b + let some k := p.identityKey | throw .missingIdentity + let some sig := p.identitySig | throw .missingIdentity + let key ← (PubKey.unmarshal? k.bytes).mapError .identity + unless key.verify (signatureInput remoteStatic) sig.bytes do throw .badSignature + let peer ← (PeerId.ofPubKey key).mapError .identity + return { key, peer, extensions := p.extensions.map fun f => ⟨f.bytes⟩ } + +/-- Our payload: the marshaled identity key, and its signature over our +static Noise key. It carries no extensions. `sign` runs in `IO`, +because secp256k1 and ECDSA draw a nonce (§3.6). -/ +def build (identity : PrivKey) (staticPub : Bytes32) : ExceptT Error IO ByteArray := do + let pub ← ExceptT.mk (pure (identity.toPublic.mapError .identity)) + let sig ← (identity.sign (signatureInput staticPub)).adapt .identity + -- Neither field reaches the varint budget, so both checks pass. + let some kf := Field.ofByteArray? pub.marshal | throw (.payloadTooLarge pub.marshal.size) + let some sf := Field.ofByteArray? sig | throw (.payloadTooLarge sig.size) + return Pb.encodeMessageB + ({ identityKey := some kf, identitySig := some sf, extensions := none } : HandshakePayload) + +end Libp2p.Security.Noise diff --git a/packages/libp2p/security/Libp2p/Security/Transport.lean b/packages/libp2p/security/Libp2p/Security/Transport.lean new file mode 100644 index 00000000..b98a70b7 --- /dev/null +++ b/packages/libp2p/security/Libp2p/Security/Transport.lean @@ -0,0 +1,444 @@ +import Conc +import Streams +import Libp2p.Security.Constants +import Libp2p.Security.Handshake + +/-! +# The Noise transport + +After the handshake, each side holds two cipher states from `Split`: +one seals our messages, and one opens the peer's. Every transport +message is one frame: the 2-byte big-endian length, then the ciphertext +of at most `maxPlaintext` bytes and its 16-byte tag (the noise-libp2p +spec, "Wire Format"). The associated data is empty. + +## The core + +The transport is a pure core (root ARCHITECTURE §3.3), at the small +scale it has. `State` holds the two cipher states and the ciphertext of +a frame that has not fully arrived. `step` takes one event: + +* `bytes`: one connection read. The core opens every whole frame in the + buffer, joins their plaintext, and keeps the rest. +* `write`: the plaintext of one application write, under a tag. The core + seals it in chunks of `maxPlaintext` (owned contract 3). +* `eof`: the connection ended. An end between frames is orderly. An end + inside a frame is `Error.truncatedFrame`, as go-libp2p's `io.ReadFull` + fails with `io.ErrUnexpectedEOF` there + (`vendor/go-libp2p/p2p/security/noise/rw.go`, lines 130 and 144). + +A failed open is `Error.decrypt`, and a failed seal is an error too, so +the loop ends and tears the connection down (owned contracts 1 and 6). + +## The shell + +Three tasks run one secure connection, as the yamux shell runs a +session (`Yamux.Loop`): + +* **The reader** reads the connection and posts `Event.bytes`. Before + each read it takes a credit, so it never reads further ahead than the + inbox has room for. +* **The loop** owns the `State`. It takes one event at a time, calls + `step`, and performs the actions. Nothing it performs parks. +* **The writer** writes the sealed frames to the connection, in order, + and answers a write's tag after its frames have gone. + +## The credits + +Noise has no window, so the read side keeps its bound through credits +(PRINCIPLES 3.4). The inbox holds `inboxChunks` chunks, and the credit +channel starts with as many tokens. The reader takes one token per +read. Each `bytes` event gives exactly one action that settles it +(`stepBytesSettles`): a `deliver` takes an inbox slot, and the reader +of the secure connection returns the token when it takes the chunk; a +`credit` returns the token at once, when the read completed no frame. +So the tokens, the reads in flight, and the chunks in the inbox always +add up to `inboxChunks`. A delivery always finds a free slot, and the +loop never parks on the inbox. While the application reads nothing, the +reader holds no token, and TCP carries the back-pressure to the peer. + +## The ends + +The loop ends in one of three ways: the owner closed the secure +connection, the connection ended, or `step` failed. `teardown` records +the answer and closes the event queue, the inbox, and the credits. +After a failure it closes the connection first, so a read sees the end +only after the teardown (owned contract 1). Then it closes the writer's +queue. The writer writes what the queue holds and answers each tag in +order, so a write never answers `closed` for frames that went out. Then +it closes the tag table, which answers the writes the loop never took, +and the connection. +-/ + +set_option autoImplicit false + +namespace Libp2p.Security.Noise.Transport + +-- `Conc` and `Streams` each define an `Error`, so the module opens the +-- names it uses, and `Error` stays `Noise.Error`. +open Conc (Async Chan OnceRef Outcome TaskSet) +open Streams (Connection Completions) +open Bytes (Blob) + +/-! ## The core -/ + +/-- The state of one secure connection. -/ +structure State where + /-- The cipher state of our messages. -/ + send : CipherState + /-- The cipher state of the peer's messages. -/ + recv : CipherState + /-- The ciphertext of a frame that has not fully arrived. It holds + less than one whole frame, so at most `2 + maxMessage` bytes. -/ + pending : Blob + deriving BEq, Repr + +/-- The state after a handshake. The leftover of the handshake arrives +as the first `bytes` event, so the state starts with no pending +bytes. -/ +def State.ofCompletion (c : Completion) : State := + ⟨c.send, c.recv, ⟨.empty⟩⟩ + +/-- An input of the transport core. -/ +inductive Event where + /-- The bytes of one connection read. -/ + | bytes (b : Blob) + /-- The plaintext of one application write, under its tag. -/ + | write (tag : Nat) (pt : Blob) + /-- The connection ended. -/ + | eof + deriving BEq, Repr + +/-- An output of the transport core. The shell performs the actions in +order. -/ +inductive Action where + /-- Write the sealed frames to the connection. -/ + | send (frames : Blob) + /-- Answer the write of the tag: the frames before it have gone. -/ + | done (tag : Nat) + /-- Hand the plaintext to the reader. It is never empty. -/ + | deliver (pt : Blob) + /-- The read completed no frame with plaintext: the reader may read + again. -/ + | credit + /-- The connection ended: the loop ends. -/ + | shutdown + deriving BEq, Repr + +/-- The number of frames that carry `n` bytes of plaintext: `n` divided +by `maxPlaintext`, rounded up. An empty write takes no frame, as go and +nim send nothing for it. -/ +def frameCount (n : Nat) : Nat := + (n + maxPlaintext - 1) / maxPlaintext + +/-- Seal the plaintext as frames of at most `maxPlaintext` bytes each, +and join the frames (owned contract 3). One write gives one joined +buffer, so the writer puts its frames on the wire together, as nim does +("to avoid interleaving / sequencing issues", +`vendor/nim-libp2p/libp2p/protocols/secure/noise.nim`, line 523). -/ +def sealFrames (cs : CipherState) (pt : ByteArray) : Except Error (CipherState × ByteArray) := + (List.range (frameCount pt.size)).foldlM (init := (cs, ByteArray.empty)) fun (cs, out) i => do + let chunk := pt.extract (i * maxPlaintext) ((i + 1) * maxPlaintext) + let (cs, ct) ← cs.encryptWithAd .empty chunk + -- `ct` holds at most `maxPlaintext + 16 = maxMessage` bytes, so the + -- frame always fits. + let some frame := Streams.Framing.encodeB? .u16be maxMessage ct + | throw (.messageTooLarge ct.size) + return (cs, out ++ frame) + +/-- Open every whole frame of `buf` from `pos` on, and join the +plaintext onto `out`. The answer holds the cipher state, the joined +plaintext, and the bytes of the frame that has not fully arrived. + +The reads move `pos` and copy only the frame they open, so a buffer of +many small frames costs one pass. Each frame takes at least the 2 bytes +of its prefix, so `fuel` frames of a buffer of `2 * fuel` bytes or less +cover it; `openFrames` passes that fuel. -/ +private def openFrom (cs : CipherState) (buf : ByteArray) : + Nat → Nat → ByteArray → Except Error (CipherState × ByteArray × ByteArray) + | 0, pos, out => .ok (cs, out, buf.extract pos buf.size) + | fuel + 1, pos, out => + -- The header comes out of a 2-byte window, so the parse copies 2 + -- bytes however much the buffer holds. + match Streams.Framing.headerB? .u16be maxMessage (buf.extract pos (pos + 2)) with + | .error .truncated => .ok (cs, out, buf.extract pos buf.size) + -- `maxMessage` is the largest length two bytes carry, so no header + -- is over the cap. The arm keeps the match total. + | .error .malformed => .error (.messageTooLarge (buf.size - pos)) + | .ok (len, size) => + let start := pos + size + if buf.size < start + len then .ok (cs, out, buf.extract pos buf.size) + else do + let (cs, pt) ← cs.decryptWithAd .empty (buf.extract start (start + len)) + openFrom cs buf fuel (start + len) (out ++ pt) + +/-- Open every whole frame of `buf`: the cipher state, the joined +plaintext, and the rest. -/ +def openFrames (cs : CipherState) (buf : ByteArray) : + Except Error (CipherState × ByteArray × ByteArray) := + openFrom cs buf (buf.size / 2 + 1) 0 .empty + +/-- The action that settles one read: the plaintext, or a credit when +the read gave none. A frame may carry an empty plaintext, and +`Connection.read` never answers an empty chunk, so an empty result +returns the credit. -/ +def settle (pt : ByteArray) : Action := + if pt.isEmpty then .credit else .deliver ⟨pt⟩ + +/-- The transition function. -/ +def step (st : State) : Event → Except Error (State × List Action) + | .bytes b => do + let (recv, pt, rest) ← openFrames st.recv (st.pending.bytes ++ b.bytes) + return ({ st with recv, pending := ⟨rest⟩ }, [settle pt]) + | .write tag pt => do + let (send, frames) ← sealFrames st.send pt.bytes + let acts := if frames.isEmpty then [.done tag] else [.send ⟨frames⟩, .done tag] + return ({ st with send }, acts) + | .eof => + -- An end inside a frame breaks the stream (`Error.truncatedFrame`). + if st.pending.bytes.isEmpty then return (st, [.shutdown]) + else throw (.truncatedFrame st.pending.bytes.size) + +/-- A `bytes` event that succeeds gives exactly one action, and it +settles the read: a delivery or a credit. The credit count of the shell +rests on this. -/ +theorem stepBytesSettles (st st' : State) (b : Blob) (acts : List Action) + (h : step st (.bytes b) = .ok (st', acts)) : + acts = [.credit] ∨ ∃ p, acts = [.deliver p] := by + simp only [step] at h + -- The `do` block binds the result of `openFrames`; a case split on it + -- lets `Except.bind` reduce. + cases hx : openFrames st.recv (st.pending.bytes ++ b.bytes) with + | error e => simp [hx, bind, Except.bind] at h + | ok x => + simp only [hx, bind, Except.bind, pure, Except.pure, Except.ok.injEq, Prod.mk.injEq] at h + obtain ⟨-, rfl⟩ := h + unfold settle + split + · exact .inl rfl + · exact .inr ⟨_, rfl⟩ + +/-! ## The shell -/ + +/-- An item for the writer. -/ +inductive Out where + /-- Write the frames. -/ + | frames (bytes : ByteArray) + /-- Answer the write of the tag: the frames before it have gone. -/ + | done (tag : Nat) + +/-- How the loop ended. -/ +inductive Ending where + /-- The owner closed the secure connection. -/ + | closed + /-- The connection ended. -/ + | shutdown + /-- `step` failed. -/ + | failed (e : Error) + +/-- The parts of a secure connection that its tasks and its surface +share. Each one is a channel, a tag table, or a cell, so a task changes +them only through their atomic operations. -/ +structure Shared where + /-- The connection under the secure connection. -/ + conn : Connection + /-- The events for the loop. -/ + events : Chan Event + /-- The writer's queue. It has no capacity limit: each item belongs + to a write whose writer waits on its tag, so the writers bound it, as + in the yamux shell. -/ + out : Chan Out + /-- The plaintext for the reader of the secure connection. -/ + inbox : Chan ByteArray + /-- The reader's credits (module doc, "The credits"). -/ + credits : Chan Unit + /-- The tags of the writes. -/ + writes : Completions Unit + /-- The answer, once the loop has ended, the first fault of a + connection read while the loop ran, and the first failed write. -/ + outcome : Outcome Error + +namespace Shared + +/-- Perform one action. The answer is true for `shutdown`. No action +parks: the writer's queue has no limit, and the credits keep a free +inbox slot for each delivery (module doc). -/ +def perform (sh : Shared) : Action → BaseIO Bool + | .send f => do + discard <| sh.out.trySend (.frames f.bytes) + return false + | .done tag => do + discard <| sh.out.trySend (.done tag) + return false + | .deliver p => do + discard <| sh.inbox.trySend p.bytes + return false + | .credit => do + discard <| sh.credits.trySend () + return false + | .shutdown => return true + +/-- The loop: take an event, step, perform, until the connection +ends. -/ +def run (sh : Shared) (st : State) : Async Ending := do + let mut st := st + repeat + -- Park: an event wakes it, and so does the close of the queue by + -- `close` or by `teardown`. + match ← sh.events.recv with + | none => return .closed + | some ev => + match step st ev with + | .error e => return .failed e + | .ok (st', acts) => + st := st' + let mut stop := false + for a in acts do + if ← sh.perform a then stop := true + if stop then return .shutdown + return .closed + +/-- End the secure connection: record the answer, and close every wait +it knows (module doc, "The ends"). -/ +def teardown (sh : Shared) (ending : Ending) : BaseIO Unit := do + sh.outcome.finish (match ending with | .failed e => .error e | _ => .ok ()) + -- A failure tears the connection down before a read sees the end + -- (owned contracts 1 and 6). + if let .failed _ := ending then sh.conn.closeQuietly + sh.events.close + -- The write tags stay open: the writer answers each one whose frames + -- it holds, after it has written them, and closes the table when it + -- ends. + sh.inbox.close + sh.credits.close + sh.out.close + +/-- The reader task (`Streams.readLoop`). The loop or the application +returns its credits (module doc, "The credits"). -/ +def reader (sh : Shared) : Async Unit := + Streams.readLoop sh.conn sh.credits sh.outcome.recordFault + (fun b => sh.events.send (.bytes ⟨b⟩)) (discard <| sh.events.send .eof) + +/-- The writer task. It answers each tag after the frames before it, so +a write answers `.ok` exactly when its frames have gone to the +connection. After a failed write the connection is torn down +(`Connection.write`), so the writer stops writing and answers the tags +with `closed`. The reader then sees the end, and the loop ends. When +the queue ends, the writer closes the tag table, which answers the +writes that the loop never took, and then the connection. + +The failed write throws to its caller through `Session.write`, so the +writer records it as a failed write (`recordWriteFault`), which the +join does not throw. The caller decides what the failure means: the +yamux shell drops it after the peer's go-away, as go-yamux does +(`Yamux.Shared.recordWriteFault`). A fault of its own in the join would +override that decision, and a peer's orderly close would reach the +owner as a fault. -/ +def writer (sh : Shared) : Async Unit := do + let mut broken := false + repeat + -- Park: the loop's next item wakes it, and so does `teardown`. + match ← sh.out.recv with + | none => break + | some (.frames b) => + if !broken then + -- Park: the peer's reads wake the write, and so does the close + -- of the connection. + try sh.conn.write b catch e => + -- The caller of the write gets the failure (doc above). + sh.outcome.recordWriteFault e + broken := true + | some (.done tag) => + sh.writes.resolve tag (if broken then .error .closed else .ok ()) + sh.writes.closeAll + sh.conn.close + +end Shared + +/-- The fault of an operation on a secure connection that has ended. -/ +private def closedError : IO.Error := + .resourceVanished 0 "noise: the secure connection is closed" + +/-- A running secure connection: the surface that the seam value wraps +as a `SecureConn`. -/ +structure Session where + /-- The next plaintext chunk, never empty. `none` at the end. -/ + read : Async (Option ByteArray) + /-- Seal and write the bytes, and return once the frames have gone to + the connection. A failure throws, after the teardown. -/ + write : ByteArray → Async Unit + /-- Close the secure connection and the connection under it. -/ + close : IO Unit + /-- Wait until the tasks have ended, and answer how the connection + ended: `.ok ()` for an orderly end, the `Noise.Error` for a broken + peer. A fault of a connection read throws. A failed write reached its + caller through `write`, and the join does not throw it again. -/ + join : Async (Except Error Unit) + +namespace Session + +/-- Run a secure connection over `conn` with the cipher states of the +handshake. `leftover` holds the ciphertext the handshake read past its +last message; it is the first event, ahead of any connection read +(owned contract 5). -/ +def new (conn : Connection) (c : Completion) (leftover : ByteArray) : BaseIO Session := do + let sh : Shared := { + conn + events := ← Chan.new eventQueueCap + out := ← Chan.newUnbounded + inbox := ← Chan.new inboxChunks + credits := ← Chan.new inboxChunks + writes := ← Completions.new + outcome := ← Outcome.new } + -- The leftover is a read of its own: it takes a credit, and the + -- reader gets the rest. The queues are empty, so each send takes. + let first := if leftover.isEmpty then 0 else 1 + if first = 1 then discard <| sh.events.trySend (.bytes ⟨leftover⟩) + for _ in [first:inboxChunks] do + discard <| sh.credits.trySend () + let tasks ← TaskSet.new + tasks.spawn do + let ending ← sh.run (State.ofCompletion c) + sh.teardown ending + tasks.spawn sh.writer + tasks.spawn sh.reader + return { + read := do + -- Park: the loop's delivery wakes it, and so does `teardown`. + match ← sh.inbox.recv with + | some b => + -- The chunk has left the inbox: its slot is free again. + discard <| sh.credits.trySend () + return some b + | none => return none + write := fun b => do + let fail : Async Unit := do + -- `Connection.write`: the connection is torn down before the + -- throw. + sh.conn.closeQuietly + throw closedError + let some (tag, slot) ← sh.writes.register | fail + if let .error _ ← sh.events.send (.write tag ⟨b⟩) then + sh.writes.resolve tag (.error .closed) + fail + -- Park: the writer answers the tag, and `teardown` closes the + -- slot. + match ← slot.recv with + | some (.ok ()) => return () + | _ => fail + -- The close of the connection wakes the reader and a parked write; + -- the close of the queue ends the loop. The mark comes first: a + -- read or a write that the close breaks is no fault + -- (`Conc.Outcome`, "The owner's close"). + close := do + sh.outcome.markClosing + sh.events.close + sh.conn.close + -- A fault of a read under a clean answer throws (§3.5). + join := do + tasks.joinAll + sh.outcome.answer } + +end Session + +end Libp2p.Security.Noise.Transport diff --git a/packages/libp2p/security/docs/ARCHITECTURE.md b/packages/libp2p/security/docs/ARCHITECTURE.md index 901237cc..d5764443 100644 --- a/packages/libp2p/security/docs/ARCHITECTURE.md +++ b/packages/libp2p/security/docs/ARCHITECTURE.md @@ -19,19 +19,24 @@ and no work is planned for it. | `Libp2p/Security/Payload.lean` | the `NoiseHandshakePayload` protobuf, its `ProtoMessage` instance (§3.2), the signature input, and the 8 KiB gate | | `Libp2p/Security/Transport.lean` | the framed AEAD reads and writes over the two cipher states of `split`, wrapped as the `SecureConn` surface | | `Libp2p/Security/Noise.lean` | `Noise.securityProtocol`, the fixed-key test value, and the `Except.mapError` into `Core.Error` (§3.5) | +| `Libp2p/Security/Mock/Core.lean` | the mock of the `libp2p/core` surface this package uses, until W5.2 merges ("The mocks" below) | +| `Libp2p/Security/Mock/Identity.lean` | the mock of the `libp2p/identity` surface this package uses, until W5.1 merges ("The mocks" below) | All declarations live in the `Libp2p.Security` namespace, and the root -module re-exports the public submodules. The values carry the names -§3.1 fixes: `Noise.securityProtocol`, and the fixed-key test value -beside it. The spike reference is `Conn/Noise.lean` (§10 row 13); the +module re-exports the public submodules. §3.1 names the production +value `Noise.securityProtocol`, and the fixed-key test value beside it +is `Noise.fixedKeyProtocol`. The spike reference is `Conn/Noise.lean` (§10 row 13); the target keeps its wire bytes, framing, and tests, and replaces the code shape (§9, delta 8). ## Public surface - The two `SecurityProtocol` values of §3.1 and §4.9: - `Noise.securityProtocol`, and the fixed-key test value beside it. - Both carry the `/noise` id and the §4.9 handshake signature, + `Noise.securityProtocol cfg`, and the fixed-key test value + `Noise.fixedKeyProtocol cfg seed` beside it. A `Noise.Config` holds + the identity key and the Noise static key; `Config.generate` draws + the static key from `Rng.system`. Both values carry the `/noise` id + and the §4.9 handshake signature, `handshake : Role → Connection → ByteArray → ExceptT Core.Error Async SecureConn`. The `ByteArray` argument is the negotiation leftover, and the `SecureConn` result holds the handshake's own leftover. @@ -61,43 +66,83 @@ opens with `h = ck =` the protocol name (zero-padded when shorter than 32 bytes, hashed only when longer), and the cipher nonce is 4 zero bytes followed by the 8-byte little-endian counter. -The state is plain data: the symmetric state, the role, the local -static key, the remote static and ephemeral keys as the handshake -learns them, and the input buffer. The buffer opens with the -negotiation leftover (§4.9), so the handshake consumes handed-on bytes -before it reads the connection (§4.7, §5.6). +The core is the Noise layer, over opaque payloads. The noise-libp2p +spec carries the libp2p payload inside messages 2 and 3, and checks it +on receipt: "Upon receiving the handshake payload, peers MUST decode +the public key from the `identity_key` field … The key MUST then be +used to validate the `identity_sig` field" (the spec, "The libp2p +Handshake Payload"). go keeps flynn's `HandshakeState` below that check +(`vendor/go-libp2p/p2p/security/noise/handshake.go`, `runHandshake` and +`handleRemoteHandshakePayload`), and js keeps `XXHandshakeState` below +`decodeHandshakePayload` +(`vendor/js-libp2p/packages/connection-encrypter-noise/src/performHandshake.ts`). The flynn vectors +carry raw payloads and a prologue, so they test the core alone. + +The state is plain data: the symmetric state, the phase of the role +with the remote static and ephemeral keys as the handshake learns +them, and the local static key. The static key and the prologue enter +when the state is built, as flynn's `NewHandshakeState` takes them +(`Config.Prologue`, `Config.StaticKeypair`; `vendor/flynn-noise`, +`state.go`). js passes `s` to the `XXHandshakeState` constructor, and +nim sets `hs.s` before the first message +(`vendor/nim-libp2p/libp2p/protocols/secure/noise.nim`, lines 379 and +420). The events carry the world in: `bytes` delivers one framed message, which the shell reads through the 2-byte length prefix with -`readExactly`. `local` posts the local static Noise key and the -marshaled handshake payload; the shell builds the payload once, through -the facade and the sign dispatch of `libp2p/identity`, and a secp256k1 -identity draws a random signature nonce (§3.6), so the built payload is -an input. `entropy` posts 32 bytes from the `Rng` seam of the facade +`Streams.Framing.read`; flynn's `ReadMessage` takes one whole message +the same way. The negotiation leftover seeds that framed reader (§4.9), so the +handshake consumes handed-on bytes before it reads the connection +(§4.7, §5.6). `local` posts the marshaled payload of our next +handshake message, as flynn's `WriteMessage` takes one payload per +message; the shell builds the libp2p payload once, through the facade +and the sign dispatch of `libp2p/identity`, and a secp256k1 identity +draws a random signature nonce (§3.6), so the built payload is an +input. `entropy` posts 32 bytes from the `Rng` seam of the facade (§3.6), and the core derives the X25519 keypair from them at the `e` token. The event set has no tick: the core has no use for time, and the upgrader owns the deadline (§5.4, contract 7 below). The actions carry the world out: `write` emits framed bytes, -`complete` ends the handshake with the two cipher states of `split`, -the local and remote `PeerId`s, and the leftover bytes the handshake -over-read, and `error` reports a `Noise.Error`. The loop tears the -connection down on `error` (§3.5). +`payload` hands out the peer's decrypted payload of one handshake +message, and `complete` ends the handshake with the two cipher states +of `split` and the remote static key. `step` returns `Except +Noise.Error` (root §3.3), and the loop tears the connection down on an +error (§3.5). + +The shell performs the actions in order, and it checks each `payload` +action with `Noise.verify` before the next one. The check answers the +identity key and the `PeerId` it derives (contract 4), and the seam +value puts the local and remote `PeerId`s into the `SecureConn`. On +the initiator, the payload of message 2 comes out before the write of +message 3, so a bad payload ends the connection before message 3 +reaches the wire, as go and js order it +(`vendor/go-libp2p/p2p/security/noise/handshake.go`, lines 78 to 102; +`vendor/js-libp2p/packages/connection-encrypter-noise/src/performHandshake.ts`, +lines 32 to 42). The bytes that the framed +reader holds past the last message are transport ciphertext, and they +seed the transport reader (contract 5); rust-libp2p keeps them the +same way, in the read buffer that `map_into_transport` hands on +(`vendor/rust-libp2p/transports/noise/src/io/handshake.rs`, line 157). The transport keeps the same shape at the small scale it has: the two cipher states are data, and one frame in or out is one pure transition -over them. The framed IO path types as `ExceptT Noise.Error Async` (§3.5), -and keys and nonces cross the facade as the proof-carrying newtypes +over them. The handshake shell types as `ExceptT Noise.Error Async` +(§3.5; `Async` is the form `IO` takes for a step that parks, the core +pair). The transport reports a `Noise.Error` through the completion of +its tasks, which the seam value maps into the `SecureConn.join`. Keys and nonces cross the facade as the proof-carrying newtypes `Key32` and `Nonce12` (§3.7). Where the proofs sit: the crypto arrives through the `Crypto` facade, which names the backend for every primitive it binds, the digests included (the crypto pairs; §4.6), and §3.3 allows a core to call pure externs. The core is deterministic in its events, and the transcript tests replay it with no task, no network, and no clock (§6). -The 2-byte framing codec proves its round-trip directly over the byte -model, as the spike proved `be16_roundtrip`; the generic-harness route -would need a wrapper newtype for the length, because §3.2 puts no -instance on a core type. +The 2-byte framing codec is the `u16be` prefix of `Streams.Framing`, +the one home of the Noise frame (the streams pair, "Two prefix shapes +exist, `uvarint` and `u16be` (the Noise frame)"). Its `Frame` newtype +carries the `LawfulCodec` proof of the round trip over the byte model, +so this package imports it and proves it a second time nowhere +(PRINCIPLES 1.5). ## Owned contracts and how the package keeps them @@ -113,6 +158,18 @@ instance on a core type. protobuf decode. The spike's reason stands: the list-based decoder cost quadratic time, and a real payload stays under 2 KiB even with an RSA-8192 identity (PRINCIPLES 2.3). + + > **Note.** The 8 KiB value comes from root §5.1. The spec sets no + > payload limit below the 65535-byte message, and the references + > decode the payload with no limit of their own: go-libp2p + > (`vendor/go-libp2p/p2p/security/noise/handshake.go`, line 252), + > rust-libp2p (`vendor/rust-libp2p/transports/noise/src/io/framed.rs`, + > lines 36 and 134), js-libp2p + > (`vendor/js-libp2p/packages/connection-encrypter-noise/src/utils.ts`, + > line 37), and nim-libp2p + > (`vendor/nim-libp2p/libp2p/protocols/secure/noise.nim`, lines 566 + > and 567). A payload between 8 KiB and 64 KiB passes there and fails + > here. 3. **The 64 KiB frame cap** (§5.1). The 2-byte length prefix bounds a message at 65535 bytes. The write path chunks plaintext at 65535 − 16, because the Poly1305 tag counts against the cap. Both numbers @@ -123,15 +180,16 @@ instance on a core type. `noise-libp2p-static-key:` plus the static Noise public key, and it must verify against the identity key in the same payload. A failure maps to `Core.Error.authentication` at the seam. The identity key - derives the `PeerId` the completion action carries. The comparison + derives the `PeerId` that `Noise.verify` answers, and the seam value + puts it into `SecureConn.remotePeer` (the core shape). The comparison against the expected id gates the muxer stage of the upgrade (§4.9: the pipeline runs security → identity check → muxer); the handshake takes no expected id, the host supplies the expectation, and the upgrader compares the completed `remotePeer` against it (the negotiate pair's refinement). The spike split it the same way. 5. **The leftover hand-over runs in both directions** (§4.7, §4.9, - §5.6). In: the `ByteArray` argument of the handshake seeds the input - buffer, ahead of any connection read. Out: the `SecureConn` carries + §5.6). In: the `ByteArray` argument of the handshake seeds its + framed reader, ahead of any connection read. Out: the `SecureConn` carries the bytes the handshake over-read, and they seed the transport reader. With §5.6 this keeps one reader per stream. The spike shared one `BufReader` across negotiation, handshake, and transport; the @@ -192,28 +250,82 @@ the spike's own recorded rule. fixed entropy events drive both roles, and the test identity is Ed25519, whose signature is deterministic (§3.6), so the suites above the transport run the real Noise path with no randomness. +- **The transport runs one loop, and credits bound its read side** + (§3.3, PRINCIPLES 3.4). A reader task, a loop task that owns the two + cipher states, and a writer task run each secure connection, as the + yamux shell runs a session. Noise has no window, so the loop cannot + hold the overflow of a slow reader. The reader takes a credit before + each connection read instead, and each read settles to one delivery + or one returned credit (`stepBytesSettles`). The inbox holds + `inboxChunks` = 1 chunk: rust-libp2p holds one decrypted frame ahead + of its reader (`Output.recv_buffer`, + `vendor/rust-libp2p/transports/noise/src/io.rs`, lines 42 and 73 to + 94), and nim-libp2p decrypts on the reader's demand (`readMessage`, + `vendor/nim-libp2p/libp2p/protocols/secure/noise.nim`, line 450). The loop never parks, writes keep their nonce order + without a lock, and TCP carries the back-pressure to the peer. +- **The responder drops the payload of message 0.** The spec puts the + libp2p payload in the second and third messages ("XX") and says + nothing of a payload in the first. The upstreams disagree: go-libp2p + (`runHandshake`, `vendor/go-libp2p/p2p/security/noise/handshake.go`), + js-libp2p (`performHandshakeResponder`, + `vendor/js-libp2p/packages/connection-encrypter-noise/src/performHandshake.ts`), + and nim-libp2p (`handshakeXXInbound`, + `vendor/nim-libp2p/libp2p/protocols/secure/noise.nim`, line 411) read + it and drop it, and rust-libp2p (`recv_empty`, + `vendor/rust-libp2p/transports/noise/src/io/handshake.rs`, line 203) + refuses one that decodes to any field. We follow go, the peer of the interop gate + (W5.9); rust's refusal is the outlier. The initiator sends an empty + payload in message 0, so a rust peer accepts it. - **No rekey.** The spike had none, and the vendored noise-libp2p spec (r5) defines none. Each transport cipher state runs its counter toward 2^64 − 1 nonces and never reaches them in a session's life. +## The mocks + +W5.1 (`libp2p/identity`) and W5.2 (`libp2p/core`) have not merged, so +the package builds against mocks of their frozen surfaces (RULES.md, +"Working ahead of the tree"). Both live under +`Libp2p.Security.Mock`, import nothing from the real packages, and +state no axiom. + +- `Mock/Core.lean` copies the types: `ProtocolId`, `Role`, + `Core.Error` with its four arms, `SecureConn`, and + `SecurityProtocol` (the core pair, public surface). +- `Mock/Identity.lean` declares the key and peer-id surface: + `KeyType`, `PubKey`, `PrivKey`, `PeerId`, and a copy of + `Identity.Error` (the identity pair, public surface). The operations + are `opaque` with values, because the suites sign and verify real + payloads and check the peer ids that go-libp2p printed. The values + cover Ed25519 alone, the key type of every suite here; every other + key type fails with `keyData`. The kernel sees no definition, so no + proof unfolds one. + +At the integration, the modules import `Libp2p.Core` and +`Libp2p.Identity`, the opens drop `Mock`, and both mock modules go. + ## What the package must not depend on - Inside libp2p, `core` and `identity` only (§2: identity → core → security). The `SecureConn` inputs and the error target come from `core`; the payload's key checks and the `PeerId` derivation come from `identity`. No import of `libp2p/negotiate`, `libp2p/mux`, - `libp2p/host`, or `libp2p/protocols`. + `libp2p/host`, or `libp2p/protocols`. Until W5.1 and W5.2 merge, both + surfaces come from the mocks of this package ("The mocks" below). - Crypto reaches the package through the `Crypto` facade only (§3.6): SHA-256, HKDF, X25519, and ChaCha20-Poly1305. No `crypto/backend` and no `crypto/pure` import. The signature checks go through the sign and verify dispatch of `libp2p/identity`. -- From `net/streams`, `Connection` and `readExactly`. The handshake +- From `net/streams`, `Connection`, `BufReader` and `Framing.read` for + the handshake reads, and `readLoop` and `Completions` for the reader + task and the write tags of the transport shell. From `base/conc`, + `Async`, and the channels, the cell, and the task set of that shell. + From `base/bytes`, the byte model and `Blob`, the bytes inside core + data. From the codec group, `codec/proto` for the payload message. + The handshake signature of §4.9 names `Connection`, and core defines no re-export for it, so this package imports `net/streams` directly and records the import here, per the core pair's rule. The direction is the group-legal one (§2: libp2p → net). -- From `base`, `Async` from `base/conc`, for the handshake signature - of §4.9 and the framed IO path. - No knowledge of multistream-select, yamux, or mplex bytes. The negotiation hands this package bytes through the leftover argument, and the handshake consumes them without knowing what produced them. diff --git a/packages/libp2p/security/docs/PLAN.md b/packages/libp2p/security/docs/PLAN.md index 2f687505..8a2a243c 100644 --- a/packages/libp2p/security/docs/PLAN.md +++ b/packages/libp2p/security/docs/PLAN.md @@ -13,7 +13,7 @@ pass, the suite passes under `+RTS -N1`, and the import lint is green. | Unit | Root deps | Deliverable | Done when | |---|---|---|---| | 0 | none | this documentation pair, the first work unit of the package (ARCHITECTURE §2) | written, and consistent with the root docs | -| 1a | W3.3, W5.2 | the handshake core and the payload: `State`/`Event`/`Action`/`step` (§3.3), the framing codec, `NoiseHandshakePayload` with its `ProtoMessage` instance, and `Noise.Error` | the core replays the transcript milestones below with no connection in the loop | +| 1a | W3.3, W5.2 | the handshake core and the payload: `State`/`Event`/`Action`/`step` (§3.3) over the `u16be` frame of `Streams.Framing` (ARCHITECTURE, "The core shape"), `NoiseHandshakePayload` with its `ProtoMessage` instance, and `Noise.Error` | the core replays the transcript milestones below with no connection in the loop | | 1b | 1a, W4.2 | the transport and the two `SecurityProtocol` values: framed AEAD reads and writes over `SecureConn`, `Noise.securityProtocol`, the fixed-key test value, and the `Except.mapError` into `Core.Error` | the root W5.3 row holds, and the milestones below pass | The split lets the pure side close on transcripts alone. The core needs @@ -47,7 +47,7 @@ Unit 0 is done. the decode runs (§5.2, PRINCIPLES 2.4). - The static-key check: a payload whose signature was made over a different static key, or whose signature fails to verify, is an - authentication error. The completion carries the `PeerId` the + authentication error. `Noise.verify` answers the `PeerId` the payload's identity key derives; the host (W5.6) supplies the expected id, and the upgrader compares it (the negotiate pair's refinement). @@ -88,3 +88,10 @@ its wrong-AD and wrong-nonce rejections. All of these arrive through W0.3, with `VECTORS.lock`, `scripts/extract-vectors.py`, and `Tests/vectors/` (§6). The go interop matrix over `tcp + /noise + yamux`, dial and listen, is the W5.9 gate and no gate of this package. + +`scripts/noise-interop.sh` runs this package alone against go-libp2p +v0.50.0's Noise transport, over loopback TCP with no negotiation, in +both directions: each side proves its peer id, 200000 bytes go each +way as four frames, and the close is orderly. It sits outside `lake +test`, as the yamux interop check does, since Go tooling stays in the +harness. diff --git a/scripts/noise-interop.sh b/scripts/noise-interop.sh new file mode 100755 index 00000000..839e93b5 --- /dev/null +++ b/scripts/noise-interop.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# The Noise interop check (packages/libp2p/security/docs/PLAN.md): this +# package's Noise seam value against go-libp2p v0.50.0's Noise transport, +# over plain loopback TCP, in both directions. Each run proves both peer +# ids, moves 200000 bytes each way (four Noise frames), and ends with an +# orderly close. +# +# Go builds offline from the module cache; run `go mod download` in +# scripts/noise-interop once on a machine that lacks it. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +out="$root/.tmp/noise-interop" +mkdir -p "$out" + +# Both builds run on two cores at the lowest priority, so the check can +# run beside other work on the machine. +( cd "$root/scripts/noise-interop" && + GOPROXY=off GOSUMDB=off nice -n19 taskset -c 0,1 go build -p 2 -o "$out/go-peer" . ) +( cd "$root" && + LEAN_NUM_THREADS=2 nice -n19 taskset -c 0,1 lake build noise-interop >"$out/lake.out" ) +lean="$root/.lake/build/bin/noise-interop" +go="$out/go-peer" + +# $1 listens and prints its port; $2 dials that port. +run() { + local name=$1 listener=$2 dialer=$3 + coproc LISTEN { timeout 120 "$listener" listen; } + # Bash unsets the coprocess variables once it exits, so take them now. + local pid=$LISTEN_PID port= + # A listener that dies before it prints its port fails this direction + # alone; `set -e` must not end the script before the other one runs. + if ! read -r port <&"${LISTEN[0]}" || [[ -z $port ]]; then + local listen_ok=0 + wait "$pid" || listen_ok=$? + echo "FAIL $name (the listener printed no port, exit $listen_ok)" + return 1 + fi + local dial_ok=0 listen_ok=0 + timeout 120 "$dialer" dial "$port" || dial_ok=$? + wait "$pid" || listen_ok=$? + if [[ $dial_ok -eq 0 && $listen_ok -eq 0 ]]; then + echo "PASS $name" + else + echo "FAIL $name (dialer $dial_ok, listener $listen_ok)" + return 1 + fi +} + +status=0 +run "lean dials go" "$go" "$lean" || status=1 +run "go dials lean" "$lean" "$go" || status=1 +exit $status diff --git a/scripts/noise-interop/go.mod b/scripts/noise-interop/go.mod new file mode 100644 index 00000000..e2055361 --- /dev/null +++ b/scripts/noise-interop/go.mod @@ -0,0 +1,30 @@ +module leanp2p/scripts/noise-interop + +go 1.26.0 + +require github.com/libp2p/go-libp2p v0.50.0 + +require ( + github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect + github.com/flynn/noise v1.1.0 // indirect + github.com/ipfs/go-cid v0.6.2 // indirect + github.com/jbenet/go-temp-err-catcher v0.1.0 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/libp2p/go-buffer-pool v0.1.0 // indirect + github.com/mr-tron/base58 v1.3.0 // indirect + github.com/multiformats/go-base32 v0.1.0 // indirect + github.com/multiformats/go-base36 v0.2.0 // indirect + github.com/multiformats/go-multiaddr v0.16.1 // indirect + github.com/multiformats/go-multibase v0.3.0 // indirect + github.com/multiformats/go-multicodec v0.10.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect + github.com/multiformats/go-multistream v0.6.1 // indirect + github.com/multiformats/go-varint v0.1.0 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect + golang.org/x/sys v0.47.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + lukechampine.com/blake3 v1.4.1 // indirect +) diff --git a/scripts/noise-interop/go.sum b/scripts/noise-interop/go.sum new file mode 100644 index 00000000..81895f4a --- /dev/null +++ b/scripts/noise-interop/go.sum @@ -0,0 +1,63 @@ +github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU= +github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= +github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= +github.com/ipfs/go-cid v0.6.2 h1:VuGwJd+KJTaMJ4S4d5EEf9SXc17YUblS5axCbocn9YE= +github.com/ipfs/go-cid v0.6.2/go.mod h1:Xhwg8NzHeK9xPCEZkCw4idzPiuNMpX3fARuI5Iwj1Lo= +github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk= +github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= +github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= +github.com/libp2p/go-libp2p v0.50.0 h1:A0tBP6mr6GV7l5ip2Q04a/IxPqVYYaCiif5s8JuXkN4= +github.com/libp2p/go-libp2p v0.50.0/go.mod h1:RjqB+dxCWNZ33yw0yeK5Pu157oNxKuaTZY6vLed4t8w= +github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI= +github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8= +github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= +github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= +github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= +github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multiaddr v0.16.1 h1:fgJ0Pitow+wWXzN9do+1b8Pyjmo8m5WhGfzpL82MpCw= +github.com/multiformats/go-multiaddr v0.16.1/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= +github.com/multiformats/go-multibase v0.3.0 h1:8helZD2+4Db7NNWFiktk2NePbF0boolBe6bDQvM4r68= +github.com/multiformats/go-multibase v0.3.0/go.mod h1:MoBLQPCkRTOL3eveIPO81860j2AQY8JwcnNlRkGRUfI= +github.com/multiformats/go-multicodec v0.10.0 h1:UpP223cig/Cx8J76jWt91njpK3GTAO1w02sdcjZDSuc= +github.com/multiformats/go-multicodec v0.10.0/go.mod h1:wg88pM+s2kZJEQfRCKBNU+g32F5aWBEjyFHXvZLTcLI= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-multistream v0.6.1 h1:4aoX5v6T+yWmc2raBHsTvzmFhOI8WVOer28DeBBEYdQ= +github.com/multiformats/go-multistream v0.6.1/go.mod h1:ksQf6kqHAb6zIsyw7Zm+gAuVo57Qbq84E27YlYqavqw= +github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= +github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260718201538-764159d718ef h1:LkZ48HFgy/TvhTI0bcWkjgFkgLyKUwcTbDjS0DUjw+A= +golang.org/x/exp v0.0.0-20260718201538-764159d718ef/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= +lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= diff --git a/scripts/noise-interop/main.go b/scripts/noise-interop/main.go new file mode 100644 index 00000000..7e9ff5ed --- /dev/null +++ b/scripts/noise-interop/main.go @@ -0,0 +1,154 @@ +// Command noise-interop is the go side of the Noise interop check +// (packages/libp2p/security/docs/PLAN.md). It runs go-libp2p v0.50.0's +// Noise transport (p2p/security/noise), with flynn/noise v1.1.0 under +// it, over plain loopback TCP: no multistream-select, so the check sees +// the Noise handshake and transport frames alone. Test-only tooling +// (CLAUDE.md, "Dependencies"). +// +// noise-interop listen accept one connection as the responder, echo the bytes +// noise-interop dial PORT connect to 127.0.0.1:PORT as the initiator, check the echo +// +// Both sides take fixed Ed25519 identities from 32-byte seeds, so each +// side knows the peer id the other must prove. +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "os" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/p2p/security/noise" +) + +// The identity seeds: 32 bytes of 'G' for this side, 32 bytes of 'L' +// for the Lean side (Tests/Interop/Noise.lean). +var goSeed = bytes.Repeat([]byte{'G'}, 32) +var leanSeed = bytes.Repeat([]byte{'L'}, 32) + +// size is past three full Noise frames of 65519 plaintext bytes, so the +// transport chunks a write into four frames. +const size = 200000 + +// payload is the deterministic byte pattern both sides build. +func payload(n int) []byte { + b := make([]byte, n) + for k := range b { + b[k] = byte((k*7 + 3) % 251) + } + return b +} + +// identity is the Ed25519 key of a seed: GenerateEd25519Key reads the +// 32-byte seed from the reader. +func identity(seed []byte) (crypto.PrivKey, error) { + k, _, err := crypto.GenerateEd25519Key(bytes.NewReader(seed)) + return k, err +} + +// transport is go-libp2p's Noise transport with our identity, and the +// peer id the Lean side must prove. +func transport() (*noise.Transport, peer.ID, error) { + k, err := identity(goSeed) + if err != nil { + return nil, "", err + } + t, err := noise.New(noise.ID, k, nil) + if err != nil { + return nil, "", err + } + lk, err := identity(leanSeed) + if err != nil { + return nil, "", err + } + want, err := peer.IDFromPrivateKey(lk) + return t, want, err +} + +func listen() error { + t, want, err := transport() + if err != nil { + return err + } + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return err + } + fmt.Println(l.Addr().(*net.TCPAddr).Port) + conn, err := l.Accept() + l.Close() + if err != nil { + return err + } + sc, err := t.SecureInbound(context.Background(), conn, "") + if err != nil { + return fmt.Errorf("handshake: %w", err) + } + if sc.RemotePeer() != want { + return fmt.Errorf("remote peer %s, want %s", sc.RemotePeer(), want) + } + got := make([]byte, size) + if _, err := io.ReadFull(sc, got); err != nil { + return fmt.Errorf("read: %w", err) + } + if _, err := sc.Write(got); err != nil { + return fmt.Errorf("write: %w", err) + } + // The dialer closes after it has read the echo: an orderly end. + // ReadAll reads to io.EOF; a single Read may answer (0, nil) first, + // when it releases the drained buffer of the last frame (rw.go). + rest, err := io.ReadAll(sc) + if err != nil || len(rest) != 0 { + return fmt.Errorf("after the echo: read %d bytes, %v; want the end", len(rest), err) + } + return sc.Close() +} + +func dial(addr string) error { + t, want, err := transport() + if err != nil { + return err + } + conn, err := net.Dial("tcp", addr) + if err != nil { + return err + } + // SecureOutbound checks that the remote proves `want`. + sc, err := t.SecureOutbound(context.Background(), conn, want) + if err != nil { + return fmt.Errorf("handshake: %w", err) + } + data := payload(size) + if _, err := sc.Write(data); err != nil { + return fmt.Errorf("write: %w", err) + } + got := make([]byte, size) + if _, err := io.ReadFull(sc, got); err != nil { + return fmt.Errorf("read: %w", err) + } + if !bytes.Equal(got, data) { + return fmt.Errorf("the echo of %d bytes differs", size) + } + return sc.Close() +} + +func main() { + var err error + switch { + case len(os.Args) == 2 && os.Args[1] == "listen": + err = listen() + case len(os.Args) == 3 && os.Args[1] == "dial": + err = dial(net.JoinHostPort("127.0.0.1", os.Args[2])) + default: + err = fmt.Errorf("usage: noise-interop listen | dial PORT") + } + if err != nil { + fmt.Fprintln(os.Stderr, "go:", err) + os.Exit(1) + } + fmt.Fprintln(os.Stderr, "go: ok") +} diff --git a/vendor/js-libp2p/packages/connection-encrypter-noise/src/performHandshake.ts b/vendor/js-libp2p/packages/connection-encrypter-noise/src/performHandshake.ts new file mode 100644 index 00000000..62378647 --- /dev/null +++ b/vendor/js-libp2p/packages/connection-encrypter-noise/src/performHandshake.ts @@ -0,0 +1,93 @@ +import { + logLocalStaticKeys, + logLocalEphemeralKeys, + logRemoteEphemeralKey, + logRemoteStaticKey, + logCipherState +} from './logger.ts' +import { ZEROLEN, XXHandshakeState } from './protocol.ts' +import { createHandshakePayload, decodeHandshakePayload } from './utils.ts' +import type { HandshakeResult, HandshakeParams } from './types.ts' +import type { AbortOptions } from '@libp2p/interface' + +export async function performHandshakeInitiator (init: HandshakeParams, options?: AbortOptions): Promise { + const { log, connection, crypto, privateKey, prologue, s, remotePeer, extensions } = init + + const payload = await createHandshakePayload(privateKey, s.publicKey, extensions) + const xx = new XXHandshakeState({ + crypto, + protocolName: 'Noise_XX_25519_ChaChaPoly_SHA256', + initiator: true, + prologue, + s + }) + + logLocalStaticKeys(xx.s, log) + log.trace('Stage 0 - Initiator starting to send first message.') + await connection.write(xx.writeMessageA(ZEROLEN), options) + log.trace('Stage 0 - Initiator finished sending first message.') + logLocalEphemeralKeys(xx.e, log) + + log.trace('Stage 1 - Initiator waiting to receive first message from responder...') + const plaintext = xx.readMessageB(await connection.read(options)) + log.trace('Stage 1 - Initiator received the message.') + logRemoteEphemeralKey(xx.re, log) + logRemoteStaticKey(xx.rs, log) + + log.trace("Initiator going to check remote's signature...") + const received = await decodeHandshakePayload(plaintext, xx.rs, remotePeer) + log.trace('All good with the signature!') + + log.trace('Stage 2 - Initiator sending third handshake message.') + await connection.write(xx.writeMessageC(payload), options) + log.trace('Stage 2 - Initiator sent message with signed payload.') + + const [cs1, cs2] = xx.ss.split() + logCipherState(cs1, cs2, log) + + return { + payload: received.payload, + remotePeer: received.remotePeer, + encrypt: (plaintext) => cs1.encryptWithAd(ZEROLEN, plaintext), + decrypt: (ciphertext, dst) => cs2.decryptWithAd(ZEROLEN, ciphertext, dst) + } +} + +export async function performHandshakeResponder (init: HandshakeParams, options?: AbortOptions): Promise { + const { log, connection, crypto, privateKey, prologue, s, remotePeer, extensions } = init + + const payload = await createHandshakePayload(privateKey, s.publicKey, extensions) + const xx = new XXHandshakeState({ + crypto, + protocolName: 'Noise_XX_25519_ChaChaPoly_SHA256', + initiator: false, + prologue, + s + }) + + logLocalStaticKeys(xx.s, log) + log.trace('Stage 0 - Responder waiting to receive first message.') + xx.readMessageA(await connection.read(options)) + log.trace('Stage 0 - Responder received first message.') + logRemoteEphemeralKey(xx.re, log) + + log.trace('Stage 1 - Responder sending out first message with signed payload and static key.') + await connection.write(xx.writeMessageB(payload), options) + log.trace('Stage 1 - Responder sent the second handshake message with signed payload.') + logLocalEphemeralKeys(xx.e, log) + + log.trace('Stage 2 - Responder waiting for third handshake message...') + const plaintext = xx.readMessageC(await connection.read(options)) + log.trace('Stage 2 - Responder received the message, finished handshake.') + const received = await decodeHandshakePayload(plaintext, xx.rs, remotePeer) + + const [cs1, cs2] = xx.ss.split() + logCipherState(cs1, cs2, log) + + return { + payload: received.payload, + remotePeer: received.remotePeer, + encrypt: (plaintext) => cs2.encryptWithAd(ZEROLEN, plaintext), + decrypt: (ciphertext, dst) => cs1.decryptWithAd(ZEROLEN, ciphertext, dst) + } +} diff --git a/vendor/js-libp2p/packages/connection-encrypter-noise/src/utils.ts b/vendor/js-libp2p/packages/connection-encrypter-noise/src/utils.ts new file mode 100644 index 00000000..d2d8b519 --- /dev/null +++ b/vendor/js-libp2p/packages/connection-encrypter-noise/src/utils.ts @@ -0,0 +1,226 @@ +import { publicKeyFromProtobuf, publicKeyToProtobuf } from '@libp2p/crypto/keys' +import { StreamMessageEvent, UnexpectedPeerError } from '@libp2p/interface' +import { peerIdFromPublicKey } from '@libp2p/peer-id' +import { AbstractMessageStream, LengthPrefixedDecoder } from '@libp2p/utils' +import { Uint8ArrayList } from 'uint8arraylist' +import { concat as uint8ArrayConcat } from 'uint8arrays/concat' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { CHACHA_TAG_LENGTH, NOISE_MSG_MAX_LENGTH_BYTES, NOISE_MSG_MAX_LENGTH_BYTES_WITHOUT_TAG } from './constants.ts' +import { uint16BEEncode, uint16BEDecode } from './encoder.ts' +import { NoiseHandshakePayload } from './proto/payload.ts' +import type { MetricsRegistry } from './metrics.ts' +import type { NoiseExtensions } from './proto/payload.ts' +import type { HandshakeResult } from './types.ts' +import type { AbortOptions, MessageStream, PeerId, PrivateKey, StreamCloseEvent } from '@libp2p/interface' +import type { SendResult } from '@libp2p/utils' + +export async function createHandshakePayload ( + privateKey: PrivateKey, + staticPublicKey: Uint8Array | Uint8ArrayList, + extensions?: NoiseExtensions +): Promise { + const identitySig = await privateKey.sign(getSignaturePayload(staticPublicKey)) + + return NoiseHandshakePayload.encode({ + identityKey: publicKeyToProtobuf(privateKey.publicKey), + identitySig, + extensions + }) +} + +export async function decodeHandshakePayload ( + payloadBytes: Uint8Array | Uint8ArrayList, + remoteStaticKey?: Uint8Array | Uint8ArrayList, + remotePeer?: PeerId +): Promise<{ payload: NoiseHandshakePayload, remotePeer: PeerId }> { + try { + const payload = NoiseHandshakePayload.decode(payloadBytes) + const publicKey = publicKeyFromProtobuf(payload.identityKey) + const payloadPeer = peerIdFromPublicKey(publicKey) + + // compare peer ids, an expected peer id may not carry its public key (e.g. RSA) + if (remotePeer != null) { + if (!remotePeer.equals(payloadPeer)) { + throw new Error(`Payload peer id ${payloadPeer} does not match expected remote peer id ${remotePeer}`) + } + } + + if (!remoteStaticKey) { + throw new Error('Remote static does not exist') + } + + const signaturePayload = getSignaturePayload(remoteStaticKey) + + if (!(await publicKey.verify(signaturePayload, payload.identitySig))) { + throw new Error('Invalid payload signature') + } + + return { payload, remotePeer: payloadPeer } + } catch (e) { + throw new UnexpectedPeerError((e as Error).message) + } +} + +export function getSignaturePayload (publicKey: Uint8Array | Uint8ArrayList): Uint8Array | Uint8ArrayList { + const prefix = uint8ArrayFromString('noise-libp2p-static-key:') + + if (publicKey instanceof Uint8Array) { + return uint8ArrayConcat([prefix, publicKey], prefix.length + publicKey.length) + } + + publicKey.prepend(prefix) + + return publicKey +} + +class EncryptedMessageStream extends AbstractMessageStream { + private stream: MessageStream + private handshake: HandshakeResult + private metrics?: MetricsRegistry + private decoder: LengthPrefixedDecoder + + constructor (stream: MessageStream, handshake: HandshakeResult, metrics?: MetricsRegistry) { + super({ + log: stream.log, + inactivityTimeout: stream.inactivityTimeout, + maxReadBufferLength: stream.maxReadBufferLength, + direction: stream.direction + }) + + this.stream = stream + this.handshake = handshake + this.metrics = metrics + this.decoder = new LengthPrefixedDecoder({ + lengthDecoder: uint16BEDecode, + maxBufferSize: 16 * 1024 * 1024, + encodingLength: () => 2 + }) + + const noiseOnMessageDecrypt = (evt: StreamMessageEvent): void => { + try { + for (const buf of this.decoder.decode(evt.data)) { + this.onData(this.decrypt(buf)) + } + } catch (err: any) { + this.abort(err) + } + } + this.stream.addEventListener('message', noiseOnMessageDecrypt) + + const noiseOnClose = (evt: StreamCloseEvent): void => { + if (evt.error != null) { + if (evt.local === true) { + this.abort(evt.error) + } else { + this.onRemoteReset() + } + } else { + this.onTransportClosed() + } + } + this.stream.addEventListener('close', noiseOnClose) + + const noiseOnDrain = (): void => { + this.safeDispatchEvent('drain') + } + this.stream.addEventListener('drain', noiseOnDrain) + + const noiseOnRemoteCloseWrite = (): void => { + this.onRemoteCloseWrite() + } + this.stream.addEventListener('remoteCloseWrite', noiseOnRemoteCloseWrite) + } + + encrypt (chunk: Uint8Array | Uint8ArrayList): Uint8ArrayList { + const output = new Uint8ArrayList() + + for (let i = 0; i < chunk.byteLength; i += NOISE_MSG_MAX_LENGTH_BYTES_WITHOUT_TAG) { + let end = i + NOISE_MSG_MAX_LENGTH_BYTES_WITHOUT_TAG + if (end > chunk.byteLength) { + end = chunk.byteLength + } + + let data: Uint8Array | Uint8ArrayList + + if (chunk instanceof Uint8Array) { + data = this.handshake.encrypt(chunk.subarray(i, end)) + } else { + data = this.handshake.encrypt(chunk.sublist(i, end)) + } + + this.metrics?.encryptedPackets.increment() + + output.append(uint16BEEncode(data.byteLength)) + output.append(data) + } + + return output + } + + decrypt (chunk: Uint8Array | Uint8ArrayList): Uint8ArrayList { + const output = new Uint8ArrayList() + + for (let i = 0; i < chunk.byteLength; i += NOISE_MSG_MAX_LENGTH_BYTES) { + let end = i + NOISE_MSG_MAX_LENGTH_BYTES + if (end > chunk.byteLength) { + end = chunk.byteLength + } + + if (end - CHACHA_TAG_LENGTH < i) { + throw new Error('Invalid chunk') + } + + let encrypted: Uint8Array | Uint8ArrayList + + if (chunk instanceof Uint8Array) { + encrypted = chunk.subarray(i, end) + } else { + encrypted = chunk.sublist(i, end) + } + + // memory allocation is not cheap so reuse the encrypted Uint8Array + // see https://github.com/ChainSafe/js-libp2p-noise/pull/242#issue-1422126164 + // this is ok because chacha20 reads bytes one by one and don't reread after that + // it's also tested in https://github.com/ChainSafe/as-chacha20poly1305/pull/1/files#diff-25252846b58979dcaf4e41d47b3eadd7e4f335e7fb98da6c049b1f9cd011f381R48 + const dst = chunk.subarray(i, end - CHACHA_TAG_LENGTH) + try { + const plaintext = this.handshake.decrypt(encrypted, dst) + this.metrics?.decryptedPackets.increment() + + output.append(plaintext) + } catch (e) { + this.metrics?.decryptErrors.increment() + throw e + } + } + + return output + } + + close (options?: AbortOptions): Promise { + return this.stream.close(options) + } + + sendPause (): void { + this.stream.pause() + } + + sendResume (): void { + this.stream.resume() + } + + sendReset (err: Error): void { + this.stream.abort(err) + } + + sendData (data: Uint8ArrayList): SendResult { + return { + sentBytes: data.byteLength, + canSendMore: this.stream.send(this.encrypt(data)) + } + } +} + +export function toMessageStream (connection: MessageStream, handshake: HandshakeResult, metrics?: MetricsRegistry): MessageStream { + return new EncryptedMessageStream(connection, handshake, metrics) +} diff --git a/vendor/nim-libp2p/libp2p/protocols/secure/noise.nim b/vendor/nim-libp2p/libp2p/protocols/secure/noise.nim new file mode 100644 index 00000000..a5760af9 --- /dev/null +++ b/vendor/nim-libp2p/libp2p/protocols/secure/noise.nim @@ -0,0 +1,669 @@ +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright (c) Status Research & Development GmbH + +{.push raises: [].} + +import std/strformat +import chronos, results, chronicles +import protobuf_serialization, protobuf_serialization/pkg/results +import stew/[endians2, byteutils] +import nimcrypto/[utils, sha2, hmac] +import ../../stream/[connection] +import ../../peerid +import ../../peerinfo +import ../../utils/[opt, shortlog, bytesview, protobuf, redact] + +import secure, ../../crypto/[crypto, chacha20poly1305, curve25519, hkdf] + +when defined(libp2p_dump): + import ../../utils/debug + +logScope: + topics = "libp2p noise" + +const + # https://godoc.org/github.com/libp2p/go-libp2p-noise#pkg-constants + NoiseCodec* = "/noise" + + PayloadString = toBytes("noise-libp2p-static-key:") + + ProtocolXXName = "Noise_XX_25519_ChaChaPoly_SHA256" + + # Empty is a special value which indicates k has not yet been initialized. + EmptyKey = default(ChaChaPolyKey) + NonceMax = uint64.high - 1 # max is reserved + NoiseSize = 32 + MaxPlainSize = int(uint16.high - NoiseSize - ChaChaPolyTag.len) + + HandshakeTimeout = 1.minutes + MaxEarlyMuxerProtocols = 100 + +type + KeyPair = object + privateKey: Curve25519Key + publicKey: Curve25519Key + + # https://noiseprotocol.org/noise.html#the-cipherstate-object + CipherState = object + k: ChaChaPolyKey + n: uint64 + + # https://noiseprotocol.org/noise.html#the-symmetricstate-object + SymmetricState = object + cs: CipherState + ck: ChaChaPolyKey + h: MDigest[256] + + # https://noiseprotocol.org/noise.html#the-handshakestate-object + HandshakeState = object + ss: SymmetricState + s: KeyPair + e: KeyPair + rs: Curve25519Key + re: Curve25519Key + + HandshakeResult = object + cs1: CipherState + cs2: CipherState + remoteP2psecret: seq[byte] + rs: Curve25519Key + + NoiseExtensionsMsg* {.proto2.} = object + streamMuxers* {.fieldNumber: 2.}: seq[string] + + Noise* = ref object of Secure + rng: Rng + localPrivateKey: PrivateKey + localPublicKey: seq[byte] + noiseKeys: KeyPair + commonPrologue: seq[byte] + muxers: seq[string] + outgoing: bool + + NoiseConnection* = ref object of SecureConn + readCs: CipherState + writeCs: CipherState + + NoiseError* = object of LPStreamError + NoiseHandshakeError* = object of NoiseError + NoiseDecryptTagError* = object of NoiseError + NoiseOversizedPayloadError* = object of NoiseError + NoiseNonceMaxError* = object of NoiseError # drop connection on purpose + + NoiseHandshakePayloadMsg* {.proto2.} = object + identityKey* {.fieldNumber: 1.}: Opt[seq[byte]] + identitySig* {.fieldNumber: 2.}: Opt[seq[byte]] + extensions* {.fieldNumber: 4.}: Opt[NoiseExtensionsMsg] + +redactType(KeyPair, exported = false) +redactType(CipherState, exported = false) +redactType(SymmetricState, exported = false) +redactType(HandshakeState, exported = false) +redactType(HandshakeResult, exported = false) +redactType(Noise) + +# Utility + +Protobuf.serializerFor( + [NoiseExtensionsMsg, NoiseHandshakePayloadMsg], withMetrics = true, domain = "noise" +) + +func matchMuxers(local, remote: seq[string], initiator: bool): string = + let + preferred = if initiator: local else: remote + supported = if initiator: remote else: local + + for muxer in preferred: + if muxer in supported: + return muxer + +func shortLog*(conn: NoiseConnection): auto = + try: + if conn == nil: + "NoiseConnection(nil)" + else: + &"{shortLog(conn.peerId)}:{conn.oid}" + except ValueError as exc: + raiseAssert(exc.msg) + +chronicles.formatIt(NoiseConnection): + shortLog(it) + +proc genKeyPair(rng: Rng): KeyPair = + let privateKey = Curve25519Key.random(rng) + KeyPair(privateKey: privateKey, publicKey: privateKey.public()) + +proc hashProtocol(name: string): MDigest[256] = + # If protocol_name is less than or equal to HASHLEN bytes in length, + # sets h to protocol_name with zero bytes appended to make HASHLEN bytes. + # Otherwise sets h = HASH(protocol_name). + + var h: MDigest[256] + if name.len <= 32: + h.data[0 .. name.high] = name.toBytes + else: + h = sha256.digest(name) + h + +proc dh(priv: Curve25519Key, pub: Curve25519Key): Curve25519Key = + var key = pub + Curve25519.mul(key, priv) + key + +# Cipherstate + +proc hasKey(cs: CipherState): bool = + cs.k != EmptyKey + +proc encrypt( + state: var CipherState, data: var openArray[byte], ad: openArray[byte] +): ChaChaPolyTag {.noinit, raises: [NoiseNonceMaxError].} = + var nonce: ChaChaPolyNonce + var tag: ChaChaPolyTag + nonce[4 ..< 12] = toBytesLE(state.n) + + ChaChaPoly.encrypt(state.k, nonce, tag, data, ad) + + inc state.n + if state.n > NonceMax: + raise (ref NoiseNonceMaxError)(msg: "Noise max nonce value reached") + tag + +proc encryptWithAd( + state: var CipherState, ad, data: openArray[byte] +): seq[byte] {.raises: [NoiseNonceMaxError].} = + var buf = newSeqOfCap[byte](data.len + sizeof(ChaChaPolyTag)) + buf.add(data) + + let tag = encrypt(state, buf, ad) + + buf.add(tag) + + trace "Noise frame encrypted", messageSize = buf.len, nonce = state.n - 1 + buf + +proc decryptWithAd( + state: var CipherState, ad, data: openArray[byte] +): seq[byte] {.raises: [NoiseDecryptTagError, NoiseNonceMaxError].} = + var + tagIn = data.toOpenArray(data.len - ChaChaPolyTag.len, data.high).intoChaChaPolyTag + tagOut: ChaChaPolyTag + nonce: ChaChaPolyNonce + buf = data[0 .. (data.high - ChaChaPolyTag.len)] + nonce[4 ..< 12] = toBytesLE(state.n) + ChaChaPoly.decrypt(state.k, nonce, tagOut, buf, ad) + if tagIn != tagOut: + debug "Noise frame authentication failed", messageSize = data.len + raise (ref NoiseDecryptTagError)(msg: "decryptWithAd failed tag authentication.") + inc state.n + if state.n > NonceMax: + raise (ref NoiseNonceMaxError)(msg: "Noise max nonce value reached") + trace "Noise frame decrypted", messageSize = data.len + buf + +# Symmetricstate + +proc init(_: type[SymmetricState]): SymmetricState = + let h = ProtocolXXName.hashProtocol + SymmetricState(h: h, ck: h.data.intoChaChaPolyKey, cs: CipherState(k: EmptyKey)) + +proc mixKey(ss: var SymmetricState, ikm: ChaChaPolyKey) = + var temp_keys: array[2, ChaChaPolyKey] + sha256.hkdf(ss.ck, ikm, [], temp_keys) + ss.ck = temp_keys[0] + ss.cs = CipherState(k: temp_keys[1]) + trace "Noise handshake key mixed" + +proc mixHash(ss: var SymmetricState, data: openArray[byte]) = + var ctx: sha256 + ctx.init() + ctx.update(ss.h.data) + ctx.update(data) + ss.h = ctx.finish() + trace "Noise handshake hash mixed" + +# We might use this for other handshake patterns/tokens +proc mixKeyAndHash(ss: var SymmetricState, ikm: openArray[byte]) {.used.} = + var temp_keys: array[3, ChaChaPolyKey] + sha256.hkdf(ss.ck, ikm, [], temp_keys) + ss.ck = temp_keys[0] + ss.mixHash(temp_keys[1]) + ss.cs = CipherState(k: temp_keys[2]) + +proc encryptAndHash( + ss: var SymmetricState, data: openArray[byte] +): seq[byte] {.raises: [NoiseNonceMaxError].} = + # according to spec if key is empty leave plaintext + let encrypted = + if ss.cs.hasKey: + ss.cs.encryptWithAd(ss.h.data, data) + else: + @data + ss.mixHash(encrypted) + encrypted + +proc decryptAndHash( + ss: var SymmetricState, data: openArray[byte] +): seq[byte] {.raises: [NoiseDecryptTagError, NoiseNonceMaxError].} = + # according to spec if key is empty leave plaintext + let decrypted = + if ss.cs.hasKey and data.len > ChaChaPolyTag.len: + ss.cs.decryptWithAd(ss.h.data, data) + else: + @data + ss.mixHash(data) + decrypted + +proc split(ss: var SymmetricState): tuple[cs1, cs2: CipherState] = + var temp_keys: array[2, ChaChaPolyKey] + sha256.hkdf(ss.ck, [], [], temp_keys) + return (CipherState(k: temp_keys[0]), CipherState(k: temp_keys[1])) + +proc init(_: type[HandshakeState]): HandshakeState = + HandshakeState(ss: SymmetricState.init()) + +template write_e(): untyped = + trace "Noise write e" + # Sets e (which must be empty) to GENERATE_KEYPAIR(). + # Appends e.public_key to the buffer. Calls MixHash(e.public_key). + hs.e = genKeyPair(p.rng) + hs.ss.mixHash(hs.e.publicKey) + + hs.e.publicKey.getBytes + +template write_s(): untyped = + trace "Noise write s" + # Appends EncryptAndHash(s.public_key) to the buffer. + hs.ss.encryptAndHash(hs.s.publicKey) + +template dh_ee(): untyped = + trace "Noise dh ee" + # Calls MixKey(DH(e, re)). + hs.ss.mixKey(dh(hs.e.privateKey, hs.re)) + +template dh_es(): untyped = + trace "Noise dh es" + # Calls MixKey(DH(e, rs)) if initiator, MixKey(DH(s, re)) if responder. + when initiator: + hs.ss.mixKey(dh(hs.e.privateKey, hs.rs)) + else: + hs.ss.mixKey(dh(hs.s.privateKey, hs.re)) + +template dh_se(): untyped = + trace "Noise dh se" + # Calls MixKey(DH(s, re)) if initiator, MixKey(DH(e, rs)) if responder. + when initiator: + hs.ss.mixKey(dh(hs.s.privateKey, hs.re)) + else: + hs.ss.mixKey(dh(hs.e.privateKey, hs.rs)) + +# might be used for other token/handshakes +template dh_ss(): untyped {.used.} = + trace "Noise dh ss" + # Calls MixKey(DH(s, rs)). + hs.ss.mixKey(dh(hs.s.privateKey, hs.rs)) + +template read_e(): untyped = + trace "Noise read e", size = msg.len + + if msg.len < Curve25519Key.len: + raise (ref NoiseHandshakeError)(msg: "Noise E, expected more data") + + # Sets re (which must be empty) to the next DHLEN bytes from the message. + # Calls MixHash(re.public_key). + hs.re[0 .. Curve25519Key.high] = msg.toOpenArray(0, Curve25519Key.high) + hs.ss.mixHash(hs.re) + + Curve25519Key.len + +template read_s(): untyped = + trace "Noise read s", size = msg.len + # Sets temp to the next DHLEN + 16 bytes of the message if HasKey() == True, + # or to the next DHLEN bytes otherwise. + # Sets rs (which must be empty) to DecryptAndHash(temp). + let rsLen = + if hs.ss.cs.hasKey: + if msg.len < Curve25519Key.len + ChaChaPolyTag.len: + raise (ref NoiseHandshakeError)(msg: "Noise S, expected more data") + Curve25519Key.len + ChaChaPolyTag.len + else: + if msg.len < Curve25519Key.len: + raise (ref NoiseHandshakeError)(msg: "Noise S, expected more data") + Curve25519Key.len + hs.rs[0 .. Curve25519Key.high] = hs.ss.decryptAndHash(msg.toOpenArray(0, rsLen - 1)) + + rsLen + +proc readFrame( + sconn: RawConn +): Future[seq[byte]] {.async: (raises: [CancelledError, LPStreamError]).} = + var besize {.noinit.}: array[2, byte] + await sconn.readExactly(addr besize[0], besize.len) + let size = uint16.fromBytesBE(besize).int + trace "Noise handshake frame received", sconn, messageSize = size + if size == 0: + return + + var buffer = newSeqUninit[byte](size) + await sconn.readExactly(addr buffer[0], buffer.len) + return buffer + +proc receiveHSMessage( + sconn: RawConn +): Future[seq[byte]] {.async: (raises: [CancelledError, LPStreamError], raw: true).} = + readFrame(sconn) + +template sendHSMessage(sconn: RawConn, parts: varargs[seq[byte]]): untyped = + # sends message (message frame) using multiple seq[byte] that + # concatenated represent entire mesage. + + var msgSize: int + for p in parts: + msgSize += p.len + + trace "Noise handshake frame sending", sconn, messageSize = msgSize + doAssert msgSize <= uint16.high.int + + await sconn.write(@(msgSize.uint16.toBytesBE)) + for p in parts: + await sconn.write(p) + +proc handshakeXXOutbound( + p: Noise, conn: RawConn, p2pSecret: seq[byte] +): Future[HandshakeResult] {.async: (raises: [CancelledError, LPStreamError]).} = + const initiator = true + var hs = HandshakeState.init() + + try: + hs.ss.mixHash(p.commonPrologue) + hs.s = p.noiseKeys + var remoteP2psecret: seq[byte] + + block: # -> e + let ebytes = write_e() + # IK might use this btw! + let hbytes = hs.ss.encryptAndHash([]) + + conn.sendHSMessage(ebytes, hbytes) + + block: # <- e, ee, s, es + var msg = BytesView.init(await conn.receiveHSMessage()) + msg.consume(read_e()) + dh_ee() + msg.consume(read_s()) + dh_es() + remoteP2psecret = hs.ss.decryptAndHash(msg.data()) + + block: # -> s, se + let sbytes = write_s() + dh_se() + # last payload must follow the encrypted way of sending + let hbytes = hs.ss.encryptAndHash(p2pSecret) + + conn.sendHSMessage(sbytes, hbytes) + + let (cs1, cs2) = hs.ss.split() + return + HandshakeResult(cs1: cs1, cs2: cs2, remoteP2psecret: remoteP2psecret, rs: hs.rs) + finally: + burnMem(hs) + +proc handshakeXXInbound( + p: Noise, conn: RawConn, p2pSecret: seq[byte] +): Future[HandshakeResult] {.async: (raises: [CancelledError, LPStreamError]).} = + const initiator = false + + var hs = HandshakeState.init() + + try: + hs.ss.mixHash(p.commonPrologue) + hs.s = p.noiseKeys + var remoteP2psecret: seq[byte] + + block: # <- e + var msg = BytesView.init(await conn.receiveHSMessage()) + msg.consume(read_e()) + # we might use this early data one day, keeping it here for clarity + let earlyData {.used.} = hs.ss.decryptAndHash(msg.data()) + + block: # -> e, ee, s, es + let ebytes = write_e() + dh_ee() + let sbytes = write_s() + dh_es() + let hbytes = hs.ss.encryptAndHash(p2pSecret) + + conn.sendHSMessage(ebytes, sbytes, hbytes) + + block: # <- s, se + var msg = BytesView.init(await conn.receiveHSMessage()) + msg.consume(read_s()) + dh_se() + remoteP2psecret = hs.ss.decryptAndHash(msg.data()) + + let (cs1, cs2) = hs.ss.split() + return + HandshakeResult(cs1: cs1, cs2: cs2, remoteP2psecret: remoteP2psecret, rs: hs.rs) + finally: + burnMem(hs) + +method readMessage*( + sconn: NoiseConnection +): Future[seq[byte]] {.async: (raises: [CancelledError, LPStreamError]).} = + while true: # Discard 0-length payloads + let frame = await sconn.stream.readFrame() + sconn.activity = true + if frame.len > ChaChaPolyTag.len: + let res = sconn.readCs.decryptWithAd([], frame) + if res.len > 0: + when defined(libp2p_dump): + dumpMessage(sconn, FlowDirection.Incoming, res) + return res + + when defined(libp2p_dump): + dumpMessage(sconn, FlowDirection.Incoming, []) + trace "Received 0-length message", sconn + +proc encryptFrame( + sconn: NoiseConnection, cipherFrame: var openArray[byte], src: openArray[byte] +) {.raises: [NoiseNonceMaxError].} = + # Frame consists of length + cipher data + tag + doAssert src.len <= MaxPlainSize + doAssert cipherFrame.len == 2 + src.len + sizeof(ChaChaPolyTag) + + cipherFrame[0 ..< 2] = toBytesBE(uint16(src.len + sizeof(ChaChaPolyTag))) + cipherFrame[2 ..< 2 + src.len()] = src + + let tag = encrypt(sconn.writeCs, cipherFrame.toOpenArray(2, 2 + src.len() - 1), []) + + cipherFrame[2 + src.len() ..< cipherFrame.len] = tag + +method write*( + sconn: NoiseConnection, message: sink seq[byte] +): Future[void] {.async: (raises: [CancelledError, LPStreamError], raw: true).} = + # Fast path: `{.async.}` would introduce a copy of `message` + const FramingSize = 2 + sizeof(ChaChaPolyTag) + + let frames = (message.len + MaxPlainSize - 1) div MaxPlainSize + + var + cipherFrames = newSeqUninit[byte](message.len + frames * FramingSize) + left = message.len + offset = 0 + woffset = 0 + + while left > 0: + let chunkSize = min(MaxPlainSize, left) + + try: + encryptFrame( + sconn, + cipherFrames.toOpenArray(woffset, woffset + chunkSize + FramingSize - 1), + message.toOpenArray(offset, offset + chunkSize - 1), + ) + except NoiseNonceMaxError as exc: + debug "Noise nonce exceeded" + let fut = newFuture[void]("noise.write.nonce") + fut.fail(exc) + return fut + + when defined(libp2p_dump): + dumpMessage( + sconn, + FlowDirection.Outgoing, + message.toOpenArray(offset, offset + chunkSize - 1), + ) + + left = left - chunkSize + offset += chunkSize + woffset += chunkSize + FramingSize + + sconn.activity = true + + # Write all `cipherFrames` in a single write, to avoid interleaving / + # sequencing issues + sconn.stream.write(move(cipherFrames)) + +method handshake*( + p: Noise, conn: RawConn, initiator: bool, peerId: Opt[PeerId] +): Future[SecureConn] {.async: (raises: [CancelledError, LPStreamError]).} = + trace "Noise handshake started", conn, initiator + + let timeout = conn.timeout + conn.timeout = HandshakeTimeout + + # https://github.com/libp2p/specs/tree/master/noise#libp2p-data-in-handshake-messages + let signedPayload = + p.localPrivateKey.sign(PayloadString & p.noiseKeys.publicKey.getBytes) + if signedPayload.isErr(): + raise signedPayload.error().toException( + NoiseHandshakeError, "Failed to sign public key" + ) + + let msg = NoiseHandshakePayloadMsg( + identityKey: Opt.some(p.localPublicKey), + identitySig: Opt.some(signedPayload.get().getBytes()), + extensions: + if p.muxers.len > 0: + Opt.some(NoiseExtensionsMsg(streamMuxers: p.muxers)) + else: + Opt.none(NoiseExtensionsMsg), + ) + + var handshakeRes = + if initiator: + await handshakeXXOutbound(p, conn, msg.encode()) + else: + await handshakeXXInbound(p, conn, msg.encode()) + + var secure = + try: + var + remoteMsg: NoiseHandshakePayloadMsg + remotePubKey: PublicKey + remoteSig: Signature + + remoteMsg = NoiseHandshakePayloadMsg + .decode(handshakeRes.remoteP2psecret) + .valueOrRaise(NoiseHandshakeError) + + if remoteMsg.identityKey.isNone or remoteMsg.identitySig.isNone: + raise newException( + NoiseHandshakeError, "NoiseHandshakePayloadMsg fields must be set" + ) + + if not remotePubKey.init(remoteMsg.identityKey.get()): + raise (ref NoiseHandshakeError)( + msg: "Failed to decode remote public key. (initiator: " & $initiator & ")" + ) + if not remoteSig.init(remoteMsg.identitySig.get()): + raise (ref NoiseHandshakeError)( + msg: "Failed to decode remote signature. (initiator: " & $initiator & ")" + ) + + let verifyPayload = PayloadString & handshakeRes.rs.getBytes + if not remoteSig.verify(verifyPayload, remotePubKey): + raise (ref NoiseHandshakeError)(msg: "Noise handshake signature verify failed.") + else: + trace "Remote Noise signature verified", conn + + let pid = PeerId.init(remotePubKey).valueOr: + raise error.toException(NoiseHandshakeError, "Invalid remote peer id") + + trace "Remote Noise peer identified", peerId = pid + + peerId.ifValue(targetPid): + if not targetPid.validate(): + raise (ref NoiseHandshakeError)(msg: "Failed to validate expected peerId.") + + if pid != targetPid: + var failedKey: PublicKey + discard extractPublicKey(targetPid, failedKey) + trace "Noise handshake peer identity rejected", + initiator, + dealt_peer = conn, + dealt_key = $failedKey, + received_peer = $pid, + received_key = $remotePubKey + raise (ref NoiseHandshakeError)( + msg: "Noise handshake, peer id don't match! " & $pid & " != " & $targetPid + ) + conn.peerId = pid + + var tmp = + NoiseConnection.new(conn, conn.peerId, conn.observedAddr, conn.localAddr) + remoteMsg.extensions.ifValue(extensions): + if extensions.streamMuxers.len <= MaxEarlyMuxerProtocols: + tmp.earlyMuxer = matchMuxers(p.muxers, extensions.streamMuxers, initiator) + if initiator: + tmp.readCs = handshakeRes.cs2 + tmp.writeCs = handshakeRes.cs1 + else: + tmp.readCs = handshakeRes.cs1 + tmp.writeCs = handshakeRes.cs2 + tmp + finally: + burnMem(handshakeRes) + + trace "Noise handshake completed", initiator, peerId = secure.peerId + + conn.timeout = timeout + + return secure + +method closeImpl*(s: NoiseConnection) {.async: (raises: []).} = + await procCall SecureConn(s).closeImpl() + + burnMem(s.readCs) + burnMem(s.writeCs) + +method init*(p: Noise) {.gcsafe.} = + procCall Secure(p).init() + p.codec = NoiseCodec + +proc new*( + T: typedesc[Noise], + rng: Rng, + privateKey: PrivateKey, + outgoing: bool = true, + commonPrologue: seq[byte] = @[], + muxers: seq[string] = @[], +): T = + let pkBytes = privateKey + .getPublicKey() + .expect("Expected valid Private Key") + .getBytes() + .expect("Couldn't get public Key bytes") + + var noise = Noise( + rng: rng, + outgoing: outgoing, + localPrivateKey: privateKey, + localPublicKey: pkBytes, + noiseKeys: genKeyPair(rng), + commonPrologue: commonPrologue, + muxers: muxers, + ) + + noise.init() + noise diff --git a/vendor/rust-libp2p/.pin b/vendor/rust-libp2p/.pin new file mode 100644 index 00000000..28aebb6b --- /dev/null +++ b/vendor/rust-libp2p/.pin @@ -0,0 +1 @@ +c449090f49b5ae2338e8f0727be98e0a739ba135 diff --git a/vendor/rust-libp2p/LICENSE b/vendor/rust-libp2p/LICENSE new file mode 100644 index 00000000..da846963 --- /dev/null +++ b/vendor/rust-libp2p/LICENSE @@ -0,0 +1,18 @@ +Copyright 2017-2020 Parity Technologies (UK) Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/rust-libp2p/transports/noise/src/io.rs b/vendor/rust-libp2p/transports/noise/src/io.rs new file mode 100644 index 00000000..84aad79d --- /dev/null +++ b/vendor/rust-libp2p/transports/noise/src/io.rs @@ -0,0 +1,151 @@ +// Copyright 2019 Parity Technologies (UK) Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +//! Noise protocol I/O. + +mod framed; +pub(crate) mod handshake; +use std::{ + cmp::min, + fmt, io, + pin::Pin, + task::{Context, Poll}, +}; + +use asynchronous_codec::Framed; +use bytes::Bytes; +use framed::{Codec, MAX_FRAME_LEN}; +use futures::{prelude::*, ready}; + +/// A noise session to a remote. +/// +/// `T` is the type of the underlying I/O resource. +pub struct Output { + io: Framed>, + recv_buffer: Bytes, + recv_offset: usize, + send_buffer: Vec, + send_offset: usize, +} + +impl fmt::Debug for Output { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NoiseOutput").finish() + } +} + +impl Output { + fn new(io: Framed>) -> Self { + Output { + io, + recv_buffer: Bytes::new(), + recv_offset: 0, + send_buffer: Vec::new(), + send_offset: 0, + } + } +} + +impl AsyncRead for Output { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + loop { + let len = self.recv_buffer.len(); + let off = self.recv_offset; + if len > 0 { + let n = min(len - off, buf.len()); + buf[..n].copy_from_slice(&self.recv_buffer[off..off + n]); + tracing::trace!(copied_bytes=%(off + n), total_bytes=%len, "read: copied"); + self.recv_offset += n; + if len == self.recv_offset { + tracing::trace!("read: frame consumed"); + // Drop the existing view so `NoiseFramed` can reuse + // the buffer when polling for the next frame below. + self.recv_buffer = Bytes::new(); + } + return Poll::Ready(Ok(n)); + } + + match Pin::new(&mut self.io).poll_next(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(None) => return Poll::Ready(Ok(0)), + Poll::Ready(Some(Err(e))) => return Poll::Ready(Err(e)), + Poll::Ready(Some(Ok(frame))) => { + self.recv_buffer = frame; + self.recv_offset = 0; + } + } + } + } +} + +impl AsyncWrite for Output { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let this = Pin::into_inner(self); + let mut io = Pin::new(&mut this.io); + let frame_buf = &mut this.send_buffer; + + // The MAX_FRAME_LEN is the maximum buffer size before a frame must be sent. + if this.send_offset == MAX_FRAME_LEN { + tracing::trace!(bytes=%MAX_FRAME_LEN, "write: sending"); + ready!(io.as_mut().poll_ready(cx))?; + io.as_mut().start_send(frame_buf)?; + this.send_offset = 0; + } + + let off = this.send_offset; + let n = min(MAX_FRAME_LEN, off.saturating_add(buf.len())); + this.send_buffer.resize(n, 0u8); + let n = min(MAX_FRAME_LEN - off, buf.len()); + this.send_buffer[off..off + n].copy_from_slice(&buf[..n]); + this.send_offset += n; + tracing::trace!(bytes=%this.send_offset, "write: buffered"); + + Poll::Ready(Ok(n)) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = Pin::into_inner(self); + let mut io = Pin::new(&mut this.io); + let frame_buf = &mut this.send_buffer; + + // Check if there is still one more frame to send. + if this.send_offset > 0 { + ready!(io.as_mut().poll_ready(cx))?; + tracing::trace!(bytes= %this.send_offset, "flush: sending"); + io.as_mut().start_send(frame_buf)?; + this.send_offset = 0; + } + + io.as_mut().poll_flush(cx) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + ready!(self.as_mut().poll_flush(cx))?; + Pin::new(&mut self.io).poll_close(cx) + } +} diff --git a/vendor/rust-libp2p/transports/noise/src/io/framed.rs b/vendor/rust-libp2p/transports/noise/src/io/framed.rs new file mode 100644 index 00000000..5364bc8d --- /dev/null +++ b/vendor/rust-libp2p/transports/noise/src/io/framed.rs @@ -0,0 +1,240 @@ +// Copyright 2020 Parity Technologies (UK) Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +//! Provides a [`Codec`] type implementing the [`Encoder`] and [`Decoder`] traits. +//! +//! Alongside a [`asynchronous_codec::Framed`] this provides a [Sink](futures::Sink) +//! and [Stream](futures::Stream) for length-delimited Noise protocol messages. + +use std::{io, mem::size_of}; + +use asynchronous_codec::{Decoder, Encoder}; +use bytes::{Buf, Bytes, BytesMut}; +use prost::Message; + +use super::handshake::proto; +use crate::{Error, protocol::PublicKey}; + +/// Max. size of a noise message. +const MAX_NOISE_MSG_LEN: usize = 65535; +/// Space given to the encryption buffer to hold key material. +const EXTRA_ENCRYPT_SPACE: usize = 1024; +/// Max. length for Noise protocol message payloads. +pub(crate) const MAX_FRAME_LEN: usize = MAX_NOISE_MSG_LEN - EXTRA_ENCRYPT_SPACE; +static_assertions::const_assert! { + MAX_FRAME_LEN + EXTRA_ENCRYPT_SPACE <= MAX_NOISE_MSG_LEN +} + +/// Codec holds the noise session state `S` and acts as a medium for +/// encoding and decoding length-delimited session messages. +pub(crate) struct Codec { + session: S, + + // We reuse write and encryption buffers across multiple messages to avoid reallocations. + // We cannot reuse read and decryption buffers because we cannot return borrowed data. + write_buffer: BytesMut, + encrypt_buffer: BytesMut, +} + +impl Codec { + pub(crate) fn new(session: S) -> Self { + Codec { + session, + write_buffer: BytesMut::default(), + encrypt_buffer: BytesMut::default(), + } + } +} + +impl Codec { + /// Checks if the session was started in the `initiator` role. + pub(crate) fn is_initiator(&self) -> bool { + self.session.is_initiator() + } + + /// Checks if the session was started in the `responder` role. + pub(crate) fn is_responder(&self) -> bool { + !self.session.is_initiator() + } + + /// Converts the underlying Noise session from the [`snow::HandshakeState`] to a + /// [`snow::TransportState`] once the handshake is complete, including the static + /// DH [`PublicKey`] of the remote if received. + /// + /// If the Noise protocol session state does not permit transitioning to + /// transport mode because the handshake is incomplete, an error is returned. + /// + /// An error is also returned if the remote's static DH key is not present or + /// cannot be parsed, as that indicates a fatal handshake error for the noise + /// `XX` pattern, which is the only handshake protocol libp2p currently supports. + pub(crate) fn into_transport(self) -> Result<(PublicKey, Codec), Error> { + let dh_remote_pubkey = self.session.get_remote_static().ok_or_else(|| { + Error::Io(io::Error::other( + "expect key to always be present at end of XX session", + )) + })?; + + let dh_remote_pubkey = PublicKey::from_slice(dh_remote_pubkey)?; + let codec = Codec::new(self.session.into_transport_mode()?); + + Ok((dh_remote_pubkey, codec)) + } +} + +impl Encoder for Codec { + type Error = io::Error; + type Item<'a> = &'a proto::NoiseHandshakePayload; + + fn encode(&mut self, item: Self::Item<'_>, dst: &mut BytesMut) -> Result<(), Self::Error> { + let item_size = item.encoded_len(); + self.write_buffer.reserve(item.encoded_len()); + self.write_buffer.clear(); + item.encode(&mut self.write_buffer)?; + + encrypt( + &self.write_buffer[..item_size], + dst, + &mut self.encrypt_buffer, + |item, buffer| self.session.write_message(item, buffer), + )?; + + Ok(()) + } +} + +impl Decoder for Codec { + type Error = io::Error; + type Item = proto::NoiseHandshakePayload; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + let Some(cleartext) = decrypt(src, |ciphertext, decrypt_buffer| { + self.session.read_message(ciphertext, decrypt_buffer) + })? + else { + return Ok(None); + }; + + let pb = proto::NoiseHandshakePayload::decode(&cleartext[..]).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "Failed decoding handshake payload", + ) + })?; + + Ok(Some(pb)) + } +} + +impl Encoder for Codec { + type Error = io::Error; + type Item<'a> = &'a [u8]; + + fn encode(&mut self, item: Self::Item<'_>, dst: &mut BytesMut) -> Result<(), Self::Error> { + encrypt(item, dst, &mut self.encrypt_buffer, |item, buffer| { + self.session.write_message(item, buffer) + }) + } +} + +impl Decoder for Codec { + type Error = io::Error; + type Item = Bytes; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + decrypt(src, |ciphertext, decrypt_buffer| { + self.session.read_message(ciphertext, decrypt_buffer) + }) + } +} + +/// Encrypts the given cleartext to `dst`. +/// +/// This is a standalone function to allow us reusing the `encrypt_buffer` and to use to across +/// different session states of the noise protocol. +fn encrypt( + cleartext: &[u8], + dst: &mut BytesMut, + encrypt_buffer: &mut BytesMut, + encrypt_fn: impl FnOnce(&[u8], &mut [u8]) -> Result, +) -> io::Result<()> { + tracing::trace!("Encrypting {} bytes", cleartext.len()); + + encrypt_buffer.resize(cleartext.len() + EXTRA_ENCRYPT_SPACE, 0); + let n = encrypt_fn(cleartext, encrypt_buffer).map_err(into_io_error)?; + + tracing::trace!("Outgoing ciphertext has {n} bytes"); + + encode_length_prefixed(&encrypt_buffer[..n], dst); + + Ok(()) +} + +/// Encrypts the given ciphertext. +/// +/// This is a standalone function so we can use it across different session states of the noise +/// protocol. In case `ciphertext` does not contain enough bytes to decrypt the entire frame, +/// `Ok(None)` is returned. +fn decrypt( + ciphertext: &mut BytesMut, + decrypt_fn: impl FnOnce(&[u8], &mut [u8]) -> Result, +) -> io::Result> { + let Some(ciphertext) = decode_length_prefixed(ciphertext) else { + return Ok(None); + }; + + tracing::trace!("Incoming ciphertext has {} bytes", ciphertext.len()); + + let mut decrypt_buffer = BytesMut::zeroed(ciphertext.len()); + let n = decrypt_fn(&ciphertext, &mut decrypt_buffer).map_err(into_io_error)?; + + tracing::trace!("Decrypted cleartext has {n} bytes"); + + Ok(Some(decrypt_buffer.split_to(n).freeze())) +} + +fn into_io_error(err: snow::Error) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, err) +} + +const U16_LENGTH: usize = size_of::(); + +fn encode_length_prefixed(src: &[u8], dst: &mut BytesMut) { + dst.reserve(U16_LENGTH + src.len()); + dst.extend_from_slice(&(src.len() as u16).to_be_bytes()); + dst.extend_from_slice(src); +} + +fn decode_length_prefixed(src: &mut BytesMut) -> Option { + if src.len() < size_of::() { + return None; + } + + let mut len_bytes = [0u8; U16_LENGTH]; + len_bytes.copy_from_slice(&src[..U16_LENGTH]); + let len = u16::from_be_bytes(len_bytes) as usize; + + if src.len() - U16_LENGTH >= len { + // Skip the length header we already read. + src.advance(U16_LENGTH); + Some(src.split_to(len).freeze()) + } else { + None + } +} diff --git a/vendor/rust-libp2p/transports/noise/src/io/handshake.rs b/vendor/rust-libp2p/transports/noise/src/io/handshake.rs new file mode 100644 index 00000000..b3b9cf7d --- /dev/null +++ b/vendor/rust-libp2p/transports/noise/src/io/handshake.rs @@ -0,0 +1,272 @@ +// Copyright 2019 Parity Technologies (UK) Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +//! Noise protocol handshake I/O. + +pub(super) mod proto { + #![allow(unreachable_pub)] + include!("../generated/mod.rs"); + pub use self::payload_proto::{NoiseExtensions, NoiseHandshakePayload}; +} + +use std::{collections::HashSet, io, mem}; + +use asynchronous_codec::Framed; +use futures::prelude::*; +use libp2p_identity as identity; +use multihash::Multihash; +use prost::Message; + +use super::framed::Codec; +use crate::{ + Error, + io::Output, + protocol::{KeypairIdentity, PublicKey, STATIC_KEY_DOMAIN}, +}; + +////////////////////////////////////////////////////////////////////////////// +// Internal + +/// Handshake state. +pub(crate) struct State { + /// The underlying I/O resource. + io: Framed>, + /// The associated public identity of the local node's static DH keypair, + /// which can be sent to the remote as part of an authenticated handshake. + identity: KeypairIdentity, + /// The received signature over the remote's static DH public key, if any. + dh_remote_pubkey_sig: Option>, + /// The known or received public identity key of the remote, if any. + id_remote_pubkey: Option, + /// The WebTransport certhashes of the responder, if any. + responder_webtransport_certhashes: Option>>, + /// The received extensions of the remote, if any. + remote_extensions: Option, +} + +/// Extensions +struct Extensions { + webtransport_certhashes: HashSet>, +} + +impl State +where + T: AsyncRead + AsyncWrite, +{ + /// Initializes the state for a new Noise handshake, using the given local + /// identity keypair and local DH static public key. The handshake messages + /// will be sent and received on the given I/O resource and using the + /// provided session for cryptographic operations according to the chosen + /// Noise handshake pattern. + pub(crate) fn new( + io: T, + session: snow::HandshakeState, + identity: KeypairIdentity, + expected_remote_key: Option, + responder_webtransport_certhashes: Option>>, + ) -> Self { + Self { + identity, + io: Framed::new(io, Codec::new(session)), + dh_remote_pubkey_sig: None, + id_remote_pubkey: expected_remote_key, + responder_webtransport_certhashes, + remote_extensions: None, + } + } +} + +impl State +where + T: AsyncRead + AsyncWrite, +{ + /// Finish a handshake, yielding the established remote identity and the + /// [`Output`] for communicating on the encrypted channel. + pub(crate) fn finish(self) -> Result<(identity::PublicKey, Output), Error> { + let is_initiator = self.io.codec().is_initiator(); + + let (pubkey, framed) = map_into_transport(self.io)?; + + let id_pk = self + .id_remote_pubkey + .ok_or_else(|| Error::AuthenticationFailed)?; + + let is_valid_signature = self.dh_remote_pubkey_sig.as_ref().is_some_and(|s| { + id_pk.verify(&[STATIC_KEY_DOMAIN.as_bytes(), pubkey.as_ref()].concat(), s) + }); + + if !is_valid_signature { + return Err(Error::BadSignature); + } + + // Check WebTransport certhashes that responder reported back to us. + if is_initiator { + // We check only if we care (i.e. Config::with_webtransport_certhashes was used). + if let Some(expected_certhashes) = self.responder_webtransport_certhashes { + let ext = self.remote_extensions.ok_or_else(|| { + Error::UnknownWebTransportCerthashes( + expected_certhashes.to_owned(), + HashSet::new(), + ) + })?; + + let received_certhashes = ext.webtransport_certhashes; + + // Expected WebTransport certhashes must be a strict subset + // of the reported ones. + if !expected_certhashes.is_subset(&received_certhashes) { + return Err(Error::UnknownWebTransportCerthashes( + expected_certhashes, + received_certhashes, + )); + } + } + } + + Ok((id_pk, Output::new(framed))) + } +} + +/// Maps the provided [`Framed`] from the [`snow::HandshakeState`] into the +/// [`snow::TransportState`]. +/// +/// This is a bit tricky because [`Framed`] cannot just be de-composed but only into its +/// [`FramedParts`](asynchronous_codec::FramedParts). However, we need to retain the original +/// [`FramedParts`](asynchronous_codec::FramedParts) because they contain the active read & write +/// buffers. +/// +/// Those are likely **not** empty because the remote may directly write to the stream again after +/// the noise handshake finishes. +fn map_into_transport( + framed: Framed>, +) -> Result<(PublicKey, Framed>), Error> +where + T: AsyncRead + AsyncWrite, +{ + let mut parts = framed.into_parts().map_codec(Some); + + let (pubkey, codec) = mem::take(&mut parts.codec) + .expect("We just set it to `Some`") + .into_transport()?; + + let parts = parts.map_codec(|_| codec); + let framed = Framed::from_parts(parts); + + Ok((pubkey, framed)) +} + +impl From for Extensions { + fn from(value: proto::NoiseExtensions) -> Self { + Extensions { + webtransport_certhashes: value + .webtransport_certhashes + .into_iter() + .filter_map(|bytes| Multihash::read(&bytes[..]).ok()) + .collect(), + } + } +} + +////////////////////////////////////////////////////////////////////////////// +// Handshake Message Futures + +/// A future for receiving a Noise handshake message. +async fn recv(state: &mut State) -> Result +where + T: AsyncRead + Unpin, +{ + match state.io.next().await { + None => Err(io::Error::new(io::ErrorKind::UnexpectedEof, "eof").into()), + Some(Err(e)) => Err(e.into()), + Some(Ok(p)) => Ok(p), + } +} + +/// A future for receiving a Noise handshake message with an empty payload. +pub(crate) async fn recv_empty(state: &mut State) -> Result<(), Error> +where + T: AsyncRead + Unpin, +{ + let payload = recv(state).await?; + if payload.encoded_len() != 0 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Expected empty payload.").into()); + } + + Ok(()) +} + +/// A future for sending a Noise handshake message with an empty payload. +pub(crate) async fn send_empty(state: &mut State) -> Result<(), Error> +where + T: AsyncWrite + Unpin, +{ + state + .io + .send(&proto::NoiseHandshakePayload::default()) + .await?; + Ok(()) +} + +/// A future for receiving a Noise handshake message with a payload identifying the remote. +pub(crate) async fn recv_identity(state: &mut State) -> Result<(), Error> +where + T: AsyncRead + Unpin, +{ + let pb = recv(state).await?; + state.id_remote_pubkey = Some(identity::PublicKey::try_decode_protobuf(&pb.identity_key)?); + + if !pb.identity_sig.is_empty() { + state.dh_remote_pubkey_sig = Some(pb.identity_sig); + } + + if let Some(extensions) = pb.extensions { + state.remote_extensions = Some(extensions.into()); + } + + Ok(()) +} + +/// Send a Noise handshake message with a payload identifying the local node to the remote. +pub(crate) async fn send_identity(state: &mut State) -> Result<(), Error> +where + T: AsyncRead + AsyncWrite + Unpin, +{ + let mut pb = proto::NoiseHandshakePayload { + identity_key: state.identity.public.encode_protobuf(), + ..Default::default() + }; + + pb.identity_sig.clone_from(&state.identity.signature); + + // If this is the responder then send WebTransport certhashes to initiator, if any. + if state.io.codec().is_responder() + && let Some(ref certhashes) = state.responder_webtransport_certhashes + { + let ext = pb + .extensions + .get_or_insert_with(proto::NoiseExtensions::default); + + ext.webtransport_certhashes = certhashes.iter().map(|hash| hash.to_bytes()).collect(); + } + + state.io.send(&pb).await?; + + Ok(()) +} From 6a68b0d4e01e16822e16abd5cd7b4406408b64fb Mon Sep 17 00:00:00 2001 From: Ivan Anishchuk Date: Fri, 6 Nov 2026 10:39:49 +0530 Subject: [PATCH 051/125] fix(security): derive Hashable on the mocked Core.Error etheorem/leanp2p#64 now derives Hashable on Core.Error (720618e). The core mock copies that shape, so it derives the same classes. --- packages/libp2p/security/Libp2p/Security/Mock/Core.lean | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/libp2p/security/Libp2p/Security/Mock/Core.lean b/packages/libp2p/security/Libp2p/Security/Mock/Core.lean index 0c516340..a268036a 100644 --- a/packages/libp2p/security/Libp2p/Security/Mock/Core.lean +++ b/packages/libp2p/security/Libp2p/Security/Mock/Core.lean @@ -47,7 +47,7 @@ inductive Error where | authentication (detail : String) | timeout (detail : String) | closed (detail : String) - deriving DecidableEq, Repr + deriving DecidableEq, Repr, Hashable /-- The log line of the error: the kind, then the rendered detail. -/ def Error.render : Error → String From 1a330ae04f82e25e39daa02fb2838198dfb7b641 Mon Sep 17 00:00:00 2001 From: Ivan Anishchuk Date: Fri, 6 Nov 2026 11:10:01 +0530 Subject: [PATCH 052/125] refactor(security): take the keys and the peer id from libp2p/identity W5.1 merged in etheorem/leanp2p#63, so the payload check, the seam values, and the suites use the real Libp2p.Identity, and Mock/Identity.lean goes. The real package covers all four key types where the mock covered Ed25519 alone. The core mock stays until W5.2 merges. --- Tests/Interop/Noise.lean | 4 +- Tests/Noise.lean | 4 +- .../security/Libp2p/Security/Handshake.lean | 8 +- .../security/Libp2p/Security/Mock/Core.lean | 8 +- .../Libp2p/Security/Mock/Identity.lean | 154 ------------------ .../security/Libp2p/Security/Noise.lean | 10 +- .../security/Libp2p/Security/Payload.lean | 6 +- packages/libp2p/security/docs/ARCHITECTURE.md | 37 ++--- 8 files changed, 30 insertions(+), 201 deletions(-) delete mode 100644 packages/libp2p/security/Libp2p/Security/Mock/Identity.lean diff --git a/Tests/Interop/Noise.lean b/Tests/Interop/Noise.lean index 74bf3eac..c70869ea 100644 --- a/Tests/Interop/Noise.lean +++ b/Tests/Interop/Noise.lean @@ -31,9 +31,7 @@ set_option autoImplicit false namespace Tests.Interop.Noise open Conc (Async) --- The keys and the peer id come from the identity mock of the package --- until W5.1 merges (RULES.md, "Working ahead of the tree"). -open Libp2p.Security.Mock.Identity +open Libp2p.Identity open Libp2p.Security /-- 32 bytes of one value, the form of both identity seeds. -/ diff --git a/Tests/Noise.lean b/Tests/Noise.lean index 76ae299e..14bdfedd 100644 --- a/Tests/Noise.lean +++ b/Tests/Noise.lean @@ -54,9 +54,7 @@ set_option autoImplicit false namespace Tests --- The keys and the peer id come from the identity mock of the package --- until W5.1 merges (RULES.md, "Working ahead of the tree"). -open Bytes Libp2p.Security.Mock.Identity Libp2p.Security +open Bytes Libp2p.Identity Libp2p.Security /-- 32 bytes from hex. -/ private def key32 (where_ hex : String) : IO Noise.Bytes32 := do diff --git a/packages/libp2p/security/Libp2p/Security/Handshake.lean b/packages/libp2p/security/Libp2p/Security/Handshake.lean index aae9f0c4..2dab1885 100644 --- a/packages/libp2p/security/Libp2p/Security/Handshake.lean +++ b/packages/libp2p/security/Libp2p/Security/Handshake.lean @@ -2,7 +2,7 @@ import Crypto import Bytes import Proto import Streams.Framing -import Libp2p.Security.Mock.Identity +import Libp2p.Identity import Libp2p.Security.Mock.Core import Libp2p.Security.Constants @@ -83,8 +83,8 @@ set_option autoImplicit false namespace Libp2p.Security.Noise --- `Role` and the identity error come from the mocks of W5.2 and W5.1 --- (RULES.md, "Working ahead of the tree"). +-- `Role` comes from the mock of W5.2 (RULES.md, "Working ahead of the +-- tree"). open Bytes Libp2p.Security.Mock.Core /-! ## Data types @@ -159,7 +159,7 @@ inductive Error where | missingIdentity /-- The identity key did not unmarshal, or no peer id derives from it. -/ - | identity (e : Mock.Identity.Error) + | identity (e : Libp2p.Identity.Error) /-- The signature does not verify over the static key of the handshake (owned contract 4). -/ | badSignature diff --git a/packages/libp2p/security/Libp2p/Security/Mock/Core.lean b/packages/libp2p/security/Libp2p/Security/Mock/Core.lean index a268036a..4315ee89 100644 --- a/packages/libp2p/security/Libp2p/Security/Mock/Core.lean +++ b/packages/libp2p/security/Libp2p/Security/Mock/Core.lean @@ -1,6 +1,6 @@ import Conc import Streams -import Libp2p.Security.Mock.Identity +import Libp2p.Identity /-! # Mock: the surface of `libp2p/core` that Noise uses @@ -11,8 +11,8 @@ ahead of the tree"). The types are copies of the core ARCHITECTURE, "Public surface": `ProtocolId`, `Role`, `Core.Error` with its four arms, and the security seam, `SecureConn` and `SecurityProtocol`, with the handshake signature of root §4.9. The mock imports nothing from the -real package, and it states no axiom. `PeerId` comes from the identity -mock beside it. +real package, and it states no axiom. `PeerId` comes from the merged +`libp2p/identity`. At the integration, the package imports `Libp2p.Core`, drops `open Libp2p.Security.Mock`, and the module goes. @@ -22,7 +22,7 @@ set_option autoImplicit false namespace Libp2p.Security.Mock.Core -open Conc Streams Libp2p.Security.Mock.Identity +open Conc Streams Libp2p.Identity /-- A protocol id: the exact text that multistream-select carries (§3.4). -/ diff --git a/packages/libp2p/security/Libp2p/Security/Mock/Identity.lean b/packages/libp2p/security/Libp2p/Security/Mock/Identity.lean deleted file mode 100644 index 44384861..00000000 --- a/packages/libp2p/security/Libp2p/Security/Mock/Identity.lean +++ /dev/null @@ -1,154 +0,0 @@ -import Bytes -import Proto -import Multiformats -import Crypto - -/-! -# Mock: the surface of `libp2p/identity` that Noise uses - -W5.1 (`libp2p/identity`, etheorem/leanp2p#11) has not merged, so this -package builds against a mock of its frozen surface (RULES.md, "Working -ahead of the tree"; the identity ARCHITECTURE, "Public surface"). The -mock imports nothing from the real package, and it states no axiom. - -The names and the shapes follow the identity ARCHITECTURE: `KeyType`, -`PubKey` with `unmarshal?`, `marshal`, `data`, and `verify`, `PrivKey` -with `ofData?`, `toPublic`, and `sign`, `PeerId` with `ofPubKey` and its -text form, and `Identity.Error`, a copy of the real error inductive. - -**Why the opaques have values.** The Noise suites sign and verify real -handshake payloads, and the go transcript checks the peer ids that -go-libp2p printed, so the mock computes. It covers Ed25519 alone, the -key type of every Noise suite and of go-libp2p's default identity: - -* the `Keys` protobuf of the peer-ids spec, field 1 the varint `Type` - and field 2 the `Data` bytes, with an Ed25519 `Data` of 32 bytes; -* the peer id: the marshaled key is 36 bytes, at most 42, so it - inlines as an identity multihash, and its text form is base58btc; -* signing and verification through the Ed25519 entries of the facade. - -Every other key type fails with `keyData`. The kernel sees no -definition, so no proof can unfold one. At the integration, the -package imports `Libp2p.Identity`, drops `open Libp2p.Security.Mock`, -and the module goes. --/ - -set_option autoImplicit false - -namespace Libp2p.Security.Mock.Identity - -open Bytes - -/-- The key types of the peer-ids spec. -/ -inductive KeyType where - | rsa - | ed25519 - | secp256k1 - | ecdsa - deriving DecidableEq, Repr - -/-- The protobuf code of a key type (the peer-ids spec, `KeyType`). -/ -def KeyType.toCode : KeyType → Nat - | .rsa => 0 - | .ed25519 => 1 - | .secp256k1 => 2 - | .ecdsa => 3 - -/-- A failure of the identity package: a copy of the real arms. -/ -inductive Error where - | message (e : Pb.Error) - | missingField (field : Nat) - | badKeyType (code : Nat) - | keyData (t : KeyType) (size : Nat) - | publicMismatch - | crypto (e : Crypto.CryptoError) - | peerIdCodec (e : Multiformats.Error) - | notPeerShape - deriving Repr, BEq - -/-- A public key: its type and its `Data` bytes. -/ -structure PubKey where - keyType : KeyType - data : ByteArray - -instance : Repr PubKey where - reprPrec k _ := s!"PubKey {repr k.keyType} {toHex k.data}" - -/-- A private key: its type and the Ed25519 seed. -/ -structure PrivKey where - keyType : KeyType - seed : Crypto.Key32 - -/-- A peer id: the bytes of its multihash. -/ -structure PeerId where - bytes : ByteArray - -instance : BEq PeerId := ⟨fun a b => a.bytes == b.bytes⟩ - -instance : Repr PeerId where - reprPrec p _ := s!"PeerId {toHex p.bytes}" - -namespace Impl - -/-- The `Keys` protobuf of an Ed25519 key: field 1 (`Type`, varint 1), -then field 2 (`Data`, 32 bytes). -/ -def ed25519Header : ByteArray := ⟨#[0x08, 0x01, 0x12, 0x20]⟩ - -def marshal (k : PubKey) : ByteArray := ed25519Header ++ k.data - -def unmarshal? (b : ByteArray) : Except Error PubKey := - if b.size == 36 && b.extract 0 4 == ed25519Header then .ok ⟨.ed25519, b.extract 4 36⟩ - else .error (.keyData .ed25519 b.size) - -def verify (k : PubKey) (msg sig : ByteArray) : Bool := - match k.keyType with - | .ed25519 => Crypto.ed25519Verify k.data msg sig - | _ => false - -def privOfData? (t : KeyType) (d : ByteArray) : Except Error PrivKey := - match t, Crypto.Key32.ofByteArray? d with - | .ed25519, some seed => .ok ⟨.ed25519, seed⟩ - | _, _ => .error (.keyData t d.size) - -def toPublic (k : PrivKey) : Except Error PubKey := do - let pub ← (Crypto.ed25519PublicKey k.seed).mapError .crypto - return ⟨.ed25519, pub.bytes⟩ - -def sign (k : PrivKey) (msg : ByteArray) : ExceptT Error IO ByteArray := - ExceptT.mk (pure ((Crypto.ed25519Sign k.seed msg).mapError .crypto)) - -/-- The identity multihash of the marshaled key: code `0x00`, then the -length, then the bytes. -/ -def ofPubKey (k : PubKey) : Except Error PeerId := - let m := marshal k - .ok ⟨ByteArray.mk #[0x00, m.size.toUInt8] ++ m⟩ - -end Impl - -/-- The strict unmarshal of a marshaled `PublicKey`. -/ -opaque PubKey.unmarshal? : ByteArray → Except Error PubKey := Impl.unmarshal? - -/-- The deterministic marshaling of a public key. -/ -opaque PubKey.marshal : PubKey → ByteArray := Impl.marshal - -/-- Verify a signature of `msg` under the key. -/ -opaque PubKey.verify : PubKey → ByteArray → ByteArray → Bool := Impl.verify - -/-- The private key of a key type and its `Data` bytes. -/ -opaque PrivKey.ofData? : KeyType → ByteArray → Except Error PrivKey := Impl.privOfData? - -/-- The public key of a private key. -/ -opaque PrivKey.toPublic : PrivKey → Except Error PubKey := Impl.toPublic - -/-- Sign `msg` with the key. -/ -opaque PrivKey.sign : PrivKey → ByteArray → ExceptT Error IO ByteArray := Impl.sign - -/-- The peer id of a public key. -/ -opaque PeerId.ofPubKey : PubKey → Except Error PeerId := Impl.ofPubKey - -/-- The legacy base58btc text form of a peer id. -/ -opaque PeerId.toString : PeerId → String := fun p => Multiformats.Base58.encode p.bytes - -instance : ToString PeerId := ⟨PeerId.toString⟩ - -end Libp2p.Security.Mock.Identity diff --git a/packages/libp2p/security/Libp2p/Security/Noise.lean b/packages/libp2p/security/Libp2p/Security/Noise.lean index a5f5a0b5..b0c62f9e 100644 --- a/packages/libp2p/security/Libp2p/Security/Noise.lean +++ b/packages/libp2p/security/Libp2p/Security/Noise.lean @@ -1,6 +1,6 @@ import Crypto import Streams -import Libp2p.Security.Mock.Identity +import Libp2p.Identity import Libp2p.Security.Mock.Core import Libp2p.Security.Constants import Libp2p.Security.Handshake @@ -56,10 +56,10 @@ set_option autoImplicit false namespace Libp2p.Security.Noise --- The keys, the peer id, and the seam types come from the mocks of --- W5.1 and W5.2 (RULES.md, "Working ahead of the tree"). At the --- integration, `Mock.Core` reads `Libp2p.Core` again. -open Libp2p.Security.Mock.Identity +-- The seam types come from the mock of W5.2 (RULES.md, "Working ahead +-- of the tree"). At the integration, `Mock.Core` reads `Libp2p.Core` +-- again. +open Libp2p.Identity open Conc (Async) open Streams (Connection BufReader) diff --git a/packages/libp2p/security/Libp2p/Security/Payload.lean b/packages/libp2p/security/Libp2p/Security/Payload.lean index 9d0bd07d..329d1781 100644 --- a/packages/libp2p/security/Libp2p/Security/Payload.lean +++ b/packages/libp2p/security/Libp2p/Security/Payload.lean @@ -1,5 +1,5 @@ import Proto -import Libp2p.Security.Mock.Identity +import Libp2p.Identity import Libp2p.Security.Constants import Libp2p.Security.Handshake @@ -37,9 +37,7 @@ set_option autoImplicit false namespace Libp2p.Security.Noise --- The keys and the peer id come from the mock of W5.1 (RULES.md, --- "Working ahead of the tree"). -open Bytes Libp2p.Security.Mock.Identity +open Bytes Libp2p.Identity /-- The bytes of one present payload field, with the varint budget of its length prefix (`Pb.Value.Valid`), so `toFields` needs no check. -/ diff --git a/packages/libp2p/security/docs/ARCHITECTURE.md b/packages/libp2p/security/docs/ARCHITECTURE.md index d5764443..0e94b1a0 100644 --- a/packages/libp2p/security/docs/ARCHITECTURE.md +++ b/packages/libp2p/security/docs/ARCHITECTURE.md @@ -19,8 +19,7 @@ and no work is planned for it. | `Libp2p/Security/Payload.lean` | the `NoiseHandshakePayload` protobuf, its `ProtoMessage` instance (§3.2), the signature input, and the 8 KiB gate | | `Libp2p/Security/Transport.lean` | the framed AEAD reads and writes over the two cipher states of `split`, wrapped as the `SecureConn` surface | | `Libp2p/Security/Noise.lean` | `Noise.securityProtocol`, the fixed-key test value, and the `Except.mapError` into `Core.Error` (§3.5) | -| `Libp2p/Security/Mock/Core.lean` | the mock of the `libp2p/core` surface this package uses, until W5.2 merges ("The mocks" below) | -| `Libp2p/Security/Mock/Identity.lean` | the mock of the `libp2p/identity` surface this package uses, until W5.1 merges ("The mocks" below) | +| `Libp2p/Security/Mock/Core.lean` | the mock of the `libp2p/core` surface this package uses, until W5.2 merges ("The mock" below) | All declarations live in the `Libp2p.Security` namespace, and the root module re-exports the public submodules. §3.1 names the production @@ -280,28 +279,18 @@ the spike's own recorded rule. (r5) defines none. Each transport cipher state runs its counter toward 2^64 − 1 nonces and never reaches them in a session's life. -## The mocks +## The mock -W5.1 (`libp2p/identity`) and W5.2 (`libp2p/core`) have not merged, so -the package builds against mocks of their frozen surfaces (RULES.md, -"Working ahead of the tree"). Both live under -`Libp2p.Security.Mock`, import nothing from the real packages, and -state no axiom. +W5.2 (`libp2p/core`) has not merged, so the package builds against a +mock of its frozen surface (RULES.md, "Working ahead of the tree"). +`Mock/Core.lean` copies the types: `ProtocolId`, `Role`, `Core.Error` +with its four arms, `SecureConn`, and `SecurityProtocol` (the core +pair, public surface). It lives under `Libp2p.Security.Mock`, imports +nothing from the real package, and states no axiom. The keys and the +peer id come from the merged `libp2p/identity`. -- `Mock/Core.lean` copies the types: `ProtocolId`, `Role`, - `Core.Error` with its four arms, `SecureConn`, and - `SecurityProtocol` (the core pair, public surface). -- `Mock/Identity.lean` declares the key and peer-id surface: - `KeyType`, `PubKey`, `PrivKey`, `PeerId`, and a copy of - `Identity.Error` (the identity pair, public surface). The operations - are `opaque` with values, because the suites sign and verify real - payloads and check the peer ids that go-libp2p printed. The values - cover Ed25519 alone, the key type of every suite here; every other - key type fails with `keyData`. The kernel sees no definition, so no - proof unfolds one. - -At the integration, the modules import `Libp2p.Core` and -`Libp2p.Identity`, the opens drop `Mock`, and both mock modules go. +At the integration, the modules import `Libp2p.Core`, the opens drop +`Mock`, and the mock module goes. ## What the package must not depend on @@ -309,8 +298,8 @@ At the integration, the modules import `Libp2p.Core` and security). The `SecureConn` inputs and the error target come from `core`; the payload's key checks and the `PeerId` derivation come from `identity`. No import of `libp2p/negotiate`, `libp2p/mux`, - `libp2p/host`, or `libp2p/protocols`. Until W5.1 and W5.2 merge, both - surfaces come from the mocks of this package ("The mocks" below). + `libp2p/host`, or `libp2p/protocols`. Until W5.2 merges, the core + surface comes from the mock of this package ("The mock" above). - Crypto reaches the package through the `Crypto` facade only (§3.6): SHA-256, HKDF, X25519, and ChaCha20-Poly1305. No `crypto/backend` and no `crypto/pure` import. The signature checks go through the sign From c4e2b738d4f047c5891ff59c5ea25423582b47cb Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:43:04 +0800 Subject: [PATCH 053/125] Add the pure gossipsub interop core Parse the captured script and retain harness parameters that have no Core field. Add total message ids, node seeds, and NDJSON records so the later stack can execute the script without changing these contracts. --- Tests/Gossipsub/Interop.lean | 329 +++++++++++++++++ Tests/fixtures/gossipsub-interop-params.json | 1 + .../libp2p/protocols/Libp2p/Protocols.lean | 1 + .../Libp2p/Protocols/Gossipsub/Interop.lean | 11 + .../Protocols/Gossipsub/Interop/Address.lean | 38 ++ .../Gossipsub/Interop/MessageId.lean | 44 +++ .../Protocols/Gossipsub/Interop/Record.lean | 56 +++ .../Protocols/Gossipsub/Interop/Script.lean | 345 ++++++++++++++++++ 8 files changed, 825 insertions(+) create mode 100644 Tests/Gossipsub/Interop.lean create mode 100644 Tests/fixtures/gossipsub-interop-params.json create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Address.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/MessageId.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Record.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Script.lean diff --git a/Tests/Gossipsub/Interop.lean b/Tests/Gossipsub/Interop.lean new file mode 100644 index 00000000..1c4c1477 --- /dev/null +++ b/Tests/Gossipsub/Interop.lean @@ -0,0 +1,329 @@ +import Tests.Support +import Libp2p.Protocols.Gossipsub.Interop + +/-! +# GossipSub interop pure-core suite + +This suite checks the adapter against the captured 32-node script and the +pure edge cases required by specification sections 2, 3, 5, 6, and 7. +The fixture count is top-level 100. Recursive instruction counts include the +80 conditional wrappers and their nested instructions, so they total 180. +-/ + +set_option autoImplicit false + +namespace Tests.Gossipsub.Interop + +open Lean (Json) +open Tests (Runner loadFixture) +open Libp2p.Protocols.Gossipsub +open Libp2p.Protocols.Gossipsub.Interop + +private def fixture : String := "gossipsub-interop-params.json" + +structure Counts where + total : Nat := 0 + ifNode : Nat := 0 + init : Nat := 0 + connect : Nat := 0 + subscribe : Nat := 0 + publish : Nat := 0 + validationDelay : Nat := 0 + wait : Nat := 0 + +private def add (left right : Counts) : Counts := + { total := left.total + right.total + ifNode := left.ifNode + right.ifNode + init := left.init + right.init + connect := left.connect + right.connect + subscribe := left.subscribe + right.subscribe + publish := left.publish + right.publish + validationDelay := left.validationDelay + right.validationDelay + wait := left.wait + right.wait } + +private def countInstruction : Instruction → Counts + | .initGossipSub _ => { total := 1, init := 1 } + | .ifNodeIDEquals _ nested => add { total := 1, ifNode := 1 } (countInstruction nested) + | .connect _ => { total := 1, connect := 1 } + | .subscribeToTopic _ _ => { total := 1, subscribe := 1 } + | .publish _ _ _ => { total := 1, publish := 1 } + | .setTopicValidationDelay _ _ => { total := 1, validationDelay := 1 } + | .waitUntil _ => { total := 1, wait := 1 } + +private def countInstructions (instructions : List Instruction) : Counts := + instructions.foldl (fun counts instruction => add counts (countInstruction instruction)) {} + +private def firstInit : List Instruction → Option GossipSubParams + | [] => none + | .initGossipSub params :: _ => some params + | .ifNodeIDEquals _ nested :: rest => firstInit (nested :: rest) + | _ :: rest => firstInit rest + +private def firstNested : List Instruction → Option Instruction + | [] => none + | .ifNodeIDEquals nodeId nested :: _ => some (.ifNodeIDEquals nodeId nested) + | _ :: rest => firstNested rest + +private def hasConnect : List Instruction → Bool + | [] => false + | .connect peers :: _ => !peers.isEmpty + | .ifNodeIDEquals _ nested :: rest => hasConnect (nested :: rest) + | _ :: rest => hasConnect rest + +private def hasSubscribe : List Instruction → Bool + | [] => false + | .subscribeToTopic topic isPartial :: _ => topic == "a-subnet" && !isPartial + | .ifNodeIDEquals _ nested :: rest => hasSubscribe (nested :: rest) + | _ :: rest => hasSubscribe rest + +private def hasPublish : List Instruction → Bool + | [] => false + | .publish messageId size topic :: _ => messageId == 0 && size == 98304 && topic == "a-subnet" + | .ifNodeIDEquals _ nested :: rest => hasPublish (nested :: rest) + | _ :: rest => hasPublish rest + +private def hasDelay : List Instruction → Bool + | [] => false + | .setTopicValidationDelay topic delay :: _ => topic == "a-subnet" && delay == 0.005 + | .ifNodeIDEquals _ nested :: rest => hasDelay (nested :: rest) + | _ :: rest => hasDelay rest + +private def hasWait : List Instruction → Bool + | [] => false + | .waitUntil seconds :: rest => seconds == 342 || hasWait rest + | .ifNodeIDEquals _ nested :: rest => hasWait (nested :: rest) + | _ :: rest => hasWait rest + +private def parsedJson (source : String) : Json := + match Json.parse source with + | .ok value => value + | .error _ => Json.null + +private def isError {α : Type} : Except String α → Bool + | .error _ => true + | .ok _ => false + +private def errorIs {α : Type} (message : String) : Except String α → Bool + | .error error => error == message + | .ok _ => false + +private def stringField (value : Json) (name : String) : Option String := + match value.getObjVal? name with + | .ok field => field.getStr?.toOption + | .error _ => none + +private def intField (value : Json) (name : String) : Option Int := + match value.getObjVal? name with + | .ok field => field.getInt?.toOption + | .error _ => none + +/-- The fixture, instruction fields, and parameter conversion. -/ +private def checkScript (r : Runner) : IO Unit := do + r.section "gossipsub interop script" "vendor/test-plans/gossipsub-interop/script_instruction.py" + let fixtureJson ← loadFixture fixture + let parsed := parseScriptJson fixtureJson + match parsed with + | .error _ => + r.check "captured script parses" false + | .ok script => + let counts := countInstructions script.instructions + r.check "top-level instruction count" (script.instructions.length == 100) + r.check "recursive instruction count" (counts.total == 180) + r.check "ifNodeIDEquals count" (counts.ifNode == 80) + r.check "initGossipSub count" (counts.init == 32) + r.check "connect count" (counts.connect == 32) + r.check "publish count" (counts.publish == 16) + r.check "waitUntil count" (counts.wait == 18) + r.check "setTopicValidationDelay count" (counts.validationDelay == 1) + r.check "subscribeToTopic count" (counts.subscribe == 1) + r.check "connect fields decode" (hasConnect script.instructions) + r.check "subscribe fields decode" (hasSubscribe script.instructions) + r.check "publish fields decode" (hasPublish script.instructions) + r.check "validation delay fields decode" (hasDelay script.instructions) + r.check "wait fields decode" (hasWait script.instructions) + match firstNested script.instructions with + | some (.ifNodeIDEquals nodeId (.initGossipSub _)) => + r.check "nested node id decodes" (nodeId == 0) + | _ => r.check "nested instruction decodes" false + match firstInit script.instructions with + | none => r.check "captured parameters present" false + | some params => + r.check "captured parameter pair retained" (params.raw.length == 1) + r.check "HeartbeatInitialDelay retained" (params.heartbeatInitialDelay.map nanosecondsToMillis == some 100) + r.check "HeartbeatInitialDelay is not mapped" (match toCoreParams params with + | .ok core => core.heartbeatInterval == Core.defaults.heartbeatInterval + | .error _ => false) + r.check "HeartbeatInitialDelay is unmapped" (params.unmapped.length == 1 && + params.unmapped.any (fun pair => pair.1 == "HeartbeatInitialDelay")) + +/-- Each unsupported and malformed input returns a typed parse error. -/ +private def checkErrors (r : Runner) : IO Unit := do + r.check "script scalar rejected" (errorIs "root.script: expected array" (parseScript "{\"script\":5}")) + r.check "unknown instruction rejected" (errorIs "script[0].type: unknown instruction type nope" (parseScript "{\"script\":[{\"type\":\"nope\"}]}")) + r.check "publish missing topic rejected" (isError (parseScriptJson (parsedJson "{\"script\":[{\"type\":\"publish\",\"messageID\":1,\"messageSizeBytes\":8}]}"))) + r.check "connect non-integer rejected" (isError (parseScriptJson (parsedJson "{\"script\":[{\"type\":\"connect\",\"connectTo\":[\"x\"]}]}"))) + r.check "addPartialMessage rejected explicitly" + (errorIs "script[0]: addPartialMessage is out of scope for v1.1" + (parseScriptJson (parsedJson "{\"script\":[{\"type\":\"addPartialMessage\"}]}"))) + r.check "publishPartial rejected explicitly" + (errorIs "script[0]: publishPartial is out of scope for v1.1" + (parseScriptJson (parsedJson "{\"script\":[{\"type\":\"publishPartial\"}]}"))) + +/-- The record encoder emits compact, escaped JSON objects. -/ +private def checkRecords (r : Runner) : IO Unit := do + let peer := peerIdLine "2026-11-06T00:00:00Z" "peer" 3 + let received := receivedMessageLine "2026-11-06T00:00:00Z" 31 (some "peer\"\\") (some "topic\"\\") + r.check "PeerID line exact shape" + (peer == "{\"id\":\"peer\",\"msg\":\"PeerID\",\"node_id\":3,\"time\":\"2026-11-06T00:00:00Z\"}") + r.check "PeerID line has required fields" + (stringField (parsedJson peer) "msg" == some "PeerID" && intField (parsedJson peer) "node_id" == some 3) + r.check "received line exact shape" + (received == "{\"from\":\"peer\\\"\\\\\",\"id\":\"31\",\"msg\":\"Received Message\",\"time\":\"2026-11-06T00:00:00Z\",\"topic\":\"topic\\\"\\\\\"}") + r.check "received line parses back" + (stringField (parsedJson received) "topic" == some "topic\"\\" && stringField (parsedJson received) "id" == some "31") + r.check "records stay single line" (!peer.contains '\n' && !received.contains '\n') +/-- The message id and node address edge cases. -/ +private def checkIds (r : Runner) : IO Unit := do + r.check "message id one" (messageIdString [0, 0, 0, 0, 0, 0, 0, 1] == "1") + r.check "message id maximum" (messageIdString [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff] == "18446744073709551615") + r.check "message id big endian" (messageIdString [1, 0, 0, 0, 0, 0, 0, 0] == "72057594037927936") + r.check "short message id is right padded" (messageIdString [1] == "72057594037927936") + r.check "node zero" (nodeId? "node0" == some 0) + r.check "node thirty one" (nodeId? "node31" == some 31) + r.check "node without number rejected" (nodeId? "node" == none) + r.check "leading zero rejected" (nodeId? "node01" == none) + r.check "negative node rejected" (nodeId? "node-1" == none) + r.check "uppercase node rejected" (nodeId? "Node1" == none) + r.check "suffix text rejected" (nodeId? "node1x" == none) + r.check "empty hostname rejected" (nodeId? "" == none) + r.check "overflow node rejected" (nodeId? "node18446744073709551616" == none) + r.check "peer seed is 32 bytes" ((peerSeed 31).length == 32) + r.check "peer seed uses little endian" ((peerSeed 31).take 8 == [31, 0, 0, 0, 0, 0, 0, 0]) + + +/-- All mapped fields, retained fields, and pure scheduling contracts. -/ +private def checkParameters (r : Runner) : IO Unit := do + let json := parsedJson "{\"D\":8,\"Dlo\":6,\"Dhi\":14,\"Dscore\":5,\"Dlazy\":7,\"HistoryLength\":9,\"HistoryGossip\":4,\"GossipFactor\":0.255,\"HeartbeatInitialDelay\":100100000,\"HeartbeatInterval\":100100000,\"FanoutTTL\":1201000000,\"PruneBackoff\":2001999999,\"Dout\":2}" + match parseGossipSubParams "params" json with + | .error _ => r.check "all parameter fields parse" false + | .ok params => + r.check "all source parameter pairs retained" (params.raw.length == 13) + r.check "unsupported fields retained" (params.unmapped.map Prod.fst == + ["Dout", "HeartbeatInitialDelay"]) + match toCoreParams params with + | .error _ => r.check "valid parameters map" false + | .ok core => + r.check "mesh D maps" (core.mesh.d == 8) + r.check "mesh Dlo maps" (core.mesh.dLow == 6) + r.check "mesh Dhi maps" (core.mesh.dHigh == 14) + r.check "mesh Dscore maps" (core.mesh.dScore == 5) + r.check "mesh Dlazy maps" (core.mesh.dLazy == 7) + r.check "heartbeat truncates nanoseconds" (core.heartbeatInterval == 100) + r.check "fanout truncates nanoseconds" (core.fanoutTtl == 1201) + r.check "prune truncates nanoseconds" (core.pruneBackoff == 2001) + r.check "gossip fraction truncates percent" (core.gossipFactor == 25) + r.check "history length maps" (core.window.historyLength == 9) + r.check "history gossip maps" (core.window.historyGossip == 4) + let message : Message := { + «from» := none + data := some [0,0,0,0,0,0,0,1,9] + seqno := none + topic := ⟨"topic"⟩ + signature := none + key := none } + r.check "core message id uses eight bytes" ((core.msgId message).bytes == [0,0,0,0,0,0,0,1]) + match parseGossipSubParams "params" (parsedJson "{}") with + | .error _ => r.check "empty parameters parse" false + | .ok params => + match toCoreParams params with + | .error _ => r.check "defaults map" false + | .ok core => + let defaults := Core.defaults + r.check "absent mesh fields use defaults" (core.mesh == defaults.mesh) + r.check "absent times use defaults" (core.heartbeatInterval == defaults.heartbeatInterval && + core.fanoutTtl == defaults.fanoutTtl && core.pruneBackoff == defaults.pruneBackoff) + r.check "absent cache uses defaults" (core.window.historyLength == defaults.window.historyLength && + core.window.historyGossip == defaults.window.historyGossip) + r.check "unknown parameter rejected" (isError + (parseGossipSubParams "params" (parsedJson "{\"FutureField\":1}"))) + r.check "negative parameter rejected" (isError + (parseGossipSubParams "params" (parsedJson "{\"HeartbeatInterval\":-1}"))) + match parseGossipSubParams "params" (parsedJson "{\"HistoryLength\":1,\"HistoryGossip\":2}") with + | .error _ => r.check "invalid cache bound rejected" false + | .ok params => r.check "invalid cache bound rejected" (isError (toCoreParams params)) + r.check "exact conversion near millisecond boundary" + (nanosecondsToMillis ⟨999999999999999999, 3⟩ == 999999999) + r.check "fractional nanosecond truncates" (nanosecondsToMillis ⟨1001000005, 1⟩ == 100) + r.check "wait blocks before deadline" (!waitComplete 119 120) + r.check "wait releases at deadline" (waitComplete 120 120) + r.check "wait releases after deadline" (waitComplete 121 120) + let captured ← loadFixture fixture + match parseScriptJson captured with + | .error _ => r.check "startup script parses" false + | .ok script => + for nodeId in List.range 32 do + r.check s!"node {nodeId} initializes first" (match prepareScript nodeId script with + | .ok (_, rest) => !rest.isEmpty + | .error _ => false) + r.check "missing startup rejected" (isError (prepareScript 32 script)) + let late := parseScript "{\"script\":[{\"type\":\"waitUntil\",\"elapsedSeconds\":0},{\"type\":\"initGossipSub\",\"gossipSubParams\":{}}]}" + r.check "late initialization rejected" (match late with + | .ok script => isError (prepareScript 0 script) + | .error _ => false) + r.check "partial default false" (match parseInstruction "i" + (parsedJson "{\"type\":\"subscribeToTopic\",\"topicID\":\"t\"}") with + | .ok (.subscribeToTopic topic flag) => topic == "t" && !flag + | _ => false) + r.check "partial explicit true retained" (match parseInstruction "i" + (parsedJson "{\"type\":\"subscribeToTopic\",\"topicID\":\"t\",\"partial\":true}") with + | .ok (.subscribeToTopic _ flag) => flag + | _ => false) + let topic := "quote\"slash\\line\nreturn\rtab\t" ++ String.singleton (Char.ofNat 1) + let line := receivedMessageLine "2026-11-06T00:00:00Z" 1 none (some topic) + r.check "all control characters escape" (!line.contains '\n' && !line.contains '\r' && + !line.contains '\t' && stringField (parsedJson line) "topic" == some topic) + r.check "empty message id zero" (messageIdString [] == "0") + r.check "message tail does not change id" (messageIdString [0,0,0,0,0,0,0,1,255] == "1") + r.check "maximum node accepted" (nodeId? "node18446744073709551615" == some 18446744073709551615) + r.check "seed spans all eight bytes" ((peerSeed 72623859790382856).take 8 == [8,7,6,5,4,3,2,1]) + r.check "seed suffix zero" ((peerSeed 72623859790382856).drop 8 == List.replicate 24 0) + +/-- The decoder preserves recursion and reports the exact failing field. -/ +private def checkDecoderDetails (r : Runner) : IO Unit := do + let source ← IO.FS.readFile "Tests/fixtures/gossipsub-interop-params.json" + r.check "captured source parses through Json.parse" (match parseScript source with + | .ok script => script.instructions.length == 100 + | .error _ => false) + r.check "missing publish topic names field" + (errorIs "script[0]: missing field topicID" + (parseScript "{\"script\":[{\"type\":\"publish\",\"messageID\":1,\"messageSizeBytes\":8}]}")) + r.check "bad connect element names index" + (errorIs "script[0].connectTo[1]: expected integer" + (parseScript "{\"script\":[{\"type\":\"connect\",\"connectTo\":[1,\"x\"]}]}")) + r.check "partial publish alias rejected explicitly" + (errorIs "script[0]: publishToNodeIDs is out of scope for v1.1" + (parseScript "{\"script\":[{\"type\":\"publishToNodeIDs\"}]}")) + r.check "nested condition recurses twice" (match parseInstruction "i" + (parsedJson "{\"type\":\"ifNodeIDEquals\",\"nodeID\":7,\"instruction\":{\"type\":\"ifNodeIDEquals\",\"nodeID\":7,\"instruction\":{\"type\":\"waitUntil\",\"elapsedSeconds\":42}}}") with + | .ok instruction => match instructionForNode 7 instruction with + | some (.waitUntil seconds) => seconds == 42 + | _ => false + | _ => false) + r.check "nested bad field includes full path" (errorIs "i.instruction: missing field elapsedSeconds" + (parseInstruction "i" (parsedJson "{\"type\":\"ifNodeIDEquals\",\"nodeID\":0,\"instruction\":{\"type\":\"waitUntil\"}}"))) + r.check "bounded decoder rejects excess nesting" (isError + (parseInstructionFuel 1 "i" (parsedJson "{\"type\":\"ifNodeIDEquals\",\"nodeID\":0,\"instruction\":{\"type\":\"waitUntil\",\"elapsedSeconds\":1}}"))) + let minimal := receivedMessageLine "2026-11-06T00:00:00Z" 18446744073709551615 none none + r.check "received optional fields omitted" (minimal == + "{\"id\":\"18446744073709551615\",\"msg\":\"Received Message\",\"time\":\"2026-11-06T00:00:00Z\"}") + +/-- The suite. -/ +def run (r : Runner) : IO Unit := do + checkScript r + checkErrors r + checkRecords r + checkIds r + checkParameters r + checkDecoderDetails r + +end Tests.Gossipsub.Interop diff --git a/Tests/fixtures/gossipsub-interop-params.json b/Tests/fixtures/gossipsub-interop-params.json new file mode 100644 index 00000000..f8d6551f --- /dev/null +++ b/Tests/fixtures/gossipsub-interop-params.json @@ -0,0 +1 @@ +{"script": [{"type": "ifNodeIDEquals", "nodeID": 0, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 100100000}}}, {"type": "ifNodeIDEquals", "nodeID": 1, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 100200000}}}, {"type": "ifNodeIDEquals", "nodeID": 2, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 100300000}}}, {"type": "ifNodeIDEquals", "nodeID": 3, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 100400000}}}, {"type": "ifNodeIDEquals", "nodeID": 4, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 100500000}}}, {"type": "ifNodeIDEquals", "nodeID": 5, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 100600000}}}, {"type": "ifNodeIDEquals", "nodeID": 6, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 100700000}}}, {"type": "ifNodeIDEquals", "nodeID": 7, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 100800000}}}, {"type": "ifNodeIDEquals", "nodeID": 8, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 100900000}}}, {"type": "ifNodeIDEquals", "nodeID": 9, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 101000000}}}, {"type": "ifNodeIDEquals", "nodeID": 10, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 101100000}}}, {"type": "ifNodeIDEquals", "nodeID": 11, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 101200000}}}, {"type": "ifNodeIDEquals", "nodeID": 12, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 101300000}}}, {"type": "ifNodeIDEquals", "nodeID": 13, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 101400000}}}, {"type": "ifNodeIDEquals", "nodeID": 14, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 101500000}}}, {"type": "ifNodeIDEquals", "nodeID": 15, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 101600000}}}, {"type": "ifNodeIDEquals", "nodeID": 16, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 101700000}}}, {"type": "ifNodeIDEquals", "nodeID": 17, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 101800000}}}, {"type": "ifNodeIDEquals", "nodeID": 18, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 101900000}}}, {"type": "ifNodeIDEquals", "nodeID": 19, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 102000000}}}, {"type": "ifNodeIDEquals", "nodeID": 20, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 102100000}}}, {"type": "ifNodeIDEquals", "nodeID": 21, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 102200000}}}, {"type": "ifNodeIDEquals", "nodeID": 22, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 102300000}}}, {"type": "ifNodeIDEquals", "nodeID": 23, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 102400000}}}, {"type": "ifNodeIDEquals", "nodeID": 24, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 102500000}}}, {"type": "ifNodeIDEquals", "nodeID": 25, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 102600000}}}, {"type": "ifNodeIDEquals", "nodeID": 26, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 102700000}}}, {"type": "ifNodeIDEquals", "nodeID": 27, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 102800000}}}, {"type": "ifNodeIDEquals", "nodeID": 28, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 102900000}}}, {"type": "ifNodeIDEquals", "nodeID": 29, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 103000000}}}, {"type": "ifNodeIDEquals", "nodeID": 30, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 103100000}}}, {"type": "ifNodeIDEquals", "nodeID": 31, "instruction": {"type": "initGossipSub", "gossipSubParams": {"HeartbeatInitialDelay": 103200000}}}, {"type": "setTopicValidationDelay", "topicID": "a-subnet", "delaySeconds": 0.005}, {"type": "ifNodeIDEquals", "nodeID": 0, "instruction": {"type": "connect", "connectTo": [8, 4, 16, 7, 31, 28, 30, 24, 13, 6, 1, 27, 17, 14, 20, 22, 29, 18, 26, 11]}}, {"type": "ifNodeIDEquals", "nodeID": 1, "instruction": {"type": "connect", "connectTo": [18, 7, 21, 27, 12, 19, 31, 25, 2, 30, 15, 26, 11, 23, 5, 28, 6, 10, 14]}}, {"type": "ifNodeIDEquals", "nodeID": 2, "instruction": {"type": "connect", "connectTo": [0, 12, 14, 25, 22, 29, 17, 24, 8, 13, 27, 3, 30, 23, 26, 31, 21, 11, 5]}}, {"type": "ifNodeIDEquals", "nodeID": 3, "instruction": {"type": "connect", "connectTo": [16, 4, 5, 1, 28, 0, 17, 15, 7, 11, 22, 18, 10, 29, 20, 31, 30, 19, 24]}}, {"type": "ifNodeIDEquals", "nodeID": 4, "instruction": {"type": "connect", "connectTo": [21, 26, 12, 16, 6, 13, 27, 1, 14, 25, 9, 2, 10, 28, 20, 19, 8, 29]}}, {"type": "ifNodeIDEquals", "nodeID": 5, "instruction": {"type": "connect", "connectTo": [10, 24, 12, 22, 6, 13, 27, 31, 18, 20, 25, 8, 21, 17, 15, 4, 16]}}, {"type": "ifNodeIDEquals", "nodeID": 6, "instruction": {"type": "connect", "connectTo": [23, 21, 7, 18, 15, 31, 8, 20, 2, 26, 24, 9, 14, 17, 29, 12]}}, {"type": "ifNodeIDEquals", "nodeID": 7, "instruction": {"type": "connect", "connectTo": [15, 26, 10, 28, 27, 24, 18, 16, 30, 20, 13, 2, 25, 4, 29, 22]}}, {"type": "ifNodeIDEquals", "nodeID": 8, "instruction": {"type": "connect", "connectTo": [16, 11, 13, 19, 12, 15, 23, 17, 28, 21, 14, 24, 20, 1, 25]}}, {"type": "ifNodeIDEquals", "nodeID": 9, "instruction": {"type": "connect", "connectTo": [17, 1, 0, 18, 22, 31, 30, 20, 11, 19, 8, 13, 2, 27, 10, 3, 15, 16]}}, {"type": "ifNodeIDEquals", "nodeID": 10, "instruction": {"type": "connect", "connectTo": [28, 27, 16, 29, 0, 25, 21, 31, 26, 22, 8, 17, 11, 14]}}, {"type": "ifNodeIDEquals", "nodeID": 11, "instruction": {"type": "connect", "connectTo": [31, 20, 28, 14, 15, 30, 26, 21, 17, 4, 23, 13, 19]}}, {"type": "ifNodeIDEquals", "nodeID": 12, "instruction": {"type": "connect", "connectTo": [19, 23, 10, 29, 7, 24, 11, 9, 16, 27, 13, 3, 31, 25]}}, {"type": "ifNodeIDEquals", "nodeID": 13, "instruction": {"type": "connect", "connectTo": [22, 24, 10, 16, 6, 17, 28, 15, 27, 25, 20]}}, {"type": "ifNodeIDEquals", "nodeID": 14, "instruction": {"type": "connect", "connectTo": [28, 31, 13, 7, 27, 26, 18, 17, 15, 24, 12, 16]}}, {"type": "ifNodeIDEquals", "nodeID": 15, "instruction": {"type": "connect", "connectTo": [18, 12, 17, 19, 16, 28, 10, 22, 31, 26]}}, {"type": "ifNodeIDEquals", "nodeID": 16, "instruction": {"type": "connect", "connectTo": [24, 18, 6, 1, 23, 19, 27, 22]}}, {"type": "ifNodeIDEquals", "nodeID": 17, "instruction": {"type": "connect", "connectTo": [20, 7, 28, 22, 19, 25, 21, 31]}}, {"type": "ifNodeIDEquals", "nodeID": 18, "instruction": {"type": "connect", "connectTo": [24, 13, 17, 12, 29, 26, 19, 10, 28, 23]}}, {"type": "ifNodeIDEquals", "nodeID": 19, "instruction": {"type": "connect", "connectTo": [0, 24, 27, 25, 21, 31, 26, 22, 29]}}, {"type": "ifNodeIDEquals", "nodeID": 20, "instruction": {"type": "connect", "connectTo": [16, 31, 10, 29, 2, 27, 22, 28, 1]}}, {"type": "ifNodeIDEquals", "nodeID": 21, "instruction": {"type": "connect", "connectTo": [25, 13, 15, 23, 29, 3, 22, 14, 30, 16]}}, {"type": "ifNodeIDEquals", "nodeID": 22, "instruction": {"type": "connect", "connectTo": [14, 1, 25, 27, 12, 4]}}, {"type": "ifNodeIDEquals", "nodeID": 23, "instruction": {"type": "connect", "connectTo": [10, 28, 9, 29, 19, 25, 15, 7, 13, 4, 14]}}, {"type": "ifNodeIDEquals", "nodeID": 24, "instruction": {"type": "connect", "connectTo": [25, 20, 31, 11, 1, 28, 21]}}, {"type": "ifNodeIDEquals", "nodeID": 25, "instruction": {"type": "connect", "connectTo": [11, 6, 14, 31, 28]}}, {"type": "ifNodeIDEquals", "nodeID": 26, "instruction": {"type": "connect", "connectTo": [24, 29, 13, 28, 16, 21, 31, 5]}}, {"type": "ifNodeIDEquals", "nodeID": 27, "instruction": {"type": "connect", "connectTo": [30, 24, 18, 25, 3]}}, {"type": "ifNodeIDEquals", "nodeID": 28, "instruction": {"type": "connect", "connectTo": [16, 5]}}, {"type": "ifNodeIDEquals", "nodeID": 29, "instruction": {"type": "connect", "connectTo": [8, 17, 27, 5, 1, 31]}}, {"type": "ifNodeIDEquals", "nodeID": 30, "instruction": {"type": "connect", "connectTo": [8, 17, 12, 28, 24, 16, 15, 22, 26, 4, 6]}}, {"type": "ifNodeIDEquals", "nodeID": 31, "instruction": {"type": "connect", "connectTo": [13]}}, {"type": "subscribeToTopic", "topicID": "a-subnet", "partial": false}, {"type": "waitUntil", "elapsedSeconds": 120}, {"type": "ifNodeIDEquals", "nodeID": 27, "instruction": {"type": "publish", "messageID": 0, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 132}, {"type": "ifNodeIDEquals", "nodeID": 3, "instruction": {"type": "publish", "messageID": 1, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 144}, {"type": "ifNodeIDEquals", "nodeID": 30, "instruction": {"type": "publish", "messageID": 2, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 156}, {"type": "ifNodeIDEquals", "nodeID": 6, "instruction": {"type": "publish", "messageID": 3, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 168}, {"type": "ifNodeIDEquals", "nodeID": 2, "instruction": {"type": "publish", "messageID": 4, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 180}, {"type": "ifNodeIDEquals", "nodeID": 2, "instruction": {"type": "publish", "messageID": 5, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 192}, {"type": "ifNodeIDEquals", "nodeID": 8, "instruction": {"type": "publish", "messageID": 6, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 204}, {"type": "ifNodeIDEquals", "nodeID": 28, "instruction": {"type": "publish", "messageID": 7, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 216}, {"type": "ifNodeIDEquals", "nodeID": 17, "instruction": {"type": "publish", "messageID": 8, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 228}, {"type": "ifNodeIDEquals", "nodeID": 16, "instruction": {"type": "publish", "messageID": 9, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 240}, {"type": "ifNodeIDEquals", "nodeID": 5, "instruction": {"type": "publish", "messageID": 10, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 252}, {"type": "ifNodeIDEquals", "nodeID": 2, "instruction": {"type": "publish", "messageID": 11, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 264}, {"type": "ifNodeIDEquals", "nodeID": 3, "instruction": {"type": "publish", "messageID": 12, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 276}, {"type": "ifNodeIDEquals", "nodeID": 20, "instruction": {"type": "publish", "messageID": 13, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 288}, {"type": "ifNodeIDEquals", "nodeID": 16, "instruction": {"type": "publish", "messageID": 14, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 300}, {"type": "ifNodeIDEquals", "nodeID": 7, "instruction": {"type": "publish", "messageID": 15, "messageSizeBytes": 98304, "topicID": "a-subnet"}}, {"type": "waitUntil", "elapsedSeconds": 312}, {"type": "waitUntil", "elapsedSeconds": 342}]} \ No newline at end of file diff --git a/packages/libp2p/protocols/Libp2p/Protocols.lean b/packages/libp2p/protocols/Libp2p/Protocols.lean index 26444ac5..b7bcd504 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols.lean @@ -6,6 +6,7 @@ import Libp2p.Protocols.Gossipsub.Cache import Libp2p.Protocols.Gossipsub.Sign import Libp2p.Protocols.Gossipsub.Core import Libp2p.Protocols.Gossipsub.Node +import Libp2p.Protocols.Gossipsub.Interop /-! # Libp2p.Protocols diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop.lean new file mode 100644 index 00000000..cf28322b --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop.lean @@ -0,0 +1,11 @@ +import Libp2p.Protocols.Gossipsub.Interop.Address +import Libp2p.Protocols.Gossipsub.Interop.MessageId +import Libp2p.Protocols.Gossipsub.Interop.Record +import Libp2p.Protocols.Gossipsub.Interop.Script + +/-! +# GossipSub interop pure core + +This root re-exports the five pure adapter parts. The parser, record encoder, +address helpers, and message id functions perform no network or file IO. +-/ diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Address.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Address.lean new file mode 100644 index 00000000..294bf940 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Address.lean @@ -0,0 +1,38 @@ +import Lean + +/-! +# GossipSub interop node addresses + +This module implements specification section 7. It accepts only the exact +lowercase prefix node followed by decimal digits. The suffix must be +non-empty, must not have leading zeroes, and must fit an unsigned 64 bit value. +The peer seed writes the node number into the first eight bytes +in little endian order and fills the remaining 24 bytes with zeroes. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub.Interop + +/-- Parse a simulation hostname into its node number. -/ +def nodeId? (hostname : String) : Option Nat := + if !hostname.startsWith "node" then + none + else + let suffix := (hostname.drop 4).toString + match suffix.toList with + | [] => none + | '0' :: _ :: _ => none + | _ => match suffix.toNat? with + | some value => if value ≤ 18446744073709551615 then some value else none + | none => none + +/-- Return one little endian byte from a natural number. -/ +def littleEndianByte (value index : Nat) : Nat := + (value / (2 ^ (8 * index))) % 256 + +/-- Build the 32 byte seed used by the interop peer id. -/ +def peerSeed (nodeId : Nat) : List Nat := + (List.range 8).map (littleEndianByte nodeId) ++ List.replicate 24 0 + +end Libp2p.Protocols.Gossipsub.Interop diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/MessageId.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/MessageId.lean new file mode 100644 index 00000000..ab53afcd --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/MessageId.lean @@ -0,0 +1,44 @@ +import Libp2p.Protocols.Gossipsub.Core + +/-! +# GossipSub interop message ids + +This module implements specification section 6. The harness reads the first +8 payload bytes as one big endian unsigned integer and prints base ten. +A short payload gets zero bytes appended on the right. This gives a total +function and matches the byte-position rule for every available byte. + +The core stores the same padded eight bytes in MessageId. A caller can use +coreMsgId in Core.Params.msgId without changing the core state machine. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub.Interop + +open Libp2p.Protocols.Gossipsub + +/-- Pad a payload on the right so the id always has eight input bytes. -/ +def paddedPayload (payload : List Nat) : List Nat := + let headBytes := payload.take 8; headBytes ++ List.replicate (8 - headBytes.length) 0 + +/-- Read up to eight bytes as a big endian natural number. -/ +def readBigEndian (bytes : List Nat) : Nat := + bytes.foldl (fun acc byte => acc * 256 + byte % 256) 0 + +/-- Read the message id as an unsigned 64 bit value. -/ +def messageIdNumber (payload : List Nat) : UInt64 := + UInt64.ofNat (readBigEndian (paddedPayload payload)) + +/-- Format the message id as an unsigned base ten string. -/ +def messageIdString (payload : List Nat) : String := + (messageIdNumber payload).toNat |> Nat.repr + +/-- Adapt a gossipsub message to the harness message id rule. -/ +def coreMsgId (message : Message) : MessageId := + ⟨paddedPayload ((message.data).getD [])⟩ + +/-- The function value for Core.Params.msgId. -/ +def msgIdFn : Sign.MsgIdFn := coreMsgId + +end Libp2p.Protocols.Gossipsub.Interop diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Record.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Record.lean new file mode 100644 index 00000000..41a85bf2 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Record.lean @@ -0,0 +1,56 @@ +import Lean + +/-! +# GossipSub interop NDJSON records + +This module implements specification section 5. It turns one event and one +RFC 3339 time string into one compact JSON object. The result has no trailing +line feed. The JSON encoder escapes quotes, backslashes, and control bytes, +so each result stays one NDJSON line. Object keys use the core tree-map +ordering. The harness reads named JSON fields, so their order carries no +meaning. The caller supplies an RFC 3339 timestamp. The received event takes +a UInt64 so its id always becomes an unsigned decimal string. Each call +emits one event, including when the caller passes a duplicate message. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub.Interop + +open Lean + +/-- Events required by the interop output contract. -/ +inductive RecordEvent where + | peerId (id : String) (nodeId : Int) + | received (id : UInt64) (sender : Option String) (topic : Option String) + deriving Repr + +/-- Build a JSON object from fields. Lean's encoder owns string escaping. -/ +def jsonObject (fields : List (String × Json)) : Json := + Json.obj (Std.TreeMap.Raw.ofList fields) + +/-- Encode one event without a trailing line feed. -/ +def recordLine (time : String) (event : RecordEvent) : String := + let base := [("time", Json.str time)] + let fields := match event with + | .peerId id nodeId => + base ++ [("msg", Json.str "PeerID"), ("id", Json.str id), + ("node_id", Json.num ⟨nodeId, 0⟩)] + | .received id sender topic => + let optional := (match sender with + | some value => [("from", Json.str value)] + | none => []) ++ (match topic with + | some value => [("topic", Json.str value)] + | none => []) + base ++ [("msg", Json.str "Received Message"), ("id", Json.str (Nat.repr id.toNat))] ++ optional + (jsonObject fields).compress + +/-- Encode the start event with a peer id and node number. -/ +def peerIdLine (time id : String) (nodeId : Int) : String := + recordLine time (.peerId id nodeId) + +/-- Encode one received message event. -/ +def receivedMessageLine (time : String) (id : UInt64) (sender topic : Option String) : String := + recordLine time (.received id sender topic) + +end Libp2p.Protocols.Gossipsub.Interop diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Script.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Script.lean new file mode 100644 index 00000000..ff37b1c4 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Script.lean @@ -0,0 +1,345 @@ +import Lean +import Libp2p.Protocols.Gossipsub.Core +import Libp2p.Protocols.Gossipsub.Interop.MessageId + +/-! +# GossipSub interop script parser + +This module implements specification sections 2 and 3. It parses the script +object and all seven v1.1 instruction forms. It reports the instruction path +and field when decoding fails. The two v1.2 partial-message forms return an +explicit out-of-scope error. + +The parser uses a fuel argument for nested instructions. This gives a total +parser with a clear maximum nesting depth and avoids a partial definition. +Missing parameter fields stay as options and therefore select Core.defaults. +The raw field retains every source pair. The unmapped field retains fields +without a Core home, including HeartbeatInitialDelay. Exact decimal integer +division truncates nanoseconds toward zero: 100100000 ns becomes 100 ms. +GossipFactor scales a fraction by 100 and truncates to Core's integer percent. +The harness model declares a float; its JVM adapter defaults it to 0.25 +(ScriptInstruction.kt:89). Negative mapped numbers and invalid cache windows +return errors. The supported nesting depth is 256 instructions. An unknown parameter name +returns an error, so a future harness field stays visible instead of becoming +an unrecorded default. + +prepareScript selects conditional instructions for one node and requires its +initialization first. waitComplete compares elapsed seconds with a deadline. +The later IO driver must keep message processing active while that guard is false. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub.Interop + +open Lean +open Libp2p.Protocols.Gossipsub + +/-- One parsed GossipSub parameter object, with all supplied fields retained. -/ +structure GossipSubParams where + d : Option Nat + dLow : Option Nat + dHigh : Option Nat + dScore : Option Nat + dLazy : Option Nat + historyLength : Option Nat + historyGossip : Option Nat + gossipFactor : Option JsonNumber + heartbeatInitialDelay : Option JsonNumber + heartbeatInterval : Option JsonNumber + fanoutTtl : Option JsonNumber + pruneBackoff : Option JsonNumber + raw : List (String × Json) + unmapped : List (String × Json) + +/-- The seven supported script instruction forms. -/ +inductive Instruction where + | initGossipSub (params : GossipSubParams) + | ifNodeIDEquals (nodeId : Int) (instruction : Instruction) + | connect (connectTo : List Int) + | subscribeToTopic (topicId : String) (isPartial : Bool) + | publish (messageId : Int) (messageSizeBytes : Int) (topicId : String) + | setTopicValidationDelay (topicId : String) (delaySeconds : Float) + | waitUntil (elapsedSeconds : Int) + +/-- The complete script value. -/ +structure Script where + instructions : List Instruction + +/-- Convert a JSON value to object pairs while adding a readable path. -/ +def objectPairs (path : String) (value : Json) : Except String (List (String × Json)) := + match value.getObj? with + | .ok object => .ok object.toList + | .error _ => .error s!"{path}: expected object" + +/-- Find one object field without confusing a missing field with a bad type. -/ +def field? (path : String) (value : Json) (name : String) : Except String (Option Json) := + match objectPairs path value with + | .error error => .error error + | .ok pairs => .ok (pairs.find? (fun pair => pair.1 == name) |>.map (fun pair => pair.2)) + +/-- Read one required field. -/ +def requiredField (path : String) (value : Json) (name : String) : Except String Json := + match field? path value name with + | .error error => .error error + | .ok (some result) => .ok result + | .ok none => .error s!"{path}: missing field {name}" + +/-- Read one optional field. -/ +def optionalField (path : String) (value : Json) (name : String) : Except String (Option Json) := + field? path value name + +/-- Decode a required natural number. -/ +def requiredNat (path : String) (value : Json) (name : String) : Except String Nat := + let fieldPath := s!"{path}.{name}" + match requiredField path value name with + | .error error => .error error + | .ok field => + match field.getNat? with + | .ok result => .ok result + | .error _ => .error s!"{fieldPath}: expected integer" + +/-- Decode an optional natural number. -/ +def optionalNat (path : String) (value : Json) (name : String) : Except String (Option Nat) := + match optionalField path value name with + | .error error => .error error + | .ok none => .ok none + | .ok (some field) => + match field.getNat? with + | .ok result => .ok (some result) + | .error _ => .error s!"{path}.{name}: expected integer" + +/-- Decode a required signed integer. -/ +def requiredInt (path : String) (value : Json) (name : String) : Except String Int := + match requiredField path value name with + | .error error => .error error + | .ok field => + match field.getInt? with + | .ok result => .ok result + | .error _ => .error s!"{path}.{name}: expected integer" + +/-- Decode a required string. -/ +def requiredString (path : String) (value : Json) (name : String) : Except String String := + match requiredField path value name with + | .error error => .error error + | .ok field => + match field.getStr? with + | .ok result => .ok result + | .error _ => .error s!"{path}.{name}: expected string" + +/-- Decode a required boolean. -/ +def requiredBool (path : String) (value : Json) (name : String) : Except String Bool := + match requiredField path value name with + | .error error => .error error + | .ok field => + match field.getBool? with + | .ok result => .ok result + | .error _ => .error s!"{path}.{name}: expected boolean" + +/-- Decode a required JSON number as a Float. -/ +def requiredFloat (path : String) (value : Json) (name : String) : Except String Float := + match requiredField path value name with + | .error error => .error error + | .ok field => + match field.getNum? with + | .ok result => .ok result.toFloat + | .error _ => .error s!"{path}.{name}: expected number" + +/-- Decode a nonnegative decimal parameter without a binary float conversion. -/ +private def optionalNumber (path : String) (value : Json) (name : String) : + Except String (Option JsonNumber) := do + match ← optionalField path value name with + | none => return none + | some field => + let number ← field.getNum?.mapError (fun _ => s!"{path}.{name}: expected number") + if number.mantissa < 0 then + throw s!"{path}.{name}: expected nonnegative number" + return some number + +/-- Decode one parameter object and keep every source pair. -/ +def parseGossipSubParams (path : String) (value : Json) : Except String GossipSubParams := do + let pairs ← objectPairs path value + let getNat (name : String) := optionalNat path value name + let d ← getNat "D" + let dLow ← getNat "Dlo" + let dHigh ← getNat "Dhi" + let dScore ← getNat "Dscore" + let dLazy ← getNat "Dlazy" + let historyLength ← getNat "HistoryLength" + let historyGossip ← getNat "HistoryGossip" + let gossipFactor ← optionalNumber path value "GossipFactor" + let heartbeatInitialDelay ← optionalNumber path value "HeartbeatInitialDelay" + let heartbeatInterval ← optionalNumber path value "HeartbeatInterval" + let fanoutTtl ← optionalNumber path value "FanoutTTL" + let pruneBackoff ← optionalNumber path value "PruneBackoff" + let mapped := ["D", "Dlo", "Dhi", "Dscore", "Dlazy", "HistoryLength", "HistoryGossip", + "GossipFactor", "HeartbeatInterval", "FanoutTTL", "PruneBackoff"] + let retained := ["Dout", "GossipRetransmission", "HeartbeatInitialDelay", "SlowHeartbeatWarning", + "PrunePeers", "UnsubscribeBackoff", "Connectors", "MaxPendingConnections", "ConnectionTimeout", + "DirectConnectTicks", "DirectConnectInitialDelay", "OpportunisticGraftTicks", "OpportunisticGraftPeers", + "GraftFloodThreshold", "MaxIHaveLength", "MaxIHaveMessages", "MaxIDontWantLength", "MaxIDontWantMessages", + "IWantFollowupTime", "IDontWantMessageThreshold", "IDontWantMessageTTL"] + return { + d := d + dLow := dLow + dHigh := dHigh + dScore := dScore + dLazy := dLazy + historyLength := historyLength + historyGossip := historyGossip + gossipFactor := gossipFactor + heartbeatInitialDelay := heartbeatInitialDelay + heartbeatInterval := heartbeatInterval + fanoutTtl := fanoutTtl + pruneBackoff := pruneBackoff + raw := pairs + unmapped := ← pairs.filterMapM fun pair => + if mapped.contains pair.1 then pure none + else if retained.contains pair.1 then pure (some pair) + else throw s!"{path}: unknown parameter {pair.1}" + } + +/-- Convert harness nanoseconds to Core milliseconds by truncating. -/ +def nanosecondsToMillis (value : JsonNumber) : Nat := + value.mantissa.toNat / (10 ^ value.exponent * 1000000) + +/-- Convert the harness fraction to Core's integer percent field. -/ +def gossipFactorToPercent (value : JsonNumber) : Nat := + (value.mantissa.toNat * 100) / 10 ^ value.exponent + +/-- Build a cache window only when its required bound holds. -/ +def validWindow (historyLength historyGossip : Nat) : Option Cache.WindowParams := + if proof : 0 < historyGossip ∧ historyGossip ≤ historyLength then + some { historyLength := historyLength, historyGossip := historyGossip, valid := proof } + else + none + +/-- Apply parsed parameters to Core.defaults and install the interop id rule. -/ +def toCoreParams (params : GossipSubParams) : Except String Core.Params := do + let base := Core.defaults + let historyLength := params.historyLength.getD base.window.historyLength + let historyGossip := params.historyGossip.getD base.window.historyGossip + let window ← match validWindow historyLength historyGossip with + | some value => pure value + | none => throw "gossipSubParams.HistoryGossip: expected 0 < HistoryGossip ≤ HistoryLength" + return { base with + mesh := { base.mesh with + d := params.d.getD base.mesh.d + dLow := params.dLow.getD base.mesh.dLow + dHigh := params.dHigh.getD base.mesh.dHigh + dScore := params.dScore.getD base.mesh.dScore + dLazy := params.dLazy.getD base.mesh.dLazy } + heartbeatInterval := params.heartbeatInterval.map nanosecondsToMillis |>.getD base.heartbeatInterval + fanoutTtl := params.fanoutTtl.map nanosecondsToMillis |>.getD base.fanoutTtl + pruneBackoff := params.pruneBackoff.map nanosecondsToMillis |>.getD base.pruneBackoff + gossipFactor := params.gossipFactor.map gossipFactorToPercent |>.getD base.gossipFactor + window := window + msgId := msgIdFn } + +/-- Decode a required integer list with indexed field errors. -/ +def parseIntList (path : String) (values : List Json) (index : Nat) : Except String (List Int) := + match values with + | [] => .ok [] + | value :: rest => + match value.getInt? with + | .error _ => .error s!"{path}[{index}]: expected integer" + | .ok result => + match parseIntList path rest (index + 1) with + | .error error => .error error + | .ok tail => .ok (result :: tail) + +/-- Decode one instruction with bounded recursive fuel. -/ +def parseInstructionFuel : Nat → String → Json → Except String Instruction + | 0, path, _ => .error s!"{path}: nesting limit exceeded" + | fuel + 1, path, value => do + let kind ← requiredString path value "type" + match kind with + | "initGossipSub" => + let params ← requiredField path value "gossipSubParams" + .initGossipSub <$> parseGossipSubParams s!"{path}.gossipSubParams" params + | "ifNodeIDEquals" => + let nodeId ← requiredInt path value "nodeID" + let nested ← requiredField path value "instruction" + let instruction ← parseInstructionFuel fuel s!"{path}.instruction" nested + .ok (.ifNodeIDEquals nodeId instruction) + | "connect" => + let field ← requiredField path value "connectTo" + match field.getArr? with + | .error _ => .error s!"{path}.connectTo: expected array" + | .ok values => .connect <$> parseIntList s!"{path}.connectTo" values.toList 0 + | "subscribeToTopic" => + let topicId ← requiredString path value "topicID" + let isPartial ← match optionalField path value "partial" with + | .error error => .error error + | .ok none => .ok false + | .ok (some field) => match field.getBool? with + | .ok result => .ok result + | .error _ => .error s!"{path}.partial: expected boolean" + .ok (.subscribeToTopic topicId isPartial) + | "publish" => + let messageId ← requiredInt path value "messageID" + let messageSizeBytes ← requiredInt path value "messageSizeBytes" + let topicId ← requiredString path value "topicID" + .ok (.publish messageId messageSizeBytes topicId) + | "setTopicValidationDelay" => + let topicId ← requiredString path value "topicID" + let delaySeconds ← requiredFloat path value "delaySeconds" + .ok (.setTopicValidationDelay topicId delaySeconds) + | "waitUntil" => + .waitUntil <$> requiredInt path value "elapsedSeconds" + | "addPartialMessage" => + .error s!"{path}: addPartialMessage is out of scope for v1.1" + | "publishPartial" | "publishToNodeIDs" => + .error s!"{path}: {kind} is out of scope for v1.1" + | _ => .error s!"{path}.type: unknown instruction type {kind}" + +/-- Decode one instruction with the module's bounded nesting depth. -/ +def parseInstruction (path : String) (value : Json) : Except String Instruction := + parseInstructionFuel 256 path value + +/-- Decode a JSON script object. -/ +def parseScriptJson (value : Json) : Except String Script := do + let field ← requiredField "root" value "script" + let values ← match field.getArr? with + | .ok result => .ok result.toList + | .error _ => .error "root.script: expected array" + let rec loop (index : Nat) (remaining : List Json) : Except String (List Instruction) := + match remaining with + | [] => .ok [] + | item :: rest => do + let instruction ← parseInstruction s!"script[{index}]" item + let tail ← loop (index + 1) rest + return instruction :: tail + return { instructions := ← loop 0 values } + +/-- Parse a complete params.json string. -/ +def parseScript (source : String) : Except String Script := do + let value ← Json.parse source + parseScriptJson value + +/-- Resolve a chain of node conditions without changing instruction order. -/ +def instructionForNode (nodeId : Int) : Instruction → Option Instruction + | .ifNodeIDEquals expected nested => + if nodeId == expected then instructionForNode nodeId nested else none + | instruction => some instruction + +/-- Separate startup parameters from the remaining instructions for one node. -/ +def prepareScript (nodeId : Int) (script : Script) : + Except String (GossipSubParams × List Instruction) := do + let selected := script.instructions.filterMap (instructionForNode nodeId) + match selected with + | .initGossipSub params :: rest => + if rest.any (fun instruction => match instruction with + | .initGossipSub _ => true + | _ => false) then + throw "script: initGossipSub must occur only at startup" + let _ ← toCoreParams params + return (params, rest) + | _ => throw "script: initGossipSub must precede every other instruction" + +/-- A wait releases the next instruction once elapsed seconds reach its deadline. +The IO driver continues delivery and forwarding between calls to this guard. -/ +def waitComplete (elapsedSeconds deadlineSeconds : Int) : Bool := + decide (elapsedSeconds ≥ deadlineSeconds) + +end Libp2p.Protocols.Gossipsub.Interop From 1b2e1d37b5a529823c512b03d4a17b3914cdff1f Mon Sep 17 00:00:00 2001 From: Ivan Anishchuk Date: Thu, 5 Nov 2026 17:09:31 +0530 Subject: [PATCH 054/125] feat(host): W5.6, the host with dedup, serving, and shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host composes the stack into a node. Its three registries change only through pure updates under OnceRef. The dedup rule is one pure function over the session table, with a theorem that both peers of a race drop the same connection. The swarm runs the dial, listen, and stream paths with go-libp2p's policy checkpoints, and close runs the shutdown order of §5.5 through gates that refuse late tasks. Identity, core, and negotiate have not merged, so the host builds against mocks of their surfaces, and the suite runs the unit 1a checks in full. Each unit 1b leg needs a running host, so the suite names them as skips until the integration. The core docs record the policy shapes this unit fixes, and the negotiate docs gain upgradeWith and negotiateStream, the two calls the host needs beside upgrade. The host docs gain newStream and the implementation record, and they now name codec/multiformats, which the peerstore and the dial path use. Closes #19 --- Tests/Host.lean | 304 ++++++++++ Tests/Main.lean | 2 + lakefile.toml | 1 + packages/libp2p/core/docs/ARCHITECTURE.md | 23 + packages/libp2p/host/Libp2p/Host.lean | 16 +- .../libp2p/host/Libp2p/Host/Constants.lean | 23 + packages/libp2p/host/Libp2p/Host/Dedup.lean | 153 +++++ packages/libp2p/host/Libp2p/Host/Error.lean | 98 ++++ packages/libp2p/host/Libp2p/Host/Gate.lean | 80 +++ .../libp2p/host/Libp2p/Host/Mock/Core.lean | 168 ++++++ .../host/Libp2p/Host/Mock/Identity.lean | 69 +++ .../host/Libp2p/Host/Mock/Negotiate.lean | 91 +++ .../libp2p/host/Libp2p/Host/Peerstore.lean | 47 ++ packages/libp2p/host/Libp2p/Host/Policy.lean | 55 ++ .../libp2p/host/Libp2p/Host/Registry.lean | 119 ++++ .../libp2p/host/Libp2p/Host/Shutdown.lean | 108 ++++ packages/libp2p/host/Libp2p/Host/Swarm.lean | 541 ++++++++++++++++++ packages/libp2p/host/docs/ARCHITECTURE.md | 168 +++++- packages/libp2p/host/docs/PLAN.md | 10 +- .../libp2p/negotiate/docs/ARCHITECTURE.md | 17 + 20 files changed, 2084 insertions(+), 9 deletions(-) create mode 100644 Tests/Host.lean create mode 100644 packages/libp2p/host/Libp2p/Host/Constants.lean create mode 100644 packages/libp2p/host/Libp2p/Host/Dedup.lean create mode 100644 packages/libp2p/host/Libp2p/Host/Error.lean create mode 100644 packages/libp2p/host/Libp2p/Host/Gate.lean create mode 100644 packages/libp2p/host/Libp2p/Host/Mock/Core.lean create mode 100644 packages/libp2p/host/Libp2p/Host/Mock/Identity.lean create mode 100644 packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean create mode 100644 packages/libp2p/host/Libp2p/Host/Peerstore.lean create mode 100644 packages/libp2p/host/Libp2p/Host/Policy.lean create mode 100644 packages/libp2p/host/Libp2p/Host/Registry.lean create mode 100644 packages/libp2p/host/Libp2p/Host/Shutdown.lean create mode 100644 packages/libp2p/host/Libp2p/Host/Swarm.lean diff --git a/Tests/Host.lean b/Tests/Host.lean new file mode 100644 index 00000000..743116d6 --- /dev/null +++ b/Tests/Host.lean @@ -0,0 +1,304 @@ +import Std.Data.HashMap +import Tests.Support +import Conc +import Streams +import Multiformats +import Libp2p.Host + +/-! +# Host suite (unit W5.6) + +The checks of `packages/libp2p/host/docs/PLAN.md`. + +Unit 1a: the dedup rule, the session table's liveness, the handler +table, the peerstore, and the `Host.Error` lines. Every check runs the +pure update functions, by value or under an `OnceRef`, with no +connection in the loop. The session handle of a table entry is a `Nat` +here, since the rule never reads it. + +Unit 1b runs hosts over the sim transport, with the real +`Noise.securityProtocol` and `Mux.yamux` values (PRINCIPLES 1.4). Every +host derives its peer id through `libp2p/identity`, and the upgrade runs +through `libp2p/negotiate`, so the legs wait for W5.1, W5.3, W5.4, and +W5.5 to merge. Until then the suite names each leg as a skip. + +The peer ids here are values of the identity mock, built directly. +-/ + +set_option autoImplicit false + +namespace Tests + +open Conc Multiformats Libp2p.Host.Mock.Identity Libp2p.Host.Mock.Core Libp2p.Host + +/-! ## Helpers -/ + +/-- A multiaddr from its text form. -/ +private def ma (s : String) : IO Multiaddr := do + match Multiaddr.parse? s with + | .ok a => return a + | .error _ => throw <| IO.userError s!"test address {s} does not parse" + +/-- The smaller and the larger of two test peers. Their binary forms +are `[0x00, 0x01, 1]` and `[0x00, 0x01, 2]`, the identity multihashes +of the one-byte keys `1` and `2`, so `small` orders first. -/ +private def small : PeerId := ⟨[0, 1, 1], "small"⟩ +private def large : PeerId := ⟨[0, 1, 2], "large"⟩ + +/-- A live table entry. -/ +private def entry (id : Nat) (role : Role) (bornMs : Nat) : Entry Nat := + { id, role, bornMs, live := true, session := id } + +/-- The session number of the entry of `peer`, when it has one. -/ +private def holder (t : SessionTable Nat) (peer : PeerId) : Option Nat := + (t.get? peer).map (·.id) + +/-- True when the decision committed the candidate and displaced the +session `id` (`none`: displaced nothing). -/ +private def committedOver (d : Decision Nat) (id : Option Nat) : Bool := + match d with + | .commit displaced => displaced.map (·.id) == id + | .keep _ => false + +/-- True when the decision kept the standing session `id`. -/ +private def keptId (d : Decision Nat) (id : Nat) : Bool := + match d with + | .keep s => s.id == id + | .commit _ => false + +/-! ## Dedup -/ + +/-- The rule of the package ARCHITECTURE, contract 2, run by value. -/ +private def dedupChecks (r : Runner) : IO Unit := do + r.check "the binary order puts the smaller test peer first" (comparePeers small large == .lt) + + -- An absent slot, and a dead standing session, take the candidate. + let (d, t) := dedup small large (entry 1 .initiator 0) {} + r.check "an empty slot takes the candidate" (committedOver d none && holder t large == some 1) + let dead : SessionTable Nat := ({} : SessionTable Nat).insert large { entry 1 .initiator 0 with live := false } + let (d, t) := dedup small large (entry 2 .initiator 10) dead + r.check "a dead standing session makes room" (committedOver d none && holder t large == some 2) + + -- A race inside the window: the connection the larger id dialed + -- loses. Each case runs on the side that holds the larger id and on + -- the side that holds the smaller one, and the standing session + -- runs first in one direction and then in the other. + let born := 1000 + let inWindow := born + simulWindowMs - 1 + -- We hold the smaller id. The peer (larger) dialed the standing + -- session, so it loses to our new dial. + let standing : SessionTable Nat := ({} : SessionTable Nat).insert large (entry 1 .responder born) + let (d, t) := dedup small large (entry 2 .initiator inWindow) standing + r.check "smaller side: our dial beats the larger peer's dial" (committedOver d (some 1) && holder t large == some 2) + -- We hold the smaller id, and we dialed the standing session. The + -- larger peer's dial arrives and loses. + let standing : SessionTable Nat := ({} : SessionTable Nat).insert large (entry 1 .initiator born) + let (d, t) := dedup small large (entry 2 .responder inWindow) standing + r.check "smaller side: the larger peer's dial loses to ours" (keptId d 1 && holder t large == some 1) + -- We hold the larger id, and we dialed the standing session. The + -- smaller peer's dial arrives and wins. + let standing : SessionTable Nat := ({} : SessionTable Nat).insert small (entry 1 .initiator born) + let (d, t) := dedup large small (entry 2 .responder inWindow) standing + r.check "larger side: the smaller peer's dial beats ours" (committedOver d (some 1) && holder t small == some 2) + -- We hold the larger id. The smaller peer dialed the standing + -- session, and our own dial loses to it. + let standing : SessionTable Nat := ({} : SessionTable Nat).insert small (entry 1 .responder born) + let (d, t) := dedup large small (entry 2 .initiator inWindow) standing + r.check "larger side: our dial loses to the smaller peer's" (keptId d 1 && holder t small == some 1) + + -- The window edge: a candidate born `simulWindowMs` after the + -- standing session is outside it, and the standing session stays, + -- even when the race rule would have dropped it. + let standing : SessionTable Nat := ({} : SessionTable Nat).insert large (entry 1 .responder born) + let (d, _) := dedup small large (entry 2 .initiator (born + simulWindowMs)) standing + r.check "outside the window the live standing session stays" (keptId d 1) + -- The same direction is no race: a second dial of ours keeps the + -- first session, inside the window too. + let standing : SessionTable Nat := ({} : SessionTable Nat).insert large (entry 1 .initiator born) + let (d, t) := dedup small large (entry 2 .initiator inWindow) standing + r.check "a second dial in the same direction keeps the first" (keptId d 1 && holder t large == some 1) + + -- Both sides at once. Peer `small` dialed connection A, and peer + -- `large` dialed connection B. Each side committed one first; the + -- other then arrives as the candidate. Both keep A, the connection + -- that the smaller id dialed. + for aFirstOnSmall in [true, false] do + for aFirstOnLarge in [true, false] do + -- On `small`, A is ours (`.initiator`), and B is the peer's. + let (firstS, secondS) := + if aFirstOnSmall then (entry 0 .initiator born, entry 1 .responder inWindow) + else (entry 1 .responder born, entry 0 .initiator inWindow) + let (_, ts) := dedup small large firstS {} + let (_, ts) := dedup small large secondS ts + -- On `large`, A is the peer's (`.responder`), and B is ours. + let (firstL, secondL) := + if aFirstOnLarge then (entry 0 .responder born, entry 1 .initiator inWindow) + else (entry 1 .initiator born, entry 0 .responder inWindow) + let (_, tl) := dedup large small firstL {} + let (_, tl) := dedup large small secondL tl + r.check s!"both sides keep the smaller id's dial (A first on small: {aFirstOnSmall}, on large: {aFirstOnLarge})" + (holder ts large == some 0 && holder tl small == some 0) + +/-- Many decisions over one peer under one cell: each runs as its own +task, through `OnceRef.updateGet`, and the table ends with exactly the +survivor the decisions name (PRINCIPLES 3.3). -/ +private def concurrentDedupChecks (r : Runner) : Async Unit := do + -- Sixteen dials of ours to one peer, all in the same direction: one + -- commits, the rest keep it, and no update is lost. + let cell : OnceRef (SessionTable Nat) ← OnceRef.new {} + let answers : OnceRef (List (Decision Nat)) ← OnceRef.new [] + let ts ← TaskSet.new + for i in [0:16] do + ts.spawn do + let d ← (cell.updateGet (dedup small large (entry i .initiator 0)) : BaseIO (Decision Nat)) + answers.update (d :: ·) + ts.joinAll + let ds ← (answers.get : BaseIO (List (Decision Nat))) + let t ← (cell.get : BaseIO (SessionTable Nat)) + let winners := ds.filter (·.committed) + r.check "sixteen same-direction dials: one commits" (winners.length == 1) + r.check "sixteen same-direction dials: the rest keep the winner" + (ds.all fun d => match d, holder t large with + | .keep s, some w => s.id == w + | .commit none, _ => true + | _, _ => false) + + -- Two opposite dials inside the window, in either order: the table + -- ends with the smaller id's dial, and the decisions agree with it. + for _ in [0:8] do + let cell : OnceRef (SessionTable Nat) ← OnceRef.new {} + let answers : OnceRef (List (Decision Nat)) ← OnceRef.new [] + let ts ← TaskSet.new + -- On `small`, our dial is the smaller id's dial. + for (i, role) in [(0, Role.initiator), (1, Role.responder)] do + ts.spawn do + let d ← (cell.updateGet (dedup small large (entry i role 0)) : BaseIO (Decision Nat)) + answers.update (d :: ·) + ts.joinAll + let t ← (cell.get : BaseIO (SessionTable Nat)) + let ds ← (answers.get : BaseIO (List (Decision Nat))) + r.check "two opposite dials: the smaller id's dial survives" (holder t large == some 0) + -- The peer's dial (1) is displaced when it ran first, and kept out + -- when it ran second; our dial (0) never loses. + r.check "two opposite dials: the decisions name the survivor" + (ds.all fun d => match d with + | .keep s => s.id == 0 + | .commit (some s) => s.id == 1 + | .commit none => true) + +/-- Liveness is a table field (contract 3): a session that exits marks +its entry dead through the cell, and the next decision reads it. -/ +private def livenessChecks (r : Runner) : Async Unit := do + let cell : OnceRef (SessionTable Nat) ← OnceRef.new {} + discard <| (cell.updateGet (dedup small large (entry 1 .initiator 0)) : BaseIO (Decision Nat)) + -- The session loop runs as a task and marks its exit on the way out. + let ts ← TaskSet.new + ts.spawn do cell.update (SessionTable.markDead large 1) + ts.joinAll + let t ← (cell.get : BaseIO (SessionTable Nat)) + r.check "an exited session reads as dead" ((t.get? large).any (!·.live) && (t.live? large).isNone) + -- A dial outside any race takes the slot of the dead session. + let d ← (cell.updateGet (dedup small large (entry 2 .initiator 5)) : BaseIO (Decision Nat)) + r.check "the next decision replaces the dead session" (committedOver d none) + -- The late exit of the replaced session leaves its successor live. + cell.update (SessionTable.markDead large 1) + let t ← (cell.get : BaseIO (SessionTable Nat)) + r.check "a stale exit leaves the successor live" ((t.live? large).any (·.id == 2)) + r.check "the live entries list the successor" + ((SessionTable.liveEntries t).map (·.2.id) == [2]) + +/-! ## The handler table -/ + +/-- A handler that half-closes its stream. -/ +private def closer (id : ProtocolId) : StreamHandler where + id := id + handle := fun s => s.close + +private def handlerChecks (r : Runner) : Async Unit := do + let ping : ProtocolId := ⟨"/ipfs/ping/1.0.0"⟩ + let ident : ProtocolId := ⟨"/ipfs/id/1.0.0"⟩ + -- A handler set under the cell is visible to the next read. + let cell : OnceRef Handlers ← OnceRef.new {} + let ts ← TaskSet.new + for h in [closer ping, closer ident] do + ts.spawn do cell.update (Handlers.set h) + ts.joinAll + let t ← (cell.get : BaseIO Handlers) + r.check "a set handler is found by its id" ((t.find? ping).any (·.id == ping)) + r.check "an unknown id finds no handler" (t.find? ⟨"/ipfs/ping/2.0.0"⟩).isNone + r.check "the table lists its ids in text order" (Handlers.ids t == [ident, ping]) + let t := Handlers.set (closer ping) t + r.check "a second set under one id replaces the first" (Handlers.ids t == [ident, ping]) + +/-! ## The peerstore -/ + +private def peerstoreChecks (r : Runner) : Async Unit := do + let a1 ← ma "/ip4/10.0.0.1/tcp/4001" + let a2 ← ma "/ip6/::1/tcp/4002" + let a3 ← ma "/memory/3" + -- Addresses round-trip, in the order they arrived. + let s := Peerstore.addAddrs large [a1, a2] {} + r.check "the peerstore returns the added addresses" (Peerstore.addrsOf s large == [a1, a2]) + r.check "an unknown peer has no addresses" (Peerstore.addrsOf s small).isEmpty + -- A redial's re-add of the same address grows nothing. + let s' := Peerstore.addAddrs large [a2, a1] s + r.check "a re-add leaves the set unchanged" (Peerstore.addrsOf s' large == [a1, a2]) + let s' := Peerstore.addAddrs large [a3, a3] s' + r.check "a new address joins once" (Peerstore.addrsOf s' large == [a1, a2, a3]) + + -- N tasks add to one store under the cell: every address lands, and + -- none twice (the base/conc milestone shape). + let cell : OnceRef Peerstore ← OnceRef.new {} + let addrs ← (List.range 32).mapM fun i => ma s!"/ip4/10.0.0.{i}/tcp/4001" + let ts ← TaskSet.new + for a in addrs do + -- Each address goes in twice, from two tasks. + for _ in [0:2] do + ts.spawn do cell.update (Peerstore.addAddrs large [a]) + ts.joinAll + let got := Peerstore.addrsOf (← (cell.get : BaseIO Peerstore)) large + r.check "concurrent adds keep every address" (addrs.all got.contains) + r.check "concurrent adds keep each address once" (got.length == addrs.length) + +/-! ## `Host.Error` -/ + +private def errorChecks (r : Runner) : IO Unit := do + let a ← ma "/memory/7" + let arms : List (Libp2p.Host.Error × String) := + [(.upgrade (.timeout "30000 ms"), "upgrade failed: timeout: 30000 ms"), + (.session (.protocol "bad frame"), "session failed: protocol error: bad frame"), + (.stream (.closed "reset"), "stream failed: closed: reset"), + (.gated .secured, "refused by policy at the secured checkpoint"), + (.noAddresses large, s!"no address for peer {large}"), + (.noTransport a, "no transport handles /memory/7"), + (.unreachable a, "nobody listens at /memory/7"), + (.dialSelf, "a dial to our own peer id"), + (.closing, "the host is closing")] + for (e, line) in arms do + r.check s!"Host.Error renders {line}" (toString e == line) + +/-! ## Unit 1b -/ + +/-- The unit 1b legs of the plan. Each one needs a host, and a host +derives its peer id through `libp2p/identity` and upgrades through +`libp2p/negotiate` (module doc). -/ +private def swarmLegs : List String := + ["the upgrade pipeline in both directions, with the identity check", + "the host's own answers: dial to self, no address, no transport, unknown protocol, failing handler, after close", + "the policy doubles: a denying gater refuses a dial and an inbound connection", + "the silent peer at the deadline, and close reaching a parked upgrade", + "the shutdown stages of §5.5 in order", + "the leftover hand-over on a served stream"] + +def Host.run (r : Runner) : IO Unit := do + r.section "host" "(packages/libp2p/host/docs/PLAN.md, test milestones, units 1a and 1b)" + dedupChecks r + (concurrentDedupChecks r).run + (livenessChecks r).run + (handlerChecks r).run + (peerstoreChecks r).run + errorChecks r + for leg in swarmLegs do + r.skip s!"host: {leg}" "needs W5.1, W5.3, W5.4, and W5.5 merged" + +end Tests diff --git a/Tests/Main.lean b/Tests/Main.lean index b43e7275..e028edd1 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -18,6 +18,7 @@ import Tests.Facade import Tests.CryptoCross import Tests.Yamux import Tests.Identity +import Tests.Host /-! # Test entry point @@ -51,4 +52,5 @@ def main : IO UInt32 := do Tests.CryptoCross.run r Tests.Yamux.run r Tests.Identity.run r + Tests.Host.run r r.summary diff --git a/lakefile.toml b/lakefile.toml index 507a62f1..94d76315 100644 --- a/lakefile.toml +++ b/lakefile.toml @@ -171,6 +171,7 @@ roots = ["Libp2p.Negotiate"] name = "Libp2pHost" srcDir = "packages/libp2p/host" roots = ["Libp2p.Host"] +globs = ["Libp2p.Host.*"] [[lean_lib]] name = "Libp2pProtocols" diff --git a/packages/libp2p/core/docs/ARCHITECTURE.md b/packages/libp2p/core/docs/ARCHITECTURE.md index 19748bd0..9d58b98c 100644 --- a/packages/libp2p/core/docs/ARCHITECTURE.md +++ b/packages/libp2p/core/docs/ARCHITECTURE.md @@ -111,6 +111,29 @@ The shapes are §4.9's, refined where this section says so. decision inputs beyond these are the seam's to refine as the host lands (W5.6). +**The policy shapes** (fixed by W5.6, as the bullet above leaves them +to the host). The peer of an inbound connection is unknown until the +handshake ends, so the gater and the resource manager each decide once +more there, with the proved peer, as go-libp2p does +(`ConnectionGater.InterceptSecured`, `vendor/go-libp2p/core/connmgr/gater.go`; +`ResourceScope.SetPeer`, `vendor/go-libp2p/core/network/rcmgr.go`). + +- `ConnectionGater`: `allowDial` (the expected peer and the address, + before a dial), `allowInbound` (the two addresses, after an accept), + and `allowSecured` (the role, the proved peer, and the two + addresses, after the handshake and before the muxer stage). Each + answers a `Bool` in `BaseIO`, so a policy has no fault channel. +- `ResourceManager.openConnection` admits a connection by its role, + the expected peer, and the remote address, and answers a + `ConnectionScope`, or `none` when it does not fit. The scope names + the proved peer (`setPeer`), admits streams and buffered bytes + (`reserveStream`, `reserveMemory`, each a `Reservation` with its + `release`), and returns every share at its own `release`. +- `AddressRanker.rank` maps a peer's addresses to a dial schedule, an + `AddrDelay` (an address and a delay in milliseconds) each: the shape + of go-libp2p's `AddrDelay` and `DialRanker` + (`vendor/go-libp2p/core/network/network.go`, lines 212 and 218). + **Test values.** §3.1 gives every seam two values from the start, the production value and a test value, and every suite above the transport runs on the test values (PRINCIPLES 1.4). The test values diff --git a/packages/libp2p/host/Libp2p/Host.lean b/packages/libp2p/host/Libp2p/Host.lean index 55b58625..569462a0 100644 --- a/packages/libp2p/host/Libp2p/Host.lean +++ b/packages/libp2p/host/Libp2p/Host.lean @@ -1,7 +1,17 @@ +import Libp2p.Host.Constants +import Libp2p.Host.Error +import Libp2p.Host.Registry +import Libp2p.Host.Dedup +import Libp2p.Host.Peerstore +import Libp2p.Host.Policy +import Libp2p.Host.Gate +import Libp2p.Host.Swarm +import Libp2p.Host.Shutdown + /-! # Libp2p.Host -The host subpackage: the upgrade pipeline, dedup, and the peerstore (ARCHITECTURE §4.9). Scaffold root. -The modules arrive with unit W5.6 of `docs/PLAN.md`. The package docs -live under `docs/` beside this file. +The host subpackage: the upgrade pipeline, dedup, and the peerstore +(ARCHITECTURE §4.9). The package docs live under `docs/` beside this +file. The root re-exports each public submodule. -/ diff --git a/packages/libp2p/host/Libp2p/Host/Constants.lean b/packages/libp2p/host/Libp2p/Host/Constants.lean new file mode 100644 index 00000000..b219b311 --- /dev/null +++ b/packages/libp2p/host/Libp2p/Host/Constants.lean @@ -0,0 +1,23 @@ +/-! +# The constants of `libp2p/host` + +Each fact has one home (ARCHITECTURE §3.8; PRINCIPLES 1.5). This module +holds the constants the host owns. The deadline of an unauthenticated +phase is not here: `net/streams` owns it (`Streams.unauthDeadlineMs`, +§5.4), and the upgrader applies it. +-/ + +set_option autoImplicit false + +namespace Libp2p.Host + +/-- The race window of the simultaneous-dial rule, in milliseconds +(§4.9; the package ARCHITECTURE, contract 2). Two connections to one +peer that run in opposite directions race when the standing session +was born less than this long before the new one. Inside the window, +the connection that the larger peer id dialed loses. Outside it, the +standing session keeps its place. Each side measures with its own +`Clock`. -/ +def simulWindowMs : Nat := 30000 + +end Libp2p.Host diff --git a/packages/libp2p/host/Libp2p/Host/Dedup.lean b/packages/libp2p/host/Libp2p/Host/Dedup.lean new file mode 100644 index 00000000..562c3216 --- /dev/null +++ b/packages/libp2p/host/Libp2p/Host/Dedup.lean @@ -0,0 +1,153 @@ +import Libp2p.Host.Mock.Identity +import Libp2p.Host.Mock.Core +import Libp2p.Host.Constants +import Libp2p.Host.Registry + +/-! +# The dedup rule + +The host keeps one session per peer (ARCHITECTURE §4.9). When a new +session to a peer finishes its upgrade, one pure function over the +session table decides which session the peer keeps, and the host +applies it with `OnceRef.updateGet`. The table holds the liveness of +each session as data (§3.8), so no IO read runs between a probe and a +commit, and no other update can change the entry under the decision +(the package ARCHITECTURE, contracts 2 and 3). + +## The rule + +The new session is the candidate. The entry already in the table, if +any, is the standing session. + +1. No standing session, or a dead one: the candidate takes the slot. +2. A live standing session and a candidate that run in opposite + directions, with the standing session born less than + `simulWindowMs` before the candidate: the two dials raced. The + connection that the larger peer id dialed loses (§4.9), and the + other one keeps the slot. +3. Any other live standing session keeps the slot, and the candidate + loses: outside the window, or in the same direction. + +Both peers must pick the same survivor in case 2, and each runs the +rule on its own. Our side dialed the connection where our role is +`.initiator`, so the loser is the connection whose initiator holds the +larger id. Both sides compare the same two ids, so they agree. + +## The order on peer ids + +"Larger" is the order of the binary form (`PeerId.toBytes`, the +multihash bytes), byte by byte. Both peers hold the same bytes for +each id. The derived `Ord` of `PeerId` also compares the text form +that one side parsed the id in, and the two sides can hold different +forms, so the rule does not use it. +-/ + +set_option autoImplicit false + +namespace Libp2p.Host + +open Libp2p.Host.Mock.Identity Libp2p.Host.Mock.Core + +/-- The order of two peer ids by their binary form, byte by byte +(module doc, "The order on peer ids"). -/ +def comparePeers (a b : PeerId) : Ordering := + compare a.toBytes.toList b.toBytes.toList + +/-- The answer of the dedup rule for one candidate. -/ +inductive Decision (α : Type) where + /-- The candidate took the slot. `displaced` is the live session it + replaced, which the caller closes; `none` when the slot was empty or + held a dead session. -/ + | commit (displaced : Option (Entry α)) + /-- The standing session keeps the slot, and the caller closes the + candidate. The caller serves the peer through `standing`. -/ + | keep (standing : Entry α) + +namespace Decision + +variable {α : Type} + +/-- True when the candidate took the slot. -/ +def committed : Decision α → Bool + | .commit _ => true + | .keep _ => false + +end Decision + +/-- The role of the connection that loses a race between two peers: +the one whose initiator holds the larger id. Our side initiated the +connection where our role is `.initiator`. -/ +def raceLoser (localPeer remotePeer : PeerId) : Role := + if comparePeers localPeer remotePeer = .gt then .initiator else .responder + +/-- True when the standing session and a candidate born at `nowMs` +raced: they run in opposite directions, and the standing session was +born less than `simulWindowMs` before. -/ +def raced {α : Type} (standing : Entry α) (candidateRole : Role) (nowMs : Nat) : Bool := + standing.role != candidateRole && nowMs < standing.bornMs + simulWindowMs + +/-- The dedup rule (module doc, "The rule"): decide whether `cand` +takes the slot of `remotePeer`, and the table after the decision. The +candidate's `bornMs` is the time of the decision. -/ +def dedup {α : Type} (localPeer remotePeer : PeerId) (cand : Entry α) + (t : SessionTable α) : Decision α × SessionTable α := + let commit (displaced : Option (Entry α)) := (.commit displaced, t.insert remotePeer cand) + match t.get? remotePeer with + | none => commit none + | some standing => + if !standing.live then commit none + else if raced standing cand.role cand.bornMs then + if cand.role = raceLoser localPeer remotePeer then (.keep standing, t) + else commit (some standing) + else (.keep standing, t) + +/-! ## Theorems + +The rule is a pure function, so its properties are theorems (§3.3, +§6). `HashMap` lookups appear as `t[r]?`, the form `simp` normalizes +`get?` to. -/ + +/-- Both peers pick the same survivor of a race. Peer `a` dialed one +connection, so `a` holds it as `.initiator` and `b` holds it as +`.responder`. The roles the two sides drop differ, so the one +connection that `a` drops as its initiator is the one `b` drops as its +responder, and the other way round. The ids differ: a dial to our own +id never reaches dedup (`Error.dialSelf`). -/ +theorem raceLoserAgree (a b : PeerId) (h : comparePeers a b ≠ .eq) : + raceLoser a b ≠ raceLoser b a := by + unfold raceLoser comparePeers at * + -- The list order is oriented: swapping the arguments swaps the answer. + have hs : compare a.toBytes.toList b.toBytes.toList = + (compare b.toBytes.toList a.toBytes.toList).swap := Std.OrientedOrd.eq_swap + cases hc : compare b.toBytes.toList a.toBytes.toList <;> simp_all [Ordering.swap] + +/-- An empty slot takes the candidate (rule 1). -/ +theorem dedupEmpty {α : Type} (l r : PeerId) (c : Entry α) (t : SessionTable α) + (h : t[r]? = none) : dedup l r c t = (.commit none, t.insert r c) := by + simp [dedup, h] + +/-- A dead standing session always makes room (rule 1), and nothing is +displaced. -/ +theorem dedupDead {α : Type} (l r : PeerId) (c s : Entry α) (t : SessionTable α) + (h : t[r]? = some s) (hd : s.live = false) : + dedup l r c t = (.commit none, t.insert r c) := by + simp [dedup, h, hd] + +/-- A decision that keeps the standing session leaves the table as it +was, so a losing candidate changes no state. -/ +theorem dedupKeepTable {α : Type} (l r : PeerId) (c s : Entry α) (t : SessionTable α) + (h : (dedup l r c t).1 = .keep s) : (dedup l r c t).2 = t := by + unfold dedup at h ⊢ + -- One bullet per branch of `dedup`: the commit branches contradict + -- `h`, and the keep branches return `t` as it is. + split + · simp_all + · split + · simp_all + · split + · split + · rfl + · simp_all + · rfl + +end Libp2p.Host diff --git a/packages/libp2p/host/Libp2p/Host/Error.lean b/packages/libp2p/host/Libp2p/Host/Error.lean new file mode 100644 index 00000000..b6aded64 --- /dev/null +++ b/packages/libp2p/host/Libp2p/Host/Error.lean @@ -0,0 +1,98 @@ +import Multiformats +import Libp2p.Host.Mock.Identity +import Libp2p.Host.Mock.Core + +/-! +# `Host.Error` + +The host and `cli/` match on one error type (ARCHITECTURE §3.5). Every +seam below the host fails with the coarse `Core.Error`, and the host +wraps it once, at its own boundary, in the arm of the phase that +failed: `upgrade`, `session`, or `stream`. The other +arms name the decisions the host makes itself: a policy hook said no, +the peerstore holds no address, no transport serves an address, nobody +listens there, the dial targets our own id, or the host is shutting +down. An `IO.Error` fault passes underneath, as a fault (§3.5; the +package ARCHITECTURE, contract 8). +-/ + +set_option autoImplicit false + +namespace Libp2p.Host + +-- The mocks of the unmerged packages (RULES.md, "Working ahead of the +-- tree"); `Core.Error` resolves through `Libp2p.Host.Mock`. +open Multiformats Libp2p.Host.Mock Libp2p.Host.Mock.Identity + +/-- The policy checkpoint that refused a connection (§3.4: a closed +set). The gater answers at the first three (`Core.ConnectionGater`), +and the resource manager at the fourth (`Core.ResourceManager`, +`Core.ConnectionScope.setPeer`). No spec gives a checkpoint a wire +code, so the set has no `toCode`/`ofCode?` pair, as `Core.Role` has +none. -/ +inductive Checkpoint where + /-- `ConnectionGater.allowDial`, before an outbound dial. -/ + | dial + /-- `ConnectionGater.allowInbound`, after an accept. -/ + | inbound + /-- `ConnectionGater.allowSecured`, after the handshake, before the + muxer stage. -/ + | secured + /-- `ResourceManager.openConnection`, or `ConnectionScope.setPeer` + once the handshake has proved the peer. -/ + | resources + deriving DecidableEq, Repr + +/-- The checkpoint's name in a log line. -/ +def Checkpoint.render : Checkpoint → String + | .dial => "dial" + | .inbound => "inbound" + | .secured => "secured" + | .resources => "resources" + +/-- A host failure: a seam failure that the host wraps, or a decision +of the host. -/ +inductive Error where + /-- The upgrade of a connection failed. The arm keeps the seam error + as it came, with its rendered detail for the log. -/ + | upgrade (e : Core.Error) + /-- A session ended with a failure after its upgrade: its `join` + answered the seam error. -/ + | session (e : Core.Error) + /-- A stream failed: its open, its negotiation, or its handler. The + host resets the stream, and the session stays. -/ + | stream (e : Core.Error) + /-- A policy hook refused the connection at this checkpoint. -/ + | gated (at_ : Checkpoint) + /-- `dialPeer` found no address for the peer in the peerstore. -/ + | noAddresses (peer : PeerId) + /-- No configured transport handles the address + (`Streams.Transport.handles`). -/ + | noTransport (addr : Multiaddr) + /-- The transport dialed the address, and nobody listens there. -/ + | unreachable (addr : Multiaddr) + /-- The dial targets the host's own peer id. -/ + | dialSelf + /-- The host is shut down, or is shutting down (§5.5). -/ + | closing + deriving DecidableEq, Repr + +namespace Error + +/-- The log line of the error. -/ +def render : Error → String + | .upgrade e => s!"upgrade failed: {e.render}" + | .session e => s!"session failed: {e.render}" + | .stream e => s!"stream failed: {e.render}" + | .gated c => s!"refused by policy at the {c.render} checkpoint" + | .noAddresses p => s!"no address for peer {p}" + | .noTransport a => s!"no transport handles {a.describe}" + | .unreachable a => s!"nobody listens at {a.describe}" + | .dialSelf => "a dial to our own peer id" + | .closing => "the host is closing" + +instance : ToString Error := ⟨render⟩ + +end Error + +end Libp2p.Host diff --git a/packages/libp2p/host/Libp2p/Host/Gate.lean b/packages/libp2p/host/Libp2p/Host/Gate.lean new file mode 100644 index 00000000..a7955ff7 --- /dev/null +++ b/packages/libp2p/host/Libp2p/Host/Gate.lean @@ -0,0 +1,80 @@ +import Conc + +/-! +# `Gate`: a task group that stops taking tasks + +The host owns the tasks of its listeners and its sessions, and it +joins them in the shutdown order (ARCHITECTURE §5.5; PRINCIPLES 3.2). +Some of those tasks start outside any task the host owns: a dial runs +in the caller's task, and its session task starts when the dial ends. +A `Conc.TaskSet` keeps every task spawned before its `joinAll` ends, +but a spawn after that would start a task nobody joins. + +A gate closes that window. `close` takes the tasks out and shuts the +gate in one atomic step, and a later `spawn` is refused. The refused +caller wakes the work with the close that reaches its park, then joins +the work in its own task. So every task has an owner that joins it, +before the shutdown or after it. + +A task's fault reaches the owner at the join, as `TaskSet.joinAll` +hands it on. +-/ + +set_option autoImplicit false + +namespace Libp2p.Host + +open Conc + +/-- A task group that refuses new tasks once its owner closes it. -/ +structure Gate where + /-- The tasks; `none` once the gate is closed. -/ + private tasks : OnceRef (Option (Array (Task (Except IO.Error Unit)))) + +namespace Gate + +/-- An open gate with no tasks. -/ +def new : BaseIO Gate := + return ⟨← OnceRef.new (some #[])⟩ + +/-- Start `act` as a task of the gate, and answer `true`. When the gate +is closed, run `wake`, which closes what `act` parks on, then join `act` +here, and answer `false`. A fault of `act` then passes to the caller. -/ +def spawn (g : Gate) (act : Async Unit) (wake : BaseIO Unit) : Async Bool := do + -- The ascriptions mark the steps `BaseIO`, so the lift into `Async` + -- applies (see `Conc.TaskSet.joinAll`). + if (← (g.tasks.get : BaseIO (Option (Array (Task (Except IO.Error Unit)))))).isNone then + -- Closed before the start: `act` runs here, after the wake. + wake + act + return false + let t ← (Wrap.spawn act : BaseIO (Task (Except IO.Error Unit))) + let kept ← (g.tasks.updateGet fun + | some ts => (true, some (ts.push t)) + | none => (false, none) : BaseIO Bool) + if kept then return true + -- The gate closed after the start, so its `close` did not take the + -- task. This caller owns it: wake it, and join it. + wake + match ← Wrap.await t with + | .ok () => return false + | .error e => throw e + +/-- Close the gate, and wait for every task it took. Then throw the +first fault in spawn order. A second close waits for nothing. -/ +def close (g : Gate) : Async Unit := do + let ts ← (g.tasks.updateGet fun ts => (ts.getD #[], none) : + BaseIO (Array (Task (Except IO.Error Unit)))) + let mut errors : Array IO.Error := #[] + for t in ts do + -- Park: the task wakes the join when it ends. The shutdown order + -- closes what each task parks on before this join (§5.5). + if let .error e ← Wrap.await t then errors := errors.push e + match errors.toList with + | [] => return () + | [e] => throw e + | es => throw <| IO.userError s!"{es.length} tasks failed: {es.map toString}" + +end Gate + +end Libp2p.Host diff --git a/packages/libp2p/host/Libp2p/Host/Mock/Core.lean b/packages/libp2p/host/Libp2p/Host/Mock/Core.lean new file mode 100644 index 00000000..a80a3f5b --- /dev/null +++ b/packages/libp2p/host/Libp2p/Host/Mock/Core.lean @@ -0,0 +1,168 @@ +import Conc +import Streams +import Libp2p.Host.Mock.Identity + +/-! +# Mock: the surface of `libp2p/core` that the host uses + +W5.2 (`libp2p/core`) has not merged, so the host builds against a mock +of its frozen surface (RULES.md, "Working ahead of the tree"). The +types are copies: the seam shapes of the core ARCHITECTURE, "Public +surface", with `MuxSession.openStream` carrying a `ProtocolId` (the mux +ARCHITECTURE, "Public surface"). The mock imports nothing from the real +package, and it states no axiom. + +## The policy shapes + +The core ARCHITECTURE fixes the three policy hooks and no signatures: +"the decision inputs beyond these are the seam's to refine as the host +lands (W5.6)". This unit fixes them, and the core ARCHITECTURE records +them. The peer of an inbound connection is unknown until the handshake +ends, so the gater and the resource manager each take a second decision +there, with the proved peer, as go-libp2p does: +`ConnectionGater.InterceptSecured` (`vendor/go-libp2p/core/connmgr/gater.go`) +and `ResourceScope.SetPeer` (`vendor/go-libp2p/core/network/rcmgr.go`). +The ranker answers a dial schedule, an address and a delay each, the +shape of go-libp2p's `AddrDelay` and `DialRanker` +(`vendor/go-libp2p/core/network/network.go`, lines 212 and 218). + +A policy answer runs in `BaseIO`: it has no fault channel, so a policy +throw cannot lose a dial. + +At the integration, the host imports `Libp2p.Core`, and the module goes. +-/ + +set_option autoImplicit false + +namespace Libp2p.Host.Mock.Core + +open Conc Streams Multiformats Libp2p.Host.Mock.Identity + +/-- A protocol id, wrapped (§3.4). -/ +structure ProtocolId where + /-- The id text. -/ + toString : String + deriving DecidableEq, Hashable, Ord, Repr + +instance : ToString ProtocolId := ⟨ProtocolId.toString⟩ + +/-- The coarse error every seam returns (§4.9). Each arm carries the +rendered error of the implementation. -/ +inductive Error where + | protocol (detail : String) + | authentication (detail : String) + | timeout (detail : String) + | closed (detail : String) + deriving DecidableEq, Repr + +/-- The log line of the error: the kind, then the rendered detail. -/ +def Error.render : Error → String + | .protocol d => s!"protocol error: {d}" + | .authentication d => s!"authentication failure: {d}" + | .timeout d => s!"timeout: {d}" + | .closed d => s!"closed: {d}" + +instance : ToString Error := ⟨Error.render⟩ + +/-- Our side of a connection (§4.9). -/ +inductive Role where + | initiator + | responder + deriving DecidableEq, Repr + +/-- The connection after the handshake: the secure connection, the two +peer ids, the bytes the handshake read past its end, and the +completion of its tasks. -/ +structure SecureConn extends Connection where + /-- Our peer id. -/ + localPeer : PeerId + /-- The peer id the handshake proved. -/ + remotePeer : PeerId + /-- The bytes the handshake read past its end (§4.7). -/ + leftover : ByteArray + /-- Wait until the secure connection's tasks end. -/ + join : ExceptT Error Async Unit + +/-- The security seam. -/ +structure SecurityProtocol where + /-- The negotiated id. -/ + id : ProtocolId + /-- Run the handshake over a connection, with the negotiation's + leftover. -/ + handshake : Role → Connection → ByteArray → ExceptT Error Async SecureConn + +/-- A muxer session. -/ +structure MuxSession where + /-- Open a stream, labeled with its protocol id. -/ + openStream : ProtocolId → ExceptT Error Async Stream + /-- The next stream the peer opened; `none` once the session ends. -/ + accept : Async (Option Stream) + /-- End the session. -/ + close : IO Unit + /-- Wait until the session loop ends. -/ + join : ExceptT Error Async Unit + +/-- The muxer seam. -/ +structure Muxer where + /-- The negotiated id. -/ + id : ProtocolId + /-- Open a session over a secure connection, with the negotiation's + leftover. -/ + «open» : Role → Connection → ByteArray → ExceptT Error Async MuxSession + +/-- The callback a served stream reaches. -/ +structure StreamHandler where + /-- The id the handler serves. -/ + id : ProtocolId + /-- Serve one stream. -/ + handle : Stream → ExceptT Error Async Unit + +/-! ## The policy hooks (module doc, "The policy shapes") -/ + +/-- The connection gater. `true` allows. -/ +structure ConnectionGater where + /-- Before an outbound dial, by the expected peer and the address. -/ + allowDial : Option PeerId → Multiaddr → BaseIO Bool + /-- After an accept, before any byte moves, by the two addresses. -/ + allowInbound : (localAddr remoteAddr : Multiaddr) → BaseIO Bool + /-- After the handshake, before the muxer stage, by the proved peer. -/ + allowSecured : Role → PeerId → (localAddr remoteAddr : Multiaddr) → BaseIO Bool + +/-- An admitted share of a limit. A second `release` does nothing. -/ +structure Reservation where + /-- Return the share. -/ + release : BaseIO Unit + +/-- The resource scope of one admitted connection. -/ +structure ConnectionScope where + /-- Name the proved peer; `false` when the connection does not fit + that peer's limits. -/ + setPeer : PeerId → BaseIO Bool + /-- Admit one more stream; `none` when it does not fit. -/ + reserveStream : BaseIO (Option Reservation) + /-- Admit this many more buffered bytes; `none` when they do not fit. -/ + reserveMemory : Nat → BaseIO (Option Reservation) + /-- Return the connection's share and every share it still holds. -/ + release : BaseIO Unit + +/-- The resource manager. -/ +structure ResourceManager where + /-- Admit a new connection, by its direction, the expected peer, and + the remote address; `none` when it does not fit. -/ + openConnection : Role → Option PeerId → Multiaddr → BaseIO (Option ConnectionScope) + +/-- One address of a dial schedule, with its delay from the start of +the dial, in milliseconds. -/ +structure AddrDelay where + /-- The address to dial. -/ + addr : Multiaddr + /-- The wait before this dial. -/ + delayMs : Nat + deriving DecidableEq, Repr + +/-- The address ranker: the dial schedule of a peer's addresses. -/ +structure AddressRanker where + /-- The schedule, in dial order. -/ + rank : List Multiaddr → List AddrDelay + +end Libp2p.Host.Mock.Core diff --git a/packages/libp2p/host/Libp2p/Host/Mock/Identity.lean b/packages/libp2p/host/Libp2p/Host/Mock/Identity.lean new file mode 100644 index 00000000..192cf86c --- /dev/null +++ b/packages/libp2p/host/Libp2p/Host/Mock/Identity.lean @@ -0,0 +1,69 @@ +/-! +# Mock: the surface of `libp2p/identity` that the host uses + +W5.1 (`libp2p/identity`) has not merged, so the host builds against a +mock of its frozen surface (RULES.md, "Working ahead of the tree"; the +identity ARCHITECTURE, "Public surface"). The mock imports nothing from +the real package, and it states no axiom. + +* `PeerId` is a copy of the shape the host reads: the binary form + (`toBytes`, the multihash bytes of the peer-ids spec) and the text + form it prints. A test builds one directly. +* `PubKey` and `PrivKey` are copies that hold their bytes. +* The derivations are `opaque`, and each fails with the name of the + package, so no run derives an identity from this mock. + +At the integration, the host imports `Libp2p.Identity`, drops +`open Libp2p.Host.Mock`, and the module goes. +-/ + +set_option autoImplicit false + +namespace Libp2p.Host.Mock.Identity + +/-- Why a key or a peer id failed to derive or to parse. -/ +inductive Error where + /-- The identity package is not integrated. -/ + | unavailable (what : String) + deriving DecidableEq, Repr + +/-- A peer id. -/ +structure PeerId where + /-- The binary form: the multihash bytes (the peer-ids spec). -/ + bytes : List Nat + /-- The text form the id prints. -/ + text : String + deriving DecidableEq, Hashable, Repr + +namespace PeerId + +/-- The binary form, the bytes a `/p2p` component carries. -/ +def toBytes (p : PeerId) : ByteArray := ⟨(p.bytes.map UInt8.ofNat).toArray⟩ + +instance : ToString PeerId := ⟨PeerId.text⟩ + +end PeerId + +/-- A public key. -/ +structure PubKey where + /-- The marshaled key. -/ + raw : ByteArray + +/-- A private key. -/ +structure PrivKey where + /-- The marshaled key. -/ + raw : ByteArray + +/-- The public half of a private key. -/ +opaque PrivKey.toPublic (_k : PrivKey) : Except Error PubKey := + .error (.unavailable "libp2p/identity (W5.1): key derivation") + +/-- The peer id of a public key. -/ +opaque PeerId.ofPubKey (_k : PubKey) : Except Error PeerId := + .error (.unavailable "libp2p/identity (W5.1): peer id derivation") + +/-- The peer id of its binary form, the value of a `/p2p` component. -/ +opaque PeerId.ofBytes? (_b : List Nat) : Except Error PeerId := + .error (.unavailable "libp2p/identity (W5.1): peer id parse") + +end Libp2p.Host.Mock.Identity diff --git a/packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean b/packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean new file mode 100644 index 00000000..f2e36815 --- /dev/null +++ b/packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean @@ -0,0 +1,91 @@ +import Conc +import Streams +import Libp2p.Host.Mock.Identity +import Libp2p.Host.Mock.Core + +/-! +# Mock: the surface of `libp2p/negotiate` that the host uses + +W5.5 (`libp2p/negotiate`) has not merged, so the host builds against a +mock (RULES.md, "Working ahead of the tree"). The mock imports nothing +from the real package, and it states no axiom. Both entries fail with +the name of the package: no body negotiates, runs a handshake, or opens +a session. + +The negotiate ARCHITECTURE freezes `Negotiate.upgrade`. The host needs +two calls beside it, and the negotiate ARCHITECTURE gains both in this +unit: + +* `upgradeWith`, the upgrade with a checkpoint after the handshake and + before the muxer stage. The host's gater and resource manager decide + there, by the proved peer (the core mock, "The policy shapes"); go's + upgrader calls `InterceptSecured` at the same place + (`vendor/go-libp2p/p2p/net/upgrader/upgrader.go`, line 176). A refusal is a + value, `UpgradeResult.refused`, and no `Core.Error` arm (§3.5). +* `negotiateStream`, multistream-select over one muxed stream. The host + serves each inbound stream through it, and `newStream` negotiates + each outbound one. The bytes it reads past the selecting message go + to the stream's front (§4.7, `Stream.prependBytes`). + +At the integration, the host imports `Libp2p.Negotiate`, and the module +goes. +-/ + +set_option autoImplicit false + +namespace Libp2p.Host.Mock.Negotiate + +open Conc Streams Libp2p.Host.Mock.Identity Libp2p.Host.Mock.Core + +/-- How a stream negotiation failed. The host reads two arms: the end of +the stream before a selection, an orderly end, and the empty proposal +list. -/ +inductive Error where + /-- The stream ended before the negotiation selected a protocol. -/ + | closed + /-- The initiator has no id to propose. -/ + | noProtocols + /-- Any other failure, rendered. -/ + | failed (detail : String) + deriving DecidableEq, Repr + +/-- The failure as the coarse seam error (§3.5). -/ +def Error.toCore : Error → Core.Error + | .closed => .closed "multistream: the stream ended before a selection" + | .noProtocols => .protocol "multistream: no protocol to propose" + | .failed d => .protocol s!"multistream: {d}" + +/-- A connection that the upgrade finished. -/ +structure Upgraded where + /-- The muxer session over the secure connection. -/ + session : MuxSession + /-- The peer id that the handshake proved, and the upgrader checked. -/ + remotePeer : PeerId + /-- Wait until the session and the secure connection under it end. -/ + join : ExceptT Core.Error Async Unit + +/-- How an upgrade with a checkpoint ended, when no stage failed. -/ +inductive UpgradeResult (ρ : Type) where + /-- Every stage ran, and the checkpoint admitted the peer. -/ + | upgraded (u : Upgraded) + /-- The checkpoint refused the proved peer, for `reason`. -/ + | refused (reason : ρ) + +/-- The upgrade with a checkpoint: negotiate the security protocol, run +the handshake, check the expected peer, call `admit` with the proved +peer, negotiate the muxer, and open the session, all under the 30 s +deadline on `clock` (§5.4). -/ +opaque upgradeWith {ρ : Type} (_clock : Clock) (_role : Role) (_conn : Connection) + (_pending : ByteArray) (_expected : Option PeerId) (_admit : PeerId → BaseIO (Option ρ)) + (_secs : List SecurityProtocol) (_muxers : List Muxer) : + ExceptT Core.Error Async (UpgradeResult ρ) := + throw (.closed "libp2p/negotiate (W5.5) is not integrated") + +/-- Negotiate one of `ids` over a stream, as `role`. The answer is the +selected id and the stream, with the bytes read past the selecting +message at its front. -/ +opaque negotiateStream (_role : Role) (_s : Stream) (_ids : List ProtocolId) : + ExceptT Error Async (ProtocolId × Stream) := + throw (.failed "libp2p/negotiate (W5.5) is not integrated") + +end Libp2p.Host.Mock.Negotiate diff --git a/packages/libp2p/host/Libp2p/Host/Peerstore.lean b/packages/libp2p/host/Libp2p/Host/Peerstore.lean new file mode 100644 index 00000000..9c95da2e --- /dev/null +++ b/packages/libp2p/host/Libp2p/Host/Peerstore.lean @@ -0,0 +1,47 @@ +import Std.Data.HashMap +import Multiformats +import Libp2p.Host.Mock.Identity + +/-! +# The peerstore + +The third host registry (ARCHITECTURE §3.8): the addresses of each +peer. `dialPeer` walks them. The table changes only through the pure +updates here, applied under `OnceRef.update` (the package +ARCHITECTURE, contract 1). The spike's peerstore lost addresses +through a get-then-set; one atomic update per add closes that race +(PRINCIPLES 3.3). + +An add skips the addresses the peer already has, so a redial that +re-adds its address grows nothing. The addresses keep the order they +first arrived in. Limits and TTLs are §9 delta 3, outside this unit +(contract 9). +-/ + +set_option autoImplicit false + +namespace Libp2p.Host + +open Multiformats Libp2p.Host.Mock.Identity + +/-- The addresses of each peer, in the order they first arrived. A +synonym with no invariant of its own (§3.7). -/ +abbrev Peerstore := Std.HashMap PeerId (Array Multiaddr) + +namespace Peerstore + +/-- Add `addrs` to the addresses of `peer`, and skip each one the peer +already has, so the result holds every address once. -/ +def addAddrs (peer : PeerId) (addrs : List Multiaddr) (s : Peerstore) : Peerstore := + let known := s.getD peer #[] + let grown := addrs.foldl (fun acc a => if acc.contains a then acc else acc.push a) known + s.insert peer grown + +/-- The addresses of `peer`, in the order they first arrived. Empty +when the store knows none. -/ +def addrsOf (s : Peerstore) (peer : PeerId) : List Multiaddr := + (s.getD peer #[]).toList + +end Peerstore + +end Libp2p.Host diff --git a/packages/libp2p/host/Libp2p/Host/Policy.lean b/packages/libp2p/host/Libp2p/Host/Policy.lean new file mode 100644 index 00000000..16665b84 --- /dev/null +++ b/packages/libp2p/host/Libp2p/Host/Policy.lean @@ -0,0 +1,55 @@ +import Libp2p.Host.Mock.Core + +/-! +# The policy values + +The three policy hooks of `libp2p/core` are seams (ARCHITECTURE §3.1): +the connection gater, the resource manager, and the address ranker. +The host takes one value of each in its configuration, and `cli/` or a +consumer may pass its own (the package ARCHITECTURE, "Decisions"). This +module holds the defaults the configuration starts from, and the +denying gater the suites use (the core pair assigned the doubles +here). The limiting values of §9, delta 4, land later behind the same +seams. +-/ + +set_option autoImplicit false + +namespace Libp2p.Host.Policy + +open Libp2p.Host.Mock.Core + +/-- The default gater: it allows every dial, every inbound connection, +and every proved peer. -/ +def permissiveGater : ConnectionGater where + allowDial _ _ := pure true + allowInbound _ _ := pure true + allowSecured _ _ _ _ := pure true + +/-- The test double that refuses at every checkpoint. A dial fails with +`Host.Error.gated .dial`, and an accepted connection closes before any +byte moves. -/ +def denyingGater : ConnectionGater where + allowDial _ _ := pure false + allowInbound _ _ := pure false + allowSecured _ _ _ _ := pure false + +/-- A share of no limit: its release returns nothing. -/ +private def freeShare : Reservation := { release := pure () } + +/-- The default resource manager: it admits every connection, peer, +stream, and buffer, and counts nothing. -/ +def unlimited : ResourceManager where + openConnection _ _ _ := pure (some { + setPeer := fun _ => pure true + reserveStream := pure (some freeShare) + reserveMemory := fun _ => pure (some freeShare) + release := pure () }) + +/-- The default address ranker: the addresses in the order given, with +no delay, the form of js-libp2p's `AddressSorter` (the core pair, +`AddressRanker`). -/ +def keepOrder : AddressRanker where + rank addrs := addrs.map fun addr => { addr, delayMs := 0 } + +end Libp2p.Host.Policy diff --git a/packages/libp2p/host/Libp2p/Host/Registry.lean b/packages/libp2p/host/Libp2p/Host/Registry.lean new file mode 100644 index 00000000..87a1661c --- /dev/null +++ b/packages/libp2p/host/Libp2p/Host/Registry.lean @@ -0,0 +1,119 @@ +import Std.Data.HashMap +import Libp2p.Host.Mock.Identity +import Libp2p.Host.Mock.Core + +/-! +# The session and handler registries + +The host keeps the shared state that remains after the loops of §3.3 +own their sessions (ARCHITECTURE §3.8). This module holds two of the +three registries; the peerstore has its own module. Each registry is a +`Std.HashMap` that the host keeps inside an `OnceRef`, and only a pure +update function, applied under `OnceRef.update` or +`OnceRef.updateGet`, changes it (the package ARCHITECTURE, contract 1; +PRINCIPLES 3.3). The functions here are data in, data out, so a test +runs them with no cell and no task. + +## The session table + +One entry per peer: the session the host serves that peer through. +The entry records what the dedup rule (`Libp2p.Host.Dedup`) reads, so +the rule runs inside one update with no probe before it (§3.8): + +* `role`, our side of the connection, which says who dialed it; +* `bornMs`, the host clock when the entry was committed; +* `live`, the liveness field. The loop of a session clears it on exit, + through `markDead` (contract 3). + +The session handle is a type parameter, `α`. The host stores its +running session there, and a test stores a plain value, so the table +functions need no connection. + +Each entry carries a session number, `id`, that the host draws once +per session. `markDead` matches it, so the exit of a session that dedup +already replaced cannot mark its successor dead. + +## The handler table + +`Std.HashMap ProtocolId StreamHandler`, keyed by the id each handler +carries (§3.10; contract 5). The ids, in their `Ord` order, are what +the host's stream negotiation answers from. +-/ + +set_option autoImplicit false + +namespace Libp2p.Host + +open Libp2p.Host.Mock.Identity Libp2p.Host.Mock.Core + +/-! ## The session table -/ + +/-- The entry of one peer in the session table. -/ +structure Entry (α : Type) where + /-- The session number. The host draws a fresh one for each session, + so two sessions of one peer never share it. -/ + id : Nat + /-- Our side of the connection: `.initiator` when we dialed it. -/ + role : Role + /-- The host clock, in milliseconds, when the entry was committed. -/ + bornMs : Nat + /-- False once the loop of the session has exited (contract 3). -/ + live : Bool + /-- The session handle. -/ + session : α + +/-- The session table: the entry of each peer the host holds a session +with. A synonym with no invariant of its own (§3.7). -/ +abbrev SessionTable (α : Type) := Std.HashMap PeerId (Entry α) + +namespace SessionTable + +variable {α : Type} + +/-- The live entry of `peer`. `none` when the table holds no entry for +the peer, or holds a dead one. -/ +def live? (t : SessionTable α) (peer : PeerId) : Option (Entry α) := + (t.get? peer).filter (·.live) + +/-- Mark the session `id` of `peer` dead. The loop of the session +calls it on exit, through the cell's update (contract 3). The entry +stays, so the next dedup decision reads it as dead. An entry with +another number belongs to a later session, and the call leaves it +alone. -/ +def markDead (peer : PeerId) (id : Nat) (t : SessionTable α) : SessionTable α := + match t.get? peer with + | some e => if e.id = id then t.insert peer { e with live := false } else t + | none => t + +/-- The live entries, for the shutdown sequence: the host closes each +session it still serves (§5.5). -/ +def liveEntries (t : SessionTable α) : List (PeerId × Entry α) := + t.toList.filter (·.2.live) + +end SessionTable + +/-! ## The handler table -/ + +/-- The handler table: the handler of each protocol id (§3.10). -/ +abbrev Handlers := Std.HashMap ProtocolId StreamHandler + +namespace Handlers + +/-- Store `h` under its id. A handler already stored under the id is +replaced. -/ +def set (h : StreamHandler) (t : Handlers) : Handlers := + t.insert h.id h + +/-- The handler of `id`. `none` when no handler serves the id. -/ +def find? (t : Handlers) (id : ProtocolId) : Option StreamHandler := + t.get? id + +/-- The ids the table serves, in `ProtocolId` order. The order is the +text order of the ids (`ProtocolId`'s derived `Ord`), so the stream +negotiation and an `ls` answer do not depend on the hash order. -/ +def ids (t : Handlers) : List ProtocolId := + (t.toList.map (·.1)).mergeSort (fun a b => compare a b != .gt) + +end Handlers + +end Libp2p.Host diff --git a/packages/libp2p/host/Libp2p/Host/Shutdown.lean b/packages/libp2p/host/Libp2p/Host/Shutdown.lean new file mode 100644 index 00000000..b1c45442 --- /dev/null +++ b/packages/libp2p/host/Libp2p/Host/Shutdown.lean @@ -0,0 +1,108 @@ +import Conc +import Streams +import Libp2p.Host.Registry +import Libp2p.Host.Gate +import Libp2p.Host.Swarm + +/-! +# The shutdown + +`close` runs the sequence of ARCHITECTURE §5.5, in order (the package +ARCHITECTURE, contract 4): + +1. set the closing flag, and close the shutdown channel that a dial + delay waits on; +2. close the listeners: each accept loop parks on its listener, and + the close wakes it; +3. close the pending connections: each inbound or outbound upgrade + parks on its connection, and the close wakes it, with the upgrade + deadline as the backstop (§5.4); +4. shut down the sessions: each serve task parks in + `MuxSession.accept`, and `MuxSession.close` wakes it with `none`; +5. join the session loops: the serve tasks, which join their stream + tasks and their sessions; +6. join the host tasks: the accept loops and the inbound upgrades, + then the listeners. + +Every stage closes the parks of the tasks that a later stage joins, so +every join returns (PRINCIPLES 3.1, 3.2). A path that registers after a +stage took its list sees the flag of stage 1, and closes what it +registered itself (`Libp2p.Host.Swarm`, "The race with the +shutdown"). + +A fault of a joined task does not stop the sequence: the later joins +still run, and `close` throws the first fault at the end. +-/ + +set_option autoImplicit false + +namespace Libp2p.Host + +open Conc Streams + +/-- The stages of the shutdown, in their order (§5.5). `close` names +each one as it starts it, so a suite can check the order. -/ +inductive Stage where + /-- The closing flag is set. -/ + | flag + /-- The listeners close. -/ + | listeners + /-- The pending connections close. -/ + | pending + /-- The sessions shut down. -/ + | sessions + /-- The session loops are joined. -/ + | sessionLoops + /-- The host tasks are joined. -/ + | hostTasks + deriving DecidableEq, Repr + +namespace Host + +/-- Run `act`, and answer its fault instead of throwing it. -/ +private def capture (act : Async Unit) : Async (Option IO.Error) := do + try + act + return none + catch e => return some e + +/-- Shut the host down in the order of §5.5 (module doc). `onStage` +hears each stage as it starts; the default ignores it. A second call +finds every list taken and every gate closed, and returns. -/ +def close (h : Host) (onStage : Stage → BaseIO Unit := fun _ => pure ()) : Async Unit := do + -- The ascriptions mark the steps `BaseIO`, so the lift into `Async` + -- applies (see `Conc.TaskSet.joinAll`). + (onStage .flag : BaseIO Unit) + (h.closing.set true : BaseIO Unit) + (h.shutdown.close : BaseIO Unit) + + (onStage .listeners : BaseIO Unit) + let listeners ← (h.listeners.updateGet fun ls => (ls.getD [], none) : BaseIO (List Listener)) + for l in listeners do + discard l.close.toBaseIO + + (onStage .pending : BaseIO Unit) + let pending ← (h.pending.updateGet fun m => (m.getD {}, none) : + BaseIO (Std.HashMap Nat Connection)) + for (_, c) in pending do + c.closeQuietly + + (onStage .sessions : BaseIO Unit) + let table ← (h.sessions.get : BaseIO (SessionTable Served)) + for (_, e) in SessionTable.liveEntries table do + discard e.session.upgraded.session.close.toBaseIO + + (onStage .sessionLoops : BaseIO Unit) + let sessionFault ← capture h.sessionTasks.close + + (onStage .hostTasks : BaseIO Unit) + let hostFault ← capture h.hostTasks.close + let mut listenerFault : Option IO.Error := none + for l in listeners do + listenerFault := listenerFault <|> (← capture l.join) + + if let some e := sessionFault <|> hostFault <|> listenerFault then throw e + +end Host + +end Libp2p.Host diff --git a/packages/libp2p/host/Libp2p/Host/Swarm.lean b/packages/libp2p/host/Libp2p/Host/Swarm.lean new file mode 100644 index 00000000..f09948b9 --- /dev/null +++ b/packages/libp2p/host/Libp2p/Host/Swarm.lean @@ -0,0 +1,541 @@ +import Std.Data.HashMap +import Conc +import Streams +import Libp2p.Host.Mock.Identity +import Libp2p.Host.Mock.Core +import Libp2p.Host.Mock.Negotiate +import Libp2p.Host.Error +import Libp2p.Host.Registry +import Libp2p.Host.Dedup +import Libp2p.Host.Peerstore +import Libp2p.Host.Policy +import Libp2p.Host.Gate + +/-! +# The swarm + +The host composes the stack into a node (ARCHITECTURE §4.9). This +module holds its configuration, its state, and its paths: + +* the dial path: `dial` and `dialPeer` pick a transport by the address + (`Transport.handles`), dial, and run the upgrade; +* the listen path: `startListening` binds a listener, and its accept + loop runs each accepted connection through the upgrade; +* the session paths: a committed session runs one serve task, which + accepts the peer's streams, negotiates each one against the handler + table, and hands it to its handler; `newStream` opens and negotiates + an outbound stream. + +`Libp2p.Host.Shutdown` holds `close`. + +## The upgrade and its checkpoints + +The policy hooks answer at the checkpoints of the core pair, in the +order the go-libp2p swarm and upgrader call them (the package +ARCHITECTURE, "Refinements fixed at implementation (unit 1b)"): +`allowDial` before a dial, `allowInbound` after an accept, the +resource manager's `openConnection` before the upgrade, and +`allowSecured` and `ConnectionScope.setPeer` inside the upgrade, after +the handshake and before the muxer stage (`Negotiate.upgradeWith`). A +refusal is `Error.gated` with the checkpoint. + +## Ownership + +Every task has an owner that joins it (PRINCIPLES 3.2): + +* the accept loop of a listener, and the upgrade of each accepted + connection, run in the gate `hostTasks`; +* the serve task of a committed session runs in the gate + `sessionTasks`; +* the task of one inbound stream runs in a `TaskSet` that its + session's serve task owns, and joins when the session ends. + +A failure of a task that no caller awaits goes to the owner of the +host through `Config.onFailure`, with the peer and the protocol when +they are known (§3.5: an owner never learns of a failure by EOF). + +## The race with the shutdown + +`close` sets the closing flag first (§5.5). A path that registers +something the shutdown must reach (a listener, a pending connection, a +session) registers it, then reads the flag. When the flag is set, the +path closes what it registered itself. Otherwise the flag came later, +so the shutdown sees the registration and closes it. The gates refuse +a task that starts after the shutdown joined their group, and the +refused caller closes and joins the work itself (`Gate.spawn`). +-/ + +set_option autoImplicit false + +namespace Libp2p.Host + +-- The mocks of the unmerged packages (RULES.md, "Working ahead of the +-- tree"); `Negotiate.Error` resolves through `Libp2p.Host.Mock`. +open Conc Streams Multiformats Libp2p.Host.Mock Libp2p.Host.Mock.Identity + Libp2p.Host.Mock.Core Libp2p.Host.Mock.Negotiate + +/-- A failure of a task that no caller awaits: an inbound upgrade, a +session that ended badly, an inbound stream. -/ +structure Failure where + /-- The peer, once the handshake proved it. -/ + peer : Option PeerId + /-- The protocol of the stream, once its negotiation selected one. -/ + protocol : Option ProtocolId + /-- What failed. -/ + error : Error + +/-- The configuration of a host: the ordered seam lists, the identity, +the handlers, the policy values, the clock, and where the failures of +owned tasks go (ARCHITECTURE §3.1). `cli/` or a consumer assembles it; +no host module names a concrete transport, security protocol, or +muxer. -/ +structure Config where + /-- The host identity. The local peer id derives from it. The + security values carry their own copy of the key, as their + configuration needs it. -/ + identity : PrivKey + /-- The transports. An address picks the first one that handles it. -/ + transports : List Transport + /-- The security protocols, in preference order. -/ + security : List SecurityProtocol + /-- The muxers, in preference order. -/ + muxers : List Muxer + /-- The handlers the host starts with. -/ + handlers : List StreamHandler := [] + /-- The connection gater. -/ + gater : ConnectionGater := Policy.permissiveGater + /-- The resource manager. -/ + resources : ResourceManager := Policy.unlimited + /-- The address ranker of `dialPeer`. -/ + ranker : AddressRanker := Policy.keepOrder + /-- The clock of the upgrade deadline, the session births, and the + dial delays. -/ + clock : Clock := Clock.system + /-- Where the failures of owned tasks go. It runs on the failing + task, so it answers at once. -/ + onFailure : Failure → BaseIO Unit := fun _ => pure () + +/-- A session the host serves: the upgraded connection and the +resource scope it holds. -/ +structure Served where + /-- The upgrade's answer: the muxer session, the proved peer, and + the join of the session and the secure connection. -/ + upgraded : Upgraded + /-- The connection's resource scope; the serve task releases it when + the session ends. -/ + scope : ConnectionScope + +/-- A running host. Every cell changes through `OnceRef.update` or +`OnceRef.updateGet` (contract 1). -/ +structure Host where + /-- The configuration. -/ + config : Config + /-- Our peer id, derived from `config.identity`. -/ + localPeer : PeerId + /-- The session table (§3.8). -/ + sessions : OnceRef (SessionTable Served) + /-- The handler table (§3.10). -/ + handlers : OnceRef Handlers + /-- The peerstore (§3.8). -/ + peerstore : OnceRef Peerstore + /-- The next session number, and the next pending key. -/ + counter : OnceRef Nat + /-- The closing flag (§5.5). -/ + closing : OnceRef Bool + /-- Closed by `close` first, so a dial delay that waits on it wakes. -/ + shutdown : Chan Unit + /-- The listeners; `none` once `close` took them. -/ + listeners : OnceRef (Option (List Listener)) + /-- The connections inside their upgrade, the pending connections of + §5.5; `none` once `close` took them. -/ + pending : OnceRef (Option (Std.HashMap Nat Connection)) + /-- The serve tasks of the sessions. -/ + sessionTasks : Gate + /-- The accept loops and the inbound upgrades. -/ + hostTasks : Gate + +namespace Host + +/-- A host over `config`. `.error` when the identity key derives no +peer id. -/ +def new (config : Config) : BaseIO (Except Identity.Error Host) := do + match config.identity.toPublic.bind PeerId.ofPubKey with + | .error e => return .error e + | .ok localPeer => + return .ok { + config, localPeer + sessions := ← OnceRef.new {} + handlers := ← OnceRef.new (config.handlers.foldl (fun t h => Handlers.set h t) {}) + peerstore := ← OnceRef.new {} + counter := ← OnceRef.new 0 + closing := ← OnceRef.new false + shutdown := ← Chan.new 1 + listeners := ← OnceRef.new (some []) + pending := ← OnceRef.new (some {}) + sessionTasks := ← Gate.new + hostTasks := ← Gate.new } + +/-! ## Small steps -/ + +/-- A fresh number, for a session or a pending connection. -/ +private def draw (h : Host) : BaseIO Nat := + h.counter.updateGet fun n => (n, n + 1) + +/-- True once `close` has begun. -/ +def isClosing (h : Host) : BaseIO Bool := + h.closing.get + +/-- Hand a failure to the owner of the host. -/ +private def report (h : Host) (peer : Option PeerId) (protocol : Option ProtocolId) + (error : Error) : BaseIO Unit := + h.config.onFailure { peer, protocol, error } + +/-- Store `handler` under its id, through the cell's update (contract +1). A served stream negotiated after the call sees it. -/ +def setHandler (h : Host) (handler : StreamHandler) : BaseIO Unit := + h.handlers.update (Handlers.set handler) + +/-- Add addresses of `peer` to the peerstore, each once. -/ +def addAddrs (h : Host) (peer : PeerId) (addrs : List Multiaddr) : BaseIO Unit := + h.peerstore.update (Peerstore.addAddrs peer addrs) + +/-- The addresses of `peer` in the peerstore. -/ +def addrsOf (h : Host) (peer : PeerId) : BaseIO (List Multiaddr) := do + return Peerstore.addrsOf (← h.peerstore.get) peer + +/-- True when the host serves a live session with `peer`. -/ +def connected (h : Host) (peer : PeerId) : BaseIO Bool := do + return (SessionTable.live? (← h.sessions.get) peer).isSome + +/-- The transport address and the expected peer of a dial address. A +trailing `/p2p/` names the peer and no transport (the addressing +spec, "The p2p multiaddr"), so it comes off before the transport +lookup, as go-libp2p's swarm drops it (the package ARCHITECTURE, unit +1b). The component's value is the peer id's binary form, the multihash +bytes. An address that is only `/p2p/` has no transport part. -/ +def splitPeer (a : Multiaddr) : Except Error (Multiaddr × Option PeerId) := + match a.components.getLast? with + | some c => + if c.proto = .«p2p» then + -- The multiaddr check already accepted the value as a peer id + -- multihash, so both lookups succeed on every valid address. + match (PeerId.ofBytes? c.value).toOption, (a.decapsulateCode (Protocol.toCode .«p2p»)).toOption with + | some p, some t => .ok (t, some p) + | _, _ => .error (.noTransport a) + else .ok (a, none) + | none => .ok (a, none) + +/-- The checkpoint the upgrade runs after the handshake (the module +doc, "The upgrade and its checkpoints"). -/ +private def admit (h : Host) (role : Role) (scope : ConnectionScope) (conn : Connection) + (peer : PeerId) : BaseIO (Option Checkpoint) := do + if !(← h.config.gater.allowSecured role peer conn.localAddr conn.remoteAddr) then + return some .secured + if !(← scope.setPeer peer) then return some .resources + return none + +/-! ## The upgrade -/ + +/-- Run the upgrade of `conn` as a pending connection: registered for +the shutdown (§5.5), under the deadline of the upgrader, with the +checkpoint. Every failure releases `scope`; the upgrader has already +closed the connection. -/ +private def upgradeConn (h : Host) (role : Role) (conn : Connection) (expected : Option PeerId) + (scope : ConnectionScope) : Async (Except Error Upgraded) := do + let key ← (h.draw : BaseIO Nat) + let registered ← (h.pending.updateGet fun + | some m => (true, some (m.insert key conn)) + | none => (false, none) : BaseIO Bool) + -- The registration, then the flag (the module doc, "The race with + -- the shutdown"). + if !registered || (← (h.isClosing : BaseIO Bool)) then + conn.closeQuietly + let unregister : BaseIO Unit := h.pending.update (·.map (·.erase key)) + let r ← try + (upgradeWith h.config.clock role conn .empty expected (h.admit role scope conn) + h.config.security h.config.muxers).run + catch e => + unregister + scope.release + throw e + unregister + match r with + | .ok (.upgraded u) => return .ok u + | .ok (.refused c) => + scope.release + return .error (.gated c) + | .error e => + scope.release + return .error (if ← (h.isClosing : BaseIO Bool) then .closing else .upgrade e) + +/-! ## Serving -/ + +/-- Serve one inbound stream: admit it, negotiate its protocol against +the handler table, and run the handler. A refused admission, a failed +negotiation, an id with no handler, and a failed handler each reset +the stream, and the session stays (contracts 5 and 6; the package +ARCHITECTURE, public surface). -/ +private def serveStream (h : Host) (peer : PeerId) (scope : ConnectionScope) (s : Stream) : + Async Unit := do + let some share ← (scope.reserveStream : BaseIO (Option Reservation)) + | s.reset + let ids := Handlers.ids (← (h.handlers.get : BaseIO Handlers)) + match ← (negotiateStream .responder s ids).run with + | .error e => + s.reset + share.release + -- The peer ended the stream before it chose a protocol: an orderly + -- end, not a failure. + unless e matches .closed do + h.report (some peer) none (.stream e.toCore) + | .ok (id, s) => + match (← (h.handlers.get : BaseIO Handlers)).find? id with + | none => + -- The negotiation selected an id from the table's ids, and the + -- handler left the table since; the stream closes (contract 6). + s.reset + share.release + | some handler => + -- The handler owns the stream from here. Its answer reaches the + -- owner of the host; a fault passes on to the session's join. + let r ← try (handler.handle s).run + catch e => + s.reset + share.release + throw e + share.release + if let .error e := r then + s.reset + h.report (some peer) (some id) (.stream e) + +/-- Serve a committed session until it ends: accept the peer's +streams, and serve each one in a task of this session. At the end, mark +the entry dead (contract 3), join the stream tasks, release the scope, +and join the session. -/ +private def serveSession (h : Host) (peer : PeerId) (e : Entry Served) : Async Unit := do + let u := e.session.upgraded + let streams ← TaskSet.new + let acceptFault ← try + repeat + -- Park: a stream of the peer wakes it, and so does the end of the + -- session, by the peer or by `MuxSession.close` (contract 4). + let some s ← u.session.accept | break + streams.spawn (h.serveStream peer e.session.scope s) + pure (none : Option IO.Error) + catch err => pure (some err) + h.sessions.update (SessionTable.markDead peer e.id) + discard u.session.close.toBaseIO + -- The session has ended, so every stream read ends, and the handlers + -- that park on their streams wake. + let streamFault ← try streams.joinAll; pure (none : Option IO.Error) + catch err => pure (some err) + e.session.scope.release + let joinFault ← try + if let .error ce ← u.join.run then + h.report (some peer) none (.session ce) + pure (none : Option IO.Error) + catch err => pure (some err) + if let some err := acceptFault <|> streamFault <|> joinFault then throw err + +/-- Commit an upgraded session through the dedup rule (contract 2), and +start its serve task. The answer is the peer the host now serves. -/ +private def register (h : Host) (role : Role) (u : Upgraded) (scope : ConnectionScope) : + Async (Except Error PeerId) := do + let id ← (h.draw : BaseIO Nat) + let bornMs ← (h.config.clock.nowMs : BaseIO Nat) + let entry : Entry Served := { id, role, bornMs, live := true, session := { upgraded := u, scope } } + let decision ← (h.sessions.updateGet (dedup h.localPeer u.remotePeer entry) : + BaseIO (Decision Served)) + match decision with + | .keep _ => + -- The standing session serves the peer; the candidate ends here. Its + -- join answer is dropped: we ended it. A fault still passes on, after + -- the scope is back. + discard u.session.close.toBaseIO + try discard u.join.run + finally scope.release + return .ok u.remotePeer + | .commit displaced => + -- A displaced session ends; its own serve task marks it dead and + -- joins it. + if let some old := displaced then + discard old.session.upgraded.session.close.toBaseIO + let wake : BaseIO Unit := discard u.session.close.toBaseIO + if !(← h.sessionTasks.spawn (h.serveSession u.remotePeer entry) wake) then + return .error .closing + -- The registration, then the flag (the module doc). + if ← (h.isClosing : BaseIO Bool) then + wake + return .error .closing + return .ok u.remotePeer + +/-! ## The dial path -/ + +/-- Dial one transport address and upgrade it, or reuse the live +session of `expected`. -/ +private def dialAddr (h : Host) (addr : Multiaddr) (expected : Option PeerId) : + Async (Except Error PeerId) := do + if ← (h.isClosing : BaseIO Bool) then return .error .closing + if expected == some h.localPeer then return .error .dialSelf + if let some p := expected then + if ← (h.connected p : BaseIO Bool) then return .ok p + if !(← (h.config.gater.allowDial expected addr : BaseIO Bool)) then + return .error (.gated .dial) + let some transport := h.config.transports.find? (·.handles addr) + | return .error (.noTransport addr) + let some scope ← (h.config.resources.openConnection .initiator expected addr : + BaseIO (Option ConnectionScope)) + | return .error (.gated .resources) + let dialed ← try transport.dial addr + catch e => + scope.release + throw e + let some conn := dialed + | scope.release + return .error (.unreachable addr) + match ← h.upgradeConn .initiator conn expected scope with + | .error e => return .error e + | .ok u => + let r ← h.register .initiator u scope + if let .ok p := r then h.addAddrs p [addr] + return r + +/-- Dial an address, or reuse a live session (the package +ARCHITECTURE, public surface). The expected peer is `peer`, or, when +the call gives none, the trailing `/p2p/` of the address. The +answer is the peer the handshake proved. -/ +def dial (h : Host) (addr : Multiaddr) (peer : Option PeerId := none) : + Async (Except Error PeerId) := do + match splitPeer addr with + | .error e => return .error e + | .ok (transportAddr, fromAddr) => h.dialAddr transportAddr (peer <|> fromAddr) + +/-- Dial `peer` through its peerstore addresses, in the ranker's +schedule, until one dial succeeds. The answer is the failure of the +last address when none does. -/ +def dialPeer (h : Host) (peer : PeerId) : Async (Except Error PeerId) := do + if peer == h.localPeer then return .error .dialSelf + if ← (h.connected peer : BaseIO Bool) then return .ok peer + let addrs ← (h.addrsOf peer : BaseIO (List Multiaddr)) + if addrs.isEmpty then return .error (.noAddresses peer) + let start ← (h.config.clock.nowMs : BaseIO Nat) + let mut last : Error := .noAddresses peer + for step in h.config.ranker.rank addrs do + let now ← (h.config.clock.nowMs : BaseIO Nat) + if now < start + step.delayMs then + let timer ← (h.config.clock.after (start + step.delayMs - now) : BaseIO (Park Unit)) + -- Park: the timer wakes it, and so does `close`, which closes + -- `shutdown` first (PRINCIPLES 3.1). + select #[timer, Park.recv h.shutdown fun _ => ()] + match ← h.dialAddr step.addr (some peer) with + | .ok p => return .ok p + | .error .closing => return .error .closing + | .error e => last := e + return .error last + +/-! ## The listen path -/ + +/-- Upgrade one accepted connection. No caller awaits it, so its +failure goes to the owner of the host; a refusal by policy is a +decision, and no failure. -/ +private def inbound (h : Host) (conn : Connection) : Async Unit := do + if !(← (h.config.gater.allowInbound conn.localAddr conn.remoteAddr : BaseIO Bool)) then + conn.closeQuietly + return + let some scope ← (h.config.resources.openConnection .responder none conn.remoteAddr : + BaseIO (Option ConnectionScope)) + | conn.closeQuietly + match ← h.upgradeConn .responder conn none scope with + | .ok u => + if let .error e ← h.register .responder u scope then + unless e matches .closing do h.report (some u.remotePeer) none e + | .error (.gated _) | .error .closing => pure () + | .error e => h.report none none e + +/-- The accept loop of a listener: each accepted connection upgrades +in a task of `hostTasks`. -/ +private def acceptLoop (h : Host) (l : Listener) : Async Unit := do + repeat + -- Park: a dialed connection wakes it, and so does the listener's + -- close (contract 4). + let some conn ← l.accept | break + discard <| h.hostTasks.spawn (h.inbound conn) conn.closeQuietly + +/-- Listen on an address through the first transport that handles it, +and start its accept loop. The answer is the address the listener +serves, with the port the system picked for a port of 0. -/ +def startListening (h : Host) (addr : Multiaddr) : Async (Except Error Multiaddr) := do + if ← (h.isClosing : BaseIO Bool) then return .error .closing + let some transport := h.config.transports.find? (·.handles addr) + | return .error (.noTransport addr) + let l ← transport.listen addr + let registered ← (h.listeners.updateGet fun + | some ls => (true, some (l :: ls)) + | none => (false, none) : BaseIO Bool) + if !registered then + -- `close` took the listeners already, so this call owns `l`. + l.close + l.join + return .error .closing + if !(← h.hostTasks.spawn (h.acceptLoop l) (discard l.close.toBaseIO)) then + l.join + return .error .closing + -- The registration, then the flag (the module doc). `close` holds + -- `l` in its list, and it joins `l` after the host tasks. + if ← (h.isClosing : BaseIO Bool) then + l.close + return .error .closing + return .ok l.addr + +/-! ## Outbound streams -/ + +/-- Open a stream to `peer` and negotiate one of `protocols`, in +preference order. The host dials the peer through the peerstore when no +session is live. The answer is the selected id and the stream, with +the bytes the negotiation over-read at its front (§4.7). The caller +owns the stream. -/ +def newStream (h : Host) (peer : PeerId) (protocols : List ProtocolId) : + Async (Except Error (ProtocolId × Stream)) := do + let some first := protocols.head? + | return .error (.stream (Negotiate.Error.noProtocols.toCore)) + if !(← (h.connected peer : BaseIO Bool)) then + if let .error e ← h.dialPeer peer then return .error e + let some e := SessionTable.live? (← (h.sessions.get : BaseIO (SessionTable Served))) peer + | return .error (.stream (.closed "the session ended before the stream opened")) + let some share ← (e.session.scope.reserveStream : BaseIO (Option Reservation)) + | return .error (.gated .resources) + match ← (e.session.upgraded.session.openStream first).run with + | .error err => + share.release + return .error (.stream err) + | .ok s => + match ← (negotiateStream .initiator s protocols).run with + | .error err => + s.reset + share.release + return .error (.stream err.toCore) + | .ok (id, s) => + -- The share returns when the stream is gone: at a reset, or once + -- our close and the peer's end have both happened. + let wroteEnd ← OnceRef.new false + let readEnd ← OnceRef.new false + let settle : BaseIO Unit := do + if (← wroteEnd.get) && (← readEnd.get) then share.release + return .ok (id, { s with + read := do + let r ← s.read + unless r matches .ok (some _) do + readEnd.set true + settle + return r + close := do + s.close + wroteEnd.set true + settle + reset := do + s.reset + share.release }) + +end Host + +end Libp2p.Host diff --git a/packages/libp2p/host/docs/ARCHITECTURE.md b/packages/libp2p/host/docs/ARCHITECTURE.md index 7933dbc5..81021bd4 100644 --- a/packages/libp2p/host/docs/ARCHITECTURE.md +++ b/packages/libp2p/host/docs/ARCHITECTURE.md @@ -20,9 +20,11 @@ libp2p group is §7, row 10). | `Libp2p/Host/Registry.lean` | the session and handler registries, each a `Std.HashMap` inside an `OnceRef`, their pure update functions, and the shared table types (§3.8, §3.10) | | `Libp2p/Host/Dedup.lean` | the dedup rule as one pure function over the session table | | `Libp2p/Host/Peerstore.lean` | the third registry, addresses by peer, deduplicated at add (§3.8) | -| `Libp2p/Host/Swarm.lean` | `dial`, `dialPeer`, `startListening`, the accept loop, the upgrade pipeline, and stream serving | +| `Libp2p/Host/Gate.lean` | `Gate`, a task group that refuses new tasks once its owner closes it, for the tasks that start outside any task the host owns | +| `Libp2p/Host/Swarm.lean` | `Config`, the `Host` state, `dial`, `dialPeer`, `startListening`, `newStream`, the accept loop, the upgrade pipeline, and stream serving | | `Libp2p/Host/Shutdown.lean` | `Host.close`, the §5.5 sequence | -| `Libp2p/Host/Policy.lean` | the permissive default gater, and the permissive and denying test doubles the core pair assigned here | +| `Libp2p/Host/Policy.lean` | the permissive default gater, and the permissive and denying test doubles the core pair assigned here; the default resource manager (admits everything) and the default ranker (keeps the order, no delay) | +| `Libp2p/Host/Mock/{Identity,Core,Negotiate}.lean` | the mocks of W5.1, W5.2, and W5.5 until they merge (see "The mocks") | All declarations live in the `Libp2p.Host` namespace, and the root module re-exports the public submodules. @@ -48,6 +50,14 @@ module re-exports the public submodules. closes the stream, never the session, and the error reaches the owner through the spawned task's completion (PRINCIPLES 3.2). This settles the question the core pair left open. +- `newStream` opens a stream to a peer and negotiates one of the given + protocol ids, in preference order. With no live session, it dials + the peer first. The answer is the selected id and the stream, with + the bytes the negotiation over-read at its front (§4.7, §5.6). This + is go-libp2p's `host.NewStream` + (`vendor/go-libp2p/core/host/host.go`, lines 61 to 65). The clients + of `libp2p/protocols` and the binaries of `cli/` reach their + outbound streams through it. - The peerstore surface: `addAddrs` (deduplicated at add, so a redial grows nothing) and `addrsOf`. Limits and TTLs stay deferred (§9, delta 3). @@ -157,6 +167,151 @@ module re-exports the public submodules. value, and a consumer passes its own. A limiting gater is open work (§9, delta 4) behind the same seam. +## Refinements fixed at implementation (unit 1a) + +The public surface leaves these points to implementation time. Unit 1a +fixes them as follows. + +- **The `Host.Error` arms.** The four proposed arms stay. Three more + name host decisions that the dial path makes: `noTransport`, no + configured transport handles the address (`Transport.handles`, the + streams pair); `unreachable`, the transport dialed and nobody + listens there (`Transport.dial` answers `none`); and `dialSelf`, the + dial targets our own id, which also keeps every dedup decision + between two different ids. `gated` names the refusing checkpoint + through the closed set `Checkpoint` (§3.4): `dial`, `inbound`, + `secured`, and `resources`. go-libp2p's swarm has the same failures: + `ErrSwarmClosed` (`vendor/go-libp2p/p2p/net/swarm/swarm.go`, line + 43), and `ErrDialToSelf`, `ErrNoTransport`, `ErrNoAddresses`, and + `ErrGaterDisallowedConnection` + (`vendor/go-libp2p/p2p/net/swarm/swarm_dial.go`, lines 49, 53, 60, + and 68). +- **The session table entry.** An entry holds a session number, our + role, the birth time on the host clock, the liveness field, and the + session handle. The handle is a type parameter, so the table + functions run in a test with no connection. The host draws a fresh + number per session, and `markDead` matches it, so the exit of a + session that dedup replaced cannot mark its successor dead. +- **The dedup function.** `dedup` returns `commit displaced` (the + candidate took the slot; `displaced` is the live session it + replaced, which the caller closes) or `keep standing` (the caller + closes the candidate and serves the peer through `standing`). The + window test is `now < born + simulWindowMs`. "Larger id" orders the + binary form of the ids (`PeerId.toBytes`), byte by byte: both peers + hold the same bytes, and a text order would compare the form that + one side parsed the id in. The theorem `raceLoserAgree` proves that + both peers drop the same connection of a race. +- **The handler ids.** The handler table lists its ids in + `ProtocolId` order, so the stream negotiation answers the same way + whatever the hash order. +- **The peerstore order.** The addresses of a peer keep the order + they first arrived in, and `dialPeer` walks them in that order + unless the address ranker reorders them. + +## Refinements fixed at implementation (unit 1b) + +- **The configuration and the state.** `Config` holds the identity, + the transport list, the ordered security and muxer lists, the + initial handlers, the gater, the resource manager, the ranker, the + `Clock`, and `onFailure`. `Host.new` derives the local peer id from + the identity and answers `.error` when the key derives none. The + host's cells are the three registries, a counter for session numbers + and pending keys, the closing flag, the listener list, and the + pending list, each changed through `OnceRef.update` (contract 1). +- **The transport choice.** A dial or a listen uses the first transport + of the list whose `handles` accepts the address (the streams pair, + public surface). A trailing `/p2p/` comes off first: it names + the peer and no transport (`vendor/specs/addressing/README.md`, "The + p2p multiaddr"), as go-libp2p's swarm drops it before its transport + lookup (`vendor/go-libp2p/p2p/net/swarm/swarm_transport.go`, lines + 37 to 48). The component's value is the peer id's binary form, which + `PeerId.ofBytes?` reads. An address that is only `/p2p/` is + `noTransport`. +- **The checkpoints, in go-libp2p's order.** `allowDial` before the + dial (`vendor/go-libp2p/p2p/net/swarm/swarm_dial.go`, lines 251 and + 550); the resource manager's `openConnection` before the transport + dial (`vendor/go-libp2p/p2p/transport/tcp/tcp.go`, line 255); + `allowInbound`, then `openConnection`, after an accept + (`vendor/go-libp2p/p2p/net/upgrader/listener.go`, lines 183 and + 193); and `allowSecured`, then `ConnectionScope.setPeer`, inside the + upgrade, through `Negotiate.upgradeWith` + (`vendor/go-libp2p/p2p/net/upgrader/upgrader.go`, line 176). A + refusal is `gated` with the checkpoint, and the scope is released. + The policy shapes are this unit's refinement of the core seams (the + core ARCHITECTURE, "The policy shapes"). +- **The answers of the dial path.** `dial` and `dialPeer` answer the + peer id the handshake proved. The caller reaches the session through + that id (`newStream`), and the host keeps the session handle. A + live session of the expected peer answers at once, with no dial. A + committed dial stores its transport address in the peerstore. + `dialPeer` walks the ranker's schedule in order and waits out each + delay on the `Clock`; the wait also wakes when `close` closes the + shutdown channel (PRINCIPLES 3.1). With no success, it answers the + failure of the last address. +- **Two more `Core.Error` wrappers.** `session` reports a running + session whose join answered a failure, and `stream` reports a + stream whose open, negotiation, or handler failed. Each wraps the + seam error once, as `upgrade` does (§3.5). +- **Failures with no caller go to `Config.onFailure`.** An inbound + upgrade, a session's end, and an inbound stream run in tasks that no + caller awaits. The owner of each task reads its completion and hands + a failure to `onFailure`, with the peer and the protocol when known, + so a failure never reaches the owner as EOF alone (§3.5; PRINCIPLES + 3.2). A policy refusal is a decision, and goes nowhere. A fault + passes on to the join of the task, and `close` throws it. +- **Stream serving.** The serve task of a session accepts the peer's + streams, and each stream runs in a task that the serve task owns and + joins when the session ends. The stream negotiates against the + handler ids through `Negotiate.negotiateStream`, which puts the + bytes it read past the selecting message at the front of the stream + (§4.7). A failed negotiation, an id whose handler left the table, + and a handler failure reset the stream; the session stays. go-libp2p + resets a stream whose negotiation fails in the same place + (`vendor/go-libp2p/p2p/host/basic/basic_host.go`, line 347). A + stream the peer ends before it picks a protocol is an orderly end, + and no failure. +- **Outbound streams.** `newStream` dials the peer through the + peerstore when no session is live, as go-libp2p's `NewStream` calls + `Connect` first (`basic_host.go`, line 452). It opens a stream, + negotiates one of the caller's ids in order, and answers the + selected id and the stream; a failed negotiation resets the stream. + The caller owns the stream. +- **Stream shares.** Every stream takes a share from its connection's + scope (`ConnectionScope.reserveStream`). A served stream returns it + when its handler ends. An outbound stream returns it at a reset, or + once our close and the peer's end have both happened. The + connection scope's release returns every share it still holds when + the session ends. +- **The registration race with `close`.** A path that registers a + listener, a pending connection, or a session reads the closing flag + after it registers, and closes what it registered when the flag is + set. The session and host task groups are `Gate`s: a task that + starts after `close` joined its group is refused, and the refused + caller wakes the work and joins it itself. `Gate` lives in + `Libp2p/Host/Gate.lean`, since a `Conc.TaskSet` keeps taking tasks + after its join. +- **The shutdown stages.** `close` takes an optional `onStage` that + hears each stage of §5.5 as it starts, the instrument of the unit + 1b milestone. A fault of a joined task does not stop the sequence, + and `close` throws the first fault at the end. + +## The mocks + +W5.1 (`libp2p/identity`), W5.2 (`libp2p/core`), and W5.5 +(`libp2p/negotiate`) have not merged, so the package builds against +mocks of their frozen surfaces (RULES.md, "Working ahead of the tree"), +under `Libp2p/Host/Mock/`. A type is a copy of the frozen shape; an +operation is `opaque`, and its body fails with the name of the missing +package. No body derives an identity, negotiates, or opens a session, +and the mocks state no axiom. The host modules open +`Libp2p.Host.Mock`, so `Core.Error` and `Negotiate.upgradeWith` resolve +as they will against the real packages, and the integration swaps the +imports and drops the `open`. + +`Host.new` derives the peer id through the identity mock, so no host +starts against the mocks, and the unit 1b milestones run at the +integration. Unit 1a runs in full. + ## What the package must not depend on - Inside libp2p, the chain puts everything below the host at its @@ -174,10 +329,13 @@ module re-exports the public submodules. `Connection`, `Listener`, and `Stream` (§2). `net/yamux` stays behind the `Muxer` value of `libp2p/mux`, and no host module names it. +- From the codec group, `codec/multiformats` for `Multiaddr`: the + peerstore holds addresses, and the dial path takes the `/p2p` + component off an address. `base/bytes` and the other codecs arrive + through `negotiate` and `identity`, and the host names none of them + directly. - From base, `base/conc` for `Async`, `OnceRef`, `TaskSet`, - `withinDeadline`, and `Clock` (§4.1). `base/bytes` and the codecs - arrive through `negotiate` and `identity`, and the host names none - of them directly. + `withinDeadline`, and `Clock` (§4.1). - No crypto by name. Key material reaches the host only through the types of its dependencies: the private key and `PeerId` come from `libp2p/identity` (§2). If a refinement needs a direct import, this diff --git a/packages/libp2p/host/docs/PLAN.md b/packages/libp2p/host/docs/PLAN.md index 29f3d135..ad025eaf 100644 --- a/packages/libp2p/host/docs/PLAN.md +++ b/packages/libp2p/host/docs/PLAN.md @@ -24,7 +24,15 @@ so unit 1a lands while the upgrader finishes. W5.2 sits outside the root row and reaches this unit through the root chain, which finishes it before W5.5. -Unit 0 is done. +Units 0 and 1a are done, and unit 1b's code builds against the mocks +of W5.1, W5.2, and W5.5 (the package ARCHITECTURE, "The mocks"). The +suite is `Tests/Host.lean`; the dedup rule also carries theorems in +`Libp2p/Host/Dedup.lean`. A host derives its peer id through +`libp2p/identity`, so the unit 1b milestones run at the integration, +and the suite names each one as a skip until then. Unit 1b needs two +calls from `libp2p/negotiate` beside `upgrade`: the post-handshake +checkpoint (`upgradeWith`) and the stream negotiation +(`negotiateStream`). The negotiate pair records both. ## Consumers diff --git a/packages/libp2p/negotiate/docs/ARCHITECTURE.md b/packages/libp2p/negotiate/docs/ARCHITECTURE.md index 936b5045..a158a4ed 100644 --- a/packages/libp2p/negotiate/docs/ARCHITECTURE.md +++ b/packages/libp2p/negotiate/docs/ARCHITECTURE.md @@ -60,6 +60,23 @@ imports it and wires it; it keeps no copy. output: the `MuxSession` and the verified remote `PeerId`; a pair or a small structure is a refinement this package fixes at implementation time. Errors surface as `Core.Error` (§3.5). +- `Negotiate.upgradeWith` is the upgrade with a checkpoint after the + handshake and before the muxer stage: it takes `admit : PeerId → + BaseIO (Option ρ)` and the clock of the deadline, and answers + `UpgradeResult ρ`, the upgraded connection or `refused` with the + reason `admit` gave. The host's gater and resource manager decide + there, by the proved peer (the core pair, "The policy shapes"), as + go's upgrader calls `InterceptSecured` + (`vendor/go-libp2p/p2p/net/upgrader/upgrader.go`, line 176). A + refusal is a value, not a `Core.Error` arm (§3.5). `upgrade` is + `upgradeWith` with a checkpoint that admits every peer. +- `Negotiate.negotiateStream : Role → Stream → List ProtocolId → + ExceptT Negotiate.Error Async (ProtocolId × Stream)` runs + multistream-select over one muxed stream: the responder that the + host's stream serving calls, and the initiator of `newStream` (the + host pair). The bytes it reads past the selecting message go to the + front of the stream (`Stream.prependBytes`, §4.7). A stream that + ends before a selection is `Negotiate.Error.closed`. ## The muxer-selection decision From 16455e2204b17f33b6367732917a7fada2df15fe Mon Sep 17 00:00:00 2001 From: Ivan Anishchuk Date: Thu, 5 Nov 2026 22:54:19 +0530 Subject: [PATCH 055/125] fix(host): refuse a /p2p-only dial address as noTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit splitPeer strips the trailing /p2p/ and dials what is left. For an address that is only /p2p/, the merged multiaddr gives back the empty address, which root §4.4 makes valid, so splitPeer passed it on. The sim transport claims every address and answered unreachable, where the rule says noTransport. splitPeer now refuses an empty rest. The unit 1b leg "a /p2p address alone has no transport" caught it on the real packages. The identity mock's PeerId.ofBytes? takes a ByteArray, as the frozen W5.1 surface does, and splitPeer converts the component value from the byte model. --- packages/libp2p/host/Libp2p/Host/Mock/Identity.lean | 2 +- packages/libp2p/host/Libp2p/Host/Swarm.lean | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/libp2p/host/Libp2p/Host/Mock/Identity.lean b/packages/libp2p/host/Libp2p/Host/Mock/Identity.lean index 192cf86c..a47b4074 100644 --- a/packages/libp2p/host/Libp2p/Host/Mock/Identity.lean +++ b/packages/libp2p/host/Libp2p/Host/Mock/Identity.lean @@ -63,7 +63,7 @@ opaque PeerId.ofPubKey (_k : PubKey) : Except Error PeerId := .error (.unavailable "libp2p/identity (W5.1): peer id derivation") /-- The peer id of its binary form, the value of a `/p2p` component. -/ -opaque PeerId.ofBytes? (_b : List Nat) : Except Error PeerId := +opaque PeerId.ofBytes? (_b : ByteArray) : Except Error PeerId := .error (.unavailable "libp2p/identity (W5.1): peer id parse") end Libp2p.Host.Mock.Identity diff --git a/packages/libp2p/host/Libp2p/Host/Swarm.lean b/packages/libp2p/host/Libp2p/Host/Swarm.lean index f09948b9..544ee24f 100644 --- a/packages/libp2p/host/Libp2p/Host/Swarm.lean +++ b/packages/libp2p/host/Libp2p/Host/Swarm.lean @@ -218,9 +218,14 @@ def splitPeer (a : Multiaddr) : Except Error (Multiaddr × Option PeerId) := | some c => if c.proto = .«p2p» then -- The multiaddr check already accepted the value as a peer id - -- multihash, so both lookups succeed on every valid address. - match (PeerId.ofBytes? c.value).toOption, (a.decapsulateCode (Protocol.toCode .«p2p»)).toOption with - | some p, some t => .ok (t, some p) + -- multihash, so both lookups succeed on every valid address. The + -- value is in the `List Nat` byte model, and `PeerId.ofBytes?` + -- reads a `ByteArray`. + match (PeerId.ofBytes? (Bytes.ofNatList c.value)).toOption, + (a.decapsulateCode (Protocol.toCode .«p2p»)).toOption with + -- An empty rest is a valid address (root §4.4: it prints as `/`), + -- and it names no transport. + | some p, some t => if t.components.isEmpty then .error (.noTransport a) else .ok (t, some p) | _, _ => .error (.noTransport a) else .ok (a, none) | none => .ok (a, none) From 29fa46a972c8328618580a0816fff2cbf36f78c1 Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Fri, 6 Nov 2026 08:35:02 +0700 Subject: [PATCH 056/125] docs(host): state the base/bytes name the dial path calls --- packages/libp2p/host/docs/ARCHITECTURE.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/libp2p/host/docs/ARCHITECTURE.md b/packages/libp2p/host/docs/ARCHITECTURE.md index 81021bd4..5ad7875a 100644 --- a/packages/libp2p/host/docs/ARCHITECTURE.md +++ b/packages/libp2p/host/docs/ARCHITECTURE.md @@ -331,9 +331,12 @@ integration. Unit 1a runs in full. it. - From the codec group, `codec/multiformats` for `Multiaddr`: the peerstore holds addresses, and the dial path takes the `/p2p` - component off an address. `base/bytes` and the other codecs arrive - through `negotiate` and `identity`, and the host names none of them - directly. + component off an address. The dial path names one `base/bytes` + function, `Bytes.ofNatList`, to give a `/p2p` component value the + `ByteArray` form that `PeerId.ofBytes?` reads. The name arrives + through the transitive `net/streams` import, and the host imports no + `base/bytes` module. The other codecs arrive through `negotiate` and + `identity`, and the host names none of them. - From base, `base/conc` for `Async`, `OnceRef`, `TaskSet`, `withinDeadline`, and `Clock` (§4.1). - No crypto by name. Key material reaches the host only through the From adf11dff1572fcf962ded01eb32af28dc370f873 Mon Sep 17 00:00:00 2001 From: Ivan Anishchuk Date: Fri, 6 Nov 2026 11:16:41 +0530 Subject: [PATCH 057/125] refactor(host): take the keys and the peer id from libp2p/identity W5.1 merged in etheorem/leanp2p#63, so the host and its two remaining mocks import the real Libp2p.Identity, and Mock/Identity.lean goes. Dedup keeps its order on PeerId.toBytes. The suite builds its two peers with PeerId.inline over one-byte keys, the same binary forms as before. The core and negotiate mocks stay until W5.2 and W5.5 merge. Part of #70. --- Tests/Host.lean | 21 +++--- packages/libp2p/host/Libp2p/Host/Dedup.lean | 4 +- packages/libp2p/host/Libp2p/Host/Error.lean | 6 +- .../libp2p/host/Libp2p/Host/Mock/Core.lean | 4 +- .../host/Libp2p/Host/Mock/Identity.lean | 69 ------------------- .../host/Libp2p/Host/Mock/Negotiate.lean | 4 +- .../libp2p/host/Libp2p/Host/Peerstore.lean | 4 +- .../libp2p/host/Libp2p/Host/Registry.lean | 4 +- packages/libp2p/host/Libp2p/Host/Swarm.lean | 4 +- packages/libp2p/host/docs/ARCHITECTURE.md | 24 +++---- packages/libp2p/host/docs/PLAN.md | 10 +-- 11 files changed, 42 insertions(+), 112 deletions(-) delete mode 100644 packages/libp2p/host/Libp2p/Host/Mock/Identity.lean diff --git a/Tests/Host.lean b/Tests/Host.lean index 743116d6..19bf872a 100644 --- a/Tests/Host.lean +++ b/Tests/Host.lean @@ -19,17 +19,15 @@ here, since the rule never reads it. Unit 1b runs hosts over the sim transport, with the real `Noise.securityProtocol` and `Mux.yamux` values (PRINCIPLES 1.4). Every host derives its peer id through `libp2p/identity`, and the upgrade runs -through `libp2p/negotiate`, so the legs wait for W5.1, W5.3, W5.4, and -W5.5 to merge. Until then the suite names each leg as a skip. - -The peer ids here are values of the identity mock, built directly. +through `libp2p/negotiate`, so the legs wait for W5.3, W5.4, and W5.5 +to merge. Until then the suite names each leg as a skip. -/ set_option autoImplicit false namespace Tests -open Conc Multiformats Libp2p.Host.Mock.Identity Libp2p.Host.Mock.Core Libp2p.Host +open Conc Multiformats Libp2p.Identity Libp2p.Host.Mock.Core Libp2p.Host /-! ## Helpers -/ @@ -39,11 +37,12 @@ private def ma (s : String) : IO Multiaddr := do | .ok a => return a | .error _ => throw <| IO.userError s!"test address {s} does not parse" -/-- The smaller and the larger of two test peers. Their binary forms -are `[0x00, 0x01, 1]` and `[0x00, 0x01, 2]`, the identity multihashes -of the one-byte keys `1` and `2`, so `small` orders first. -/ -private def small : PeerId := ⟨[0, 1, 1], "small"⟩ -private def large : PeerId := ⟨[0, 1, 2], "large"⟩ +/-- The smaller and the larger of two test peers: the inlined ids of +the one-byte keys `1` and `2`. Their binary forms are `[0x00, 0x01, 1]` +and `[0x00, 0x01, 2]`, so `small` orders first. The size bound +`1 ≤ 42` holds by evaluation. -/ +private def small : PeerId := PeerId.inline (ByteArray.mk #[1]) (by decide) +private def large : PeerId := PeerId.inline (ByteArray.mk #[2]) (by decide) /-- A live table entry. -/ private def entry (id : Nat) (role : Role) (bornMs : Nat) : Entry Nat := @@ -299,6 +298,6 @@ def Host.run (r : Runner) : IO Unit := do (peerstoreChecks r).run errorChecks r for leg in swarmLegs do - r.skip s!"host: {leg}" "needs W5.1, W5.3, W5.4, and W5.5 merged" + r.skip s!"host: {leg}" "needs W5.3, W5.4, and W5.5 merged" end Tests diff --git a/packages/libp2p/host/Libp2p/Host/Dedup.lean b/packages/libp2p/host/Libp2p/Host/Dedup.lean index 562c3216..6a88bce4 100644 --- a/packages/libp2p/host/Libp2p/Host/Dedup.lean +++ b/packages/libp2p/host/Libp2p/Host/Dedup.lean @@ -1,4 +1,4 @@ -import Libp2p.Host.Mock.Identity +import Libp2p.Identity import Libp2p.Host.Mock.Core import Libp2p.Host.Constants import Libp2p.Host.Registry @@ -46,7 +46,7 @@ set_option autoImplicit false namespace Libp2p.Host -open Libp2p.Host.Mock.Identity Libp2p.Host.Mock.Core +open Libp2p.Identity Libp2p.Host.Mock.Core /-- The order of two peer ids by their binary form, byte by byte (module doc, "The order on peer ids"). -/ diff --git a/packages/libp2p/host/Libp2p/Host/Error.lean b/packages/libp2p/host/Libp2p/Host/Error.lean index b6aded64..854042d6 100644 --- a/packages/libp2p/host/Libp2p/Host/Error.lean +++ b/packages/libp2p/host/Libp2p/Host/Error.lean @@ -1,5 +1,5 @@ import Multiformats -import Libp2p.Host.Mock.Identity +import Libp2p.Identity import Libp2p.Host.Mock.Core /-! @@ -20,9 +20,9 @@ set_option autoImplicit false namespace Libp2p.Host --- The mocks of the unmerged packages (RULES.md, "Working ahead of the +-- The mock of the unmerged core (RULES.md, "Working ahead of the -- tree"); `Core.Error` resolves through `Libp2p.Host.Mock`. -open Multiformats Libp2p.Host.Mock Libp2p.Host.Mock.Identity +open Multiformats Libp2p.Host.Mock Libp2p.Identity /-- The policy checkpoint that refused a connection (§3.4: a closed set). The gater answers at the first three (`Core.ConnectionGater`), diff --git a/packages/libp2p/host/Libp2p/Host/Mock/Core.lean b/packages/libp2p/host/Libp2p/Host/Mock/Core.lean index a80a3f5b..ff0528ad 100644 --- a/packages/libp2p/host/Libp2p/Host/Mock/Core.lean +++ b/packages/libp2p/host/Libp2p/Host/Mock/Core.lean @@ -1,6 +1,6 @@ import Conc import Streams -import Libp2p.Host.Mock.Identity +import Libp2p.Identity /-! # Mock: the surface of `libp2p/core` that the host uses @@ -36,7 +36,7 @@ set_option autoImplicit false namespace Libp2p.Host.Mock.Core -open Conc Streams Multiformats Libp2p.Host.Mock.Identity +open Conc Streams Multiformats Libp2p.Identity /-- A protocol id, wrapped (§3.4). -/ structure ProtocolId where diff --git a/packages/libp2p/host/Libp2p/Host/Mock/Identity.lean b/packages/libp2p/host/Libp2p/Host/Mock/Identity.lean deleted file mode 100644 index a47b4074..00000000 --- a/packages/libp2p/host/Libp2p/Host/Mock/Identity.lean +++ /dev/null @@ -1,69 +0,0 @@ -/-! -# Mock: the surface of `libp2p/identity` that the host uses - -W5.1 (`libp2p/identity`) has not merged, so the host builds against a -mock of its frozen surface (RULES.md, "Working ahead of the tree"; the -identity ARCHITECTURE, "Public surface"). The mock imports nothing from -the real package, and it states no axiom. - -* `PeerId` is a copy of the shape the host reads: the binary form - (`toBytes`, the multihash bytes of the peer-ids spec) and the text - form it prints. A test builds one directly. -* `PubKey` and `PrivKey` are copies that hold their bytes. -* The derivations are `opaque`, and each fails with the name of the - package, so no run derives an identity from this mock. - -At the integration, the host imports `Libp2p.Identity`, drops -`open Libp2p.Host.Mock`, and the module goes. --/ - -set_option autoImplicit false - -namespace Libp2p.Host.Mock.Identity - -/-- Why a key or a peer id failed to derive or to parse. -/ -inductive Error where - /-- The identity package is not integrated. -/ - | unavailable (what : String) - deriving DecidableEq, Repr - -/-- A peer id. -/ -structure PeerId where - /-- The binary form: the multihash bytes (the peer-ids spec). -/ - bytes : List Nat - /-- The text form the id prints. -/ - text : String - deriving DecidableEq, Hashable, Repr - -namespace PeerId - -/-- The binary form, the bytes a `/p2p` component carries. -/ -def toBytes (p : PeerId) : ByteArray := ⟨(p.bytes.map UInt8.ofNat).toArray⟩ - -instance : ToString PeerId := ⟨PeerId.text⟩ - -end PeerId - -/-- A public key. -/ -structure PubKey where - /-- The marshaled key. -/ - raw : ByteArray - -/-- A private key. -/ -structure PrivKey where - /-- The marshaled key. -/ - raw : ByteArray - -/-- The public half of a private key. -/ -opaque PrivKey.toPublic (_k : PrivKey) : Except Error PubKey := - .error (.unavailable "libp2p/identity (W5.1): key derivation") - -/-- The peer id of a public key. -/ -opaque PeerId.ofPubKey (_k : PubKey) : Except Error PeerId := - .error (.unavailable "libp2p/identity (W5.1): peer id derivation") - -/-- The peer id of its binary form, the value of a `/p2p` component. -/ -opaque PeerId.ofBytes? (_b : ByteArray) : Except Error PeerId := - .error (.unavailable "libp2p/identity (W5.1): peer id parse") - -end Libp2p.Host.Mock.Identity diff --git a/packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean b/packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean index f2e36815..a399021b 100644 --- a/packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean +++ b/packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean @@ -1,6 +1,6 @@ import Conc import Streams -import Libp2p.Host.Mock.Identity +import Libp2p.Identity import Libp2p.Host.Mock.Core /-! @@ -35,7 +35,7 @@ set_option autoImplicit false namespace Libp2p.Host.Mock.Negotiate -open Conc Streams Libp2p.Host.Mock.Identity Libp2p.Host.Mock.Core +open Conc Streams Libp2p.Identity Libp2p.Host.Mock.Core /-- How a stream negotiation failed. The host reads two arms: the end of the stream before a selection, an orderly end, and the empty proposal diff --git a/packages/libp2p/host/Libp2p/Host/Peerstore.lean b/packages/libp2p/host/Libp2p/Host/Peerstore.lean index 9c95da2e..0823180b 100644 --- a/packages/libp2p/host/Libp2p/Host/Peerstore.lean +++ b/packages/libp2p/host/Libp2p/Host/Peerstore.lean @@ -1,6 +1,6 @@ import Std.Data.HashMap import Multiformats -import Libp2p.Host.Mock.Identity +import Libp2p.Identity /-! # The peerstore @@ -22,7 +22,7 @@ set_option autoImplicit false namespace Libp2p.Host -open Multiformats Libp2p.Host.Mock.Identity +open Multiformats Libp2p.Identity /-- The addresses of each peer, in the order they first arrived. A synonym with no invariant of its own (§3.7). -/ diff --git a/packages/libp2p/host/Libp2p/Host/Registry.lean b/packages/libp2p/host/Libp2p/Host/Registry.lean index 87a1661c..5954fdb3 100644 --- a/packages/libp2p/host/Libp2p/Host/Registry.lean +++ b/packages/libp2p/host/Libp2p/Host/Registry.lean @@ -1,5 +1,5 @@ import Std.Data.HashMap -import Libp2p.Host.Mock.Identity +import Libp2p.Identity import Libp2p.Host.Mock.Core /-! @@ -44,7 +44,7 @@ set_option autoImplicit false namespace Libp2p.Host -open Libp2p.Host.Mock.Identity Libp2p.Host.Mock.Core +open Libp2p.Identity Libp2p.Host.Mock.Core /-! ## The session table -/ diff --git a/packages/libp2p/host/Libp2p/Host/Swarm.lean b/packages/libp2p/host/Libp2p/Host/Swarm.lean index 544ee24f..01a0668f 100644 --- a/packages/libp2p/host/Libp2p/Host/Swarm.lean +++ b/packages/libp2p/host/Libp2p/Host/Swarm.lean @@ -1,7 +1,7 @@ import Std.Data.HashMap import Conc import Streams -import Libp2p.Host.Mock.Identity +import Libp2p.Identity import Libp2p.Host.Mock.Core import Libp2p.Host.Mock.Negotiate import Libp2p.Host.Error @@ -71,7 +71,7 @@ namespace Libp2p.Host -- The mocks of the unmerged packages (RULES.md, "Working ahead of the -- tree"); `Negotiate.Error` resolves through `Libp2p.Host.Mock`. -open Conc Streams Multiformats Libp2p.Host.Mock Libp2p.Host.Mock.Identity +open Conc Streams Multiformats Libp2p.Host.Mock Libp2p.Identity Libp2p.Host.Mock.Core Libp2p.Host.Mock.Negotiate /-- A failure of a task that no caller awaits: an inbound upgrade, a diff --git a/packages/libp2p/host/docs/ARCHITECTURE.md b/packages/libp2p/host/docs/ARCHITECTURE.md index 5ad7875a..b795cdbf 100644 --- a/packages/libp2p/host/docs/ARCHITECTURE.md +++ b/packages/libp2p/host/docs/ARCHITECTURE.md @@ -24,7 +24,7 @@ libp2p group is §7, row 10). | `Libp2p/Host/Swarm.lean` | `Config`, the `Host` state, `dial`, `dialPeer`, `startListening`, `newStream`, the accept loop, the upgrade pipeline, and stream serving | | `Libp2p/Host/Shutdown.lean` | `Host.close`, the §5.5 sequence | | `Libp2p/Host/Policy.lean` | the permissive default gater, and the permissive and denying test doubles the core pair assigned here; the default resource manager (admits everything) and the default ranker (keeps the order, no delay) | -| `Libp2p/Host/Mock/{Identity,Core,Negotiate}.lean` | the mocks of W5.1, W5.2, and W5.5 until they merge (see "The mocks") | +| `Libp2p/Host/Mock/{Core,Negotiate}.lean` | the mocks of W5.2 and W5.5 until they merge (see "The mocks") | All declarations live in the `Libp2p.Host` namespace, and the root module re-exports the public submodules. @@ -297,20 +297,20 @@ fixes them as follows. ## The mocks -W5.1 (`libp2p/identity`), W5.2 (`libp2p/core`), and W5.5 -(`libp2p/negotiate`) have not merged, so the package builds against -mocks of their frozen surfaces (RULES.md, "Working ahead of the tree"), -under `Libp2p/Host/Mock/`. A type is a copy of the frozen shape; an -operation is `opaque`, and its body fails with the name of the missing -package. No body derives an identity, negotiates, or opens a session, -and the mocks state no axiom. The host modules open +W5.2 (`libp2p/core`) and W5.5 (`libp2p/negotiate`) have not merged, +so the package builds against mocks of their frozen surfaces (RULES.md, +"Working ahead of the tree"), under `Libp2p/Host/Mock/`. A type is a +copy of the frozen shape; an operation is `opaque`, and its body fails +with the name of the missing package. No body negotiates or opens a +session, and the mocks state no axiom. The host modules open `Libp2p.Host.Mock`, so `Core.Error` and `Negotiate.upgradeWith` resolve as they will against the real packages, and the integration swaps the -imports and drops the `open`. +imports and drops the `open`. The keys and the peer id come from the +merged `libp2p/identity`. -`Host.new` derives the peer id through the identity mock, so no host -starts against the mocks, and the unit 1b milestones run at the -integration. Unit 1a runs in full. +Every upgrade runs through the negotiate mock and fails there, and the +legs also need the Noise and yamux values (W5.3, W5.4), so the unit 1b +milestones run at the integration. Unit 1a runs in full. ## What the package must not depend on diff --git a/packages/libp2p/host/docs/PLAN.md b/packages/libp2p/host/docs/PLAN.md index ad025eaf..647da5dd 100644 --- a/packages/libp2p/host/docs/PLAN.md +++ b/packages/libp2p/host/docs/PLAN.md @@ -25,11 +25,11 @@ root row and reaches this unit through the root chain, which finishes it before W5.5. Units 0 and 1a are done, and unit 1b's code builds against the mocks -of W5.1, W5.2, and W5.5 (the package ARCHITECTURE, "The mocks"). The -suite is `Tests/Host.lean`; the dedup rule also carries theorems in -`Libp2p/Host/Dedup.lean`. A host derives its peer id through -`libp2p/identity`, so the unit 1b milestones run at the integration, -and the suite names each one as a skip until then. Unit 1b needs two +of W5.2 and W5.5 (the package ARCHITECTURE, "The mocks"), with the +merged `libp2p/identity`. The suite is `Tests/Host.lean`; the dedup +rule also carries theorems in `Libp2p/Host/Dedup.lean`. A host +upgrades through `libp2p/negotiate`, so the unit 1b milestones run at +the integration, and the suite names each one as a skip until then. Unit 1b needs two calls from `libp2p/negotiate` beside `upgrade`: the post-handshake checkpoint (`upgradeWith`) and the stream negotiation (`negotiateStream`). The negotiate pair records both. From e4615f518e187de6f7e2ecdc0ae2140d80202efe Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:13:24 +0800 Subject: [PATCH 058/125] Add Shadow host resolution and dry-run plan --- Tests/Gossipsub/Interop.lean | 60 ++++++++++ .../Libp2p/Protocols/Gossipsub/Interop.lean | 7 +- .../Protocols/Gossipsub/Interop/Hosts.lean | 89 ++++++++++++++ .../Protocols/Gossipsub/Interop/Schedule.lean | 110 ++++++++++++++++++ 4 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Hosts.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Schedule.lean diff --git a/Tests/Gossipsub/Interop.lean b/Tests/Gossipsub/Interop.lean index 1c4c1477..1c74f8e5 100644 --- a/Tests/Gossipsub/Interop.lean +++ b/Tests/Gossipsub/Interop.lean @@ -317,6 +317,64 @@ private def checkDecoderDetails (r : Runner) : IO Unit := do r.check "received optional fields omitted" (minimal == "{\"id\":\"18446744073709551615\",\"msg\":\"Received Message\",\"time\":\"2026-11-06T00:00:00Z\"}") +/-- Hosts parsing and exact name resolution. -/ +private def checkHosts (r : Runner) : IO Unit := do + let shadow := "127.0.0.1 localhost\n11.0.0.1 node0\n11.0.0.2 node1 node-one n1 # aliases\n11.0.0.3 node10\n" + let hosts := parseHosts shadow + r.check "Shadow hosts IPv4 rows parse" (hosts.length == 4) + r.check "primary name lookup" (lookup? hosts "node0" == some [11,0,0,1]) + r.check "alias lookup" (lookup? hosts "node-one" == some [11,0,0,2]) + r.check "missing name lookup" (lookup? hosts "node2" == none) + r.check "prefix node1 does not match node10" (lookup? hosts "node1" == some [11,0,0,2]) + r.check "suffix node1x does not match" (lookup? hosts "node1x" == none) + r.check "CRLF and whitespace parse" (lookup? (parseHosts " 11.0.0.4\tnode4\r\n") "node4" == some [11,0,0,4]) + r.check "IPv6 row ignored" (lookup? (parseHosts "::1 localhost\n") "localhost" == none) + r.check "malformed row ignored" (lookup? (parseHosts "11.0.0 node-bad\n") "node-bad" == none) + r.check "comment and blank rows ignored" (parseHosts "# only\n\n # tail\n" == []) + r.check "multiaddr prefix" (multiaddrPrefix [11,0,0,2] == some "/ip4/11.0.0.2/tcp/9000") + +private def checkPlan (r : Runner) : IO Unit := do + let source ← loadFixture fixture + match parseScriptJson source with + | .error _ => r.check "virtual plan parses fixture" false + | .ok script => + match plan script 0, plan script 0, plan script 2, plan script 31 with + | .ok node0, .ok again, .ok node2, .ok node31 => + r.check "virtual plan is deterministic" (node0 == again) + r.check "node pruning differs" (node0 != node2 && node2 != node31) + r.check "node0 first action is init at t=0" (match node0.head? with + | some action => + action.time == 0 && (match action.action with + | .initGossipSub _ => true + | _ => false) + | none => false) + let node0Publishes := node0.filterMap fun item => match item.action with + | .publish _ _ _ => some item.time + | _ => none + r.check "node0 has zero publishes in this fixture" (node0Publishes == []) + let publishes := node2.filterMap fun item => match item.action with + | .publish id size topic => some (item.time, id.toNat, size.toNat, topic) + | _ => none + r.check "node2 has three publishes" (publishes.length == 3) + r.check "node2 publish times and fields" (publishes == + [(168000, 4, 98304, "a-subnet"), (180000, 5, 98304, "a-subnet"), + (252000, 11, 98304, "a-subnet")]) + r.check "node0 subscribe is full at t=0" (node0.any fun item => item.time == 0 && match item.action with + | .subscribeToTopic topic isPartial => topic == "a-subnet" && !isPartial + | _ => false) + r.check "validation delay is topic scoped at 5ms" (node0.any fun item => match item.action with + | .setTopicValidationDelay topic delay => topic == "a-subnet" && delay == 5 && item.time == 0 + | _ => false) + r.check "node0 connects at t=0" (node0.any fun item => item.time == 0 && match item.action with + | .connect _ => true + | _ => false) + r.check "node0 wait reaches 342s" (node0.any fun item => match item.action with + | .waitUntil deadline => deadline == 342 && item.time == 342000 + | _ => false) + r.check "node0 has no action after 342s" (node0.all (fun item => item.time ≤ 342000)) + | _, _, _, _ => + r.check "virtual plan builds for nodes 0, 2, and 31" false + /-- The suite. -/ def run (r : Runner) : IO Unit := do checkScript r @@ -325,5 +383,7 @@ def run (r : Runner) : IO Unit := do checkIds r checkParameters r checkDecoderDetails r + checkHosts r + checkPlan r end Tests.Gossipsub.Interop diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop.lean index cf28322b..16f157b4 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop.lean @@ -1,11 +1,14 @@ import Libp2p.Protocols.Gossipsub.Interop.Address +import Libp2p.Protocols.Gossipsub.Interop.Hosts import Libp2p.Protocols.Gossipsub.Interop.MessageId import Libp2p.Protocols.Gossipsub.Interop.Record +import Libp2p.Protocols.Gossipsub.Interop.Schedule import Libp2p.Protocols.Gossipsub.Interop.Script /-! # GossipSub interop pure core -This root re-exports the five pure adapter parts. The parser, record encoder, -address helpers, and message id functions perform no network or file IO. +This root re-exports the pure adapter parts. The parser, hosts resolver, virtual +schedule, record encoder, address helpers, and message id functions perform no +network IO. Only the hosts-file edge reads the filesystem. -/ diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Hosts.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Hosts.lean new file mode 100644 index 00000000..9502b516 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Hosts.lean @@ -0,0 +1,89 @@ +import Lean +import Multiformats.Multiaddr.Ip + +/-! +# Shadow hosts resolution + +Shadow writes one hosts file per simulated node. Each file keeps the +loopback row and adds one IPv4 row per simulated host, for example +`11.0.0.1 node0`. The probe in GATE-PROBES-2026-11-06.md confirmed the +format, so the adapter resolves `nodeN` from that file instead of calling +`getaddrinfo` through FFI. + +This module keeps the parsing pure. `parseHosts` and `lookup?` run on a +string, so the suite pins them without a filesystem. Only `lookupHostsFile` +reads `/etc/hosts`; it delegates every decision to the pure parser. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub.Interop + +open Multiformats.Multiaddr + +/-- A parsed IPv4 address holds its four octets, the wire shape `parseIp4?` returns. -/ +abbrev IPv4 := List Nat + +/-- Drop the ASCII whitespace a hosts line can carry at either end. -/ +private def trim (text : String) : String := text.trimAscii.toString + +/-- Reuse the strict dotted-quad parser from the multiformats package. -/ +private def parseIPv4? (text : String) : Option IPv4 := + match parseIp4? text with + | .ok bytes => some bytes + | .error _ => none + +/-- Parse one hosts line into an IPv4 address and its exact names. + +A line is ` [...]`. A `#` starts a comment that runs to +the end of the line, spaces and tabs separate fields, and blank fields are +dropped. The line is ignored when the address is not a strict IPv4 value, +when it carries no name, or when it is empty after the comment. IPv6 rows +fail the IPv4 parser and are therefore ignored rather than rejected. -/ +private def parseLine (line : String) : Option (IPv4 × List String) := + let body := (trim line).splitOn "#" |>.headD "" |>.trimAscii.toString + let fields := (body.split (fun c => c == ' ' || c == '\t')).toList + let fields := fields.filter (fun field => !field.isEmpty) + match fields with + | address :: names => + let address := address.toString + let names := names.map String.Slice.toString + if names.isEmpty then none + else parseIPv4? address |>.map (fun ip => (ip, names)) + | [] => none + +/-- Parse hosts content into IPv4 rows with their exact names. + +The parser splits on line feeds only. A carriage return survives a split and +the field trim removes it, so CRLF content parses without a second pass. -/ +def parseHosts (content : String) : List (IPv4 × List String) := + content.splitOn "\n" |>.filterMap parseLine + +/-- Find the IPv4 address of one name across primary names and aliases. + +The comparison is exact. `node1` does not match the row `11.0.0.3 node10`, +and `node1x` matches nothing, because list equality does not accept a +prefix. The first row that carries the name wins, matching the order the +hosts file states. -/ +def lookup? (hosts : List (IPv4 × List String)) (name : String) : Option IPv4 := + hosts.find? (fun entry => entry.2.any (fun candidate => candidate == name)) + |>.map Prod.fst + +/-- Build the transport address prefix for one resolved address. + +The interop transport fixes the port at 9000 (specification section 8). +The caller appends `/p2p/` once the identity package derives it, +so this function returns only the prefix it can build from the address. -/ +def multiaddrPrefix (ip : IPv4) : Option String := + ip4String? ip |>.toOption + |>.map (fun text => s!"/ip4/{text}/tcp/9000") + +/-- Read Shadow's hosts file and resolve one name through the pure parser. + +This is the only definition in the module that touches the filesystem, and +it performs no parsing of its own. -/ +def lookupHostsFile (name : String) : IO (Option IPv4) := do + let content ← IO.FS.readFile "/etc/hosts" + pure (lookup? (parseHosts content) name) + +end Libp2p.Protocols.Gossipsub.Interop diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Schedule.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Schedule.lean new file mode 100644 index 00000000..5a7ddc92 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Schedule.lean @@ -0,0 +1,110 @@ +import Libp2p.Protocols.Gossipsub.Interop.Script + +/-! +# GossipSub interop virtual clock + +The pure core needs a dry run over the captured 342 second scenario before +any IO driver exists. `plan` is that dry run: it selects one node's +instructions with `prepareScript`, projects each instruction to an `Action`, +and stamps every action with the virtual time a driver would reach it at. + +A `waitUntil` instruction is a barrier, not an action the node sleeps +through. The virtual clock jumps to the deadline and the actions behind the +wait inherit the new time. The IO driver keeps the same contract: it calls +`waitComplete` between its ticks while delivery and forwarding continue. + +`GossipSubParams` carries `Lean.Json` values, which have no `BEq` instance, +so `Action.beq` compares the parsed fields directly. Two scripts that parse +to the same fields therefore produce plans the test can compare for +determinism. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub.Interop + +/-- Virtual time in milliseconds, matching the gossipsub core's `Millis`. -/ +abbrev Millis := Nat + +/-- One instruction the selected node executes, without the node conditions. -/ +inductive Action where + | initGossipSub (params : GossipSubParams) + | connect (peers : List Int) + | subscribeToTopic (topic : String) (isPartial : Bool) + | publish (messageId size : Int) (topic : String) + | setTopicValidationDelay (topic : String) (delayMillis : Nat) + | waitUntil (deadlineSeconds : Int) + +/-- One action and the virtual time it executes at. -/ +structure ScheduledAction where + time : Millis + action : Action + +/-- Compare two parameter objects by their parsed fields, since `Json` has no `BEq`. -/ +private def sameParams : GossipSubParams → GossipSubParams → Bool + | left, right => left.raw == right.raw && left.unmapped == right.unmapped + && left.heartbeatInitialDelay == right.heartbeatInitialDelay + && left.heartbeatInterval == right.heartbeatInterval + && left.fanoutTtl == right.fanoutTtl && left.pruneBackoff == right.pruneBackoff + && left.historyLength == right.historyLength && left.historyGossip == right.historyGossip + && left.gossipFactor == right.gossipFactor && left.d == right.d && left.dLow == right.dLow + && left.dHigh == right.dHigh && left.dScore == right.dScore && left.dLazy == right.dLazy + +/-- Structural equality over actions, field by field. -/ +def Action.beq : Action → Action → Bool + | .initGossipSub left, .initGossipSub right => sameParams left right + | .connect left, .connect right => left == right + | .subscribeToTopic lTopic lPartial, .subscribeToTopic rTopic rPartial => + lTopic == rTopic && lPartial == rPartial + | .publish lId lSize lTopic, .publish rId rSize rTopic => + lId == rId && lSize == rSize && lTopic == rTopic + | .setTopicValidationDelay lTopic lDelay, .setTopicValidationDelay rTopic rDelay => + lTopic == rTopic && lDelay == rDelay + | .waitUntil left, .waitUntil right => left == right + | _, _ => false + +instance : BEq Action := ⟨Action.beq⟩ + +instance : BEq ScheduledAction where + beq left right := left.time == right.time && left.action == right.action + +/-- Flatten node conditions away and convert a validation delay to milliseconds. + +The delay arrives as a `Float` in seconds. The nearest whole millisecond, +floored toward zero, keeps `0.005` at exactly 5 ms. -/ +private def actionOf : Instruction → Action + | .initGossipSub params => .initGossipSub params + | .ifNodeIDEquals _ nested => actionOf nested + | .connect peers => .connect peers + | .subscribeToTopic topic isPartial => .subscribeToTopic topic isPartial + | .publish id size topic => .publish id size topic + | .setTopicValidationDelay topic delay => + .setTopicValidationDelay topic (Float.floor (delay * 1000.0)).toUInt64.toNat + | .waitUntil deadline => .waitUntil deadline + +/-- Stamp each instruction with the virtual time a driver reaches it at. + +The clock starts at zero. A `waitUntil` moves the clock to its deadline in +milliseconds. A negative deadline would move the clock backwards, so it +clamps to the current time instead. -/ +private def expand (clock : Millis) : List Instruction → List ScheduledAction + | [] => [] + | instruction :: rest => + match instruction with + | .waitUntil deadline => + let next := if deadline ≤ 0 then clock else deadline.toNat * 1000 + { time := next, action := actionOf instruction } :: expand next rest + | _ => { time := clock, action := actionOf instruction } :: expand clock rest + +/-- Build the virtual-clock plan for one node. + +The plan starts with the startup `initGossipSub` at time zero, because the +harness states every node initializes before any other instruction. The +remaining actions keep script order. `waitComplete` is the guard the live +driver pairs with each `waitUntil` in this list. -/ +def plan (script : Script) (nodeId : Int) : Except String (List ScheduledAction) := do + let (params, selected) ← prepareScript nodeId script + let start := { time := 0, action := Action.initGossipSub params } + pure (start :: expand 0 selected) + +end Libp2p.Protocols.Gossipsub.Interop From 32df0b0653f7bd6206dff24795bf0fb99b41e728 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:28:37 +0800 Subject: [PATCH 059/125] Pin monotonic virtual interop schedule --- Tests/Gossipsub/Interop.lean | 42 ++++++++++++++++--- .../Protocols/Gossipsub/Interop/Schedule.lean | 11 ++--- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/Tests/Gossipsub/Interop.lean b/Tests/Gossipsub/Interop.lean index 1c74f8e5..97190e2a 100644 --- a/Tests/Gossipsub/Interop.lean +++ b/Tests/Gossipsub/Interop.lean @@ -333,13 +333,27 @@ private def checkHosts (r : Runner) : IO Unit := do r.check "comment and blank rows ignored" (parseHosts "# only\n\n # tail\n" == []) r.check "multiaddr prefix" (multiaddrPrefix [11,0,0,2] == some "/ip4/11.0.0.2/tcp/9000") +private def nondecreasing : List ScheduledAction → Bool + | [] | [_] => true + | left :: right :: rest => left.time ≤ right.time && nondecreasing (right :: rest) + +private def publishRowsFor (script : Script) (nodeId : Nat) : List (Nat × Nat × Nat × Nat × String) := + match plan script (Int.ofNat nodeId) with + | .error _ => [] + | .ok actions => actions.filterMap fun item => match item.action with + | .publish id size topic => some (nodeId, item.time, id.toNat, size.toNat, topic) + | _ => none + +private def allPublishRows (script : Script) : List (Nat × Nat × Nat × Nat × String) := + (List.range 32).flatMap (publishRowsFor script) + private def checkPlan (r : Runner) : IO Unit := do let source ← loadFixture fixture match parseScriptJson source with | .error _ => r.check "virtual plan parses fixture" false | .ok script => - match plan script 0, plan script 0, plan script 2, plan script 31 with - | .ok node0, .ok again, .ok node2, .ok node31 => + match plan script 0, plan script 0, plan script 1, plan script 2, plan script 31 with + | .ok node0, .ok again, .ok node1, .ok node2, .ok node31 => r.check "virtual plan is deterministic" (node0 == again) r.check "node pruning differs" (node0 != node2 && node2 != node31) r.check "node0 first action is init at t=0" (match node0.head? with @@ -359,6 +373,15 @@ private def checkPlan (r : Runner) : IO Unit := do r.check "node2 publish times and fields" (publishes == [(168000, 4, 98304, "a-subnet"), (180000, 5, 98304, "a-subnet"), (252000, 11, 98304, "a-subnet")]) + r.check "all 16 fixture publishes stay attached to their nodes" (allPublishRows script == + [(2, 168000, 4, 98304, "a-subnet"), (2, 180000, 5, 98304, "a-subnet"), + (2, 252000, 11, 98304, "a-subnet"), (3, 132000, 1, 98304, "a-subnet"), + (3, 264000, 12, 98304, "a-subnet"), (5, 240000, 10, 98304, "a-subnet"), + (6, 156000, 3, 98304, "a-subnet"), (7, 300000, 15, 98304, "a-subnet"), + (8, 192000, 6, 98304, "a-subnet"), (16, 228000, 9, 98304, "a-subnet"), + (16, 288000, 14, 98304, "a-subnet"), (17, 216000, 8, 98304, "a-subnet"), + (20, 276000, 13, 98304, "a-subnet"), (27, 120000, 0, 98304, "a-subnet"), + (28, 204000, 7, 98304, "a-subnet"), (30, 144000, 2, 98304, "a-subnet")]) r.check "node0 subscribe is full at t=0" (node0.any fun item => item.time == 0 && match item.action with | .subscribeToTopic topic isPartial => topic == "a-subnet" && !isPartial | _ => false) @@ -372,8 +395,17 @@ private def checkPlan (r : Runner) : IO Unit := do | .waitUntil deadline => deadline == 342 && item.time == 342000 | _ => false) r.check "node0 has no action after 342s" (node0.all (fun item => item.time ≤ 342000)) - | _, _, _, _ => - r.check "virtual plan builds for nodes 0, 2, and 31" false + r.check "node1 has full startup and wait sequence" (node1.length == 22 && + node1.head?.map (fun item => item.time == 0) == some true && + node1.getLast?.map (fun item => item.time == 342000) == some true) + r.check "node31 has full startup and wait sequence" (node31.length == 22 && + node31.head?.map (fun item => item.time == 0) == some true && + node31.getLast?.map (fun item => item.time == 342000) == some true) + r.check "positive waits never move virtual time backwards" (match plan script 0 with + | .ok actions => nondecreasing actions + | .error _ => false) + | _, _, _, _, _ => + r.check "virtual plan builds for nodes 0, 1, 2, and 31" false /-- The suite. -/ def run (r : Runner) : IO Unit := do @@ -386,4 +418,4 @@ def run (r : Runner) : IO Unit := do checkHosts r checkPlan r -end Tests.Gossipsub.Interop +end Tests.Gossipsub.Interop \ No newline at end of file diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Schedule.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Schedule.lean index 5a7ddc92..8c3cf122 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Schedule.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Interop/Schedule.lean @@ -84,15 +84,16 @@ private def actionOf : Instruction → Action /-- Stamp each instruction with the virtual time a driver reaches it at. -The clock starts at zero. A `waitUntil` moves the clock to its deadline in -milliseconds. A negative deadline would move the clock backwards, so it -clamps to the current time instead. -/ +The clock starts at zero. A `waitUntil` requests its deadline in milliseconds. +The plan keeps the clock monotonic when a later deadline is already past. A +nonpositive deadline also keeps the current time. -/ private def expand (clock : Millis) : List Instruction → List ScheduledAction | [] => [] | instruction :: rest => match instruction with | .waitUntil deadline => - let next := if deadline ≤ 0 then clock else deadline.toNat * 1000 + let requested := if deadline ≤ 0 then clock else deadline.toNat * 1000 + let next := max clock requested { time := next, action := actionOf instruction } :: expand next rest | _ => { time := clock, action := actionOf instruction } :: expand clock rest @@ -107,4 +108,4 @@ def plan (script : Script) (nodeId : Int) : Except String (List ScheduledAction) let start := { time := 0, action := Action.initGossipSub params } pure (start :: expand 0 selected) -end Libp2p.Protocols.Gossipsub.Interop +end Libp2p.Protocols.Gossipsub.Interop \ No newline at end of file From cd8bf249c02054fc22fccce76e7810974dac7dfd Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:42:37 +0800 Subject: [PATCH 060/125] Test virtual clock barriers and publish assignments --- Tests/Gossipsub/Interop.lean | 92 +++++++++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 12 deletions(-) diff --git a/Tests/Gossipsub/Interop.lean b/Tests/Gossipsub/Interop.lean index 97190e2a..e98e6ada 100644 --- a/Tests/Gossipsub/Interop.lean +++ b/Tests/Gossipsub/Interop.lean @@ -337,16 +337,36 @@ private def nondecreasing : List ScheduledAction → Bool | [] | [_] => true | left :: right :: rest => left.time ≤ right.time && nondecreasing (right :: rest) -private def publishRowsFor (script : Script) (nodeId : Nat) : List (Nat × Nat × Nat × Nat × String) := +private structure FixturePublish where + messageId : Nat + nodeId : Nat + time : Nat + size : Nat + topic : String + deriving BEq, DecidableEq + +private def publishRowsFor (script : Script) (nodeId : Nat) : List FixturePublish := match plan script (Int.ofNat nodeId) with | .error _ => [] | .ok actions => actions.filterMap fun item => match item.action with - | .publish id size topic => some (nodeId, item.time, id.toNat, size.toNat, topic) + | .publish id size topic => some ⟨id.toNat, nodeId, item.time, size.toNat, topic⟩ | _ => none -private def allPublishRows (script : Script) : List (Nat × Nat × Nat × Nat × String) := +private def allPublishRows (script : Script) : List FixturePublish := (List.range 32).flatMap (publishRowsFor script) +private def publishRowsForPlan (script : Script) (nodeId : Nat) : List (Nat × Nat × Nat × String) := + match plan script (Int.ofNat nodeId) with + | .error _ => [] + | .ok actions => actions.filterMap fun item => match item.action with + | .publish id size topic => some (item.time, id.toNat, size.toNat, topic) + | _ => none + +private def waitPlan (source : String) : Option (List ScheduledAction) := + match parseScript source with + | .error _ => none + | .ok script => (plan script 0).toOption + private def checkPlan (r : Runner) : IO Unit := do let source ← loadFixture fixture match parseScriptJson source with @@ -373,15 +393,44 @@ private def checkPlan (r : Runner) : IO Unit := do r.check "node2 publish times and fields" (publishes == [(168000, 4, 98304, "a-subnet"), (180000, 5, 98304, "a-subnet"), (252000, 11, 98304, "a-subnet")]) + r.check "node 1 publish subset empty" (publishRowsForPlan script 1 == []) + r.check "node 2 publish subset exact" (publishRowsForPlan script 2 == + [(168000, 4, 98304, "a-subnet"), (180000, 5, 98304, "a-subnet"), + (252000, 11, 98304, "a-subnet")]) + r.check "node 3 publish subset exact" (publishRowsForPlan script 3 == + [(132000, 1, 98304, "a-subnet"), (264000, 12, 98304, "a-subnet")]) + r.check "node 16 publish subset exact" (publishRowsForPlan script 16 == + [(228000, 9, 98304, "a-subnet"), (288000, 14, 98304, "a-subnet")]) + r.check "node 31 publish subset empty" (publishRowsForPlan script 31 == []) + r.check "publish count per node" (((List.range 32).filterMap fun nodeId => + let count := (publishRowsFor script nodeId).length + if count == 0 then none else some (nodeId, count)) == + [(2,3), (3,2), (5,1), (6,1), (7,1), (8,1), (16,2), (17,1), (20,1), (27,1), (28,1), (30,1)]) + r.check "all 32 node plans succeed" ((List.range 32).all fun nodeId => + (plan script (Int.ofNat nodeId)).isOk) + let publishRows := allPublishRows script + let publishIds := publishRows.map (·.messageId) + r.check "fixture has exactly 16 publishes" (publishRows.length == 16) + r.check "publish IDs cover 0 through 15 exactly once" (publishIds.length == 16 && + publishIds.eraseDups.length == 16 && publishIds.all (fun id => id ≤ 15)) + r.check "all publish sizes and topics match fixture" (publishRows.all (fun row => row.size == 98304 && row.topic == "a-subnet")) r.check "all 16 fixture publishes stay attached to their nodes" (allPublishRows script == - [(2, 168000, 4, 98304, "a-subnet"), (2, 180000, 5, 98304, "a-subnet"), - (2, 252000, 11, 98304, "a-subnet"), (3, 132000, 1, 98304, "a-subnet"), - (3, 264000, 12, 98304, "a-subnet"), (5, 240000, 10, 98304, "a-subnet"), - (6, 156000, 3, 98304, "a-subnet"), (7, 300000, 15, 98304, "a-subnet"), - (8, 192000, 6, 98304, "a-subnet"), (16, 228000, 9, 98304, "a-subnet"), - (16, 288000, 14, 98304, "a-subnet"), (17, 216000, 8, 98304, "a-subnet"), - (20, 276000, 13, 98304, "a-subnet"), (27, 120000, 0, 98304, "a-subnet"), - (28, 204000, 7, 98304, "a-subnet"), (30, 144000, 2, 98304, "a-subnet")]) + [⟨4, 2, 168000, 98304, "a-subnet"⟩, + ⟨5, 2, 180000, 98304, "a-subnet"⟩, + ⟨11, 2, 252000, 98304, "a-subnet"⟩, + ⟨1, 3, 132000, 98304, "a-subnet"⟩, + ⟨12, 3, 264000, 98304, "a-subnet"⟩, + ⟨10, 5, 240000, 98304, "a-subnet"⟩, + ⟨3, 6, 156000, 98304, "a-subnet"⟩, + ⟨15, 7, 300000, 98304, "a-subnet"⟩, + ⟨6, 8, 192000, 98304, "a-subnet"⟩, + ⟨9, 16, 228000, 98304, "a-subnet"⟩, + ⟨14, 16, 288000, 98304, "a-subnet"⟩, + ⟨8, 17, 216000, 98304, "a-subnet"⟩, + ⟨13, 20, 276000, 98304, "a-subnet"⟩, + ⟨0, 27, 120000, 98304, "a-subnet"⟩, + ⟨7, 28, 204000, 98304, "a-subnet"⟩, + ⟨2, 30, 144000, 98304, "a-subnet"⟩]) r.check "node0 subscribe is full at t=0" (node0.any fun item => item.time == 0 && match item.action with | .subscribeToTopic topic isPartial => topic == "a-subnet" && !isPartial | _ => false) @@ -401,6 +450,25 @@ private def checkPlan (r : Runner) : IO Unit := do r.check "node31 has full startup and wait sequence" (node31.length == 22 && node31.head?.map (fun item => item.time == 0) == some true && node31.getLast?.map (fun item => item.time == 342000) == some true) + let reverseWait := waitPlan "{\"script\":[{\"type\":\"initGossipSub\",\"gossipSubParams\":{}},{\"type\":\"waitUntil\",\"elapsedSeconds\":300},{\"type\":\"waitUntil\",\"elapsedSeconds\":100},{\"type\":\"connect\",\"connectTo\":[]}] }" + r.check "earlier wait barrier remains at t=300" (match reverseWait with + | some actions => nondecreasing actions && actions.length == 4 && + actions[2]?.map (·.time) == some 300000 && actions.getLast?.map (·.time) == some 300000 + | none => false) + r.check "action after earlier wait stays at t=300" (match reverseWait with + | some actions => actions.getLast?.map (fun item => match item.action with + | .connect _ => item.time == 300000 + | _ => false) == some true + | none => false) + let sameWait := waitPlan "{\"script\":[{\"type\":\"initGossipSub\",\"gossipSubParams\":{}},{\"type\":\"waitUntil\",\"elapsedSeconds\":300},{\"type\":\"waitUntil\",\"elapsedSeconds\":300}] }" + r.check "equal wait barrier is idempotent" (match sameWait with + | some actions => actions.length == 3 && actions[1]?.map (·.time) == some 300000 && + actions[2]?.map (·.time) == some 300000 + | none => false) + let zeroWait := waitPlan "{\"script\":[{\"type\":\"initGossipSub\",\"gossipSubParams\":{}},{\"type\":\"waitUntil\",\"elapsedSeconds\":0},{\"type\":\"connect\",\"connectTo\":[]}] }" + r.check "zero wait as first action adds no delay" (match zeroWait with + | some actions => actions.length == 3 && actions.all (fun item => item.time == 0) + | none => false) r.check "positive waits never move virtual time backwards" (match plan script 0 with | .ok actions => nondecreasing actions | .error _ => false) @@ -418,4 +486,4 @@ def run (r : Runner) : IO Unit := do checkHosts r checkPlan r -end Tests.Gossipsub.Interop \ No newline at end of file +end Tests.Gossipsub.Interop From 7a64f6557bf82b2d87d91416fe1ca7e45dcff6b6 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:55:04 +0800 Subject: [PATCH 061/125] Remove identity mock after #63 merge --- Tests/Gossipsub/Identity.lean | 25 ++++++++ .../Libp2p/Protocols/Mock/Identity.lean | 57 ------------------- 2 files changed, 25 insertions(+), 57 deletions(-) create mode 100644 Tests/Gossipsub/Identity.lean delete mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean diff --git a/Tests/Gossipsub/Identity.lean b/Tests/Gossipsub/Identity.lean new file mode 100644 index 00000000..da2a8b39 --- /dev/null +++ b/Tests/Gossipsub/Identity.lean @@ -0,0 +1,25 @@ +import Libp2p.Identity + +/-! +# Identity fixtures for protocol transcripts + +The transcripts need distinct validated peer ids without a native crypto +call. Each id is an identity multihash of one byte. Seed-derived keys +belong to the identity and crypto suites. +-/ + +set_option autoImplicit false + +namespace Tests.Gossipsub.Identity + +/-- A validated identity multihash for one transcript peer. -/ +def peer (n : Nat) : Libp2p.Identity.PeerId := + Libp2p.Identity.PeerId.inline (ByteArray.mk #[n.toUInt8]) (by + simp [Libp2p.Identity.maxInlineKeyBytes]) + +/-- A sized public key for policy tests that control verification. -/ +def publicKey (n : Nat) : Libp2p.Identity.PubKey := + Libp2p.Identity.PubKey.ofEd25519 + ⟨ByteArray.mk (Array.replicate 32 n.toUInt8), by simp⟩ + +end Tests.Gossipsub.Identity diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean deleted file mode 100644 index 4f1f40ff..00000000 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean +++ /dev/null @@ -1,57 +0,0 @@ -import Std - -/-! -# Identity mock - -`Libp2p.Identity` (issue #11) is not merged. This mock declares the key -and peer id facts of `packages/libp2p/identity/docs/ARCHITECTURE.md`. -That doc freezes `KeyType` and what a key and a peer id hold, and it -leaves the signatures open. This mock fixes minimal signatures. -`PubKey` and `PeerId` drop their proof fields. The real identity package -replaces this module at integration. - -The stand-in bodies are executable, so the suite can drive both outcomes -of `unmarshalPubKey?`. The theorems of `Identify` stay true for any -replacement, because they are conditional on these opaque results. --/ - -set_option autoImplicit false - -namespace Libp2p.Protocols.Mock.Identity - -/-- The closed set of key types: rsa (0), ed25519 (1), secp256k1 (2), -ecdsa (3). -/ -inductive KeyType where - | rsa | ed25519 | secp256k1 | ecdsa - deriving DecidableEq, BEq, Repr - -/-- A public key: its type and its data bytes. -/ -structure PubKey where - keyType : KeyType - data : List Nat - deriving DecidableEq, Repr - -/-- A peer id: the derived bytes and the text form. -/ -structure PeerId where - bytes : List Nat - text : String - deriving DecidableEq, BEq, Repr - -/-- Executable stand-in: a nonempty buffer parses as an ed25519 key, -an empty buffer does not parse. -/ -opaque unmarshalPubKey? (bs : List Nat) : Option PubKey := - if bs.isEmpty then none else some ⟨.ed25519, bs⟩ - -/-- Executable stand-in: the derived id carries the key bytes. -/ -opaque peerIdOfPubKey (k : PubKey) : PeerId := - ⟨k.data, ""⟩ - -/-- Executable stand-in: the raw key data. The mock `unmarshalPubKey?` -parses any nonempty buffer as `⟨.ed25519, buffer⟩`, and `peerIdOfPubKey` -reads only the buffer, so the round trip holds for a nonempty ed25519 -stand-in. The real identity package writes the serialized PublicKey -protobuf (peer-ids spec); that body replaces this one at integration. -/ -opaque marshalPubKey (k : PubKey) : List Nat := - k.data - -end Libp2p.Protocols.Mock.Identity From 1d53ad07377fcb308a291f150e794f7cd5250c26 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:55:17 +0800 Subject: [PATCH 062/125] Complete identity mock swap after #63 --- Tests/Gossipsub/Cache.lean | 3 ++- Tests/Gossipsub/Core.lean | 11 ++++++----- Tests/Gossipsub/Node.lean | 13 +++++++------ Tests/Gossipsub/Sign.lean | 9 +++++---- .../Libp2p/Protocols/Gossipsub/Core.lean | 6 +----- .../Libp2p/Protocols/Gossipsub/Node.lean | 2 +- .../Libp2p/Protocols/Gossipsub/Sign.lean | 17 +++++++++++++++-- .../Libp2p/Protocols/Gossipsub/Types.lean | 9 ++++----- .../protocols/Libp2p/Protocols/Mock/Core.lean | 4 ++-- .../protocols/Libp2p/Protocols/Mock/Host.lean | 8 ++++---- 10 files changed, 47 insertions(+), 35 deletions(-) diff --git a/Tests/Gossipsub/Cache.lean b/Tests/Gossipsub/Cache.lean index 1febd9de..0423a18e 100644 --- a/Tests/Gossipsub/Cache.lean +++ b/Tests/Gossipsub/Cache.lean @@ -1,4 +1,5 @@ import Tests.Support +import Tests.Gossipsub.Identity import Libp2p.Protocols.Gossipsub.Cache /-! @@ -47,7 +48,7 @@ def msg (t : TopicId) (n : Nat) : Message := { «from» := none, data := some [n], seqno := none, topic := t, signature := none, key := none } /-- A peer from one number. -/ -def peer (n : Nat) : Peer := { bytes := [n], text := s!"p{n}" } +def peer (n : Nat) : Peer := Identity.peer n /-- A promise with explicit fields, for the expected lists. -/ def promiseOf (p : Peer) (i : MessageId) (d : Millis) : IwantPromise := diff --git a/Tests/Gossipsub/Core.lean b/Tests/Gossipsub/Core.lean index 7c781118..93f4bf49 100644 --- a/Tests/Gossipsub/Core.lean +++ b/Tests/Gossipsub/Core.lean @@ -1,4 +1,5 @@ import Tests.Support +import Tests.Gossipsub.Identity import Libp2p.Protocols.Gossipsub.Core /-! @@ -27,11 +28,11 @@ open Libp2p.Protocols.Gossipsub.Core private def ta : TopicId := ⟨"t"⟩ private def tb : TopicId := ⟨"u"⟩ -private def pa : Peer := ⟨[1], "a"⟩ -private def pb : Peer := ⟨[2], "b"⟩ -private def pc : Peer := ⟨[3], "c"⟩ -private def pd : Peer := ⟨[4], "d"⟩ -private def px : Peer := ⟨[9], "x"⟩ +private def pa : Peer := Identity.peer 1 +private def pb : Peer := Identity.peer 2 +private def pc : Peer := Identity.peer 3 +private def pd : Peer := Identity.peer 4 +private def px : Peer := Identity.peer 9 private def prims : Sign.Prims := { unmarshalKey := fun _ => none diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index 3bff0f21..272d6d1c 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -1,4 +1,5 @@ import Tests.Support +import Tests.Gossipsub.Identity import Libp2p.Protocols.Gossipsub.Node /-! @@ -21,11 +22,11 @@ open Libp2p.Protocols.Gossipsub open Libp2p.Protocols.Gossipsub.Node open Libp2p.Protocols.Mock.Host (Switch HostSeam) -private def pa : Peer := ⟨[1], "a"⟩ -private def pb : Peer := ⟨[2], "b"⟩ -private def pc : Peer := ⟨[3], "c"⟩ -private def pd : Peer := ⟨[4], "d"⟩ -private def px : Peer := ⟨[9], "control"⟩ +private def pa : Peer := Identity.peer 1 +private def pb : Peer := Identity.peer 2 +private def pc : Peer := Identity.peer 3 +private def pd : Peer := Identity.peer 4 +private def px : Peer := Identity.peer 9 private def topic : TopicId := ⟨"t"⟩ private def patience : Nat := 5000 @@ -121,7 +122,7 @@ private def fixture (sw : Switch) (me : Peer) (config : Config := {}) : Async Fi let barriers ← (Chan.new 64 : BaseIO (Chan Unit)) let receipts ← (Chan.new 2048 : BaseIO (Chan (Peer × List Nat))) let deliveries ← (Chan.new 2048 : BaseIO (Chan Message)) - let controlPeer : Peer := ⟨me.bytes ++ [99], me.text ++ "-control"⟩ + let controlPeer := Identity.peer (100 + me.hash.digest.headD 0) let cfg := { config with clock := clock.clock order := fun tick peers => config.order tick (peers.filter (· != controlPeer)) diff --git a/Tests/Gossipsub/Sign.lean b/Tests/Gossipsub/Sign.lean index df70dcf8..e1b44b5d 100644 --- a/Tests/Gossipsub/Sign.lean +++ b/Tests/Gossipsub/Sign.lean @@ -1,4 +1,5 @@ import Tests.Support +import Tests.Gossipsub.Identity import Libp2p.Protocols.Gossipsub.Sign /-! @@ -21,7 +22,7 @@ namespace Tests.Gossipsub.Sign open Libp2p.Protocols.Gossipsub open Libp2p.Protocols.Gossipsub.Sign -open Libp2p.Protocols.Mock.Identity +open Libp2p.Identity /-- An encoder that reads the `from`, `data`, `seqno`, and topic fields. It reads neither the `signature` nor the `key` field. -/ @@ -45,9 +46,9 @@ private def tagEncode (m : Message) : List Nat := key, the derived peer id carries the key bytes, and the verifier accepts. -/ private def testPrimsOk : Prims := - { unmarshalKey := fun bs => if bs.isEmpty then none else some ⟨.ed25519, bs⟩ - inlineKey := fun bs => if bs.isEmpty then none else some ⟨.ed25519, bs⟩ - idOfKey := fun k => k.data + { unmarshalKey := fun bs => if bs.isEmpty then none else some (Identity.publicKey (bs.headD 0)) + inlineKey := fun bs => if bs.isEmpty then none else some (Identity.publicKey (bs.headD 0)) + idOfKey := fun k => [k.data[0]!.toNat] verify := fun _ _ _ => true } /-- The rejecting stand-ins: the verifier rejects. -/ diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Core.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Core.lean index 4326fb6d..01f006cc 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Core.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Core.lean @@ -884,11 +884,7 @@ def defaults : Params where iwantDeadline := 3000 window := { historyLength := 5, historyGossip := 3, valid := by decide } seen := { capacity := 65536, ttl := 120000, valid := by decide } - prims := - { unmarshalKey := fun _ => none - inlineKey := fun _ => none - idOfKey := fun _ => [] - verify := fun _ _ _ => false } + prims := Sign.Prims.identity msgId := fun m => ⟨m.data.getD []⟩ /-! ## Accessor lemmas -/ diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index 916195f8..260cc548 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -119,7 +119,7 @@ def spread (i : Nat) : Millis := (i * 7919 + 1) % 1000 /-- Replayable peer order for a heartbeat. The hash combines its counter with each peer's identity bytes. The loop supplies only known peers. -/ def defaultOrder (tick : Nat) (peers : List Peer) : List Peer := - let key (peer : Peer) : UInt64 := mixHash (hash tick) (hash peer.bytes) + let key (peer : Peer) : UInt64 := mixHash (hash tick) (hash (Bytes.toNatList peer.toBytes)) peers.mergeSort (fun a b => key a ≤ key b) /-- The configuration of one node: the router parameters, the clock the diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Sign.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Sign.lean index 601246db..eb357a16 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Sign.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Sign.lean @@ -1,5 +1,5 @@ import Libp2p.Protocols.Gossipsub.Types -import Libp2p.Protocols.Mock.Identity +import Libp2p.Identity import Bytes /-! @@ -62,7 +62,7 @@ set_option autoImplicit false namespace Libp2p.Protocols.Gossipsub.Sign -open Libp2p.Protocols.Mock.Identity +open Libp2p.Identity /-- The two deterministic signature policies of the spec. The spec names no numeric codes for the policies, so this is a plain inductive @@ -127,6 +127,19 @@ structure Prims where /-- Check a signature over the signed bytes. -/ verify : PubKey → List Nat → List Nat → Bool +/-- The identity functions at the router boundary. A failed derivation +gives empty bytes, which cannot encode a peer id. The inline path reads +only an identity multihash and checks its public key protobuf. -/ +def Prims.identity : Prims where + unmarshalKey bs := (PubKey.unmarshal? (Bytes.ofNatList bs)).toOption + inlineKey bs := do + let peer ← (PeerId.ofBytes? (Bytes.ofNatList bs)).toOption + if peer.hash.code != .«identity» then none + else (PubKey.unmarshal? (Bytes.ofNatList peer.hash.digest)).toOption + idOfKey k := ((PeerId.ofPubKey k).toOption.map + (fun peer => Bytes.toNatList peer.toBytes)).getD [] + verify k msg sig := k.verify (Bytes.ofNatList msg) (Bytes.ofNatList sig) + /-- The bytes a signature covers: the prefix, then the encoded message with the `signature` and `key` fields absent. -/ def signInput (encode : Message → List Nat) (m : Message) : List Nat := diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Types.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Types.lean index 47a4b6cc..c2fd2ec9 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Types.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Types.lean @@ -1,4 +1,4 @@ -import Libp2p.Protocols.Mock.Identity +import Libp2p.Identity /-! # Gossipsub shared types @@ -21,7 +21,7 @@ set_option autoImplicit false namespace Libp2p.Protocols.Gossipsub -open Libp2p.Protocols.Mock.Identity +open Libp2p.Identity /-- A topic, by its name. -/ structure TopicId where @@ -77,8 +77,7 @@ structure Thresholds where /-- A point in time, in milliseconds. -/ abbrev Millis := Nat -/-- The peer id of the identity mock. The real identity package -replaces it at integration. -/ -abbrev Peer := PeerId +/-- The validated peer id of the identity package. -/ +abbrev Peer := Libp2p.Identity.PeerId end Libp2p.Protocols.Gossipsub diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean index 6ef4a895..11eba4dc 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean @@ -1,7 +1,7 @@ import Std import Conc import Streams -import Libp2p.Protocols.Mock.Identity +import Libp2p.Identity /-! # Core seam mock @@ -39,7 +39,7 @@ structure StreamHandler where /-- The id the handler serves. -/ id : ProtocolId /-- The offered stream and the peer that opened it. -/ - handle : Identity.PeerId → Streams.Stream → ExceptT Error Conc.Async Unit + handle : Libp2p.Identity.PeerId → Streams.Stream → ExceptT Error Conc.Async Unit /-- One map from a stream error to the coarse error, shared by every handler of this package. `closed` stays closed, `reset` is a protocol diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean index 4e885a03..3327f873 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean @@ -1,7 +1,7 @@ import Std import Conc import Streams -import Libp2p.Protocols.Mock.Identity +import Libp2p.Identity import Libp2p.Protocols.Mock.Core /-! @@ -33,7 +33,7 @@ set_option autoImplicit false namespace Libp2p.Protocols.Mock.Host -open Libp2p.Protocols.Mock.Identity +open Libp2p.Identity /-- The peerstore seam the push handler updates: addresses by peer, deduplicated at add. -/ @@ -149,9 +149,9 @@ private def open_ (sw : Switch) (me peer : PeerId) (protocols : List Core.Protoc BaseIO (Except Core.Error (Core.ProtocolId × Streams.Stream)) := do let st ← sw.state.get let some entry := st.entries.find? (fun e => decide (e.peer = peer)) - | return .error (.protocol s!"no peer {peer.text}") + | return .error (.protocol s!"no peer {peer}") let some (id, handler) := choose entry.handlers protocols - | return .error (.protocol s!"peer {peer.text} serves none of the protocols") + | return .error (.protocol s!"peer {peer} serves none of the protocols") let toPeer ← Conc.Pipe.new 8 let fromPeer ← Conc.Pipe.new 8 -- One atomic step checks the flag and records the pipes, so `close` From 57caabceeefd1bee85a5d481bfaac86b4a77d9cc Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:34:09 +0800 Subject: [PATCH 063/125] Wait for matching unsubscribe record --- Tests/Gossipsub/Node.lean | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index 272d6d1c..d04e15b9 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -42,6 +42,13 @@ private def receive {α : Type} (c : Chan α) : Async (Option α) := do let timer ← (Park.sleep patience none : BaseIO (Park (Option α))) select #[Park.recv c id, timer] +/-- Wait for one matching value while preserving unrelated observations. -/ +private partial def receiveWhere {α : Type} (c : Chan α) (wanted : α → Bool) : + Async (Option α) := do + match ← receive c with + | some value => if wanted value then some value else receiveWhere c wanted + | none => none + private def marker : Rpc.RPC := subOptRpc ⟨"node-suite-barrier"⟩ false /-- A clock with a signal for the absolute time of each armed deadline. -/ @@ -474,8 +481,8 @@ private def surfaceChecks (r : Tests.Runner) : Async Unit := do discard (seen.drain : BaseIO (List Seen)) discard (f.node.unsubscribe topic : BaseIO Bool) r.check "node.surface.unsubscribe-barrier" (← barrier f) - let off ← receive seen - r.check "node.surface.negative-subscription-record" (off.any (fun s => s.rpc == subOptRpc topic false)) + let off ← receiveWhere seen (fun s => s.rpc == subOptRpc topic false) + r.check "node.surface.negative-subscription-record" off.isSome discard <| stream.write (frame (Core.publishRpc (message [21])) ++ frame marker) r.check "node.surface.post-unsubscribe-barrier" ((← receive f.barriers).isSome) r.check "node.surface.unsubscribe-removes-callbacks" ((← (tags.tryRecv : BaseIO (Option Nat))).isNone) From a9dc3696b21ed577d2f2b48c1137294421186cc0 Mon Sep 17 00:00:00 2001 From: Ivan Anishchuk Date: Fri, 6 Nov 2026 16:07:49 +0530 Subject: [PATCH 064/125] feat(host): hand each served stream the context of its connection The core mock follows etheorem/leanp2p#64: StreamHandler.handle takes a StreamContext with the proved remotePeer and the connection's remoteAddr. The session entry keeps the remote address from the upgrade, and serveStream builds the context once per session. --- Tests/Host.lean | 2 +- .../libp2p/host/Libp2p/Host/Mock/Core.lean | 12 ++++++-- packages/libp2p/host/Libp2p/Host/Swarm.lean | 28 +++++++++++-------- packages/libp2p/host/docs/ARCHITECTURE.md | 5 +++- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/Tests/Host.lean b/Tests/Host.lean index 19bf872a..0bb1e43e 100644 --- a/Tests/Host.lean +++ b/Tests/Host.lean @@ -211,7 +211,7 @@ private def livenessChecks (r : Runner) : Async Unit := do /-- A handler that half-closes its stream. -/ private def closer (id : ProtocolId) : StreamHandler where id := id - handle := fun s => s.close + handle := fun _ s => s.close private def handlerChecks (r : Runner) : Async Unit := do let ping : ProtocolId := ⟨"/ipfs/ping/1.0.0"⟩ diff --git a/packages/libp2p/host/Libp2p/Host/Mock/Core.lean b/packages/libp2p/host/Libp2p/Host/Mock/Core.lean index ff0528ad..6678d411 100644 --- a/packages/libp2p/host/Libp2p/Host/Mock/Core.lean +++ b/packages/libp2p/host/Libp2p/Host/Mock/Core.lean @@ -110,12 +110,20 @@ structure Muxer where leftover. -/ «open» : Role → Connection → ByteArray → ExceptT Error Async MuxSession +/-- What the host knows about the connection under a served stream. -/ +structure StreamContext where + /-- The peer id the security handshake proved. -/ + remotePeer : PeerId + /-- The peer's transport address, as the connection reports it. -/ + remoteAddr : Multiaddr + /-- The callback a served stream reaches. -/ structure StreamHandler where /-- The id the handler serves. -/ id : ProtocolId - /-- Serve one stream. -/ - handle : Stream → ExceptT Error Async Unit + /-- Serve one stream of the connection that `StreamContext` + describes. -/ + handle : StreamContext → Stream → ExceptT Error Async Unit /-! ## The policy hooks (module doc, "The policy shapes") -/ diff --git a/packages/libp2p/host/Libp2p/Host/Swarm.lean b/packages/libp2p/host/Libp2p/Host/Swarm.lean index 01a0668f..2294d866 100644 --- a/packages/libp2p/host/Libp2p/Host/Swarm.lean +++ b/packages/libp2p/host/Libp2p/Host/Swarm.lean @@ -115,12 +115,15 @@ structure Config where task, so it answers at once. -/ onFailure : Failure → BaseIO Unit := fun _ => pure () -/-- A session the host serves: the upgraded connection and the -resource scope it holds. -/ +/-- A session the host serves: the upgraded connection, the peer's +transport address, and the resource scope it holds. -/ structure Served where /-- The upgrade's answer: the muxer session, the proved peer, and the join of the session and the secure connection. -/ upgraded : Upgraded + /-- The connection's remote address; every served stream's + `StreamContext` carries it. -/ + remoteAddr : Multiaddr /-- The connection's resource scope; the serve task releases it when the session ends. -/ scope : ConnectionScope @@ -280,8 +283,9 @@ the handler table, and run the handler. A refused admission, a failed negotiation, an id with no handler, and a failed handler each reset the stream, and the session stays (contracts 5 and 6; the package ARCHITECTURE, public surface). -/ -private def serveStream (h : Host) (peer : PeerId) (scope : ConnectionScope) (s : Stream) : - Async Unit := do +private def serveStream (h : Host) (ctx : StreamContext) (scope : ConnectionScope) + (s : Stream) : Async Unit := do + let peer := ctx.remotePeer let some share ← (scope.reserveStream : BaseIO (Option Reservation)) | s.reset let ids := Handlers.ids (← (h.handlers.get : BaseIO Handlers)) @@ -303,7 +307,7 @@ private def serveStream (h : Host) (peer : PeerId) (scope : ConnectionScope) (s | some handler => -- The handler owns the stream from here. Its answer reaches the -- owner of the host; a fault passes on to the session's join. - let r ← try (handler.handle s).run + let r ← try (handler.handle ctx s).run catch e => s.reset share.release @@ -319,13 +323,14 @@ the entry dead (contract 3), join the stream tasks, release the scope, and join the session. -/ private def serveSession (h : Host) (peer : PeerId) (e : Entry Served) : Async Unit := do let u := e.session.upgraded + let ctx : StreamContext := { remotePeer := peer, remoteAddr := e.session.remoteAddr } let streams ← TaskSet.new let acceptFault ← try repeat -- Park: a stream of the peer wakes it, and so does the end of the -- session, by the peer or by `MuxSession.close` (contract 4). let some s ← u.session.accept | break - streams.spawn (h.serveStream peer e.session.scope s) + streams.spawn (h.serveStream ctx e.session.scope s) pure (none : Option IO.Error) catch err => pure (some err) h.sessions.update (SessionTable.markDead peer e.id) @@ -344,11 +349,12 @@ private def serveSession (h : Host) (peer : PeerId) (e : Entry Served) : Async U /-- Commit an upgraded session through the dedup rule (contract 2), and start its serve task. The answer is the peer the host now serves. -/ -private def register (h : Host) (role : Role) (u : Upgraded) (scope : ConnectionScope) : - Async (Except Error PeerId) := do +private def register (h : Host) (role : Role) (u : Upgraded) (remoteAddr : Multiaddr) + (scope : ConnectionScope) : Async (Except Error PeerId) := do let id ← (h.draw : BaseIO Nat) let bornMs ← (h.config.clock.nowMs : BaseIO Nat) - let entry : Entry Served := { id, role, bornMs, live := true, session := { upgraded := u, scope } } + let entry : Entry Served := + { id, role, bornMs, live := true, session := { upgraded := u, remoteAddr, scope } } let decision ← (h.sessions.updateGet (dedup h.localPeer u.remotePeer entry) : BaseIO (Decision Served)) match decision with @@ -401,7 +407,7 @@ private def dialAddr (h : Host) (addr : Multiaddr) (expected : Option PeerId) : match ← h.upgradeConn .initiator conn expected scope with | .error e => return .error e | .ok u => - let r ← h.register .initiator u scope + let r ← h.register .initiator u conn.remoteAddr scope if let .ok p := r then h.addAddrs p [addr] return r @@ -452,7 +458,7 @@ private def inbound (h : Host) (conn : Connection) : Async Unit := do | conn.closeQuietly match ← h.upgradeConn .responder conn none scope with | .ok u => - if let .error e ← h.register .responder u scope then + if let .error e ← h.register .responder u conn.remoteAddr scope then unless e matches .closing do h.report (some u.remotePeer) none e | .error (.gated _) | .error .closing => pure () | .error e => h.report none none e diff --git a/packages/libp2p/host/docs/ARCHITECTURE.md b/packages/libp2p/host/docs/ARCHITECTURE.md index b795cdbf..93db8fb6 100644 --- a/packages/libp2p/host/docs/ARCHITECTURE.md +++ b/packages/libp2p/host/docs/ARCHITECTURE.md @@ -46,7 +46,10 @@ module re-exports the public submodules. enters the upgrade pipeline as a pending connection. - `setHandler` stores one `StreamHandler` under its `ProtocolId` through `OnceRef.update`. The registry is the - `Std.HashMap ProtocolId StreamHandler` of §3.10. A handler failure + `Std.HashMap ProtocolId StreamHandler` of §3.10. The host calls the + handler with the `StreamContext` of the session: the peer the + upgrade proved, and the connection's remote address, which the + session entry keeps from the upgrade. A handler failure closes the stream, never the session, and the error reaches the owner through the spawned task's completion (PRINCIPLES 3.2). This settles the question the core pair left open. From d02d1343a1a4e218c6c1141366948d34c6a49267 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 4 Nov 2026 18:15:42 +0530 Subject: [PATCH 065/125] docs(mux): freeze adapter and test-value contracts --- packages/libp2p/mux/docs/ARCHITECTURE.md | 77 +++++++++++++++--------- packages/libp2p/mux/docs/PLAN.md | 20 ++++-- 2 files changed, 65 insertions(+), 32 deletions(-) diff --git a/packages/libp2p/mux/docs/ARCHITECTURE.md b/packages/libp2p/mux/docs/ARCHITECTURE.md index 4cc8db5e..e458275d 100644 --- a/packages/libp2p/mux/docs/ARCHITECTURE.md +++ b/packages/libp2p/mux/docs/ARCHITECTURE.md @@ -18,47 +18,59 @@ same node code ran under both muxers in-process and over loopback TCP | Module | Holds, and why | |---|---| | `Libp2p/Mux.lean` | the root module; one import line per public submodule | -| `Libp2p/Mux/Constants.lean` | `Mux.yamuxId` (`/yamux/1.0.0`), `Mux.mplexId` (`/mplex/6.7.0`), `Mux.testId`, and the 1 MiB mplex message cap; the named ids of §3.4 and the cap of §5.1, one home per fact (§3.8) | -| `Libp2p/Mux/Yamux.lean` | `Mux.yamux`, the yamux value over the session of `net/yamux` | +| `Libp2p/Mux/Constants.lean` | `Mux.yamuxId` (`/yamux/1.0.0`), `Mux.mplexId` (`/mplex/6.7.0`), `Mux.testId` (`/leanp2p/test-mux/1.0.0`), and `Mux.mplexMessageCap` (1 MiB); the named ids of §3.4 and the cap of §5.1, one home per fact (§3.8) | +| `Libp2p/Mux/Yamux.lean` | `Mux.yamux`, the yamux value over the session of `net/yamux`; the `Role` conversion below | | `Libp2p/Mux/Mplex.lean` | the mplex message codec and `Mux.mplex` (P2, §8); the spike reference is `Conn/Mplex.lean` (§10 row 15, §7 row 10) | -| `Libp2p/Mux/Test.lean` | the `Muxer` test value under the fixed id `Mux.testId` (§3.1) | +| `Libp2p/Mux/Test.lean` | `Mux.test : Muxer`, the test value under `Mux.testId` (§3.1) | All declarations live in the `Libp2p.Mux` namespace, and the root module re-exports the public submodules. -The test value needs no wire format. Its `id` is the fixed -`Mux.testId` of `Constants.lean`, and its `open` builds a session over -any `Connection`, streams and all, with ids handed out from a fixed -sequence. A suite above the transport dials itself over the sim pair -and predicts every id (§3.1, PRINCIPLES 1.4). +The test value needs no wire format. Its name is `Mux.test : Muxer`, +and its home is `Libp2p/Mux/Test.lean`. Its `id` is `Mux.testId`. +`Mux.testId.toString = "/leanp2p/test-mux/1.0.0"`. This +repository-local protocol id is used only by test suites. + +`open` builds a session over any `Connection`. An `initiator` session +issues `1`, then `3`, then `5`, and so on. A `responder` session +issues `2`, then `4`, then `6`, and so on. Each new session starts +again at the first id of its role. The sequences are disjoint, so the +two ends of one sim pair can open at the same time and still receive +different ids. Stream id `0` stays unused. This role partition is a +test-local rule for predictable, collision-free ids. It does not +define Yamux wire behavior. A suite above the transport dials itself +over the sim pair and predicts every id (§3.1, PRINCIPLES 1.4). ## Public surface - `Mux.yamux : Muxer` carries `id := Mux.yamuxId` and the core seam's `open : Role → Connection → ByteArray → ExceptT Core.Error Async - MuxSession` (§4.9; the core pair, public surface). The `Role` picks - the stream-id parity inside the session entry of `net/yamux` (the - yamux pair, public surface). The `ByteArray` argument is the - negotiation leftover; `open` hands it to the session entry as data, - and the session loop turns it into the first `Event` (§4.7). + MuxSession` (§4.9; the core pair, public surface). `Yamux.lean` + converts `Role` as the decision below records, and the session entry + of `net/yamux` then applies that package's parity. The `ByteArray` + argument is the negotiation leftover. `open` hands it to the session + entry as data, and the session loop turns it into the first `Event` + (§4.7). - The error mapping happens once, in `Yamux.lean`: the value lifts the session failure into `Core.Error` with one `Except.mapError` (§3.5). The upgrader and the host match on `Core.Error` alone. -- The test value of `Test.lean`, per the core pair's assignment (the - core pair, public surface, "Test values"). +- `Mux.test : Muxer` is the test value of `Test.lean` (the core pair, + public surface, "Test values"). Its id and its stream-id sequences + are the fixed id and allocation rule above. - `Mux.mplex : Muxer` is the P2 surface: `id := Mux.mplexId`, the pure message codec, and a session that keys streams by - `(id, initiator)`. The declared message length is capped at 1 MiB - (§5.1), with the cap named in `Constants.lean`. + `(id, initiator)`. The declared message length is capped at + `Mux.mplexMessageCap` (§5.1). - `MuxSession.openStream` carries a `ProtocolId`; the decisions below record it (the core pair, public surface). ## Owned contracts and how the package keeps them 1. **The seam carries `id : ProtocolId`, and code compares ids - only** (§3.1, §3.4). The string literals `/yamux/1.0.0` and - `/mplex/6.7.0` appear in `Constants.lean` and in no production - code. The negotiator sees `ProtocolId` values. + only** (§3.1, §3.4). The string literals `/yamux/1.0.0`, + `/mplex/6.7.0`, and `/leanp2p/test-mux/1.0.0` appear in + `Constants.lean` and in no production code. The negotiator sees + `ProtocolId` values. 2. **A seam value holds no protocol decisions** (§3.1). `Mux.yamux` moves the role, the connection, and the leftover into the session entry of `net/yamux`, and maps the error back. The frames are that @@ -80,9 +92,10 @@ and predicts every id (§3.1, PRINCIPLES 1.4). key it names. The spike validated the rule against the go-mplex spec, in-process, and the suite keeps the validation. 6. **The 1 MiB cap is checked before allocation** (§5.1, - PRINCIPLES 2.3). A declared length over the cap is a protocol - error, and it tears the session down. The number lives in - `Constants.lean`, beside the ids. + PRINCIPLES 2.3). `Mux.mplexMessageCap` names that 1 MiB limit. + `Constants.lean` owns it, beside the ids. A declared length over + the cap is a protocol error, and it tears the session down. + `Mux.mplex` rejects that length before allocation. ## Decisions @@ -91,11 +104,19 @@ and predicts every id (§3.1, PRINCIPLES 1.4). consumer can use it, and the id assignment stays a one-line decision at the seam (§9, delta 10). A new muxer lands as a new value, with no edit to the upgrader (PRINCIPLES 1.3). -- **One `open` field with a `Role`, following the core pair's fold.** - The spike split the directions into two seam entries; the core pair - folded them into one field and left the parity rules inside the - implementation packages (the core pair, decisions). The yamux value - passes the `Role` through, and the session entry picks the parity. +- **`Libp2p/Mux/Yamux.lean` converts `Role`, and the Yamux session + applies parity.** The core pair folded the spike's client and server + entries into one `open` field (the core pair, decisions). The + adapter then maps the role. `Core.Role.initiator` selects the Yamux + client side, which opens odd ids, starting at `1` and advancing by + two. `Core.Role.responder` selects the Yamux server side, which + opens even ids, starting at `2` and advancing by two. Yamux reserves + stream id `0` for session messages. An inbound SYN creates a stream + when its id has the remote parity. A SYN whose id has the local + parity creates no stream state. `Libp2p/Mux/Yamux.lean` translates + `Core.Role` to the side selector that the final W4.3 public API + exports. `net/yamux` keeps its own client and server sides and + imports no libp2p module. - **`MuxSession.openStream` takes a `ProtocolId`.** The spike's `MuxHandle.openStream` took the name, mplex put it in its NewStream frame, and the other muxers ignored it (§10 row 12; the core pair, diff --git a/packages/libp2p/mux/docs/PLAN.md b/packages/libp2p/mux/docs/PLAN.md index c76118e0..434af7f0 100644 --- a/packages/libp2p/mux/docs/PLAN.md +++ b/packages/libp2p/mux/docs/PLAN.md @@ -12,13 +12,14 @@ the suite passes under `+RTS -N1`, and the import lint is green. | Unit | Root deps | Deliverable | Done when | |---|---|---|---| | 0 | none | this documentation pair, the first work unit of the package (ARCHITECTURE §2) | written, and consistent with the root docs | -| 1 | W4.3, W5.2 | the yamux `Muxer` value over the W4.3 session: `Mux.yamux`, the constants module, the `Except.mapError` into `Core.Error`, and the fixed-ids test value | the root W5.4 row holds, and the unit 1 milestones below pass | +| 1 | W4.3, W5.2 | the yamux `Muxer` value over the W4.3 session: `Mux.yamux` with the `Role` conversion, the constants module, the `Except.mapError` into `Core.Error`, and `Mux.test` | the root W5.4 row holds, and the unit 1 milestones below pass | | 2 | W2.1, W5.2, unit 1 | the mplex value (P2, §8): the message codec, `Mux.mplex`, the go-compatible `(id, initiator)` identity, the half-close rules, and the 1 MiB cap | the mplex half of the root W5.4 deliverable holds, and the unit 2 milestones below pass | Unit 2's deps are the package's own call. The root graph lets mplex follow W5.4 at any time, and the mplex core needs the seam (W5.2) and no yamux session. Unit 1 comes first because it lays the constants -module and the test-value pattern that unit 2 reuses. +module, including `Mux.mplexMessageCap`, and the test-value pattern +that unit 2 reuses. Unit 2 checks a declared length against that cap. Unit 0 is done. @@ -54,6 +55,17 @@ Unit 0 is done. - The fixed-ids test value serves a suite above, kept honest: a demo registry dials itself over the sim pair and serves one handler through the test session (§3.1). +- The build checks `Mux.test.id = Mux.testId` and + `Mux.testId.toString = "/leanp2p/test-mux/1.0.0"`. +- An initiator session of `Mux.test` allocates `1`, `3`, and `5`. + A responder session allocates `2`, `4`, and `6`. A new session + starts again at the first id of its role. Opens from opposite roles + at the same time receive different ids (the architecture, the test + value). +- `Mux.yamux` maps `initiator` to the Yamux client and `responder` to + the Yamux server (the architecture, the `Role` decision). +- The Yamux wire parity cases belong to the `net/yamux` suite (the + yamux pair, plan). - The suite passes under `+RTS -N1` (§6, PRINCIPLES 5.3). ## Test milestones (unit 2) @@ -64,8 +76,8 @@ Unit 0 is done. map holds `(0, initiator)` beside `(0, non-initiator)` with no cross-delivery. The flag bit routes every inbound message to the key it names. -- The 1 MiB cap: a declared length one over the cap is a protocol - error, rejected before allocation (§5.1, PRINCIPLES 2.3). +- The 1 MiB cap: a declared length one over `Mux.mplexMessageCap` is a + protocol error, rejected before allocation (§5.1, PRINCIPLES 2.3). - A header varint that cannot complete within 9 bytes is a protocol error, not a request for more bytes; a short prefix stays incomplete (§5.2, PRINCIPLES 5.4). From 4c343031ab6df5faeb935da75a42ddc8f23aaa35 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 4 Nov 2026 18:44:03 +0530 Subject: [PATCH 066/125] feat(mux): add protocol constants --- Tests/Main.lean | 2 + Tests/Mux.lean | 34 +++++++++++++++++ lakefile.toml | 1 + packages/libp2p/mux/Libp2p/Mux.lean | 10 +++-- packages/libp2p/mux/Libp2p/Mux/Constants.lean | 37 +++++++++++++++++++ packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean | 26 +++++++++++++ 6 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 Tests/Mux.lean create mode 100644 packages/libp2p/mux/Libp2p/Mux/Constants.lean create mode 100644 packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean diff --git a/Tests/Main.lean b/Tests/Main.lean index b43e7275..2e649466 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -8,6 +8,7 @@ import Tests.Snappy import Tests.Multiformats import Tests.MultiaddrWire import Tests.Streams +import Tests.Mux import Tests.Backend import Tests.HazmatPins import Tests.BackendX509 @@ -41,6 +42,7 @@ def main : IO UInt32 := do Tests.Multiformats.run r Tests.MultiaddrWire.run r Tests.Streams.run r + Tests.Mux.run r Tests.Backend.run r Tests.HazmatPins.run r Tests.BackendX509.run r diff --git a/Tests/Mux.lean b/Tests/Mux.lean new file mode 100644 index 00000000..a60fe98e --- /dev/null +++ b/Tests/Mux.lean @@ -0,0 +1,34 @@ +import Tests.Support +import Libp2p.Mux + +/-! +# Mux suite (unit W5.4) + +The constant checks this slice can run: the three protocol ids and +the named mplex cap (`packages/libp2p/mux/docs/PLAN.md`, the id +milestone, and `packages/libp2p/mux/docs/ARCHITECTURE.md`, the +constants module). +-/ + +set_option autoImplicit false + +namespace Tests + +open Libp2p.Mux + +def Mux.run (r : Runner) : IO Unit := do + r.section "mux constants" "packages/libp2p/mux/docs/ARCHITECTURE.md" + r.check "yamuxId.toString is /yamux/1.0.0" + (yamuxId.toString == "/yamux/1.0.0") + r.check "mplexId.toString is /mplex/6.7.0" + (mplexId.toString == "/mplex/6.7.0") + r.check "testId.toString is /leanp2p/test-mux/1.0.0" + (testId.toString == "/leanp2p/test-mux/1.0.0") + r.check "mplexMessageCap is 1 MiB" + (mplexMessageCap == 1024 * 1024) + r.check "the three protocol ids are pairwise distinct" + (yamuxId.toString != mplexId.toString + && yamuxId.toString != testId.toString + && mplexId.toString != testId.toString) + +end Tests diff --git a/lakefile.toml b/lakefile.toml index 507a62f1..3dbfdd5d 100644 --- a/lakefile.toml +++ b/lakefile.toml @@ -161,6 +161,7 @@ roots = ["Libp2p.Security"] name = "Libp2pMux" srcDir = "packages/libp2p/mux" roots = ["Libp2p.Mux"] +globs = ["Libp2p.Mux.*"] [[lean_lib]] name = "Libp2pNegotiate" diff --git a/packages/libp2p/mux/Libp2p/Mux.lean b/packages/libp2p/mux/Libp2p/Mux.lean index a5878680..78275d37 100644 --- a/packages/libp2p/mux/Libp2p/Mux.lean +++ b/packages/libp2p/mux/Libp2p/Mux.lean @@ -1,7 +1,11 @@ +import Libp2p.Mux.Constants + /-! # Libp2p.Mux -The mux subpackage: the `Muxer` values over the muxer sessions (ARCHITECTURE §4.9). Scaffold root. -The modules arrive with unit W5.4 of `docs/PLAN.md`. The package docs -live under `docs/` beside this file. +The mux subpackage: the `Muxer` values over the muxer sessions +(ARCHITECTURE §4.9). This root re-exports the public submodules. +`Constants.lean` is the first of them. The `Muxer` values arrive with +the later units of W5.4 (`docs/PLAN.md`). The package docs live under +`docs/` beside this file. -/ diff --git a/packages/libp2p/mux/Libp2p/Mux/Constants.lean b/packages/libp2p/mux/Libp2p/Mux/Constants.lean new file mode 100644 index 00000000..ec23874f --- /dev/null +++ b/packages/libp2p/mux/Libp2p/Mux/Constants.lean @@ -0,0 +1,37 @@ +import Libp2p.Mux.Mock.Core + +/-! +# The constants of `libp2p/mux` + +Each fact has one home (ARCHITECTURE §3.8). This module holds the +three protocol ids and the mplex length cap. The production +definitions of the ids live only here (§3.4). + +`ProtocolId` is the temporary copy in `Libp2p.Mux.Mock.Core`. W5.2 +has not merged, so the real `Libp2p.Core.ProtocolId` is not on this +branch. When W5.2 lands, this import becomes `Libp2p.Core`. The four +public names stay. +-/ + +set_option autoImplicit false + +namespace Libp2p.Mux + +open Mock.Core + +/-- The yamux protocol id. Peers negotiate `/yamux/1.0.0` on the wire. -/ +def yamuxId : ProtocolId := { toString := "/yamux/1.0.0" } + +/-- The mplex protocol id. Peers negotiate `/mplex/6.7.0` on the wire. -/ +def mplexId : ProtocolId := { toString := "/mplex/6.7.0" } + +/-- The repository-local protocol id for test suites: +`/leanp2p/test-mux/1.0.0`. -/ +def testId : ProtocolId := { toString := "/leanp2p/test-mux/1.0.0" } + +/-- The mplex declared-length cap: 1 MiB, spelled `1024 * 1024`. +The future P2 mplex implementation checks a declared length against +this cap before allocation. This module names the cap. -/ +def mplexMessageCap : Nat := 1024 * 1024 + +end Libp2p.Mux diff --git a/packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean b/packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean new file mode 100644 index 00000000..c199d5fb --- /dev/null +++ b/packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean @@ -0,0 +1,26 @@ +/-! +# Mock: the `ProtocolId` surface of `libp2p/core` + +W5.2 (`libp2p/core`) is not yet merged, so this package builds against +a temporary copy of the documented `ProtocolId` surface (RULES.md, +"Working ahead of the tree"; the core pair, public surface). The copy +is `structure ProtocolId where toString : String`. It derives +`DecidableEq` and `Hashable` from that surface, and `Repr` from the +standard identifier classes (ARCHITECTURE §3.2, §3.4). + +The module holds no protocol logic. It adds no trusted declarations. +When W5.2 lands, delete this module. `Constants.lean` then imports +`Libp2p.Core` in its place. +-/ + +set_option autoImplicit false + +namespace Libp2p.Mux.Mock.Core + +/-- A protocol id: an open wrapped string (ARCHITECTURE §3.4). Peers +define ids, so the set stays open. -/ +structure ProtocolId where + toString : String + deriving DecidableEq, Repr, Hashable + +end Libp2p.Mux.Mock.Core From f4f93f7bc6857e389c92a48a82c2785496dd7460 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 10:53:16 +0530 Subject: [PATCH 067/125] docs(mux): define the private test-session protocol --- packages/libp2p/mux/docs/ARCHITECTURE.md | 366 ++++++++++++++++++++--- packages/libp2p/mux/docs/PLAN.md | 70 ++++- 2 files changed, 387 insertions(+), 49 deletions(-) diff --git a/packages/libp2p/mux/docs/ARCHITECTURE.md b/packages/libp2p/mux/docs/ARCHITECTURE.md index e458275d..1c8917eb 100644 --- a/packages/libp2p/mux/docs/ARCHITECTURE.md +++ b/packages/libp2p/mux/docs/ARCHITECTURE.md @@ -17,29 +17,20 @@ same node code ran under both muxers in-process and over loopback TCP | Module | Holds, and why | |---|---| -| `Libp2p/Mux.lean` | the root module; one import line per public submodule | +| `Libp2p/Mux.lean` | the root module; one import line per public submodule. It does not re-export the mock or the private test protocol | | `Libp2p/Mux/Constants.lean` | `Mux.yamuxId` (`/yamux/1.0.0`), `Mux.mplexId` (`/mplex/6.7.0`), `Mux.testId` (`/leanp2p/test-mux/1.0.0`), and `Mux.mplexMessageCap` (1 MiB); the named ids of §3.4 and the cap of §5.1, one home per fact (§3.8) | -| `Libp2p/Mux/Yamux.lean` | `Mux.yamux`, the yamux value over the session of `net/yamux`; the `Role` conversion below | -| `Libp2p/Mux/Mplex.lean` | the mplex message codec and `Mux.mplex` (P2, §8); the spike reference is `Conn/Mplex.lean` (§10 row 15, §7 row 10) | -| `Libp2p/Mux/Test.lean` | `Mux.test : Muxer`, the test value under `Mux.testId` (§3.1) | +| `Libp2p/Mux/Yamux.lean` | `Mux.yamux`, the yamux value over the session of `net/yamux`; the `Role` conversion below. Planned | +| `Libp2p/Mux/Mplex.lean` | the mplex message codec and `Mux.mplex` (P2, §8); the spike reference is `Conn/Mplex.lean` (§10 row 15, §7 row 10). Planned | +| `Libp2p/Mux/Test.lean` | `Mux.test : Muxer`, the independent test session under `Mux.testId`. Planned. Private frame and session state stay behind this value | +| `Libp2p/Mux/Mock/Core.lean` | the temporary `ProtocolId` used until W5.2. Present. Isolated, and deleted when `Libp2p.Core` lands | All declarations live in the `Libp2p.Mux` namespace, and the root module re-exports the public submodules. -The test value needs no wire format. Its name is `Mux.test : Muxer`, -and its home is `Libp2p/Mux/Test.lean`. Its `id` is `Mux.testId`. -`Mux.testId.toString = "/leanp2p/test-mux/1.0.0"`. This -repository-local protocol id is used only by test suites. - -`open` builds a session over any `Connection`. An `initiator` session -issues `1`, then `3`, then `5`, and so on. A `responder` session -issues `2`, then `4`, then `6`, and so on. Each new session starts -again at the first id of its role. The sequences are disjoint, so the -two ends of one sim pair can open at the same time and still receive -different ids. Stream id `0` stays unused. This role partition is a -test-local rule for predictable, collision-free ids. It does not -define Yamux wire behavior. A suite above the transport dials itself -over the sim pair and predicts every id (§3.1, PRINCIPLES 1.4). +`Libp2p/Mux.lean`, `Libp2p/Mux/Constants.lean`, and +`Libp2p/Mux/Mock/Core.lean` are in the tree. `Yamux.lean`, +`Mplex.lean`, and `Test.lean` are the remaining modules of this +package. ## Public surface @@ -54,9 +45,8 @@ over the sim pair and predicts every id (§3.1, PRINCIPLES 1.4). - The error mapping happens once, in `Yamux.lean`: the value lifts the session failure into `Core.Error` with one `Except.mapError` (§3.5). The upgrader and the host match on `Core.Error` alone. -- `Mux.test : Muxer` is the test value of `Test.lean` (the core pair, - public surface, "Test values"). Its id and its stream-id sequences - are the fixed id and allocation rule above. +- `Mux.test : Muxer` is the test session below. Callers use the + `Muxer` and `MuxSession` seams. Its id is `Mux.testId`. - `Mux.mplex : Muxer` is the P2 surface: `id := Mux.mplexId`, the pure message codec, and a session that keys streams by `(id, initiator)`. The declared message length is capped at @@ -64,6 +54,309 @@ over the sim pair and predicts every id (§3.1, PRINCIPLES 1.4). - `MuxSession.openStream` carries a `ProtocolId`; the decisions below record it (the core pair, public surface). +## Test session + +### An independent test muxer + +`Mux.test` is an independent deterministic test muxer. It sends a +private repository-only frame over `Connection`. The frame exists so +repository tests can predict every byte. It is not a standardized or +interoperable libp2p protocol. `Mux.test` is not an alias over Yamux. +Its id is `Mux.testId`, and +`Mux.testId.toString = "/leanp2p/test-mux/1.0.0"`. + +### Boundary + +The public value is `Mux.test : Muxer`. A caller sees the W5.2 +`Muxer` and `MuxSession` seams. Frame types, parser state, stream ids, +terminal state, admission gates, and scheduling state are private. +They do not appear in public Lean declarations. + +Temporary W5.2 types live only under `Libp2p/Mux/Mock/`. +`Mock/Core.lean` holds `ProtocolId`. The package deletes that mock +when `Libp2p.Core` lands. The private test protocol is not part of +the mock. + +### Private frame + +The header is nine bytes. The payload follows it. + +| Field | Width | Encoding | Meaning | +|---|---:|---|---| +| kind | 1 byte | `1=open`, `2=data`, `3=fin`, `4=reset` | whole discriminator | +| stream id | 4 bytes | unsigned big-endian | nonzero stream id | +| length | 4 bytes | unsigned big-endian | payload byte count | +| payload | `length` bytes | raw bytes | data frames only | + +An open, a fin, and a reset require length zero. A data frame requires +a length in `1 … testPayloadCap`. The frame has no version, flags, +acknowledgement, session-close, or protocol-id field. Closing the +session closes the underlying `Connection`. `openStream` accepts a +`ProtocolId`, and this muxer does not encode it. + +### Policy constants + +These values are repository-test policy. They are not standardized +protocol values. `testPayloadCap` is not `Mux.mplexMessageCap`. + +| Constant | Value | Reason | +|---|---|---| +| `testPayloadCap` | `16 * 1024` | Caps one data payload of this test session | +| Live streams | 32 | Caps the streams the session holds at once | +| Accept backlog | 16 | Caps inbound streams waiting for `accept` | +| Inbound frame credit | 4 | Caps unread frames queued on one live stream | +| Event channel | 32 | Caps events waiting for the one owner loop | +| Admitted writes | 4 | Caps writes the session has admitted on one stream | +| Parser frame budget | 8 | Caps the complete frames one turn takes from the parser | + +### Stream ids + +An initiator session issues odd ids, starting at `1` and advancing by +two. A responder session issues even ids, starting at `2` and advancing +by two. The first three initiator ids are `1`, `3`, and `5`. The first +three responder ids are `2`, `4`, and `6`. A new session starts again +at the first id of its role. Zero stays unused. + +`nextLocal` and `nextRemote` are non-wrapping `Nat` high-water values. +A remote open matches the sequence only when its id equals +`nextRemote` and its parity is the remote parity. A skipped, future, +wrong-parity, zero, or duplicate open is a protocol error. The last +odd id `2^32 - 1` and the last even id `2^32 - 2` may each be issued +once. After `nextLocal` or `nextRemote` exceeds `UInt32.max`, the +session issues no new id and accepts no new id. + +When a remote open matches that next id, the session adds two to +`nextRemote`. If the accept backlog and the live-stream cap both have +room, the stream enters the live table. If either cap is full, the +session sends reset and does not add a live stream. That id is then +retired. The new value of `nextRemote` remains the next id a remote +open may match. + +A local open that is waiting for admission does not read or advance +`nextLocal`. It exposes no id. The session takes the id only when +admission succeeds. If the session shuts down while the open waits, +the open fails and still takes no id. + +The Yamux adapter decision below records the same odd and even +partition for Yamux. The test session uses the partition so the two +ends of one pair can open at the same time and still receive different +ids. The test session does not implement Yamux. + +### Stream lifetime + +The live table holds streams that are open now. It holds no tombstone +for an id that has gone. + +A local reset or a received reset aborts both directions for new +operations. The id retires at once. No new inbound data is admitted. +No new write succeeds. The session does not echo a received reset. +Admitted writes complete with `Streams.Error.reset`. Bytes already +accepted into the inbox stay readable. After those bytes drain, one +read returns `Streams.Error.reset`. The following read returns +`.ok none`. The stream handle may still drain that inbox after the +id has retired. + +A received fin closes the remote write direction. The inbox closes +at that fin. Bytes already queued stay readable. The application +reads them, and the next read answers `.ok none`. This holds whether +or not the local write direction is still open. + +Local half-close closes the local write direction only after the +flush in Scheduling and writes. The id leaves the live table only +when both write directions are closed. The inbox outlives that +removal, so the drain and the EOF stay observable. + +A second fin for a direction that is already closed is ignored. Data +that arrives after a received fin is ignored. The session stays up. +Data for a read direction that is still open is delivered. + +An id with its opener's parity, below that opener's high-water value, +and absent from the live table, is retired. `nextLocal` is the local +high-water value. `nextRemote` is the remote high-water value. A +data, fin, or reset frame for a retired id is ignored. An open for a +retired id is a duplicate open and a protocol error. A data, fin, or +reset frame for an id that was never opened is a protocol error. An +id is never reused. + +### Parser and codec + +The header codec and the frame codec cover values their types can +construct. The parser performs the semantic checks. It checks a +declared payload length against `testPayloadCap` before it allocates +the body. An unknown kind, an id of zero, a control length other than +zero, a data length of zero, or a data length above the cap fails at +that check. + +A partial header or a partial body waits while the connection stays +open. A connection end during a partial header or body is a protocol +error. A connection end with an empty parser is the connection-end +cause. `Connection.read = none` is a connection end. The seam does not +distinguish a peer FIN, a peer RST, and a local close. + +### Ownership + +One reader task and one owner loop serve the session. The loop is the +only task that steps the pure session state, and it is the only task +that writes a frame. Callers post events on the bounded event channel. +The loop is the only caller of `Outcome.finish`. `TaskSet.joinAll` +returns before `Outcome.answer`. The reader and the loop catch an +`IO.Error` from the connection and return. `joinAll` therefore does +not throw that error a second time. + +The owner does not cancel either task. Shutdown closes the connection +and the event channel, and that close wakes a parked task. Stream +inboxes and completion tags wake parked callers on reset and during +teardown. The session retains a caller's write array only while that +admitted write is still pending. + +### Terminal causes + +The private terminal causes are running, owner close, connection end, +an IO fault that carries its `IO.Error`, and a protocol failure that +carries its `Core.Error`. + +The first atomic claim out of running wins. A later cause leaves the +winner in place. Only a winning owner close calls +`Outcome.markClosing`. No claimant writes fault state into `Outcome`. +Teardown records a winning IO fault once, through `Outcome.recordFault`, +before `Outcome.finish`. + +`Core.Error` is the seam error named in the root architecture (§4.9). +This package does not declare it. + +Owner close and connection end call `Outcome.finish (.ok ())`. +No fault is recorded. `Outcome.answer` returns that success. + +An IO fault calls `Outcome.finish (.ok ())` after the one +`recordFault`. `finish (.ok ())` stores a successful protocol result. +That result carries no protocol error. `Outcome.answer` throws the +previously recorded `IO.Error`. + +When a protocol failure wins, teardown calls +`Outcome.finish (.error protocolError)`. `Outcome.answer` returns +that protocol error. A later cause does not alter the winner, and +teardown does not record an IO fault for it. + +Teardown that still sees running is an internal invariant failure. +It throws. It does not call `Outcome.finish`. + +An empty parser selects connection end. A partial frame selects a +protocol failure. That split is fixed under Parser and codec. + +Teardown releases the waiters. A pending open completes with the +closed arm of `Core.Error` on owner close, connection end, and an IO +fault. It completes with the stored `Core.Error` on a protocol +failure. A stream write completes with `Streams.Error.reset` when +that stream is reset, and with `Streams.Error.closed` when the session +ends the write for any other cause. Scheduling and writes records the +same closed result for a write that `Connection.write` itself fails. +Teardown closes every live inbox and every inbox still queued for +`accept`, so a parked read wakes. + +### Scheduling and writes + +A turn processes at most one available event. It then takes at most +the parser frame budget of complete frames, then one outbound chunk. +One chunk is at most `testPayloadCap` bytes. A large write cannot +hold the connection for the rest of its bytes. + +A complete frame already in the parser, or an outbound chunk that is +ready, keeps the loop runnable when no new event is queued. The loop +parks only when a queued event, a complete buffered frame, and a +ready outbound chunk are all absent. + +Streams that have admitted bytes take turns. Writes on one stream +keep their admission order. One logical write is split into private +chunks. It resolves only after its last `Connection.write` returns. +An empty write on an open stream succeeds at once and registers no +completion. + +A stream admits at most the admitted-write cap of writes. A further +writer waits, and the call stays open. A local half-close stops +admission. Writes already admitted flush. The session sends fin +after that flush returns. A flush that throws follows the +`Connection.write` rule below, and no fin is sent. A write that is +still waiting for admission completes with `Streams.Error.closed`. +It does not stay parked. A write that arrives after the half-close, +including an empty write, completes with `Streams.Error.closed`. + +`Connection.write` throws `IO.Error` and tears the connection down. +The logical `Stream.write` has no arm for that error. The loop +resolves that write, and every write still waiting, with +`Streams.Error.closed`. `Connection.write` does not say whether the +peer received the bytes. When the IO fault won the terminal claim, +`Outcome.answer` throws the stored `IO.Error`. When owner close +already won, `markClosing` has ended the outcome, the fault is not +recorded, and `answer` returns success. The stream write is +`Streams.Error.closed` in both cases. + +A local reset sends one reset frame. Admitted writes, and writes +still waiting for admission, complete with `Streams.Error.reset`. +Stream lifetime records a received reset and the moment the id +leaves the live table. + +### Accept and backpressure + +The accept backlog queues at most its cap of inbound streams. Each +live stream queues at most the inbound frame credit of unread frames. +The owner loop does not park on a stream inbox. + +A full inbox resets that live stream only. A remote open that finds +the accept backlog or the live-stream cap full follows the refusal +rule in Stream ids. The session stays up. A local open past the +live-stream cap waits. Stream ids records that this wait takes no id. + +After terminal shutdown has linearized, `accept` returns no newly +accepted stream. A stream accepted immediately before that point may +still be returned. The caller then sees its inbox close. + +### Resources + +One byte subtotal is calculable. It counts these copies. Each copy +is counted where it currently sits: + +- copied inbound slices in the event channel, each at most + `testPayloadCap` bytes; +- parser-owned bytes: one incomplete frame, the slice being appended, + and one extra slice; +- one outbound frame: its header and at most `testPayloadCap` payload + bytes; +- inbox payloads: at most the live-stream cap times the inbound frame + credit, each at most `testPayloadCap` bytes. + +The session states no bound on process memory. The subtotal above is +the only byte total the session accounts. + +The event channel bounds how many events wait. It does not bound the +bytes of every event. A copied inbound slice in the channel is inside +the subtotal. A write event retains the caller's array. The count of +admitted writes is at most the live-stream cap times the +admitted-write cap. The length of each retained array is the +caller's, and it has no session cap. + +`Connection.read` returns one array of any positive length. The reader +keeps that array while it copies slices from it. That array is not +part of the subtotal. + +A caller parked outside admission holds its own array until the call +returns. The count of those callers has no session cap. Maps, +channels, tasks, objects, and allocator overhead sit outside the +subtotal. + +### Errors + +A malformed private frame and an invalid stream sequence are protocol +errors of this package. The session maps them to the protocol arm of +`Core.Error` (§3.5, §4.9). An open that cannot take an id receives +that same error, and the session stays up. + +A stream reset completes with `Streams.Error.reset`. A stream +operation after the stream or the session has closed completes with +`Streams.Error.closed`. Terminal causes records which of success, a +thrown `IO.Error`, or a returned `Core.Error` the session owner +observes, and how pending opens are released. A full admission cap +leaves the session open. + ## Owned contracts and how the package keeps them 1. **The seam carries `id : ProtocolId`, and code compares ids @@ -78,13 +371,14 @@ over the sim pair and predicts every id (§3.1, PRINCIPLES 1.4). 3. **Lifecycle is part of the seam** (§3.1, §4.1). The `MuxSession` each value returns exposes `close : IO Unit` and the session completion, and the owner closes and then joins. -4. **Half-close follows the spec exactly, in both muxers** (§5.3, +4. **Half-close follows the spec exactly, in yamux and mplex** (§5.3, PRINCIPLES 2.2). Close ends the write direction of the closer, and the read direction keeps working, so the response after our FIN still arrives. Reset aborts both directions. The yamux session carries its own half-close suite in `net/yamux` (the yamux pair, plan); the mplex suite of the plan pins the same sentence, with the - case the spike's old test missed. + case the spike's old test missed. The test session's half-close is + under scheduling and writes. 5. **The mplex identity rule is the go-compatibility contract** (§4.9). Both sides number their streams from zero, so an id alone names no stream, and the session keys every stream by @@ -122,11 +416,12 @@ over the sim pair and predicts every id (§3.1, PRINCIPLES 1.4). frame, and the other muxers ignored it (§10 row 12; the core pair, plan). §3.4 calls a bare `String` a smell, and every real caller passes a protocol id, so the seam carries the id. The seam keeps one - signature, so `Mux.yamux` and `Mux.mplex` stay interchangeable at - every call site. The yamux value drops the id before it calls the - session of `net/yamux`, whose SYN frame has no field for it. The - mplex wire label is the id's string form: the frame carries the name - as its data, a label the spec gives no semantic value. + signature, so `Mux.yamux`, `Mux.mplex`, and `Mux.test` stay + interchangeable at every call site. The yamux value drops the id + before it calls the session of `net/yamux`, whose SYN frame has no + field for it. The test session drops the id. The mplex wire + label is the id's string form: the frame carries the name as its + data, a label the spec gives no semantic value. - **mplex is P2 and stays in this package's plan** (§8). The eth2 spec removed it, so consensus never waits, and older go-libp2p peers and other stacks still speak it, so the interop surface of §4.9 stays @@ -140,17 +435,18 @@ over the sim pair and predicts every id (§3.1, PRINCIPLES 1.4). `libp2p/protocols`, or `libp2p/identity`. A muxer knows no `PeerId`, and the `MuxSession` surface names only streams. - From the net group, `net/yamux` and `net/streams` (§2). The yamux - value imports the session entry of `net/yamux`; the mplex and test - sessions import `Connection` and the `Stream` seam. `core` defines - no re-export for `Connection`, so these modules import `net/streams` + value imports the session entry of `net/yamux`. The mplex session + and the test session import `Connection` and the `Stream` seam, and + the test session does not import `net/yamux`. `core` defines no + re-export for `Connection`, so these modules import `net/streams` directly and record the import here, per the core pair's rule (the security pair records the same import). - From the codec group, `codec/varint` only: the mplex header and length are uvarints, and §2's group chain puts `codec` to the right of `libp2p`. -- From `base`, what the mplex loop needs: `Async`, `Chan`, `TaskSet`, - and `OnceRef` from `base/conc`, and the byte model, `DecodeError`, and - the `Codec`/`LawfulCodec` classes from `base/bytes`, the sets the - yamux pair lists for the same job. +- From `base`, what the mplex and test loops need: `Async`, `Chan`, + `TaskSet`, and `OnceRef` from `base/conc`, and the byte model, + `DecodeError`, and the `Codec`/`LawfulCodec` classes from + `base/bytes`, the sets the yamux pair lists for the same job. - No crypto. The muxer runs after security and moves plaintext, and no field names a key, a hash, or a cipher. diff --git a/packages/libp2p/mux/docs/PLAN.md b/packages/libp2p/mux/docs/PLAN.md index 434af7f0..2502c22b 100644 --- a/packages/libp2p/mux/docs/PLAN.md +++ b/packages/libp2p/mux/docs/PLAN.md @@ -12,14 +12,14 @@ the suite passes under `+RTS -N1`, and the import lint is green. | Unit | Root deps | Deliverable | Done when | |---|---|---|---| | 0 | none | this documentation pair, the first work unit of the package (ARCHITECTURE §2) | written, and consistent with the root docs | -| 1 | W4.3, W5.2 | the yamux `Muxer` value over the W4.3 session: `Mux.yamux` with the `Role` conversion, the constants module, the `Except.mapError` into `Core.Error`, and `Mux.test` | the root W5.4 row holds, and the unit 1 milestones below pass | +| 1 | W4.3, W5.2 | the yamux `Muxer` value over the W4.3 session: `Mux.yamux` with the `Role` conversion, the constants module, the `Except.mapError` into `Core.Error`, and the independent `Mux.test` session, with its private frame codec and incremental parser. The temporary W5.2 seam mock stays isolated under `Libp2p/Mux/Mock/` and is replaced by `Libp2p.Core` when that package lands | the root W5.4 row holds, and the unit 1 milestones below pass | | 2 | W2.1, W5.2, unit 1 | the mplex value (P2, §8): the message codec, `Mux.mplex`, the go-compatible `(id, initiator)` identity, the half-close rules, and the 1 MiB cap | the mplex half of the root W5.4 deliverable holds, and the unit 2 milestones below pass | Unit 2's deps are the package's own call. The root graph lets mplex follow W5.4 at any time, and the mplex core needs the seam (W5.2) and no yamux session. Unit 1 comes first because it lays the constants -module, including `Mux.mplexMessageCap`, and the test-value pattern -that unit 2 reuses. Unit 2 checks a declared length against that cap. +module, including `Mux.mplexMessageCap`. Unit 2 checks a declared +length against that cap. Unit 0 is done. @@ -52,21 +52,63 @@ Unit 0 is done. - The error path maps once: a hostile frame tears the session down, and the completion reports `Core.Error.protocol` through the single `Except.mapError` (§3.5). -- The fixed-ids test value serves a suite above, kept honest: a demo - registry dials itself over the sim pair and serves one handler - through the test session (§3.1). -- The build checks `Mux.test.id = Mux.testId` and - `Mux.testId.toString = "/leanp2p/test-mux/1.0.0"`. -- An initiator session of `Mux.test` allocates `1`, `3`, and `5`. - A responder session allocates `2`, `4`, and `6`. A new session - starts again at the first id of its role. Opens from opposite roles - at the same time receive different ids (the architecture, the test - value). +- The private frame codec and the incremental parser (the + architecture, private frame; parser and codec). +- `Mux.test` is independent of Yamux. The build checks + `Mux.test.id = Mux.testId` and + `Mux.testId.toString = "/leanp2p/test-mux/1.0.0"` (the architecture, + an independent test muxer). +- The temporary W5.2 seam mock stays isolated, and the package + replaces it with `Libp2p.Core` when W5.2 lands (the architecture, + boundary). +- Pure state-transition tests, then deterministic two-ended sim tests + (PRINCIPLES 1.4). A demo registry dials itself over the sim pair and + serves one handler through the test session (§3.1). +- Simultaneous opens in both roles, and several concurrent streams + (the architecture, stream ids). +- Ordered writes, multi-frame writes, and an empty write (the + architecture, scheduling and writes). +- Half-close and reset (the architecture, scheduling and writes; + stream lifetime). +- Both directions close, the id leaves the live table, and a read + still drains buffered bytes to EOF (the architecture, stream + lifetime). +- A reset retires the id at once (the architecture, stream lifetime). +- Reset ordering on one stream: queue bytes, reset, read those bytes, + then `.error .reset` once, then `.ok none`. New writes fail, and + the id is already retired (the architecture, stream lifetime). +- Data after a received fin, and a duplicate fin, are ignored on a + live stream (the architecture, stream lifetime). +- A remote open refused for capacity consumes that expected id, and + the next remote id is accepted (the architecture, stream ids). +- A local open that waits takes no id (the architecture, stream ids). +- A blocked read, write, open, and `accept` each wake on shutdown + (the architecture, ownership; terminal causes). +- A writer waiting outside admission wakes on half-close and on + shutdown, and completes with the closed stream result (the + architecture, scheduling and writes). +- Malformed header and body cases. Partial input followed by more + bytes. Partial input followed by connection end (the architecture, + parser and codec). +- Id parity, strict sequencing, the final valid ids, and exhaustion + (the architecture, stream ids). +- Accept and inbox backpressure (the architecture, accept and + backpressure). +- Owner-close, connection-end, fault, and protocol terminal races + (the architecture, terminal causes). +- Fairness under a large write and under dense inbound frames (the + architecture, scheduling and writes). +- Buffered frames and a multi-frame write make progress while no new + event is queued (the architecture, scheduling and writes). +- Resource checks separate the copied-byte subtotal from retained + caller arrays, the `Connection.read` array, and parked callers (the + architecture, resources). - `Mux.yamux` maps `initiator` to the Yamux client and `responder` to the Yamux server (the architecture, the `Role` decision). - The Yamux wire parity cases belong to the `net/yamux` suite (the yamux pair, plan). -- The suite passes under `+RTS -N1` (§6, PRINCIPLES 5.3). +- The ordinary suite and the `+RTS -N1` suite both pass (§6, + PRINCIPLES 5.3). ## Test milestones (unit 2) From ef207660df1ac29b34f62bb1dcc4ee5e168f32ca Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 12:24:29 +0530 Subject: [PATCH 068/125] feat(mux): add private test framing --- Tests/Mux.lean | 399 +++++++- packages/libp2p/mux/Libp2p/Mux/Test/Wire.lean | 957 ++++++++++++++++++ packages/libp2p/mux/docs/ARCHITECTURE.md | 34 +- 3 files changed, 1374 insertions(+), 16 deletions(-) create mode 100644 packages/libp2p/mux/Libp2p/Mux/Test/Wire.lean diff --git a/Tests/Mux.lean b/Tests/Mux.lean index a60fe98e..aaf42ab1 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -1,17 +1,407 @@ import Tests.Support import Libp2p.Mux +import Libp2p.Mux.Test.Wire /-! # Mux suite (unit W5.4) -The constant checks this slice can run: the three protocol ids and -the named mplex cap (`packages/libp2p/mux/docs/PLAN.md`, the id -milestone, and `packages/libp2p/mux/docs/ARCHITECTURE.md`, the -constants module). +The constant checks, and the private test-wire codec and parser +(`packages/libp2p/mux/docs/ARCHITECTURE.md`). -/ set_option autoImplicit false +namespace Tests.Mux.Wire + +open Bytes +open Libp2p.Mux.Test.Wire + +/-- The largest stream id on the 32-bit field. -/ +private def idMax : UInt32 := 0xFFFFFFFF + +private theorem id1_ok : (1 : UInt32) ≠ 0 := by decide +private theorem id2_ok : (2 : UInt32) ≠ 0 := by decide +private theorem idMax_ok : idMax ≠ 0 := by decide + +private def twoBytes : List Nat := [9, 8] +private def fourBytes : List Nat := [1, 2, 3, 4] +private def capPayload : List Nat := List.replicate testPayloadCap 1 + +private theorem twoBytes_lt : ∀ x ∈ twoBytes, x < 256 := by decide +private theorem fourBytes_lt : ∀ x ∈ fourBytes, x < 256 := by decide +private theorem oneByte_lt : ∀ x ∈ [7], x < 256 := by decide + +private theorem twoBytes_ok : lengthOk Kind.data twoBytes.length = true := by decide +private theorem fourBytes_ok : lengthOk Kind.data fourBytes.length = true := by decide +private theorem oneByte_ok : lengthOk Kind.data [7].length = true := by decide + +private theorem capPayload_lt : ∀ x ∈ capPayload, x < 256 := by + intro x hx + simp [capPayload, List.mem_replicate] at hx + omega + +private theorem capPayload_ok : lengthOk Kind.data capPayload.length = true := by + simpa [capPayload, List.length_replicate] using + (by decide : lengthOk Kind.data testPayloadCap = true) + +private def sampleOpen : Frame := + Frame.ofControl Kind.open 1 (by decide) id1_ok + +private def sampleData : Frame := + Frame.ofData 2 twoBytes id2_ok twoBytes_lt twoBytes_ok + +private def sampleFin : Frame := + Frame.ofControl Kind.fin idMax (by decide) idMax_ok + +private def sampleReset : Frame := + Frame.ofControl Kind.reset 1 (by decide) id1_ok + +private def sampleMaxData : Frame := + Frame.ofData idMax [7] idMax_ok oneByte_lt oneByte_ok + +private def sampleCap : Frame := + Frame.ofData 1 capPayload id1_ok capPayload_lt capPayload_ok + +private def sampleFour : Frame := + Frame.ofData 1 fourBytes id1_ok fourBytes_lt fourBytes_ok + +private def frames : List Frame := + [sampleOpen, sampleData, sampleFin, sampleReset, sampleMaxData, sampleCap, sampleFour] + +private def overCapLength : UInt32 := + UInt32.ofNatLT (testPayloadCap + 1) (by decide) + +private def overCapHeader : Header where + kind := Kind.data + streamId := 1 + length := overCapLength + +private def zeroHeader : Header where + kind := Kind.open + streamId := 0 + length := 0 + +private def headers : List Header := [ + zeroHeader, + { kind := Kind.open, streamId := 1, length := 0 }, + { kind := Kind.data, streamId := 2, length := 0 }, + overCapHeader, + { kind := Kind.fin, streamId := idMax, length := 0 }, + { kind := Kind.reset, streamId := 0x01020304, length := 0x01020304 }, +] + +/-- The production header decoder in the harness shape. -/ +private def headerProduction (b : ByteArray) : Except DecodeError (Header × List Nat) := + (Header.decodeB? b).map fun (h, rest) => (h, toNatList rest) + +/-- The production frame decoder in the harness shape. -/ +private def frameProduction (b : ByteArray) : Except DecodeError (Frame × List Nat) := + (Frame.decodeB? b).map fun (f, rest) => (f, toNatList rest) + +/-- A partial retain stays inside its documented bound. +A ready parser is empty, or it holds at least one header. -/ +private def withinBound (p : Parser) : Bool := + match p.gap with + | .empty => + if p.hasComplete then headerSize ≤ p.retainedBytes else p.retainedBytes == 0 + | .partialHeader => 0 < p.retainedBytes && p.retainedBytes < headerSize + | .partialBody => + headerSize ≤ p.retainedBytes && p.retainedBytes < headerSize + testPayloadCap + +/-- Frames, the gap, the retain, and whether a complete frame remains. -/ +private def view (fs : List Frame) (p : Parser) : + List Frame × Gap × Nat × Bool × Bool := + (fs, p.gap, p.retainedBytes, p.hasComplete, withinBound p) + +/-- Two feeds, and the view after each one. -/ +private def continues (first rest : List Nat) + (v1 v2 : List Frame × Gap × Nat × Bool × Bool) : Bool := + match Parser.feed Parser.empty first with + | .error _ => false + | .ok (fs1, p1) => + view fs1 p1 == v1 && + match Parser.feed p1 rest with + | .error _ => false + | .ok (fs2, p2) => view fs2 p2 == v2 + +/-- Every byte of one frame, split after `i` bytes, reassembles. -/ +private def splitAt (frame : Frame) (i : Nat) : Bool := + let wire := Frame.encode frame + match Parser.feed Parser.empty (wire.take i) with + | .error _ => false + | .ok (fs1, p1) => + match Parser.feed p1 (wire.drop i) with + | .error _ => false + | .ok (fs2, p2) => + fs1 ++ fs2 == [frame] && p2.gap == .empty && p2.retainedBytes == 0 + && !p2.hasComplete && withinBound p1 && withinBound p2 + +/-- The same split on `ByteArray` chunks. -/ +private def splitBAt (frame : Frame) (i : Nat) : Bool := + let wire := Frame.encodeB frame + match Parser.feedB Parser.empty (wire.extract 0 i) with + | .error _ => false + | .ok (fs1, p1) => + match Parser.feedB p1 (wire.extract i wire.size) with + | .error _ => false + | .ok (fs2, p2) => + fs1 ++ fs2 == [frame] && p2.gap == .empty && p2.retainedBytes == 0 + && !p2.hasComplete && withinBound p1 && withinBound p2 + +/-- The model feed and `feedB` agree on frames and observations. -/ +private def aligned (chunk : List Nat) : Bool := + match Parser.feed Parser.empty chunk, Parser.feedB Parser.empty (ofNatList chunk) with + | .ok (fs, p), .ok (fsB, pB) => + fs == fsB && p.gap == pB.gap && p.retainedBytes == pB.retainedBytes + && p.hasComplete == pB.hasComplete && withinBound p && withinBound pB + | .error e, .error eB => e == eB + | _, _ => false + +/-- The private test wire. -/ +def run (r : Runner) : IO Unit := do + r.section "mux test wire" "packages/libp2p/mux/Libp2p/Mux/Test/Wire.lean" + r.check "bytes32 1 is big-endian" (bytes32 1 == [0, 0, 0, 1]) + r.check "bytes32 2 is big-endian" (bytes32 2 == [0, 0, 0, 2]) + r.check "bytes32 0x01020304 is big-endian" (bytes32 0x01020304 == [1, 2, 3, 4]) + r.check "bytes32 of the largest stream id is big-endian" (bytes32 idMax == [255, 255, 255, 255]) + r.check "open id 1 encodes kind, id, and a zero length" + (Frame.encode sampleOpen == Kind.open.toCode :: (bytes32 1 ++ bytes32 0)) + r.check "data id 2 encodes a two-byte payload" + (Frame.encode sampleData == Kind.data.toCode :: (bytes32 2 ++ bytes32 2 ++ twoBytes)) + r.check "fin at the largest stream id encodes a zero length" + (Frame.encode sampleFin == Kind.fin.toCode :: (bytes32 idMax ++ bytes32 0)) + r.check "reset id 1 encodes a zero length" + (Frame.encode sampleReset == Kind.reset.toCode :: (bytes32 1 ++ bytes32 0)) + r.check "data at the largest stream id encodes one payload byte" + (Frame.encode sampleMaxData == Kind.data.toCode :: (bytes32 idMax ++ bytes32 1 ++ [7])) + r.check "header round trip keeps a suffix" + (headers.all fun h => succeedsWith (h, [7]) (Header.decode? (Header.encode h ++ [7]))) + r.check "frame round trip keeps a suffix" + (frames.all fun f => succeedsWith (f, [4, 5]) (Frame.decode? (Frame.encode f ++ [4, 5]))) + r.check "header round-trip harness" (checkRoundTrips headers) + r.check "frame round-trip harness" (checkRoundTrips frames) + r.check "header ByteArray matches the model" + (headers.all fun h => Header.encodeB h == ofNatList (Header.encode h)) + r.check "frame ByteArray matches the model" + (frames.all fun f => Frame.encodeB f == ofNatList (Frame.encode f)) + r.check "header ByteArray decode keeps a suffix" + (headers.all fun h => + match Header.decodeB? ((Header.encodeB h).push 9) with + | .ok (got, rest) => got == h && toNatList rest == [9] + | .error _ => false) + r.check "frame ByteArray decode keeps a suffix" + (frames.all fun f => + match Frame.decodeB? ((Frame.encodeB f).push 4) with + | .ok (got, rest) => got == f && toNatList rest == [4] + | .error _ => false) + let headerPins := + (headers.map Header.encodeB) + ++ (headers.map fun h => (Header.encodeB h).push 9) + ++ ((List.range headerSize).map fun i => ofNatList ((Frame.encode sampleOpen).take i)) + ++ [ofNatList (0 :: bytes32 1 ++ bytes32 0), ofNatList (5 :: bytes32 1 ++ bytes32 0)] + r.check "header model pin" (checkModelPins headerProduction headerPins) + let framePins := + (frames.map Frame.encodeB) + ++ (frames.map fun f => (Frame.encodeB f).push 4) + ++ ((List.range headerSize).map fun i => ofNatList ((Frame.encode sampleOpen).take i)) + ++ [ofNatList (Header.encode zeroHeader), + ofNatList (Header.encode overCapHeader), + ofNatList (Header.encode overCapHeader ++ [1]), + ofNatList (5 :: bytes32 1 ++ bytes32 0), + ofNatList (Header.encode { kind := Kind.data, streamId := 1, length := 4 } ++ [1, 2, 3])] + r.check "frame model pin" (checkModelPins frameProduction framePins) + r.check "kind 0 is malformed" (failsWith .malformed (Header.decode? (0 :: bytes32 1 ++ bytes32 0))) + r.check "kind 5 is malformed" (failsWith .malformed (Header.decode? (5 :: bytes32 1 ++ bytes32 0))) + r.check "kind 255 is malformed" (failsWith .malformed (Header.decode? (255 :: bytes32 1 ++ bytes32 0))) + r.check "a non-byte kind is malformed" (failsWith .malformed (Header.decode? [300])) + r.check "a non-byte length is malformed" + (failsWith .malformed (Header.decode? (Kind.open.toCode :: (bytes32 1 ++ [0, 0, 0, 300])))) + r.check "a zero stream id is a header" + (succeedsWith (zeroHeader, []) (Header.decode? (Header.encode zeroHeader))) + r.check "a zero stream id is not a frame" + (failsWith .malformed (Frame.decode? (Header.encode zeroHeader)) + && failsWith .malformed (Parser.feed Parser.empty (Header.encode zeroHeader))) + r.check "an open with a nonzero length is malformed" + (failsWith .malformed (Frame.decode? (Header.encode { kind := Kind.open, streamId := 1, length := 1 } ++ [9]))) + r.check "a fin with a nonzero length is malformed" + (failsWith .malformed (Frame.decode? (Header.encode { kind := Kind.fin, streamId := 1, length := 1 } ++ [9]))) + r.check "a reset with a nonzero length is malformed" + (failsWith .malformed (Frame.decode? (Header.encode { kind := Kind.reset, streamId := 1, length := 1 } ++ [9]))) + r.check "a data frame of length zero is malformed" + (failsWith .malformed (Frame.decode? (Header.encode { kind := Kind.data, streamId := 1, length := 0 }))) + r.check "a data frame at the cap round-trips" + (succeedsWith (sampleCap, []) (Frame.decode? (Frame.encode sampleCap))) + -- The header alone is `malformed`. The cap check returns before a body read. + r.check "a data length one above the cap is malformed with no body" + (failsWith .malformed (Frame.decode? (Header.encode overCapHeader)) + && failsWith .malformed (Frame.decodeB? (Header.encodeB overCapHeader)) + && failsWith .malformed (Parser.feed Parser.empty (Header.encode overCapHeader)) + && failsWith .malformed (Parser.feedB Parser.empty (Header.encodeB overCapHeader))) + r.check "a data length one above the cap keeps no body prefix" + (failsWith .malformed (Parser.feed Parser.empty (Header.encode overCapHeader ++ [1])) + && failsWith .malformed (Parser.feedB Parser.empty (ofNatList (Header.encode overCapHeader ++ [1]))) + && failsWith .malformed (Frame.decode? (Header.encode overCapHeader ++ [1]))) + let openBytes := Frame.encode sampleOpen + for i in List.range headerSize do + let head := openBytes.take i + r.check s!"model header truncated at {i}" (failsWith .truncated (Header.decode? head)) + r.check s!"model frame truncated at {i}" (failsWith .truncated (Frame.decode? head)) + r.check s!"bytes header truncated at {i}" (failsWith .truncated (Header.decodeB? (ofNatList head))) + r.check s!"bytes frame truncated at {i}" (failsWith .truncated (Frame.decodeB? (ofNatList head))) + let bodyHeader : Header := { kind := Kind.data, streamId := 1, length := 4 } + let shortBody := Header.encode bodyHeader ++ [1, 2, 3] + r.check "a short data body is truncated" + (failsWith .truncated (Frame.decode? shortBody) + && failsWith .truncated (Frame.decodeB? (ofNatList shortBody))) + r.check "a short data body waits as a partial body" + (continues shortBody [4] + ([], .partialBody, headerSize + 3, false, true) + ([sampleFour], .empty, 0, false, true)) + let dataBytes := Frame.encode sampleData + for i in List.range (dataBytes.length + 1) do + r.check s!"model data frame split at {i}" (splitAt sampleData i) + r.check s!"bytes data frame split at {i}" (splitBAt sampleData i) + let many := Frame.encode sampleOpen ++ Frame.encode sampleData ++ Frame.encode sampleFin + r.check "three frames arrive in one chunk" + (continues many [] + ([sampleOpen, sampleData, sampleFin], .empty, 0, false, true) + ([], .empty, 0, false, true)) + let finBytes := Frame.encode sampleFin + let partialNext := Frame.encode sampleOpen ++ finBytes.take 3 + r.check "a complete frame can leave a partial header" + (continues partialNext (finBytes.drop 3) + ([sampleOpen], .partialHeader, 3, false, true) + ([sampleFin], .empty, 0, false, true)) + r.check "a partial header continues in the next chunk" + (continues (openBytes.take 4) (openBytes.drop 4) + ([], .partialHeader, 4, false, true) + ([sampleOpen], .empty, 0, false, true)) + r.check "an empty parser has an empty gap" + (Parser.empty.gap == .empty && Parser.empty.retainedBytes == 0 + && !Parser.empty.hasComplete && withinBound Parser.empty) + r.check "an empty feed leaves the empty gap" + (continues [] [] + ([], .empty, 0, false, true) + ([], .empty, 0, false, true)) + r.check "empty, partial header, and partial body stay distinct" + (Gap.empty != .partialHeader && Gap.empty != .partialBody + && Gap.partialHeader != .partialBody) + let burst := 2 * parserFrameBudget + 1 + let burstBytes := (List.replicate burst (Frame.encode sampleOpen)).flatten + r.check "a burst keeps frames past the budget" + (parserFrameBudget == 8 && + match Parser.feed Parser.empty burstBytes with + | .ok (first, p1) => + first.length == 8 && first.all (· == sampleOpen) + && p1.hasComplete && p1.gap == .empty + && p1.retainedBytes == (parserFrameBudget + 1) * headerSize + && withinBound p1 + && match Parser.drain p1 with + | .ok (second, p2) => + second.length == 8 && second.all (· == sampleOpen) + && p2.hasComplete && p2.retainedBytes == headerSize + && withinBound p2 + && match Parser.drain p2 with + | .ok (third, p3) => + third.length == 1 && third.all (· == sampleOpen) + && !p3.hasComplete && p3.retainedBytes == 0 && p3.gap == .empty + && withinBound p3 + | .error _ => false + | .error _ => false + | .error _ => false) + r.check "the burst agrees on the model and the byte path" + (aligned burstBytes) + let mut capParser := Parser.empty + let mut capFrames : List Frame := [] + let mut capOk := true + for b in Frame.encode sampleCap do + if capOk then + match Parser.feed capParser [b] with + | .ok (fs, p1) => + if withinBound p1 && p1.retainedBytes < headerSize + testPayloadCap then + capFrames := capFrames ++ fs + capParser := p1 + else + capOk := false + | .error _ => capOk := false + r.check "a cap frame arrives one byte at a time" + (capOk && capFrames == [sampleCap] && capParser.gap == .empty + && capParser.retainedBytes == 0 && !capParser.hasComplete) + let mut capParserB := Parser.empty + let mut capFramesB : List Frame := [] + let mut capOkB := true + let capWire := Frame.encodeB sampleCap + for i in List.range capWire.size do + if capOkB then + match Parser.feedB capParserB (capWire.extract i (i + 1)) with + | .ok (fs, p1) => + if withinBound p1 && p1.retainedBytes < headerSize + testPayloadCap then + capFramesB := capFramesB ++ fs + capParserB := p1 + else + capOkB := false + | .error _ => capOkB := false + r.check "a cap frame arrives one byte at a time on bytes" + (capOkB && capFramesB == [sampleCap] && capParserB.gap == capParser.gap + && capParserB.retainedBytes == capParser.retainedBytes + && capParserB.hasComplete == capParser.hasComplete) + let wide := capWire ++ Frame.encodeB sampleOpen + r.check "a chunk above the payload cap parses" + (testPayloadCap < wide.size && + match Parser.feedB Parser.empty wide with + | .ok (fs, p) => + fs == [sampleCap, sampleOpen] && p.gap == .empty + && p.retainedBytes == 0 && !p.hasComplete + | .error _ => false) + let bodyAt := headerSize + 1 + r.check "a partial body survives an empty feed" + (match Parser.feed Parser.empty (Frame.encode sampleFour |>.take bodyAt) with + | .ok ([], pHeader) => + pHeader.gap == .partialBody && pHeader.retainedBytes == headerSize + 1 + && !pHeader.hasComplete && withinBound pHeader + && match Parser.feed pHeader [] with + | .ok ([], pStill) => + pStill.gap == .partialBody && pStill.retainedBytes == headerSize + 1 + && withinBound pStill + && match Parser.feed pStill (Frame.encode sampleFour |>.drop bodyAt) with + | .ok ([got], pDone) => + got == sampleFour && pDone.gap == .empty && pDone.retainedBytes == 0 + && !pDone.hasComplete + | _ => false + | _ => false + | _ => false) + r.check "an empty feed leaves a partial header in place" + (match Parser.feed Parser.empty (openBytes.take 4) with + | .ok ([], p1) => + p1.gap == .partialHeader && p1.retainedBytes == 4 && withinBound p1 + && match Parser.feed p1 [] with + | .ok ([], p2) => + p2.gap == .partialHeader && p2.retainedBytes == 4 && !p2.hasComplete + && match Parser.feed p2 (openBytes.drop 4) with + | .ok ([got], p3) => + got == sampleOpen && !p3.hasComplete && p3.retainedBytes == 0 + | _ => false + | _ => false + | _ => false) + let followed := Frame.encode sampleFour ++ Frame.encode sampleOpen ++ finBytes.take 3 + r.check "a finished body can leave complete and partial frames" + (continues followed (finBytes.drop 3) + ([sampleFour, sampleOpen], .partialHeader, 3, false, true) + ([sampleFin], .empty, 0, false, true)) + let overBytes := Header.encode overCapHeader + r.check "a cap-plus-one header fails before a body is retained" + (match Parser.feed Parser.empty (overBytes.take 4) with + | .ok ([], p1) => + p1.gap == .partialHeader && p1.retainedBytes == 4 && withinBound p1 + && failsWith .malformed (Parser.feed p1 (overBytes.drop 4 ++ List.replicate 32 1)) + && p1.gap == .partialHeader && p1.retainedBytes == 4 && !p1.hasComplete + | _ => false) + r.check "the model and byte paths agree on the sample chunk" + (aligned many && aligned shortBody && aligned overBytes + && aligned (Frame.encode sampleCap) && aligned followed) + +end Tests.Mux.Wire + namespace Tests open Libp2p.Mux @@ -30,5 +420,6 @@ def Mux.run (r : Runner) : IO Unit := do (yamuxId.toString != mplexId.toString && yamuxId.toString != testId.toString && mplexId.toString != testId.toString) + Mux.Wire.run r end Tests diff --git a/packages/libp2p/mux/Libp2p/Mux/Test/Wire.lean b/packages/libp2p/mux/Libp2p/Mux/Test/Wire.lean new file mode 100644 index 00000000..d83fd7eb --- /dev/null +++ b/packages/libp2p/mux/Libp2p/Mux/Test/Wire.lean @@ -0,0 +1,957 @@ +import Bytes + +/-! +# Private test wire + +The frame of `Mux.test` (`packages/libp2p/mux/docs/ARCHITECTURE.md`, +Private frame). The header is nine bytes: a kind, a stream id, and a +length. Each field is fixed-width and big-endian. A data payload +follows the header. An open, a fin, and a reset carry no payload. + +`Header` is every nine-byte spelling whose kind is one of the four +codes. A stream id of zero and a length outside the data rule are +still headers. `LawfulCodec` holds for every such header. + +`Frame` stores the stream id rule and the length rule in its fields, +so a value satisfies both, and `LawfulCodec` holds for it. `takeFrame` +is the check that builds one. A data length above `testPayloadCap` +fails in that check, before `takeBytes` reads a body. + +`Parser` accepts `ByteArray` chunks. `feed`, `feedB`, and `drain` +each emit at most `parserFrameBudget` complete frames and keep the +rest. A partial body stores the bytes already received. The next +chunk fills the count still required. `feedB` copies a body only +after the cap check, and only up to the bytes still required. A chunk +of any length is accepted. The session slices a connection read +before it calls `feedB`. A later session reads the gap when the +connection ends. This module leaves that decision alone. +-/ + +set_option autoImplicit false + +namespace Libp2p.Mux.Test.Wire + +open Bytes + +/-! ## Widths and codes -/ + +/-- A header is one kind byte, then a stream id, then a length. -/ +def headerSize : Nat := 9 + +/-- The most bytes one data payload of this test session may carry. -/ +def testPayloadCap : Nat := 16 * 1024 + +/-- The four kinds the private frame names. -/ +inductive Kind where + /-- Open a stream. -/ + | open + /-- Bytes for an open stream. -/ + | data + /-- Half-close one direction. -/ + | fin + /-- Abort both directions for new operations. -/ + | reset + deriving DecidableEq, Repr, BEq + +namespace Kind + +/-- The kind byte. The architecture table is the list this matches. -/ +def toCode : Kind → Nat + | .open => 1 + | .data => 2 + | .fin => 3 + | .reset => 4 + +/-- The kind of a byte, when the byte is one of the four codes. -/ +def ofCode? : Nat → Option Kind + | 1 => some .open + | 2 => some .data + | 3 => some .fin + | 4 => some .reset + | _ => none + +end Kind + +/-- The length rule. Data takes between one byte and `testPayloadCap`. +A control kind takes an empty payload. -/ +def lengthOk (k : Kind) (n : Nat) : Bool := + match k with + | .data => n != 0 && decide (n ≤ testPayloadCap) + | _ => n == 0 + +/-! ## Big-endian `UInt32` -/ + +/-- The unsigned value of four big-endian bytes. -/ +private def pack (a b c d : Nat) : Nat := + ((a * 256 + b) * 256 + c) * 256 + d + +/-- Four bytes pack to a value `UInt32.ofNatLT` accepts. -/ +private theorem pack_lt {a b c d : Nat} + (_ha : a < 256) (_hb : b < 256) (_hc : c < 256) (_hd : d < 256) : + pack a b c d < UInt32.size := by + simp only [pack, UInt32.size] + omega + +/-- Four big-endian bytes as a `UInt32`. Each byte is below 256, so +`ofNatLT` does not reduce the sum. -/ +private def be32 (a b c d : Nat) (ha : a < 256) (hb : b < 256) (hc : c < 256) + (hd : d < 256) : UInt32 := + UInt32.ofNatLT (pack a b c d) (pack_lt ha hb hc hd) + +/-- The four big-endian bytes of a `UInt32`. -/ +def bytes32 (v : UInt32) : List Nat := + let n := v.toNat + [n / 16777216, n / 65536 % 256, n / 256 % 256, n % 256] + +private theorem be32_bytes32 (v : UInt32) : + be32 (v.toNat / 16777216) (v.toNat / 65536 % 256) (v.toNat / 256 % 256) (v.toNat % 256) + (by have := v.toNat_lt; omega) (by have := v.toNat_lt; omega) + (by have := v.toNat_lt; omega) (by have := v.toNat_lt; omega) = v := by + apply UInt32.toNat_inj.mp + simp only [be32, UInt32.toNat_ofNatLT, pack] + have := v.toNat_lt + omega + +private theorem bytes32_be32 {a b c d : Nat} (ha : a < 256) (hb : b < 256) (hc : c < 256) + (hd : d < 256) : bytes32 (be32 a b c d ha hb hc hd) = [a, b, c, d] := by + simp only [bytes32, be32, UInt32.toNat_ofNatLT] + have h : + pack a b c d / 16777216 = a ∧ + pack a b c d / 65536 % 256 = b ∧ + pack a b c d / 256 % 256 = c ∧ + pack a b c d % 256 = d := by + simp only [pack] + omega + obtain ⟨ha, hb, hc, hd⟩ := h + simp [ha, hb, hc, hd] + +/-- Four `UInt8` values, big-endian. `UInt8.toNat` is below 256. -/ +private def be32Of (a b c d : UInt8) : UInt32 := + be32 a.toNat b.toNat c.toNat d.toNat + (by simpa [UInt8.size] using a.toNat_lt) + (by simpa [UInt8.size] using b.toNat_lt) + (by simpa [UInt8.size] using c.toNat_lt) + (by simpa [UInt8.size] using d.toNat_lt) + +/-! ## Header -/ + +/-- A kind byte and the two `UInt32` fields. The stream id may be zero. +The length may sit outside the data rule. Those checks belong to `Frame`. -/ +structure Header where + kind : Kind + streamId : UInt32 + length : UInt32 + deriving DecidableEq, Repr, BEq + +namespace Header + +/-- The nine header bytes. -/ +def encode (h : Header) : List Nat := + h.kind.toCode :: (bytes32 h.streamId ++ bytes32 h.length) + +/-- The kind at the front of a header. A short input is `truncated`. +A non-byte, or a byte outside the four codes, is `malformed`. -/ +private def readKind : List Nat → Except DecodeError (Kind × List Nat) + | [] => .error .truncated + | 1 :: rest => .ok (.open, rest) + | 2 :: rest => .ok (.data, rest) + | 3 :: rest => .ok (.fin, rest) + | 4 :: rest => .ok (.reset, rest) + | _ :: _ => .error .malformed + +/-- The next big-endian `UInt32`. Fewer than four bytes is `truncated` +when those bytes are bytes. A non-byte among them is `malformed`. -/ +private def readU32 : List Nat → Except DecodeError (UInt32 × List Nat) + | a :: b :: c :: d :: rest => + if ha : a < 256 then + if hb : b < 256 then + if hc : c < 256 then + if hd : d < 256 then + .ok (be32 a b c d ha hb hc hd, rest) + else + .error .malformed + else + .error .malformed + else + .error .malformed + else + .error .malformed + | l => if l.any (· ≥ 256) then .error .malformed else .error .truncated + +private theorem readKind_open {t : List Nat} {k : Kind} {rest : List Nat} + (h : readKind (1 :: t) = .ok (k, rest)) : k = Kind.open ∧ t = rest := by + simp only [readKind, Except.ok.injEq, Prod.mk.injEq] at h + exact ⟨h.1.symm, h.2⟩ + +private theorem readKind_data {t : List Nat} {k : Kind} {rest : List Nat} + (h : readKind (2 :: t) = .ok (k, rest)) : k = Kind.data ∧ t = rest := by + simp only [readKind, Except.ok.injEq, Prod.mk.injEq] at h + exact ⟨h.1.symm, h.2⟩ + +private theorem readKind_fin {t : List Nat} {k : Kind} {rest : List Nat} + (h : readKind (3 :: t) = .ok (k, rest)) : k = Kind.fin ∧ t = rest := by + simp only [readKind, Except.ok.injEq, Prod.mk.injEq] at h + exact ⟨h.1.symm, h.2⟩ + +private theorem readKind_reset {t : List Nat} {k : Kind} {rest : List Nat} + (h : readKind (4 :: t) = .ok (k, rest)) : k = Kind.reset ∧ t = rest := by + simp only [readKind, Except.ok.injEq, Prod.mk.injEq] at h + exact ⟨h.1.symm, h.2⟩ + +private theorem readKind_inv {l : List Nat} {k : Kind} {rest : List Nat} + (h : readKind l = .ok (k, rest)) : l = k.toCode :: rest := by + cases l with + | nil => simp [readKind] at h + | cons n t => + match n with + | 0 => simp [readKind] at h + | 1 => + obtain ⟨hk, rfl⟩ := readKind_open h + cases k <;> simp at hk <;> (try cases hk) <;> simp [Kind.toCode] + | 2 => + obtain ⟨hk, rfl⟩ := readKind_data h + cases k <;> simp at hk <;> (try cases hk) <;> simp [Kind.toCode] + | 3 => + obtain ⟨hk, rfl⟩ := readKind_fin h + cases k <;> simp at hk <;> (try cases hk) <;> simp [Kind.toCode] + | 4 => + obtain ⟨hk, rfl⟩ := readKind_reset h + cases k <;> simp at hk <;> (try cases hk) <;> simp [Kind.toCode] + | n + 5 => + simp [readKind] at h + +private theorem readU32_inv {l : List Nat} {v : UInt32} {rest : List Nat} + (h : readU32 l = .ok (v, rest)) : l = bytes32 v ++ rest := by + match l with + | a :: b :: c :: d :: rest' => + simp only [readU32] at h + split at h + · next ha => + split at h + · next hb => + split at h + · next hc => + split at h + · next hd => + simp only [Except.ok.injEq, Prod.mk.injEq] at h + obtain ⟨hv, rfl⟩ := h + have hbytes : bytes32 v = [a, b, c, d] := by + simpa [← hv] using bytes32_be32 ha hb hc hd + simp [hbytes] + · cases h + · cases h + · cases h + · cases h + | [] => simp [readU32] at h + | [_] => + simp only [readU32] at h + split at h <;> cases h + | [_, _] => + simp only [readU32] at h + split at h <;> cases h + | [_, _, _] => + simp only [readU32] at h + split at h <;> cases h + +private theorem readU32_bytes32 (v : UInt32) (rest : List Nat) : + readU32 (bytes32 v ++ rest) = .ok (v, rest) := by + have h0 : v.toNat / 16777216 < 256 := by have := v.toNat_lt; omega + have h1 : v.toNat / 65536 % 256 < 256 := by have := v.toNat_lt; omega + have h2 : v.toNat / 256 % 256 < 256 := by have := v.toNat_lt; omega + have h3 : v.toNat % 256 < 256 := by have := v.toNat_lt; omega + simp only [bytes32, List.cons_append, List.nil_append, readU32] + rw [dite_eq_left h0, dite_eq_left h1, dite_eq_left h2, dite_eq_left h3] + apply congrArg (fun id => Except.ok (id, rest)) + apply UInt32.toNat_inj.mp + simp only [be32, UInt32.toNat_ofNatLT, pack] + have := v.toNat_lt + omega + +/-- One header, and the bytes after it. -/ +def decode? (l : List Nat) : Except DecodeError (Header × List Nat) := + match readKind l with + | .error e => .error e + | .ok (kind, l1) => + match readU32 l1 with + | .error e => .error e + | .ok (streamId, l2) => + match readU32 l2 with + | .error e => .error e + | .ok (length, rest) => .ok ({ kind, streamId, length }, rest) + +theorem decode?_append (h : Header) (rest : List Nat) : + decode? (encode h ++ rest) = .ok (h, rest) := by + have hlist : + encode h ++ rest = h.kind.toCode :: (bytes32 h.streamId ++ (bytes32 h.length ++ rest)) := by + simp [encode, List.cons_append, List.append_assoc] + rw [hlist] + cases hk : h.kind <;> simp only [decode?, readKind, Kind.toCode] <;> + rw [readU32_bytes32] <;> dsimp <;> rw [readU32_bytes32] <;> dsimp <;> cases h <;> rw [← hk] <;> rfl + +private theorem encode_of_decode {l : List Nat} {h : Header} {rest : List Nat} + (hd : decode? l = .ok (h, rest)) : encode h ++ rest = l := by + simp only [decode?] at hd + split at hd + · cases hd + · next kind l1 hk => + split at hd + · cases hd + · next streamId l2 hi => + split at hd + · cases hd + · next length rest' hl => + simp only [Except.ok.injEq, Prod.mk.injEq] at hd + obtain ⟨rfl, rfl⟩ := hd + simp [encode, readKind_inv hk, readU32_inv hi, readU32_inv hl, List.append_assoc] + +instance : Codec Header where + encode := encode + decode? := decode? + +instance : LawfulCodec Header where + decodeEncode h := by + show decode? (encode h) = .ok (h, []) + simpa using decode?_append h [] + encodeDecode _l h rest hd := by + show encode h ++ rest = _ + exact encode_of_decode hd + +/-- The header bytes as a `ByteArray`. -/ +def encodeB (h : Header) : ByteArray := + ofNatList (encode h) + +/-- One header from the front of `b`. The walk uses the same `be32` +bounds as the model. -/ +def decodeB? (b : ByteArray) : Except DecodeError (Header × ByteArray) := + if hsz : headerSize ≤ b.size then + have h0 : 0 < b.size := by simp only [headerSize] at hsz; omega + have h1 : 1 < b.size := by simp only [headerSize] at hsz; omega + have h2 : 2 < b.size := by simp only [headerSize] at hsz; omega + have h3 : 3 < b.size := by simp only [headerSize] at hsz; omega + have h4 : 4 < b.size := by simp only [headerSize] at hsz; omega + have h5 : 5 < b.size := by simp only [headerSize] at hsz; omega + have h6 : 6 < b.size := by simp only [headerSize] at hsz; omega + have h7 : 7 < b.size := by simp only [headerSize] at hsz; omega + have h8 : 8 < b.size := by simp only [headerSize] at hsz; omega + match Kind.ofCode? (b[0]'h0).toNat with + | none => .error .malformed + | some kind => + let streamId := be32Of (b[1]'h1) (b[2]'h2) (b[3]'h3) (b[4]'h4) + let length := be32Of (b[5]'h5) (b[6]'h6) (b[7]'h7) (b[8]'h8) + .ok ({ kind, streamId, length }, b.extract headerSize b.size) + else + .error .truncated + +end Header + +/-! ## Frame -/ + +/-- A frame the length rule and the stream id rule both allow. -/ +structure Frame where + kind : Kind + streamId : UInt32 + payload : List Nat + bytes : ∀ x ∈ payload, x < 256 + idOk : streamId ≠ 0 + fits : lengthOk kind payload.length = true + +namespace Frame + +theorem ext {a b : Frame} (hk : a.kind = b.kind) (hi : a.streamId = b.streamId) + (hp : a.payload = b.payload) : a = b := by + cases a + cases b + cases hk + cases hi + cases hp + rfl + +/-- A control frame. The payload is empty. -/ +def ofControl (k : Kind) (id : UInt32) (hk : k ≠ .data) (hid : id ≠ 0) : Frame where + kind := k + streamId := id + payload := [] + bytes := by + intro _ hx + cases hx + idOk := hid + fits := by + match k, hk with + | .open, _ => rfl + | .fin, _ => rfl + | .reset, _ => rfl + | .data, hk => exact (hk rfl).elim + +/-- A data frame whose payload satisfies the length rule. -/ +def ofData (id : UInt32) (payload : List Nat) (hid : id ≠ 0) + (hb : ∀ x ∈ payload, x < 256) (hs : lengthOk .data payload.length = true) : Frame where + kind := .data + streamId := id + payload := payload + bytes := hb + idOk := hid + fits := hs + +instance : DecidableEq Frame := fun a b => + if h : a.kind = b.kind ∧ a.streamId = b.streamId ∧ a.payload = b.payload then + isTrue (ext h.1 h.2.1 h.2.2) + else + isFalse fun heq => by + apply h + cases heq + exact ⟨rfl, rfl, rfl⟩ + +instance : BEq Frame where + beq a b := a.kind == b.kind && a.streamId == b.streamId && a.payload == b.payload + +end Frame + +/-- The payload length fits in the `UInt32` length field. `lengthOk` +bounds it by `testPayloadCap` before `ofNatLT`. -/ +private theorem payload_lt_size (f : Frame) : f.payload.length < UInt32.size := by + have h := f.fits + have hc : testPayloadCap < UInt32.size := by decide + cases hk : f.kind <;> simp only [lengthOk, hk] at h + · simp only [beq_iff_eq] at h + omega + · simp only [Bool.and_eq_true, bne_iff_ne, decide_eq_true_eq] at h + omega + · simp only [beq_iff_eq] at h + omega + · simp only [beq_iff_eq] at h + omega + +/-- The header a frame writes. The length is the payload length. -/ +private def headerOf (f : Frame) : Header where + kind := f.kind + streamId := f.streamId + length := UInt32.ofNatLT f.payload.length (payload_lt_size f) + +namespace Frame + +/-- The header, then the payload. -/ +def encode (f : Frame) : List Nat := + Header.encode (headerOf f) ++ f.payload + +/-- The first `k` model bytes. A non-byte in that prefix is `malformed`. +A short prefix of bytes is `truncated`. -/ +private def takeBytes (k : Nat) (l : List Nat) : Except DecodeError (List Nat × List Nat) := + if ¬∀ x ∈ l.take k, x < 256 then .error .malformed + else if l.length < k then .error .truncated + else .ok (l.take k, l.drop k) + +private theorem takeBytesAppend {b : List Nat} (rest : List Nat) (h : ∀ x ∈ b, x < 256) : + takeBytes b.length (b ++ rest) = .ok (b, rest) := by + have htake : (b ++ rest).take b.length = b := by simp + unfold takeBytes + rw [htake] + split + · next hn => exact absurd h hn + · split + · next hl => simp at hl; omega + · simp + +private theorem ofTakeBytes {k : Nat} {l b rest : List Nat} + (h : takeBytes k l = .ok (b, rest)) : + b ++ rest = l ∧ b.length = k ∧ ∀ x ∈ b, x < 256 := by + unfold takeBytes at h + split at h + · cases h + · next hbytes => + split at h + · cases h + · next _hlen => + simp only [Except.ok.injEq, Prod.mk.injEq] at h + obtain ⟨rfl, rfl⟩ := h + refine ⟨List.take_append_drop k l, ?_, by simpa using hbytes⟩ + simp only [List.length_take] + omega + +/-- The data frame for a length that already passed `lengthOk`. +`takeBytes` runs only after that check. -/ +private def finishData (h : Header) (n : Nat) (_hn : n = h.length.toNat) (hid : h.streamId ≠ 0) + (hs : lengthOk Kind.data n = true) (rest : List Nat) : Except DecodeError (Frame × List Nat) := + match ht : takeBytes n rest with + | .error e => .error e + | .ok (payload, after) => + .ok (ofData h.streamId payload hid (ofTakeBytes ht).2.2 (by + rw [(ofTakeBytes ht).2.1] + exact hs), after) + +/-- A frame from a decoded header and the bytes after it. +A failing length rule returns `malformed` before `takeBytes`. -/ +private def takeFrame (h : Header) (rest : List Nat) : Except DecodeError (Frame × List Nat) := + if hid : h.streamId ≠ 0 then + if hk : h.kind = Kind.data then + if hs : lengthOk Kind.data h.length.toNat = true then + finishData h h.length.toNat rfl hid hs rest + else + .error .malformed + else if _hs : lengthOk h.kind h.length.toNat = true then + .ok (ofControl h.kind h.streamId hk hid, rest) + else + .error .malformed + else + .error .malformed + +/-- A data length above `testPayloadCap` is `malformed` on the header +alone. `takeFrame` returns before it reads `rest`. -/ +private theorem takeFrame_overCap {h : Header} {rest : List Nat} + (hk : h.kind = Kind.data) (hid : h.streamId ≠ 0) (hc : testPayloadCap < h.length.toNat) : + takeFrame h rest = .error .malformed := by + have hs : lengthOk Kind.data h.length.toNat = false := by + simp only [lengthOk, Bool.and_eq_false_iff] + exact Or.inr (decide_eq_false (by omega : ¬h.length.toNat ≤ testPayloadCap)) + unfold takeFrame + rw [dite_eq_left hid, dite_eq_left hk, dite_eq_right (by simp [hs])] + +/-- One frame from the front of the model, and the suffix. -/ +def decode? (l : List Nat) : Except DecodeError (Frame × List Nat) := + match Header.decode? l with + | .error e => .error e + | .ok (h, rest) => takeFrame h rest + +/-- A declared data length above `testPayloadCap` is `malformed` once +the header is present, including when the body bytes are absent. -/ +theorem decode?_overCap {l : List Nat} {h : Header} {rest : List Nat} + (hd : Header.decode? l = .ok (h, rest)) (hk : h.kind = Kind.data) (hid : h.streamId ≠ 0) + (hc : testPayloadCap < h.length.toNat) : decode? l = .error .malformed := by + simp only [decode?, hd, takeFrame_overCap hk hid hc] + +private theorem control_payload_nil {f : Frame} (hk : f.kind ≠ Kind.data) : f.payload = [] := by + have hlen := f.fits + cases hk' : f.kind <;> simp only [lengthOk, hk'] at hlen + · simp only [beq_iff_eq] at hlen + exact List.eq_nil_of_length_eq_zero hlen + · exact absurd hk' hk + · simp only [beq_iff_eq] at hlen + exact List.eq_nil_of_length_eq_zero hlen + · simp only [beq_iff_eq] at hlen + exact List.eq_nil_of_length_eq_zero hlen + +private theorem takeFrame_control (f : Frame) (rest : List Nat) (hk : f.kind ≠ Kind.data) : + takeFrame (headerOf f) (f.payload ++ rest) = .ok (f, rest) := by + have hp : f.payload = [] := control_payload_nil hk + unfold takeFrame + rw [dite_eq_left (by simpa [headerOf] using f.idOk)] + rw [dite_eq_right (by simpa [headerOf] using hk)] + rw [dite_eq_left (by + cases hk' : f.kind <;> simp only [hk'] at hk + · simp [hk', headerOf, hp, lengthOk] + · exact absurd rfl hk + · simp [hk', headerOf, hp, lengthOk] + · simp [hk', headerOf, hp, lengthOk])] + simp only [hp, List.nil_append] + exact congrArg (fun g => Except.ok (g, rest)) + (ext (by unfold ofControl; rfl) (by unfold ofControl; rfl) (by unfold ofControl; simp [hp])) + +private theorem takeFrame_data (f : Frame) (rest : List Nat) (hk : f.kind = Kind.data) : + takeFrame (headerOf f) (f.payload ++ rest) = .ok (f, rest) := by + have hs : lengthOk Kind.data f.payload.length = true := by simpa [hk] using f.fits + unfold takeFrame + rw [dite_eq_left (by simpa [headerOf] using f.idOk)] + rw [dite_eq_left (by simpa [headerOf] using hk)] + rw [dite_eq_left (by simp [headerOf, UInt32.toNat_ofNatLT, hs])] + unfold finishData + have hlen : (headerOf f).length.toNat = f.payload.length := by + simp [headerOf, UInt32.toNat_ofNatLT] + split + · next _ ht => + rw [hlen] at ht + rw [takeBytesAppend rest f.bytes] at ht + cases ht + · next _ _ ht => + rw [hlen] at ht + rw [takeBytesAppend rest f.bytes] at ht + simp only [Except.ok.injEq, Prod.mk.injEq] at ht + obtain ⟨rfl, rfl⟩ := ht + exact congrArg (fun g => Except.ok (g, rest)) (ext hk.symm rfl rfl) + +private theorem takeFrame_append (f : Frame) (rest : List Nat) : + takeFrame (headerOf f) (f.payload ++ rest) = .ok (f, rest) := by + cases hk : f.kind + · exact takeFrame_control f rest (by simp [hk]) + · exact takeFrame_data f rest hk + · exact takeFrame_control f rest (by simp [hk]) + · exact takeFrame_control f rest (by simp [hk]) + +theorem decode?_append (f : Frame) (rest : List Nat) : + decode? (encode f ++ rest) = .ok (f, rest) := by + have hbuf : encode f ++ rest = Header.encode (headerOf f) ++ (f.payload ++ rest) := by + simp [encode, List.append_assoc] + simpa [decode?, hbuf, Header.decode?_append (headerOf f) (f.payload ++ rest)] using + takeFrame_append f rest + +private theorem encode_of_decode {l : List Nat} {f : Frame} {rest : List Nat} + (hd : decode? l = .ok (f, rest)) : encode f ++ rest = l := by + simp only [decode?] at hd + split at hd + · cases hd + · next h rest1 hh => + have hhead := Header.encode_of_decode hh + simp only [takeFrame, finishData] at hd + split at hd + · next hid => + split at hd + · next hk => + split at hd + · next hs => + split at hd + · cases hd + · next payload after ht => + simp only [Except.ok.injEq, Prod.mk.injEq] at hd + obtain ⟨hf, rfl⟩ := hd + obtain ⟨hsplit, hlen, _⟩ := ofTakeBytes ht + have hpay : f.payload = payload := by + simpa [ofData] using (congrArg Frame.payload hf).symm + have hkind : f.kind = Kind.data := (congrArg Frame.kind hf).symm + have hidf : f.streamId = h.streamId := (congrArg Frame.streamId hf).symm + have hlenU : h.length = UInt32.ofNatLT f.payload.length (payload_lt_size f) := by + apply UInt32.toNat_inj.mp + simp [hlen, hpay] + have hhdr : h = headerOf f := by + cases h + rw [Header.mk.injEq] + exact ⟨hk.trans hkind.symm, hidf.symm, hlenU⟩ + calc + encode f ++ after = Header.encode (headerOf f) ++ f.payload ++ after := by + simp [encode] + _ = h.encode ++ (payload ++ after) := by + rw [hhdr, hpay, List.append_assoc] + _ = h.encode ++ rest1 := by rw [hsplit] + _ = l := hhead + · cases hd + · next hk => + split at hd + · next hs => + simp only [Except.ok.injEq, Prod.mk.injEq] at hd + obtain ⟨hf, rfl⟩ := hd + have hp : f.payload = [] := by + simpa [ofControl] using (congrArg Frame.payload hf).symm + have hkind : f.kind = h.kind := (congrArg Frame.kind hf).symm + have hidf : f.streamId = h.streamId := (congrArg Frame.streamId hf).symm + have hzero : h.length.toNat = 0 := by + cases hk' : h.kind <;> simp only [lengthOk, hk', beq_iff_eq] at hs + · exact hs + · exact absurd hk' hk + · exact hs + · exact hs + have hlenU : h.length = UInt32.ofNatLT f.payload.length (payload_lt_size f) := by + apply UInt32.toNat_inj.mp + simp [hp, hzero] + have hhdr : h = headerOf f := by + cases h + rw [Header.mk.injEq] + exact ⟨hkind.symm, hidf.symm, hlenU⟩ + calc + encode f ++ rest1 = Header.encode (headerOf f) ++ f.payload ++ rest1 := by + simp [encode] + _ = h.encode ++ rest1 := by + rw [hhdr, hp, List.append_nil] + _ = l := hhead + · cases hd + · cases hd + +instance : Codec Frame where + encode := encode + decode? := decode? + +instance : LawfulCodec Frame where + decodeEncode f := by + show decode? (encode f) = .ok (f, []) + simpa using decode?_append f [] + encodeDecode _l f rest hd := by + show encode f ++ rest = _ + exact encode_of_decode hd + +/-- The frame bytes as a `ByteArray`. -/ +def encodeB (f : Frame) : ByteArray := + ofNatList (encode f) + +/-- One frame from the front of `b`. The length rule runs before +`extract`, so an over-cap length does not copy a body. -/ +def decodeB? (b : ByteArray) : Except DecodeError (Frame × ByteArray) := + match Header.decodeB? b with + | .error e => .error e + | .ok (h, rest) => + if hid : h.streamId ≠ 0 then + let n := h.length.toNat + if hk : h.kind = Kind.data then + if hs : lengthOk Kind.data n = true then + if rest.size < n then + .error .truncated + else + let payload := toNatList (rest.extract 0 n) + if hlen : payload.length = n then + if hb : ∀ x ∈ payload, x < 256 then + .ok (ofData h.streamId payload hid hb (by simpa [hlen] using hs), + rest.extract n rest.size) + else + .error .malformed + else + .error .malformed + else + .error .malformed + else if _hs : lengthOk h.kind n = true then + .ok (ofControl h.kind h.streamId hk hid, rest) + else + .error .malformed + else + .error .malformed + +end Frame + +/-! ## Incremental parser -/ + +/-- What an unfinished feed is waiting for. The session maps a nonempty +gap at connection end to a protocol error. An empty gap leaves the +connection-end cause to that session. -/ +inductive Gap where + | empty + | partialHeader + | partialBody + deriving DecidableEq, Repr, BEq + +/-- Complete frames one owner-loop turn takes from this parser. +The policy table in the mux architecture is this constant. -/ +def parserFrameBudget : Nat := 8 + +/-- A data length `lengthOk` accepts is in `1 … testPayloadCap`. -/ +private theorem lengthOk_data {n : Nat} (h : lengthOk Kind.data n = true) : + 0 < n ∧ n ≤ testPayloadCap := by + simp only [lengthOk, Bool.and_eq_true, bne_iff_ne, decide_eq_true_eq] at h + omega + +/-- Bytes of a `ByteArray` are bytes. -/ +private theorem toNatList_lt (b : ByteArray) : ∀ x ∈ toNatList b, x < 256 := by + intro x hx + simp only [toNatList, List.mem_map] at hx + obtain ⟨u, _, rfl⟩ := hx + simpa [UInt8.size] using u.toNat_lt + +/-- The three shapes a parser may hold. Constructors stay private. +`ready` is empty, or it begins with a complete frame. +`partialHeader` is shorter than `headerSize` and nonempty. +`partialBody` is a nonzero data id, a length inside the cap, the saved +header bytes, and a body shorter than that length. -/ +private inductive Raw where + | ready (input : ByteArray) + | partialHeader (buf : ByteArray) (hShort : buf.size < headerSize) (hPos : 0 < buf.size) + | partialBody (streamId : UInt32) (declared : Nat) (headerBytes bodyBytes : ByteArray) + (idOk : streamId ≠ 0) (lenOk : 0 < declared ∧ declared ≤ testPayloadCap) + (short : bodyBytes.size < declared) + +/-- Parser state. The raw constructors are private, so a caller cannot +build a control header, an over-cap length, an oversized body, two +buffers, or a non-byte entry. -/ +structure Parser where + private mk :: + private raw : Raw + +namespace Parser + +/-- A parser with no buffered bytes. -/ +def empty : Parser := + ⟨.ready ByteArray.empty⟩ + +/-- Empty, waiting on a header, or waiting on a body. +`ready` reports `.empty`, including when a complete frame is retained. -/ +def gap (p : Parser) : Gap := + match p.raw with + | .ready _ => .empty + | .partialHeader _ _ _ => .partialHeader + | .partialBody _ _ _ _ _ _ _ => .partialBody + +/-- Bytes the parser is holding. +A partial header is shorter than `headerSize`. +A partial body holds its nine header bytes plus a body shorter than +the declared length. A ready parser holds unparsed bytes that already +arrived, including complete frames a pull did not emit. -/ +def retainedBytes (p : Parser) : Nat := + match p.raw with + | .ready input => input.size + | .partialHeader buf _ _ => buf.size + | .partialBody _ _ headerBytes bodyBytes _ _ _ => headerBytes.size + bodyBytes.size + +/-- A later `drain` can emit a frame without another chunk. -/ +def hasComplete (p : Parser) : Bool := + match p.raw with + | .ready input => input.size != 0 + | .partialHeader _ _ _ => false + | .partialBody _ _ _ _ _ _ _ => false + +/-- Push the first `n` bytes of `src` onto `dst`. +Each step is one `push`, so a body that arrives one byte at a time +does not copy the bytes already kept. -/ +private def pushCount (dst src : ByteArray) (n : Nat) : ByteArray := + let rec go (k : Nat) (acc : ByteArray) : ByteArray := + if k < n then + if h : k < src.size then + go (k + 1) (acc.push (src[k]'h)) + else + acc + else + acc + termination_by n - k + go 0 dst + +private def holdHeader (buf : ByteArray) : Except DecodeError Parser := + if h0 : buf.size = 0 then + .ok empty + else if h : buf.size < headerSize then + .ok ⟨.partialHeader buf h (by omega)⟩ + else + .error .malformed + +/-- The suffix of a pull. A full frame at the front stays in `ready`. +A short header, or a data header whose body is still short, becomes +that partial shape. An illegal header fails before a body is kept. -/ +private def classify (b : ByteArray) : Except DecodeError Parser := + if h0 : b.size = 0 then + .ok empty + else if h : b.size < headerSize then + .ok ⟨.partialHeader b h (by omega)⟩ + else + match Header.decodeB? (b.extract 0 headerSize) with + | .error e => .error e + | .ok (header, _) => + if hid : header.streamId ≠ 0 then + if header.kind = Kind.data then + let n := header.length.toNat + if hlen : lengthOk Kind.data n = true then + have lenOk := lengthOk_data hlen + if b.size < headerSize + n then + let bodyBytes := b.extract headerSize b.size + if hgot : bodyBytes.size < n then + .ok ⟨.partialBody header.streamId n (b.extract 0 headerSize) bodyBytes hid lenOk hgot⟩ + else + .error .malformed + else + .ok ⟨.ready b⟩ + else + .error .malformed + else if header.length = 0 then + .ok ⟨.ready b⟩ + else + .error .malformed + else + .error .malformed + +private def emitData (id : UInt32) (declared : Nat) (bodyBytes : ByteArray) + (idOk : id ≠ 0) (lenOk : 0 < declared ∧ declared ≤ testPayloadCap) : + Except DecodeError Frame := + let payload := toNatList bodyBytes + if hlen : payload.length = declared then + .ok <| Frame.ofData id payload idOk (toNatList_lt bodyBytes) (by + simp only [lengthOk, hlen, Bool.and_eq_true, bne_iff_ne, decide_eq_true_eq] + exact ⟨Nat.ne_of_gt lenOk.1, lenOk.2⟩) + else + .error .malformed + +/-- Emit at most `budget` frames from `b`, starting at `i`. +Budget 0 keeps the suffix, including a complete frame, for a later pull. -/ +private def pullBytes : Nat → ByteArray → Nat → List Frame → Except DecodeError (List Frame × Parser) + | 0, b, i, acc => + (classify (b.extract i b.size)).map fun p => (acc.reverse, p) + | budget + 1, b, i, acc => + if i ≥ b.size then + .ok (acc.reverse, empty) + else if b.size - i < headerSize then + (holdHeader (b.extract i b.size)).map fun p => (acc.reverse, p) + else + match Header.decodeB? (b.extract i (i + headerSize)) with + | .error e => .error e + | .ok (header, _) => + let j := i + headerSize + if hid : header.streamId ≠ 0 then + if hk : header.kind = Kind.data then + let n := header.length.toNat + if hlen : lengthOk Kind.data n = true then + have lenOk := lengthOk_data hlen + if b.size - j < n then + let bodyBytes := b.extract j b.size + if hgot : bodyBytes.size < n then + .ok (acc.reverse, ⟨.partialBody header.streamId n + (b.extract i j) bodyBytes hid lenOk hgot⟩) + else + .error .malformed + else + match emitData header.streamId n (b.extract j (j + n)) hid lenOk with + | .error e => .error e + | .ok frame => pullBytes budget b (j + n) (frame :: acc) + else + .error .malformed + else if header.length = 0 then + let frame := Frame.ofControl header.kind header.streamId hk hid + pullBytes budget b j (frame :: acc) + else + .error .malformed + else + .error .malformed + +/-- Append `chunk` to `p` and emit at most `budget` frames. +A partial body copies at most the bytes still required. It does not +encode its header again. -/ +private def run (p : Parser) (chunk : ByteArray) (budget : Nat) : + Except DecodeError (List Frame × Parser) := + match p.raw with + | .ready input => + if input.size = 0 then + pullBytes budget chunk 0 [] + else if chunk.size = 0 then + pullBytes budget input 0 [] + else + pullBytes budget (pushCount input chunk chunk.size) 0 [] + | .partialHeader buf _ _ => + let need := headerSize - buf.size + let take := min need chunk.size + let filled := pushCount buf chunk take + let rest := chunk.extract take chunk.size + if h : filled.size < headerSize then + if h0 : filled.size = 0 then + .ok ([], empty) + else + .ok ([], ⟨.partialHeader filled h (by omega)⟩) + else + pullBytes budget (filled ++ rest) 0 [] + | .partialBody id declared headerBytes bodyBytes idOk lenOk _ => + let need := declared - bodyBytes.size + let take := min need chunk.size + let filled := pushCount bodyBytes chunk take + let rest := chunk.extract take chunk.size + if hgot : filled.size < declared then + .ok ([], ⟨.partialBody id declared headerBytes filled idOk lenOk hgot⟩) + else if budget = 0 then + .ok ([], ⟨.ready (headerBytes ++ filled ++ rest)⟩) + else + match emitData id declared filled idOk lenOk with + | .error e => .error e + | .ok frame => pullBytes (budget - 1) rest 0 [frame] + +/-- Emit at most `parserFrameBudget` complete frames already in `p`. +The chunk is empty, so this needs no new connection bytes. -/ +def drain (p : Parser) : Except DecodeError (List Frame × Parser) := + run p ByteArray.empty parserFrameBudget + +/-- Append one `ByteArray` chunk and emit at most `parserFrameBudget` +frames. The copy of a data body waits until `lengthOk` accepts the +length, and it copies at most the bytes still required. Every chunk +boundary is accepted, so a long read is not a protocol error. A frame +may span any number of chunks. This function does not turn the chunk +into a model list, and it does not cap the bytes it retains. The +session slices a connection read to `testPayloadCap` before the call. -/ +def feedB (p : Parser) (chunk : ByteArray) : Except DecodeError (List Frame × Parser) := + run p chunk parserFrameBudget + +/-- `feedB` for a model chunk. A non-byte is `malformed` before the +chunk becomes a `ByteArray`. The pull emits at most `parserFrameBudget` +frames. -/ +def feed (p : Parser) (chunk : List Nat) : Except DecodeError (List Frame × Parser) := + if chunk.any (· ≥ 256) then + .error .malformed + else + feedB p (ofNatList chunk) + +end Parser + +end Libp2p.Mux.Test.Wire diff --git a/packages/libp2p/mux/docs/ARCHITECTURE.md b/packages/libp2p/mux/docs/ARCHITECTURE.md index 1c8917eb..5a730133 100644 --- a/packages/libp2p/mux/docs/ARCHITECTURE.md +++ b/packages/libp2p/mux/docs/ARCHITECTURE.md @@ -17,20 +17,22 @@ same node code ran under both muxers in-process and over loopback TCP | Module | Holds, and why | |---|---| -| `Libp2p/Mux.lean` | the root module; one import line per public submodule. It does not re-export the mock or the private test protocol | +| `Libp2p/Mux.lean` | the root module; one import line per public submodule. It does not re-export the mock or the private test wire | | `Libp2p/Mux/Constants.lean` | `Mux.yamuxId` (`/yamux/1.0.0`), `Mux.mplexId` (`/mplex/6.7.0`), `Mux.testId` (`/leanp2p/test-mux/1.0.0`), and `Mux.mplexMessageCap` (1 MiB); the named ids of §3.4 and the cap of §5.1, one home per fact (§3.8) | | `Libp2p/Mux/Yamux.lean` | `Mux.yamux`, the yamux value over the session of `net/yamux`; the `Role` conversion below. Planned | | `Libp2p/Mux/Mplex.lean` | the mplex message codec and `Mux.mplex` (P2, §8); the spike reference is `Conn/Mplex.lean` (§10 row 15, §7 row 10). Planned | -| `Libp2p/Mux/Test.lean` | `Mux.test : Muxer`, the independent test session under `Mux.testId`. Planned. Private frame and session state stay behind this value | +| `Libp2p/Mux/Test.lean` | `Mux.test : Muxer`, the independent test session under `Mux.testId`. Planned | +| `Libp2p/Mux/Test/Wire.lean` | the private header, frame, and incremental parser. Present. Internal, and tests import it directly | | `Libp2p/Mux/Mock/Core.lean` | the temporary `ProtocolId` used until W5.2. Present. Isolated, and deleted when `Libp2p.Core` lands | -All declarations live in the `Libp2p.Mux` namespace, and the root -module re-exports the public submodules. +Public declarations live in the `Libp2p.Mux` namespace, and the root +module re-exports the public submodules. The private wire declarations +live in `Libp2p.Mux.Test.Wire`. -`Libp2p/Mux.lean`, `Libp2p/Mux/Constants.lean`, and -`Libp2p/Mux/Mock/Core.lean` are in the tree. `Yamux.lean`, -`Mplex.lean`, and `Test.lean` are the remaining modules of this -package. +`Libp2p/Mux.lean`, `Libp2p/Mux/Constants.lean`, +`Libp2p/Mux/Mock/Core.lean`, and `Libp2p/Mux/Test/Wire.lean` are in +the tree. `Yamux.lean`, `Mplex.lean`, and `Test.lean` are the remaining +modules of this package. ## Public surface @@ -182,11 +184,18 @@ id is never reused. The header codec and the frame codec cover values their types can construct. The parser performs the semantic checks. It checks a -declared payload length against `testPayloadCap` before it allocates +declared payload length against `testPayloadCap` before it retains the body. An unknown kind, an id of zero, a control length other than zero, a data length of zero, or a data length above the cap fails at that check. +One pull emits at most `parserFrameBudget` complete frames. The +private wire module is the home of that constant. A later pull emits +a complete frame the first pull kept. It needs no new connection +bytes. A partial body stores the bytes already received and the +length still required. The next chunk does not encode that header +again. + A partial header or a partial body waits while the connection stays open. A connection end during a partial header or body is a protocol error. A connection end with an empty parser is the connection-end @@ -334,9 +343,10 @@ admitted writes is at most the live-stream cap times the admitted-write cap. The length of each retained array is the caller's, and it has no session cap. -`Connection.read` returns one array of any positive length. The reader -keeps that array while it copies slices from it. That array is not -part of the subtotal. +`Connection.read` may return an array of any positive length. `feedB` +does not reject a chunk because of its size. The session preserves the +copied-byte subtotal by feeding slices no larger than `testPayloadCap`. +The original array stays outside that subtotal while the reader owns it. A caller parked outside admission holds its own array until the call returns. The count of those callers has no session cap. Maps, From 9eb3bf7ad58d5b92d9ce47cd1afeab268cf0f548 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 12:49:35 +0530 Subject: [PATCH 069/125] feat(mux): add the temporary core seam --- Tests/Mux.lean | 62 +++++++++++++- packages/libp2p/mux/Libp2p/Mux/Constants.lean | 9 ++- packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean | 81 +++++++++++++++---- .../mux/Libp2p/Mux/Mock/Core/ProtocolId.lean | 27 +++++++ packages/libp2p/mux/docs/ARCHITECTURE.md | 21 +++-- 5 files changed, 173 insertions(+), 27 deletions(-) create mode 100644 packages/libp2p/mux/Libp2p/Mux/Mock/Core/ProtocolId.lean diff --git a/Tests/Mux.lean b/Tests/Mux.lean index aaf42ab1..3e054eb5 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -1,11 +1,13 @@ import Tests.Support import Libp2p.Mux +import Libp2p.Mux.Mock.Core import Libp2p.Mux.Test.Wire /-! # Mux suite (unit W5.4) -The constant checks, and the private test-wire codec and parser +The constant checks, the temporary core seam, and the private +test-wire codec and parser (`packages/libp2p/mux/docs/ARCHITECTURE.md`). -/ @@ -402,6 +404,63 @@ def run (r : Runner) : IO Unit := do end Tests.Mux.Wire +namespace Tests.Mux.Seam + +open Libp2p.Mux +open Libp2p.Mux.Mock.Core + +/-- The detail carried by one seam error. -/ +private def detail : Error → String + | .protocol d => d + | .authentication d => d + | .timeout d => d + | .closed d => d + +example (s : MuxSession) : + ProtocolId → ExceptT Error Conc.Async Streams.Stream := + s.openStream + +example (s : MuxSession) : Conc.Async (Option Streams.Stream) := + s.accept + +example (s : MuxSession) : IO Unit := + s.close + +example (s : MuxSession) : Conc.Async (Except Error Unit) := + s.join + +example (m : Muxer) : + Role → Streams.Connection → ByteArray → ExceptT Error Conc.Async MuxSession := + m.open + +/-- A value with `Mux.testId` and the seam fields. It runs no session. -/ +example : Muxer := { + id := testId + «open» := fun _ _ _ => + pure { + openStream := fun _ => throw (Error.closed "unused") + accept := pure none + close := pure () + join := pure (.ok ()) + } +} + +/-- The temporary seam types. -/ +def run (r : Runner) : IO Unit := do + r.section "mux core seam" "packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean" + r.check "the two roles are distinct" + (decide (Role.initiator ≠ Role.responder)) + let p := Error.protocol "wire" + let a := Error.authentication "wire" + let t := Error.timeout "wire" + let c := Error.closed "wire" + r.check "the four error arms keep their detail and stay distinct" + (detail p == "wire" && detail a == "wire" && detail t == "wire" && detail c == "wire" + && decide (p ≠ a) && decide (p ≠ t) && decide (p ≠ c) + && decide (a ≠ t) && decide (a ≠ c) && decide (t ≠ c)) + +end Tests.Mux.Seam + namespace Tests open Libp2p.Mux @@ -421,5 +480,6 @@ def Mux.run (r : Runner) : IO Unit := do && yamuxId.toString != testId.toString && mplexId.toString != testId.toString) Mux.Wire.run r + Mux.Seam.run r end Tests diff --git a/packages/libp2p/mux/Libp2p/Mux/Constants.lean b/packages/libp2p/mux/Libp2p/Mux/Constants.lean index ec23874f..1287a263 100644 --- a/packages/libp2p/mux/Libp2p/Mux/Constants.lean +++ b/packages/libp2p/mux/Libp2p/Mux/Constants.lean @@ -1,4 +1,4 @@ -import Libp2p.Mux.Mock.Core +import Libp2p.Mux.Mock.Core.ProtocolId /-! # The constants of `libp2p/mux` @@ -7,9 +7,10 @@ Each fact has one home (ARCHITECTURE §3.8). This module holds the three protocol ids and the mplex length cap. The production definitions of the ids live only here (§3.4). -`ProtocolId` is the temporary copy in `Libp2p.Mux.Mock.Core`. W5.2 -has not merged, so the real `Libp2p.Core.ProtocolId` is not on this -branch. When W5.2 lands, this import becomes `Libp2p.Core`. The four +`ProtocolId` is the temporary copy in +`Libp2p.Mux.Mock.Core.ProtocolId`. This module imports that file +alone, so the constants do not import the session seam. W5.2 has not +merged. When W5.2 lands, this import becomes `Libp2p.Core`. The four public names stay. -/ diff --git a/packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean b/packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean index c199d5fb..f1f67417 100644 --- a/packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean +++ b/packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean @@ -1,26 +1,75 @@ +import Libp2p.Mux.Mock.Core.ProtocolId +import Conc +import Streams + /-! -# Mock: the `ProtocolId` surface of `libp2p/core` - -W5.2 (`libp2p/core`) is not yet merged, so this package builds against -a temporary copy of the documented `ProtocolId` surface (RULES.md, -"Working ahead of the tree"; the core pair, public surface). The copy -is `structure ProtocolId where toString : String`. It derives -`DecidableEq` and `Hashable` from that surface, and `Repr` from the -standard identifier classes (ARCHITECTURE §3.2, §3.4). - -The module holds no protocol logic. It adds no trusted declarations. -When W5.2 lands, delete this module. `Constants.lean` then imports -`Libp2p.Core` in its place. +# Mock: the muxer seam of `libp2p/core` + +W5.2 is not merged. This module copies the seam that `Mux.test` must +compile against (the core pair, public surface; the mux pair, public +surface). It holds no protocol logic. + +`Role` and the four `Error` arms follow the core architecture. The +`String` on each arm is a provisional rendered payload. `MuxSession.join` +is a provisional name for the completion the owner joins. Neither +spelling is a landed `Libp2p.Core` declaration. + +`Connection` and `Stream` come from `Streams`. `Async` comes from +`Conc`. This module does not copy those types. When W5.2 lands, delete +this module and `Mock/Core/ProtocolId.lean`. The imports move to the +`Libp2p.Core` modules that own the same names. -/ set_option autoImplicit false namespace Libp2p.Mux.Mock.Core -/-- A protocol id: an open wrapped string (ARCHITECTURE §3.4). Peers -define ids, so the set stays open. -/ -structure ProtocolId where - toString : String +open Conc + +/-- The local direction of a connection. The core architecture names +`initiator` and `responder`, and gives the set no wire code. -/ +inductive Role where + | initiator + | responder deriving DecidableEq, Repr, Hashable +/-- The coarse seam error. The four arms are the categories in the +core architecture. `detail` is a provisional `String` rendering of the +implementation error. -/ +inductive Error where + | protocol (detail : String) + | authentication (detail : String) + | timeout (detail : String) + | closed (detail : String) + deriving DecidableEq, Repr, Hashable + +/-- The session view a `Muxer` returns. `join` is the provisional +completion the owner waits on after `close`. -/ +structure MuxSession where + /-- Open one outbound stream. The argument is a `ProtocolId`. -/ + openStream : + ProtocolId → ExceptT Error Async Streams.Stream + /-- The next inbound stream. `none` means the session has closed. -/ + accept : + Async (Option Streams.Stream) + /-- Close the session. -/ + close : + IO Unit + /-- Wait until the session tasks end. `.ok ()` is an orderly end. + `.error` is the protocol result. A fault throws. -/ + join : + Async (Except Error Unit) + +/-- A muxer selected by its protocol id. `«open»` is the field `open`: +`open` is a Lean keyword. -/ +structure Muxer where + /-- The id the negotiator compares. -/ + id : ProtocolId + /-- Start a session. The `ByteArray` is the negotiation leftover. -/ + «open» : + Role → + Streams.Connection → + ByteArray → + ExceptT Error Async MuxSession + end Libp2p.Mux.Mock.Core diff --git a/packages/libp2p/mux/Libp2p/Mux/Mock/Core/ProtocolId.lean b/packages/libp2p/mux/Libp2p/Mux/Mock/Core/ProtocolId.lean new file mode 100644 index 00000000..4c29041e --- /dev/null +++ b/packages/libp2p/mux/Libp2p/Mux/Mock/Core/ProtocolId.lean @@ -0,0 +1,27 @@ +/-! +# Mock: the `ProtocolId` surface of `libp2p/core` + +W5.2 (`libp2p/core`) is not yet merged, so this package builds against +a temporary copy of the documented `ProtocolId` surface (RULES.md, +"Working ahead of the tree"; the core pair, public surface). The copy +is `structure ProtocolId where toString : String`. It derives +`DecidableEq` and `Hashable` from that surface, and `Repr` from the +standard identifier classes (ARCHITECTURE §3.2, §3.4). + +This module imports nothing. `Constants.lean` imports it alone, so the +protocol ids stay free of the session seam. The seam lives in +`Libp2p/Mux/Mock/Core.lean`. When W5.2 lands, delete both mock +modules. `Constants.lean` then imports `Libp2p.Core`. +-/ + +set_option autoImplicit false + +namespace Libp2p.Mux.Mock.Core + +/-- A protocol id: an open wrapped string (ARCHITECTURE §3.4). Peers +define ids, so the set stays open. -/ +structure ProtocolId where + toString : String + deriving DecidableEq, Repr, Hashable + +end Libp2p.Mux.Mock.Core diff --git a/packages/libp2p/mux/docs/ARCHITECTURE.md b/packages/libp2p/mux/docs/ARCHITECTURE.md index 5a730133..755fdd83 100644 --- a/packages/libp2p/mux/docs/ARCHITECTURE.md +++ b/packages/libp2p/mux/docs/ARCHITECTURE.md @@ -23,16 +23,18 @@ same node code ran under both muxers in-process and over loopback TCP | `Libp2p/Mux/Mplex.lean` | the mplex message codec and `Mux.mplex` (P2, §8); the spike reference is `Conn/Mplex.lean` (§10 row 15, §7 row 10). Planned | | `Libp2p/Mux/Test.lean` | `Mux.test : Muxer`, the independent test session under `Mux.testId`. Planned | | `Libp2p/Mux/Test/Wire.lean` | the private header, frame, and incremental parser. Present. Internal, and tests import it directly | -| `Libp2p/Mux/Mock/Core.lean` | the temporary `ProtocolId` used until W5.2. Present. Isolated, and deleted when `Libp2p.Core` lands | +| `Libp2p/Mux/Mock/Core/ProtocolId.lean` | the temporary `ProtocolId`. Present. `Constants.lean` imports only this module, so the constants stay free of `Conc` and `Streams` | +| `Libp2p/Mux/Mock/Core.lean` | the temporary `Role`, `Error`, `Muxer`, and `MuxSession` that let W5.4 compile before W5.2. Present. The public root does not import it | Public declarations live in the `Libp2p.Mux` namespace, and the root module re-exports the public submodules. The private wire declarations live in `Libp2p.Mux.Test.Wire`. `Libp2p/Mux.lean`, `Libp2p/Mux/Constants.lean`, -`Libp2p/Mux/Mock/Core.lean`, and `Libp2p/Mux/Test/Wire.lean` are in -the tree. `Yamux.lean`, `Mplex.lean`, and `Test.lean` are the remaining -modules of this package. +`Libp2p/Mux/Mock/Core/ProtocolId.lean`, `Libp2p/Mux/Mock/Core.lean`, +and `Libp2p/Mux/Test/Wire.lean` are in the tree. `Yamux.lean`, +`Mplex.lean`, and `Test.lean` are the remaining modules of this +package. ## Public surface @@ -75,8 +77,15 @@ terminal state, admission gates, and scheduling state are private. They do not appear in public Lean declarations. Temporary W5.2 types live only under `Libp2p/Mux/Mock/`. -`Mock/Core.lean` holds `ProtocolId`. The package deletes that mock -when `Libp2p.Core` lands. The private test protocol is not part of +`Mock/Core/ProtocolId.lean` holds `ProtocolId`, and `Constants.lean` +imports that module alone. `Mock/Core.lean` holds `Role`, `Error`, +`Muxer`, and `MuxSession`, so this package can compile before W5.2. +The four error arms are the categories in the core architecture. +Each arm carries a `String` as a provisional rendered payload. +`MuxSession.join` is the provisional name of the completion the owner +joins. W5.2 has not landed these declarations. When it lands, delete +both mock modules. The imports then move to the `Libp2p.Core` modules +that own the same names. The private test protocol is not part of the mock. ### Private frame From 55c3dfe8ed46d975cff0dd0093afa40e8830edc2 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 15:12:13 +0530 Subject: [PATCH 070/125] feat(mux): add pure test-session state --- Tests/Mux.lean | 1136 ++++++++++++++++- .../libp2p/mux/Libp2p/Mux/Test/State.lean | 985 ++++++++++++++ packages/libp2p/mux/docs/ARCHITECTURE.md | 149 ++- 3 files changed, 2243 insertions(+), 27 deletions(-) create mode 100644 packages/libp2p/mux/Libp2p/Mux/Test/State.lean diff --git a/Tests/Mux.lean b/Tests/Mux.lean index 3e054eb5..f8b2cdb5 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -2,12 +2,13 @@ import Tests.Support import Libp2p.Mux import Libp2p.Mux.Mock.Core import Libp2p.Mux.Test.Wire +import Libp2p.Mux.Test.State /-! # Mux suite (unit W5.4) -The constant checks, the temporary core seam, and the private -test-wire codec and parser +The constant checks, the temporary core seam, the private +test-wire codec and parser, and the pure session transitions (`packages/libp2p/mux/docs/ARCHITECTURE.md`). -/ @@ -461,6 +462,1136 @@ def run (r : Runner) : IO Unit := do end Tests.Mux.Seam +namespace Tests.Mux.State + +open Libp2p.Mux.Mock.Core +open Libp2p.Mux.Test.Wire +open Libp2p.Mux.Test.State + +private def byte (n : UInt8) : ByteArray := + ⟨#[n]⟩ + +private def filled (n : Nat) (v : UInt8) : ByteArray := + ⟨Array.replicate n v⟩ + +private def tally (p : Action → Bool) (as : List Action) : Nat := + as.foldl (fun n a => if p a then n + 1 else n) 0 + +private def isLivePermit : Action → Bool + | .returnLivePermit => true + | _ => false + +private def isWritePermit : Action → Bool + | .returnWritePermit _ => true + | _ => false + +private def livePermits (as : List Action) : Nat := + tally isLivePermit as + +private def writePermits (as : List Action) : Nat := + tally isWritePermit as + +private def writePermitIds (as : List Action) : List UInt32 := + as.filterMap fun a => + match a with + | .returnWritePermit id => some id + | _ => none + +private def acceptPermitIds (as : List Action) : List UInt32 := + as.filterMap fun a => + match a with + | .returnAcceptPermit id => some id + | _ => none + +private def okW : Except Streams.Error Unit := .ok () + +private def resetW : Except Streams.Error Unit := .error .reset + +private def closedW : Except Streams.Error Unit := .error .closed + +private def resolutions (as : List Action) : List (Nat × Except Streams.Error Unit) := + as.filterMap fun a => + match a with + | .resolveWrite tag result => some (tag, result) + | _ => none + +private def answerEq (a b : Except Streams.Error Unit) : Bool := + match a, b with + | .ok (), .ok () => true + | .error x, .error y => x == y + | _, _ => false + +private def answersEq : List (Nat × Except Streams.Error Unit) → + List (Nat × Except Streams.Error Unit) → Bool + | [], [] => true + | (t1, a1) :: xs, (t2, a2) :: ys => + t1 == t2 && answerEq a1 a2 && answersEq xs ys + | _, _ => false + +private def resolveCount (tag : Nat) (result : Except Streams.Error Unit) + (as : List Action) : Nat := + tally (fun a => + match a with + | .resolveWrite t r => t == tag && answerEq r result + | _ => false) as + +private def isProtocol (c : Cause) (e : Error) : Bool := + Cause.isProtocol c e + +private def isRunning (c : Cause) : Bool := + match c with + | .running => true + | _ => false + +private def isOwnerClose (c : Cause) : Bool := + match c with + | .ownerClose => true + | _ => false + +private def isConnectionEnd (c : Cause) : Bool := + match c with + | .connectionEnd => true + | _ => false + +private def faultText (c : Cause) : Option String := + match Cause.fault? c with + | some e => some (toString e) + | none => none + +private def terminalText (as : List Action) : Option String := + as.foldl (fun acc a => + match a with + | .terminal c => faultText c + | _ => acc) none + +private def portEnds (as : List Action) : List (UInt32 × PortEnd) := + as.filterMap fun a => + match a with + | .setPort id reason => some (id, reason) + | _ => none + +private def findIdx (p : Action → Bool) (as : List Action) : Option Nat := + let rec go : Nat → List Action → Option Nat + | _, [] => none + | i, a :: rest => if p a then some i else go (i + 1) rest + go 0 as + +private def isSetPort (id : UInt32) (reason : PortEnd) : Action → Bool + | .setPort i r => i == id && r == reason + | _ => false + +private def isCloseInbox (id : UInt32) : Action → Bool + | .closeInbox i => i == id + | _ => false + +/-- `setPort` for this id and reason occurs, and it precedes `closeInbox`. -/ +private def portBeforeInbox (as : List Action) (id : UInt32) (reason : PortEnd) : Bool := + match findIdx (isSetPort id reason) as, findIdx (isCloseInbox id) as with + | some i, some j => i < j + | _, _ => false + +private def isCloseWrite (id : UInt32) : Action → Bool + | .closeWritePermits i => i == id + | _ => false + +/-- `setPort` for this id and reason precedes `closeWritePermits`. -/ +private def modeBeforePermits (as : List Action) (id : UInt32) (reason : PortEnd) : Bool := + match findIdx (isSetPort id reason) as, findIdx (isCloseWrite id) as with + | some i, some j => i < j + | _, _ => false + +private def closeWriteIds (as : List Action) : List UInt32 := + as.filterMap fun a => + match a with + | .closeWritePermits id => some id + | _ => none + +private def enqueueIds (as : List Action) : List UInt32 := + as.filterMap fun a => + match a with + | .enqueueAccept id => some id + | _ => none + +private def closedAccept (a : Action) : Bool := + match a with + | .closeAccept => true + | _ => false + +private def openIds (as : List Action) : List UInt32 := + as.filterMap fun a => + match a with + | .resolveOpen (.ok id) => some id + | _ => none + +private def openErrors (as : List Action) : List Error := + as.filterMap fun a => + match a with + | .resolveOpen (.error e) => some e + | _ => none + +private def chunkBytes (as : List Action) : List ByteArray := + as.filterMap fun a => + match a with + | .sendChunk _ _ bytes => some bytes + | _ => none + +private def chunkIds (as : List Action) : List UInt32 := + as.filterMap fun a => + match a with + | .sendChunk id _ _ => some id + | _ => none + +private def chunkTags (as : List Action) : List Nat := + as.filterMap fun a => + match a with + | .sendChunk _ tag _ => some tag + | _ => none + +private def sentKinds (as : List Action) : List Kind := + as.filterMap fun a => + match a with + | .sendFrame frame => some frame.kind + | .sendChunk _ _ _ => some .data + | _ => none + +private def delivers (as : List Action) : Nat := + tally (fun a => match a with | .deliver _ _ => true | _ => false) as + +private def isErr (r : Except InvariantError Step) (e : InvariantError) : Bool := + match r with + | .error got => got == e + | .ok _ => false + +/-- Run one happy-path transition. An invariant error names `op` and +the canonical diagnostic, and it stops that section. -/ +private def require {α : Type} (op : String) (x : Except InvariantError α) : ExceptT String IO α := + match x with + | .ok a => pure a + | .error e => throw s!"{op}: {e.message}" + +/-- Append `ackSend` when the step left an intent. -/ +private def acknowledged (step : Step) : Except InvariantError Step := + if step.state.outbound.isNone then + .ok step + else + match State.ackSend step.state with + | .ok done => .ok ⟨done.state, step.actions ++ done.actions⟩ + | .error e => .error e + +private def openAck (s : State) (id : UInt32) (gotLive gotAccept : Bool) : + Except InvariantError Step := + match State.openRemote s id gotLive gotAccept with + | .error e => .error e + | .ok step => acknowledged step + +private def localReset (s : State) (id : UInt32) : Except InvariantError Step := + match State.resetLocal s id with + | .error e => .error e + | .ok step => acknowledged step + +private def openN : Nat → State → List UInt32 → Except InvariantError (State × List UInt32) + | 0, s, ids => .ok (s, ids) + | n + 1, s, ids => + match acknowledged (State.openLocal s) with + | .error e => .error e + | .ok step => openN n step.state (ids ++ openIds step.actions) + +private def pumpN : Nat → State → List Action → Except InvariantError (State × List Action) + | 0, s, acc => .ok (s, acc) + | n + 1, s, acc => + match acknowledged (State.pump s) with + | .error e => .error e + | .ok step => pumpN n step.state (acc ++ step.actions) + +private def admitAll (s : State) (id : UInt32) (start : Nat) (bytes : List ByteArray) : + State × List Action := + let rec go (s : State) (acts : List Action) (n : Nat) : List ByteArray → State × List Action + | [] => (s, acts) + | b :: rest => + let step := State.admitWrite s id n b + go step.state (acts ++ step.actions) (n + 1) rest + go s [] start bytes + +private theorem id1 : (1 : UInt32) ≠ 0 := by decide + +private def oneByte : List Nat := [9] + +private theorem oneByte_lt : ∀ x ∈ oneByte, x < 256 := by decide + +private theorem oneByte_ok : lengthOk Kind.data oneByte.length = true := by decide + +private def dataFrame : Frame := + Frame.ofData 1 oneByte id1 oneByte_lt oneByte_ok + +private def twoBytes : List Nat := [1, 2] + +private theorem twoBytes_lt : ∀ x ∈ twoBytes, x < 256 := by decide + +private theorem twoBytes_ok : lengthOk Kind.data twoBytes.length = true := by decide + +private def twoFrame : Frame := + Frame.ofData 1 twoBytes id1 twoBytes_lt twoBytes_ok + +private def manyFrames (n : Nat) : List Nat := + (List.range n).flatMap fun _ => Frame.encode dataFrame + +private theorem openNotData : Kind.open ≠ Kind.data := by decide + +private theorem id2 : (2 : UInt32) ≠ 0 := by decide + +private def openFrame : Frame := + Frame.ofControl .open 2 openNotData id2 + +private def openFrames (n : Nat) : List Nat := + (List.range n).flatMap fun _ => Frame.encode openFrame + +private def applyAll (s : State) (frames : List Frame) : + Except InvariantError (State × List Action) := + frames.foldlM (fun (acc : State × List Action) f => + let (s, acts) := acc + match State.apply s f with + | .error e => .error e + | .ok step => .ok (step.state, acts ++ step.actions)) (s, []) + +/-- Local and remote id progression. -/ +private def runIds (r : Runner) : ExceptT String IO Unit := do + let initiator := State.empty .initiator + let responder := State.empty .responder + r.check "an initiator starts at local 1 and remote 2" + (initiator.nextLocal == 1 && initiator.nextRemote == 2) + r.check "a responder starts at local 2 and remote 1" + (responder.nextLocal == 2 && responder.nextRemote == 1) + let (initState, initIds) ← require "openN" (openN 3 initiator []) + let (respState, respIds) ← require "openN" (openN 3 responder []) + r.check "an initiator issues 1, 3, and 5" + (decide (initIds = [1, 3, 5]) && initState.nextLocal == 7) + r.check "a responder issues 2, 4, and 6" + (decide (respIds = [2, 4, 6]) && respState.nextLocal == 8) + r.check "issueId refuses zero" + (issueId 0 true == none && issueId 0 false == none) + r.check "issueId refuses the wrong parity" + (issueId 2 true == none && issueId 1 false == none) + let zero ← require "openAck" (openAck initiator 0 false false) + r.check "a zero remote id is a protocol failure and does not advance" + (isProtocol zero.state.cause protocolZero && + zero.state.nextRemote == 2 && + livePermits zero.actions == 0 && + acceptPermitIds zero.actions == []) + let wrong ← require "openAck" (openAck initiator 1 false false) + r.check "a wrong-parity remote id is a protocol failure and does not advance" + (isProtocol wrong.state.cause protocolParity && + wrong.state.nextRemote == 2 && + livePermits wrong.actions == 0 && + acceptPermitIds wrong.actions == []) + let reserved ← require "openAck" (openAck initiator 0 true true) + r.check "a reservation on an invalid open is returned" + (isProtocol reserved.state.cause protocolZero && + reserved.state.nextRemote == 2 && + livePermits reserved.actions == 1 && + decide (acceptPermitIds reserved.actions = [0])) + let lastOdd := UInt32.size - 1 + let lastEven := UInt32.size - 2 + let oddOnce ← require "acknowledged" (acknowledged (State.openLocal { initiator with nextLocal := lastOdd })) + let evenOnce ← require "acknowledged" (acknowledged (State.openLocal { responder with nextLocal := lastEven })) + r.check "the final initiator id is issued once" + (decide (openIds oddOnce.actions = [UInt32.ofNatLT lastOdd (by decide)]) && + oddOnce.state.nextLocal == lastOdd + 2) + r.check "the final responder id is issued once" + (decide (openIds evenOnce.actions = [UInt32.ofNatLT lastEven (by decide)]) && + evenOnce.state.nextLocal == UInt32.size) + let oddStop := State.openLocal oddOnce.state + let evenStop := State.openLocal evenOnce.state + r.check "initiator exhaustion keeps the Nat and returns the live permit" + (oddStop.state.nextLocal == oddOnce.state.nextLocal && + oddStop.state.nextLocal ≥ UInt32.size && + issueId oddStop.state.nextLocal true == none && + isRunning oddStop.state.cause && + livePermits oddStop.actions == 1 && + decide (openErrors oddStop.actions = [protocolExhausted]) && + (oddStop.state.live.get? 1).isNone) + r.check "responder exhaustion keeps the Nat and returns the live permit" + (evenStop.state.nextLocal == UInt32.size && + issueId evenStop.state.nextLocal false == none && + isRunning evenStop.state.cause && + livePermits evenStop.actions == 1 && + decide (openErrors evenStop.actions = [protocolExhausted])) + r.check "a fresh session has taken no local id" + (initiator.nextLocal == firstLocal .initiator && initiator.order.isEmpty && + (initiator.live.get? 1).isNone) + +/-- Remote admission and permit accounting. -/ +private def runAdmission (r : Runner) : ExceptT String IO Unit := do + let initiator := State.empty .initiator + match State.classifyRemote initiator 0, State.classifyRemote initiator 1, + State.classifyRemote initiator 2 with + | .error zeroErr, .error parityErr, .ok () => + r.check "classifyRemote rejects an open before either permit is reserved" + (decide (zeroErr = protocolZero) && decide (parityErr = protocolParity)) + | _, _, _ => + r.check "classifyRemote rejects an open before either permit is reserved" false + let blocked ← require "openAck" (openAck initiator 2 false false) + r.check "a remote open with no reservation resets that id and retires it" + (blocked.state.nextRemote == 4 && (blocked.state.live.get? 2).isNone && + blocked.state.retired 2 && livePermits blocked.actions == 0 && + acceptPermitIds blocked.actions == [] && + portEnds blocked.actions == [] && + decide (sentKinds blocked.actions = [.reset]) && + isRunning blocked.state.cause && + (blocked.state.writeMode? 2).isNone) + let held ← require "openAck" (openAck initiator 2 true false) + r.check "a refused remote open returns the acquired live permit once" + (held.state.nextRemote == 4 && (held.state.live.get? 2).isNone && + held.state.retired 2 && livePermits held.actions == 1 && + acceptPermitIds held.actions == [] && + portEnds held.actions == [] && + decide (sentKinds held.actions = [.reset])) + let acceptOnly ← require "openAck" (openAck initiator 2 false true) + r.check "a refused remote open returns the acquired accept permit once" + (acceptOnly.state.nextRemote == 4 && acceptOnly.state.retired 2 && + livePermits acceptOnly.actions == 0 && + decide (acceptPermitIds acceptOnly.actions = [2]) && + portEnds acceptOnly.actions == [] && + (acceptOnly.state.writeMode? 2).isNone) + let nextRemote ← require "openAck" (openAck held.state 4 true true) + r.check "the next remote id is accepted after a refusal" + ((nextRemote.state.live.get? 4).isSome && nextRemote.state.nextRemote == 6 && + livePermits nextRemote.actions == 0 && + acceptPermitIds nextRemote.actions == [] && + decide (enqueueIds nextRemote.actions = [4]) && + isRunning nextRemote.state.cause) + let accepted ← require "openAck" (openAck initiator 2 true true) + let duplicate ← require "openAck" (openAck accepted.state 2 false false) + r.check "a duplicate open is a protocol failure and does not advance" + (isProtocol duplicate.state.cause protocolDuplicate && + duplicate.state.nextRemote == accepted.state.nextRemote && + livePermits duplicate.actions == 1 && + acceptPermitIds duplicate.actions == [] && + decide (enqueueIds accepted.actions = [2]) && + acceptPermitIds accepted.actions == []) + let future ← require "openAck" (openAck initiator 6 false false) + r.check "a future open is a protocol failure and does not advance" + (isProtocol future.state.cause protocolFuture && + future.state.nextRemote == 2 && + livePermits future.actions == 0 && + acceptPermitIds future.actions == [] && + portEnds future.actions == []) + let sealed := State.claim accepted.state .ownerClose + r.check "session teardown does not return a transferred accept reservation" + (livePermits sealed.actions == 1 && + acceptPermitIds sealed.actions == [] && + tally closedAccept sealed.actions == 1 && + portBeforeInbox sealed.actions 2 .sessionClosed && + modeBeforePermits sealed.actions 2 .sessionClosed) + +/-- Queued writes and completion identity. -/ +private def runWrites (r : Runner) : ExceptT String IO Unit := do + let initiator := State.empty .initiator + let (opened, ids) ← require "openN" (openN 1 initiator []) + let id := ids.head! + let four := [byte 1, byte 2, byte 3, byte 4] + let (queued, admitActs) := admitAll opened id 1 four + let (pumped, chunks) ← require "pumpN" (pumpN 4 queued []) + r.check "four admitted writes stay in admission order" + (admitActs.isEmpty && queued.admittedCount id == 4 && + decide (queued.admittedBytes id = four) && + decide (chunkBytes chunks = four) && + decide (chunkTags chunks = [1, 2, 3, 4]) && + answersEq (resolutions chunks) [(1, okW), (2, okW), (3, okW), (4, okW)] && + decide (writePermitIds chunks = [id, id, id, id]) && + pumped.admittedCount id == 0) + let fifth := State.admitWrite queued id 5 (byte 5) + r.check "a fifth write is not stored and its permit is returned once" + (fifth.state.admittedCount id == 4 && + decide (fifth.state.admittedBytes id = four) && + resolveCount 5 closedW fifth.actions == 1 && + decide (writePermitIds fifth.actions = [id])) + let halfFifth := State.halfClose fifth.state id + let resetFifth ← require "localReset" (localReset fifth.state id) + let shutFifth := State.claim fifth.state .ownerClose + r.check "half-close, reset, and shutdown close write admission after the port mode" + (halfFifth.state.admittedCount id == 4 && + resolveCount 5 closedW halfFifth.actions == 0 && + modeBeforePermits halfFifth.actions id .halfClosed && + decide (closeWriteIds halfFifth.actions = [id]) && + resolveCount 5 resetW resetFifth.actions == 0 && + modeBeforePermits resetFifth.actions id .reset && + decide (closeWriteIds resetFifth.actions = [id]) && + resolveCount 5 closedW shutFifth.actions == 0 && + modeBeforePermits shutFifth.actions id .sessionClosed && + decide (closeWriteIds shutFifth.actions = [id]) && + resolveCount 1 closedW shutFifth.actions == 1 && + resolveCount 4 closedW shutFifth.actions == 1) + let halfAgain := State.halfClose halfFifth.state id + r.check "a second half-close releases nothing" + (halfAgain.actions.isEmpty) + let (pair, pairIds) ← require "openN" (openN 2 initiator []) + let left := pairIds[0]! + let right := pairIds[1]! + let (leftQueued, _) := admitAll pair left 1 [byte 1, byte 2] + let (bothQueued, _) := admitAll leftQueued right 3 [byte 3] + let (_, bothActs) ← require "pumpN" (pumpN 3 bothQueued []) + r.check "every tag on two streams resolves once with its own permit" + (resolveCount 1 okW bothActs == 1 && + resolveCount 2 okW bothActs == 1 && + resolveCount 3 okW bothActs == 1 && + decide (chunkTags bothActs = [1, 3, 2]) && + decide (writePermitIds bothActs = [left, right, left])) + let big := filled (testPayloadCap + 3) 1 + let (large, _) := admitAll opened id 7 [big] + let (afterLarge, largeActs) ← require "pumpN" (pumpN 2 large []) + let pieces := chunkBytes largeActs + r.check "one logical write keeps its tag and byte order across chunks" + (pieces.length == 2 && + (pieces[0]?.map (·.size) == some testPayloadCap) && + (pieces[1]?.map (·.size) == some 3) && + decide (chunkTags largeActs = [7, 7]) && + resolveCount 7 okW largeActs == 1 && + decide (writePermitIds largeActs = [id]) && + afterLarge.admittedCount id == 0) + +/-- FIN, reset, and clean-close lifecycle. -/ +private def runLifecycle (r : Runner) : ExceptT String IO Unit := do + let initiator := State.empty .initiator + let (opened, ids) ← require "openN" (openN 1 initiator []) + let id := ids.head! + let (two, _) := admitAll opened id 1 [byte 1, byte 2] + let half := State.halfClose two id + let (afterFin, finActs) ← require "pumpN" (pumpN 3 half.state []) + r.check "half-close publishes the mode and emits no fin before the queue drains" + (modeBeforePermits half.actions id .halfClosed && + decide (closeWriteIds half.actions = [id]) && + decide (portEnds half.actions = [(id, .halfClosed)]) && + decide (sentKinds finActs = [.data, .data, .fin]) && + decide (chunkTags finActs = [1, 2]) && + resolveCount 1 okW finActs == 1 && + resolveCount 2 okW finActs == 1) + let again := State.pump afterFin + r.check "a second pump does not emit another fin" + (again.actions.isEmpty) + let late := State.admitWrite half.state id 9 (byte 9) + r.check "half-close refuses a new write and returns that permit" + (late.state.admittedCount id == 2 && + resolveCount 9 closedW late.actions == 1 && + decide (writePermitIds late.actions = [id])) + let resetHalf ← require "localReset" (localReset half.state id) + r.check "reset replaces half-close and does not close admission again" + (resolveCount 1 resetW resetHalf.actions == 1 && + resolveCount 2 resetW resetHalf.actions == 1 && + decide (writePermitIds resetHalf.actions = [id, id]) && + decide (sentKinds resetHalf.actions = [.reset]) && + portBeforeInbox resetHalf.actions id .reset && + decide (closeWriteIds resetHalf.actions = []) && + livePermits resetHalf.actions == 1 && + (resetHalf.state.live.get? id).isNone && + (resetHalf.state.writeMode? id).isNone) + let emptyHalf := State.writeEmpty half.state 3 .halfClosed + r.check "an empty write after half-close resolves closed" + (answersEq (resolutions emptyHalf.actions) [(3, closedW)] && + writePermits emptyHalf.actions == 0) + let shutHalf := State.claim half.state .ownerClose + r.check "shutdown after half-close does not close write admission again" + (decide (portEnds shutHalf.actions = [(id, .sessionClosed)]) && + decide (closeWriteIds shutHalf.actions = [])) + let emptyWrite := State.writeEmpty opened 0 .open + r.check "an empty write resolves its tag from the port and takes no permit" + (answersEq (resolutions emptyWrite.actions) [(0, okW)] && + writePermits emptyWrite.actions == 0 && + emptyWrite.state.admittedCount id == 0) + let emptyReset := State.writeEmpty opened 1 .reset + r.check "an empty write on a reset port resolves reset" + (answersEq (resolutions emptyReset.actions) [(1, resetW)] && + writePermits emptyReset.actions == 0) + let (resetBase, _) := admitAll opened id 1 [byte 1, byte 2] + let reset ← require "localReset" (localReset resetBase id) + r.check "reset resolves each admitted tag and returns that stream's permits" + (resolveCount 1 resetW reset.actions == 1 && + resolveCount 2 resetW reset.actions == 1 && + decide (writePermitIds reset.actions = [id, id]) && + livePermits reset.actions == 1 && + decide (sentKinds reset.actions = [.reset]) && + (chunkBytes reset.actions).isEmpty && + portBeforeInbox reset.actions id .reset) + r.check "reset removes the id without turning absence into a write mode" + ((reset.state.live.get? id).isNone && reset.state.retired id && + (reset.state.writeMode? id).isNone) + let later ← require "dataRemote" (State.dataRemote reset.state id oneByte) + r.check "data for a retired id is ignored" + (later.actions.isEmpty && isRunning later.state.cause) + let resetAgain ← require "localReset" (localReset reset.state id) + r.check "a second reset returns no permit" + (livePermits resetAgain.actions == 0 && writePermits resetAgain.actions == 0 && + resetAgain.actions.isEmpty && portEnds resetAgain.actions == []) + let remote ← require "openAck" (openAck initiator 2 true true) + let remoteReset ← require "resetRemote" (State.resetRemote remote.state 2) + let reopened ← require "openAck" (openAck remoteReset.state 2 false false) + r.check "an open for a retired id is a protocol failure" + (remoteReset.state.retired 2 && + livePermits remoteReset.actions == 1 && + acceptPermitIds remoteReset.actions == [] && + decide (enqueueIds remote.actions = [2]) && + modeBeforePermits remoteReset.actions 2 .reset && + portBeforeInbox remoteReset.actions 2 .reset && + decide (sentKinds remoteReset.actions = []) && + isProtocol reopened.state.cause protocolDuplicate && + reopened.state.nextRemote == remoteReset.state.nextRemote && + livePermits reopened.actions == 0 && + acceptPermitIds reopened.actions == [] && + portEnds reopened.actions == []) + let fin ← require "finRemote" (State.finRemote opened id) + r.check "a received fin leaves the local write direction open" + (decide (fin.state.writeMode? id = some .open) && + decide (fin.state.readMode? id = some .fin) && + (fin.state.live.get? id).isSome && + decide (sentKinds fin.actions = []) && + portEnds fin.actions == []) + let stillWrites := State.admitWrite fin.state id 7 (byte 7) + r.check "a local write is still admitted after a received fin" + (stillWrites.actions.isEmpty && stillWrites.state.admittedCount id == 1) + let secondFin ← require "finRemote" (State.finRemote fin.state id) + let afterData ← require "apply" (State.apply fin.state dataFrame) + r.check "a duplicate fin and data after fin are ignored" + (secondFin.actions.isEmpty && afterData.actions.isEmpty && + isRunning afterData.state.cause && + decide (afterData.state.readMode? id = some .fin)) + let (bothWays, _) ← require "openN" (openN 1 initiator []) + let openedLocal ← require "openLocal" (acknowledged (State.openLocal initiator)) + let localId := (openIds openedLocal.actions).head! + let halfOnly := State.halfClose bothWays localId + let finOnly ← require "acknowledged" (acknowledged (State.pump halfOnly.state)) + let remoteFin ← require "finRemote" (State.finRemote finOnly.state localId) + r.check "the live permit returns once, when both directions are closed" + (livePermits halfOnly.actions == 0 && + livePermits finOnly.actions == 0 && + portEnds finOnly.actions == [] && + (finOnly.state.live.get? localId).isSome && + livePermits remoteFin.actions == 1 && + portEnds remoteFin.actions == [] && + (remoteFin.state.live.get? localId).isNone && + (remoteFin.state.writeMode? localId).isNone) + +/-- Terminal-cause arbitration. -/ +private def runTerminal (r : Runner) : ExceptT String IO Unit := do + let initiator := State.empty .initiator + let (opened, ids) ← require "openN" (openN 1 initiator []) + let id := ids.head! + let readerErr := IO.userError "reader" + let first := State.claim opened (.readerFault readerErr) + let second := State.claim first.state .ownerClose + r.check "the first terminal claim keeps its IO.Error" + (faultText first.state.cause == some "reader" && + terminalText first.actions == some "reader" && + faultText second.state.cause == some "reader" && + second.actions.isEmpty) + let owner := State.claim opened .ownerClose + let readerAfter := State.claim owner.state (.readerFault (IO.userError "later")) + r.check "a later reader fault does not replace owner close" + (isOwnerClose owner.state.cause && + isOwnerClose readerAfter.state.cause && + readerAfter.actions.isEmpty && + terminalText readerAfter.actions == none && + faultText owner.state.cause == none) + r.check "reset does not replace sessionClosed" + (decide (WriteMode.applyReset .sessionClosed = .sessionClosed) && + decide (WriteMode.applyReset .open = .reset) && + decide (WriteMode.applyReset .halfClosed = .reset) && + decide (WriteMode.applyReset .reset = .reset)) + r.check "session close does not replace reset" + (decide (WriteMode.applySessionClose .reset = .reset) && + decide (WriteMode.applySessionClose .open = .sessionClosed) && + decide (WriteMode.applySessionClose .sessionClosed = .sessionClosed)) + let sealed := State.claim opened .ownerClose + let resetSealed ← require "localReset" (localReset sealed.state id) + r.check "a session-closed stream stays sessionClosed after reset" + (decide (sealed.state.writeMode? id = some .sessionClosed) && + portBeforeInbox sealed.actions id .sessionClosed && + decide (resetSealed.state.writeMode? id = some .sessionClosed) && + livePermits resetSealed.actions == 0 && + writePermits resetSealed.actions == 0 && + portEnds resetSealed.actions == [] && + (sentKinds resetSealed.actions).isEmpty) + let closedWrite := State.writeEmpty sealed.state 8 .sessionClosed + r.check "a write on a session-closed stream resolves closed" + (answersEq (resolutions closedWrite.actions) [(8, closedW)]) + let (resetBase, _) := admitAll opened id 1 [byte 1] + let reset ← require "localReset" (localReset resetBase id) + let closedAfterReset := State.claim reset.state .ownerClose + let resetWrite := State.writeEmpty closedAfterReset.state 4 .reset + r.check "a later session close does not overwrite the reset port action" + (portBeforeInbox reset.actions id .reset && + modeBeforePermits reset.actions id .reset && + decide (closeWriteIds reset.actions = [id]) && + portEnds closedAfterReset.actions == [] && + decide (closeWriteIds closedAfterReset.actions = []) && + livePermits closedAfterReset.actions == 0 && + answersEq (resolutions resetWrite.actions) [(4, resetW)] && + (closedAfterReset.state.writeMode? id).isNone) + let (pair, pairIds) ← require "openN" (openN 2 initiator []) + let left := pairIds[0]! + let right := pairIds[1]! + let (withLeft, _) := admitAll pair left 10 [byte 1] + let (withBoth, _) := admitAll withLeft right 11 [byte 2, byte 3] + let closed := State.claim withBoth .ownerClose + r.check "teardown returns each stream's write permits and no extra permit" + (resolveCount 10 closedW closed.actions == 1 && + resolveCount 11 closedW closed.actions == 1 && + resolveCount 12 closedW closed.actions == 1 && + decide (writePermitIds closed.actions = [left, right, right]) && + livePermits closed.actions == 2 && + acceptPermitIds closed.actions == [] && + portBeforeInbox closed.actions left .sessionClosed && + modeBeforePermits closed.actions left .sessionClosed && + portBeforeInbox closed.actions right .sessionClosed && + modeBeforePermits closed.actions right .sessionClosed && + !portBeforeInbox closed.actions left .reset && + !portBeforeInbox closed.actions right .reset) + +/-- Parser-to-state integration and scheduling fairness. -/ +private def runParser (r : Runner) : ExceptT String IO Unit := do + let initiator := State.empty .initiator + let (opened, _) ← require "openN" (openN 1 initiator []) + match Parser.feed Parser.empty [1] with + | .ok ([], held) => + let ended := State.inboundEnd (State.setParser initiator held) + r.check "a partial header at connection end is a protocol failure" + (decide (held.gap = .partialHeader) && + isProtocol ended.state.cause protocolPartial) + let ignored := State.claim ended.state .connectionEnd + r.check "a later connection-end claim leaves the partial-frame failure" + (isProtocol ignored.state.cause protocolPartial && ignored.actions.isEmpty) + | _ => + r.check "a partial header at connection end is a protocol failure" false + r.check "a later connection-end claim leaves the partial-frame failure" false + let quiet := State.inboundEnd initiator + r.check "an empty parser at connection end selects connection end" + (isConnectionEnd quiet.state.cause && + decide (initiator.parser.gap = .empty)) + match Parser.feed Parser.empty ((Frame.encode twoFrame).dropLast) with + | .ok ([], heldBody) => + let ended := State.inboundEnd (State.setParser initiator heldBody) + r.check "a partial body at connection end is a protocol failure" + (decide (heldBody.gap = .partialBody) && + isProtocol ended.state.cause protocolPartial) + | _ => + r.check "a partial body at connection end is a protocol failure" false + let budget := parserFrameBudget * 2 + 1 + match Parser.feed Parser.empty (manyFrames budget) with + | .ok (firstPull, kept) => + let pending := State.setParser initiator kept + let noEnd := State.inboundEnd pending + let once := State.pull (State.setParser opened kept) + let twice := State.pull once.state + let (_, applied) ← require "applyAll" (applyAll once.state once.frames) + r.check "one pull returns a budget of frames and admits none of them" + (firstPull.length == parserFrameBudget && kept.hasComplete && + noEnd.actions.isEmpty && + isRunning noEnd.state.cause && + once.frames.length == parserFrameBudget && + once.actions.isEmpty && + once.state.parser.hasComplete && + twice.frames.length == 1 && + twice.actions.isEmpty && + !twice.state.parser.hasComplete && + delivers applied == parserFrameBudget) + | _ => + r.check "one pull returns a budget of frames and admits none of them" false + match Parser.feed Parser.empty (openFrames (parserFrameBudget + 1)) with + | .ok (_, keptOpen) => + let pulled := State.pull (State.setParser initiator keptOpen) + let ignored ← + match pulled.frames.head? with + | some frame => require "apply" (State.apply pulled.state frame) + | none => pure ⟨pulled.state, []⟩ + let committed ← require "openAck" (openAck pulled.state 2 true true) + r.check "a pulled open waits for an explicit reservation" + (keptOpen.hasComplete && + pulled.frames.length == 1 && + pulled.actions.isEmpty && + pulled.state.order.isEmpty && + pulled.state.nextRemote == 2 && + ignored.actions.isEmpty && + ignored.state.nextRemote == 2 && + (committed.state.live.get? 2).isSome && + committed.state.nextRemote == 4 && + livePermits committed.actions == 0 && + acceptPermitIds committed.actions == []) + | _ => + r.check "a pulled open waits for an explicit reservation" false + let (pair, pairIds) ← require "openN" (openN 2 initiator []) + let left := pairIds[0]! + let right := pairIds[1]! + let (busy, _) := admitAll pair left 1 [filled (testPayloadCap + 1) 2] + let (fairState, _) := admitAll busy right 2 [byte 8] + let (fair, fairActs) ← require "pumpN" (pumpN 3 fairState []) + r.check "admitted streams take turns between chunks" + (decide (chunkIds fairActs = [left, right, left]) && + decide (chunkTags fairActs = [1, 2, 1]) && + (chunkBytes fairActs)[0]?.map (·.size) == some testPayloadCap && + (chunkBytes fairActs)[1]?.map (·.size) == some 1 && + (chunkBytes fairActs)[2]?.map (·.size) == some 1 && + resolveCount 1 okW fairActs == 1 && + resolveCount 2 okW fairActs == 1 && + decide (writePermitIds fairActs = [right, left]) && + fair.admittedCount left == 0) + r.check "the policy caps match the architecture table" + (liveStreamCap == 32 && acceptBacklogCap == 16 && admittedWriteCap == 4) + +/-- Accept-reservation ownership. The runtime channel holds each +envelope after `enqueueAccept`. These checks do not consult the +owner after it has exited. -/ +private def runAccept (r : Runner) : ExceptT String IO Unit := do + let initiator := State.empty .initiator + let accepted ← require "openRemote" (State.openRemote initiator 2 true true) + let reset ← require "resetRemote" (State.resetRemote accepted.state 2) + r.check "reset before accept leaves the reservation with the envelope" + (decide (enqueueIds accepted.actions = [2]) && + acceptPermitIds accepted.actions == [] && + acceptPermitIds reset.actions == [] && + (reset.state.live.get? 2).isNone && + reset.state.retired 2) + let again ← require "openRemote" (State.openRemote initiator 2 true true) + let half := State.halfClose again.state 2 + let finned ← require "acknowledged" (acknowledged (State.pump half.state)) + let closed ← require "finRemote" (State.finRemote finned.state 2) + r.check "clean retirement before accept does not return the reservation" + (decide (enqueueIds again.actions = [2]) && + acceptPermitIds finned.actions == [] && + acceptPermitIds closed.actions == [] && + (closed.state.live.get? 2).isNone) + let first ← require "openRemote" (State.openRemote initiator 2 true true) + let second ← require "openRemote" (State.openRemote first.state 4 true true) + let resetSecond ← require "resetRemote" (State.resetRemote second.state 4) + r.check "reset does not release either buffered accept" + (decide (enqueueIds (first.actions ++ second.actions) = [2, 4]) && + acceptPermitIds resetSecond.actions == [] && + (resetSecond.state.live.get? 2).isSome && + (resetSecond.state.live.get? 4).isNone) + let shut := State.claim second.state .ownerClose + let againOwner := State.claim shut.state .ownerClose + r.check "shutdown closes accept and leaves each envelope to its consumer" + (decide (enqueueIds (first.actions ++ second.actions) = [2, 4]) && + acceptPermitIds shut.actions == [] && + tally closedAccept shut.actions == 1 && + againOwner.actions.isEmpty) + r.check "accepted streams transfer in admission order" + (decide (enqueueIds (first.actions ++ second.actions) = [2, 4])) + let refused ← require "openRemote" (State.openRemote second.state 6 false false) + let refusedAck ← require "ackSend" (State.ackSend refused.state) + let reused ← require "openRemote" (State.openRemote refusedAck.state 8 true true) + r.check "a missing accept permit refuses the id and the next permit admits the next id" + (refused.state.outbound.isSome && + refused.state.nextRemote == 6 && + decide (enqueueIds refused.actions = []) && + acceptPermitIds refusedAck.actions == [] && + livePermits refusedAck.actions == 0 && + refusedAck.state.nextRemote == 8 && + decide (enqueueIds reused.actions = [8]) && + reused.state.outbound.isNone && + (reused.state.live.get? 8).isSome && + reused.state.nextRemote == 10) + let held ← require "openRemote" (State.openRemote initiator 2 true false) + let heldAck ← require "ackSend" (State.ackSend held.state) + r.check "a refusal returns each acquired permit once and does not enqueue" + (decide (enqueueIds held.actions = []) && + livePermits heldAck.actions == 1 && + acceptPermitIds heldAck.actions == [] && + heldAck.state.nextRemote == 4 && + isErr (State.ackSend heldAck.state) .noOutbound && + heldAck.state.outbound.isNone) + +/-- Send intent, acknowledgement, and failure. -/ +private def runSend (r : Runner) : ExceptT String IO Unit := do + let initiator := State.empty .initiator + let (opened, ids) ← require "openN" (openN 1 initiator []) + let id := ids.head! + let err := IO.userError "write" + let big := filled (testPayloadCap + 3) 9 + let (large, _) := admitAll opened id 1 [big] + let chunk := State.pump large + let failedChunk ← require "failSend" (State.failSend chunk.state err) + let later := State.claim failedChunk.state (.readerFault (IO.userError "later")) + r.check "a failed non-final chunk resolves its tag once" + (chunk.state.admittedCount id == 1 && + chunk.actions.length == 1 && + resolveCount 1 closedW failedChunk.actions == 1 && + resolveCount 1 okW failedChunk.actions == 0 && + decide (writePermitIds failedChunk.actions = [id]) && + livePermits failedChunk.actions == 1 && + decide (sentKinds failedChunk.actions = []) && + faultText failedChunk.state.cause == some "write" && + terminalText failedChunk.actions == some "write" && + later.actions.isEmpty && + faultText later.state.cause == some "write") + let (one, _) := admitAll opened id 2 [byte 4] + let finalIntent := State.pump one + let failedFinal ← require "failSend" (State.failSend finalIntent.state err) + r.check "a failed final chunk does not lose its write permit" + (finalIntent.state.admittedCount id == 1 && + resolveCount 2 closedW failedFinal.actions == 1 && + resolveCount 2 okW failedFinal.actions == 0 && + decide (writePermitIds failedFinal.actions = [id]) && + livePermits failedFinal.actions == 1) + let openIntent := State.openLocal initiator + let failedOpen ← require "failSend" (State.failSend openIntent.state (IO.userError "open")) + r.check "a failed open wakes the opener and returns the live permit" + (openIntent.state.nextLocal == 1 && + (openIntent.state.live.get? 1).isNone && + decide (openErrors failedOpen.actions = [closedError]) && + livePermits failedOpen.actions == 1 && + failedOpen.state.nextLocal == 1 && + faultText failedOpen.state.cause == some "open" && + terminalText failedOpen.actions == some "open") + let refuseIntent ← require "openRemote" (State.openRemote initiator 2 true false) + let failedRefuse ← require "failSend" (State.failSend refuseIntent.state (IO.userError "refuse")) + r.check "a failed refusal reset returns the acquired live permit once" + (refuseIntent.state.nextRemote == 2 && + livePermits refuseIntent.actions == 0 && + failedRefuse.state.nextRemote == 2 && + livePermits failedRefuse.actions == 1 && + acceptPermitIds failedRefuse.actions == [] && + (State.claim failedRefuse.state .ownerClose).actions.isEmpty) + let acceptRefuse ← require "openRemote" (State.openRemote initiator 2 false true) + let failedAccept ← require "failSend" (State.failSend acceptRefuse.state (IO.userError "refuse-accept")) + r.check "a failed refusal reset returns the acquired accept permit once" + (decide (acceptPermitIds failedAccept.actions = [2]) && + livePermits failedAccept.actions == 0 && + failedAccept.state.nextRemote == 2 && + decide (enqueueIds acceptRefuse.actions = [])) + let (two, _) := admitAll opened id 3 [byte 1, byte 2] + let resetIntent ← require "resetLocal" (State.resetLocal two id) + let failedReset ← require "failSend" (State.failSend resetIntent.state (IO.userError "reset")) + r.check "a failed local reset resolves each tag closed and sends no fin" + ((resetIntent.state.live.get? id).isSome && + (resolutions resetIntent.actions).isEmpty && + resolveCount 3 closedW failedReset.actions == 1 && + resolveCount 4 closedW failedReset.actions == 1 && + resolveCount 3 resetW failedReset.actions == 0 && + resolveCount 4 resetW failedReset.actions == 0 && + decide (writePermitIds failedReset.actions = [id, id]) && + livePermits failedReset.actions == 1 && + portBeforeInbox failedReset.actions id .sessionClosed && + decide (sentKinds failedReset.actions = [])) + let (bare, bareIds) ← require "openN" (openN 1 initiator []) + let bareId := bareIds.head! + let half := State.halfClose bare bareId + let finIntent := State.pump half.state + let failedFin ← require "failSend" (State.failSend finIntent.state (IO.userError "fin")) + r.check "a failed fin does not lose the live permit" + ((finIntent.state.live.get? bareId).isSome && + livePermits finIntent.actions == 0 && + livePermits failedFin.actions == 1 && + decide (sentKinds failedFin.actions = []) && + (State.pump failedFin.state).actions.isEmpty) + let (retiring, retIds) ← require "openN" (openN 1 initiator []) + let retId := retIds.head! + let readFin ← require "finRemote" (State.finRemote retiring retId) + let halfRet := State.halfClose readFin.state retId + let finRet := State.pump halfRet.state + let failedRet ← require "failSend" (State.failSend finRet.state (IO.userError "fin-retire")) + r.check "a failed retiring fin returns the live permit once" + ((finRet.state.live.get? retId).isSome && + livePermits failedRet.actions == 1 && + decide (sentKinds failedRet.actions = []) && + acceptPermitIds failedRet.actions == []) + let (queued, _) := admitAll opened id 8 [byte 8] + let flushing := State.halfClose queued id + let dataIntent := State.pump flushing.state + let failedFlush ← require "failSend" (State.failSend dataIntent.state (IO.userError "flush")) + r.check "no fin follows a failed data flush" + (decide (sentKinds dataIntent.actions = [.data]) && + decide (sentKinds failedFlush.actions = []) && + (State.pump failedFlush.state).actions.isEmpty && + resolveCount 8 closedW failedFlush.actions == 1 && + resolveCount 8 okW failedFlush.actions == 0) + let held ← require "openRemote" (State.openRemote initiator 2 true true) + let (withWrite, _) := admitAll held.state 2 9 [byte 9] + let send := State.pump withWrite + let failedHeld ← require "failSend" (State.failSend send.state (IO.userError "accept-held")) + let afterOwner := State.claim failedHeld.state .ownerClose + r.check "a failed send does not return a transferred accept reservation" + (decide (enqueueIds held.actions = [2]) && + acceptPermitIds failedHeld.actions == [] && + decide (writePermitIds failedHeld.actions = [2]) && + livePermits failedHeld.actions == 1 && + resolveCount 9 closedW failedHeld.actions == 1 && + afterOwner.actions.isEmpty) + let openIntent := State.openLocal initiator + let busy := State.openLocal openIntent.state + let openedBusy ← require "ackSend" (State.ackSend busy.state) + r.check "a local open during a send returns its permit and leaves the send" + (isRunning busy.state.cause && + busy.state.outbound.isSome && + busy.state.nextLocal == 1 && + (busy.state.live.get? 1).isNone && + decide (openErrors busy.actions = [State.busyOpen]) && + livePermits busy.actions == 1 && + decide (openIds openedBusy.actions = [1]) && + livePermits openedBusy.actions == 0 && + openedBusy.state.outbound.isNone && + (openedBusy.state.live.get? 1).isSome) + let (queued, _) := admitAll opened id 6 [byte 6] + let sending := State.pump queued + let s := sending.state + let committed ← require "ackSend" (State.ackSend s) + r.check "a reset during a send is sendInFlight and the write still commits once" + (isErr (State.resetLocal s id) .sendInFlight && + isErr (State.openRemote s 2 true true) .sendInFlight && + isErr (State.resetRemote s id) .sendInFlight && + isErr (State.dataRemote s id [9]) .sendInFlight && + isErr (State.finRemote s id) .sendInFlight && + isErr (State.apply s dataFrame) .sendInFlight && + s.outbound.isSome && + s.nextRemote == 2 && + (s.live.get? 2).isNone && + s.admittedCount id == 1 && + resolveCount 6 okW committed.actions == 1 && + decide (writePermitIds committed.actions = [id]) && + committed.state.outbound.isNone && + isErr (State.ackSend committed.state) .noOutbound && + isErr (State.failSend committed.state err) .noOutbound) + +private theorem resetNotData : Kind.reset ≠ Kind.data := by decide + +private theorem finNotData : Kind.fin ≠ Kind.data := by decide + +/-- Forged completions and scheduler violations. -/ +private def runInvariant (r : Runner) : ExceptT String IO Unit := do + let initiator := State.empty .initiator + let (opened, ids) ← require "openN" (openN 1 initiator []) + let id := ids.head! + let diagnostics : List InvariantError := [ + .noOutbound, .sendAfterTerminal, .missingStream id, .emptyWriteQueue id, + .writeTagMismatch id 9 1, .noRemainingChunk id, .openAlreadyLive id, + .resetUnchanged id, .sendInFlight] + r.check "toIOError carries the canonical invariant diagnostic" + (diagnostics.all (fun e => + toString (InvariantError.toIOError e) == InvariantError.message e) && + InvariantError.message (.writeTagMismatch id 9 1) == + s!"mux: write tag mismatch {id} intent 9 queue 1" && + InvariantError.message .sendInFlight == "mux: send in flight" && + InvariantError.message .noOutbound == "mux: no outbound send" && + InvariantError.message .sendAfterTerminal == + "mux: send completion after the session ended") + r.check "an acknowledgement or failure with no send keeps the session" + (isErr (State.ackSend initiator) .noOutbound && + isErr (State.failSend initiator (IO.userError "none")) .noOutbound && + initiator.outbound.isNone && + initiator.nextLocal == 1 && + isRunning initiator.cause) + let intent := State.openLocal initiator + let ended := { intent.state with cause := .ownerClose } + r.check "an acknowledgement after shutdown keeps the uncommitted open" + (isErr (State.ackSend ended) .sendAfterTerminal && + isErr (State.failSend ended (IO.userError "late")) .sendAfterTerminal && + ended.outbound.isSome && + ended.nextLocal == 1 && + (ended.live.get? 1).isNone && + isOwnerClose ended.cause) + let emptyQ := { opened with outbound := some (.chunk id 1 (byte 1) 0) } + r.check "a chunk acknowledgement with an empty queue keeps the intent" + (isErr (State.ackSend emptyQ) (.emptyWriteQueue id) && + emptyQ.outbound.isSome && + emptyQ.admittedCount id == 0) + let gone := { opened with live := {}, order := #[], outbound := some (.chunk id 1 (byte 1) 0) } + r.check "a chunk acknowledgement with no stream keeps the intent" + (isErr (State.ackSend gone) (.missingStream id) && + gone.outbound.isSome && + (gone.live.get? id).isNone) + let (queued, _) := admitAll opened id 1 [byte 1] + let mismatch := { queued with outbound := some (.chunk id 9 (byte 1) 0) } + r.check "a chunk acknowledgement with the wrong tag keeps the queue" + (isErr (State.ackSend mismatch) (.writeTagMismatch id 9 1) && + mismatch.admittedCount id == 1 && + mismatch.outbound.isSome) + if h : id ≠ 0 then + let head? := + match queued.live.get? id with + | some st => st.admitted[0]? + | none => none + let spent := + match queued.live.get? id, head? with + | some st, some head => + let st' := { st with admitted := #[{ head with sent := head.bytes.size }] } + { queued with + live := queued.live.insert id st' + outbound := some (.chunk id 1 head.bytes 0) } + | _, _ => queued + r.check "a chunk acknowledgement with no remaining bytes keeps the intent" + (isErr (State.ackSend spent) (.noRemainingChunk id) && + spent.outbound.isSome && + spent.admittedCount id == 1) + let liveOpen := { + opened with outbound := some (.openStream id (Frame.ofControl .open id openNotData h)) } + r.check "an open acknowledgement for a live id keeps that stream" + (isErr (State.ackSend liveOpen) (.openAlreadyLive id) && + (liveOpen.live.get? id).isSome && + liveOpen.nextLocal == opened.nextLocal && + liveOpen.outbound.isSome) + let resetStuck := + match opened.live.get? id with + | some st => + { opened with + live := opened.live.insert id { st with write := .sessionClosed } + outbound := some (.reset id (Frame.ofControl .reset id resetNotData h)) } + | none => opened + r.check "a reset acknowledgement that cannot change the mode keeps the stream" + (isErr (State.ackSend resetStuck) (.resetUnchanged id) && + resetStuck.writeMode? id == some .sessionClosed && + resetStuck.outbound.isSome && + isRunning resetStuck.cause) + let noReset := { + opened with + live := {} + order := #[] + outbound := some (.reset id (Frame.ofControl .reset id resetNotData h)) } + let noFin := { + opened with + live := {} + order := #[] + outbound := some (.fin id (Frame.ofControl .fin id finNotData h) 0) } + r.check "a reset or fin acknowledgement with no stream keeps the intent" + (isErr (State.ackSend noReset) (.missingStream id) && + isErr (State.ackSend noFin) (.missingStream id) && + noReset.outbound.isSome && + noFin.outbound.isSome && + (noReset.live.get? id).isNone) + else + r.check "a chunk acknowledgement with no remaining bytes keeps the intent" false + r.check "an open acknowledgement for a live id keeps that stream" false + r.check "a reset acknowledgement that cannot change the mode keeps the stream" false + r.check "a reset or fin acknowledgement with no stream keeps the intent" false + let (goodQ, _) := admitAll opened id 4 [byte 4] + let good := State.pump goodQ + let committed ← require "ackSend" (State.ackSend good.state) + let failed ← require "failSend" (State.failSend good.state (IO.userError "once")) + r.check "a valid acknowledgement commits once and a second completion is refused" + (resolveCount 4 okW committed.actions == 1 && + committed.state.outbound.isNone && + isErr (State.ackSend committed.state) .noOutbound && + resolveCount 4 closedW failed.actions == 1 && + isErr (State.failSend failed.state (IO.userError "twice")) .noOutbound && + faultText failed.state.cause == some "once") + +/-- Record an invariant error from one section, then continue the suite. -/ +private def finish (r : Runner) (act : ExceptT String IO Unit) : IO Unit := do + match ← act.run with + | .ok () => pure () + | .error msg => r.check msg false + +/-- The pure owner-loop transitions. -/ +def run (r : Runner) : IO Unit := do + r.section "mux test state" "packages/libp2p/mux/Libp2p/Mux/Test/State.lean" + finish r (runIds r) + finish r (runAdmission r) + finish r (runWrites r) + finish r (runLifecycle r) + finish r (runTerminal r) + finish r (runParser r) + finish r (runAccept r) + finish r (runSend r) + finish r (runInvariant r) + +end Tests.Mux.State + namespace Tests open Libp2p.Mux @@ -481,5 +1612,6 @@ def Mux.run (r : Runner) : IO Unit := do && mplexId.toString != testId.toString) Mux.Wire.run r Mux.Seam.run r + Mux.State.run r end Tests diff --git a/packages/libp2p/mux/Libp2p/Mux/Test/State.lean b/packages/libp2p/mux/Libp2p/Mux/Test/State.lean new file mode 100644 index 00000000..12327ee9 --- /dev/null +++ b/packages/libp2p/mux/Libp2p/Mux/Test/State.lean @@ -0,0 +1,985 @@ +import Libp2p.Mux.Test.Wire +import Libp2p.Mux.Mock.Core +import Streams + +/-! +# Pure test-session state + +The owner loop is the only stepper of this state +(`packages/libp2p/mux/docs/ARCHITECTURE.md`, stream ids, stream +lifetime, scheduling and writes, terminal causes). The state is pure. +It holds the parser, the two id counters, the live table, one +unacknowledged send, the admitted writes, and the terminal cause. + +A transition returns the next state and an ordered list of actions. +`sendFrame` and `sendChunk` are intents. `ackSend` commits a successful +connection write. `failSend` claims the writer fault with the caught +`IO.Error` and releases that send's resources once. `setPort` precedes +`closeWritePermits` for the same id. `closeInbox` stays a separate +action. An acknowledgement or a scheduler step that breaks an +internal invariant returns `InvariantError` and leaves the caller's +state in place. The shell throws `InvariantError.toIOError`. + +This module does not read or write a connection. It stores no channel, +task, atomic cell, waiting caller, or `Stream`. + +A local open already holds one live permit. A nonempty write already +holds one write permit and its completion tag. An empty write has a +tag and no write permit. A remote open is classified before either +permit is reserved. `pull` returns frames and does not admit them. +The counters below name the caps the runtime preloads. This state +does not keep the callers that are still waiting for a permit. + +`UInt32.size` is `2^32`, the exclusive bound of `UInt32.ofNatLT`. +The last initiator id is `UInt32.size - 1`. The last responder id is +`UInt32.size - 2`. Those values are not `UInt32.size`. +-/ + +set_option autoImplicit false + +namespace Libp2p.Mux.Test.State + +open Libp2p.Mux.Mock.Core +open Libp2p.Mux.Test.Wire + +/-! ## Caps and ids -/ + +/-- Live streams held at one time. The runtime permit channel preloads +this many tokens. -/ +def liveStreamCap : Nat := 32 + +/-- Inbound streams waiting for `accept`. The runtime accept channel +and its permit source use this capacity. This state enqueues only +when the shell has reserved an accept permit. -/ +def acceptBacklogCap : Nat := 16 + +/-- Writes already admitted on one stream. The fifth writer waits +outside this state. A call past this cap is an invariant guard. -/ +def admittedWriteCap : Nat := 4 + +/-- `true` when `n` is odd. -/ +def oddNat (n : Nat) : Bool := + n % 2 == 1 + +/-- Initiator local ids are odd. Responder local ids are even. -/ +def localIsOdd (role : Role) : Bool := + match role with + | .initiator => true + | .responder => false + +/-- Remote ids use the other parity. -/ +def remoteIsOdd (role : Role) : Bool := + !localIsOdd role + +/-- The first local id of `role`. -/ +def firstLocal (role : Role) : Nat := + match role with + | .initiator => 1 + | .responder => 2 + +/-- The first remote id of `role`. -/ +def firstRemote (role : Role) : Nat := + match role with + | .initiator => 2 + | .responder => 1 + +/-- Convert `next` only when it is below `UInt32.size`, nonzero, and of +the requested parity. The caller adds two in `Nat` after a `some`. -/ +def issueId (next : Nat) (odd : Bool) : Option UInt32 := + if h : next < UInt32.size ∧ next ≠ 0 then + if oddNat next == odd then + some (UInt32.ofNatLT next h.left) + else + none + else + none + +/-! ## Terminal cause -/ + +/-- The first cause that leaves `running`. A reader or writer fault +stores the `IO.Error` the shell passes to `Outcome.recordFault`. -/ +inductive Cause where + | running + | ownerClose + | connectionEnd + | readerFault (error : IO.Error) + | writerFault (error : IO.Error) + | protocol (error : Error) + +namespace Cause + +/-- `true` while no claim has won. -/ +def isRunning : Cause → Bool + | .running => true + | _ => false + +/-- The `IO.Error` a winning fault gives to `Outcome.recordFault`. +Owner close, connection end, and protocol failure carry none. -/ +def fault? : Cause → Option IO.Error + | .readerFault e | .writerFault e => some e + | _ => none + +/-- `true` when `c` is the protocol error `e`. -/ +def isProtocol (c : Cause) (e : Error) : Bool := + match c with + | .protocol err => err == e + | _ => false + +end Cause + +/-- Protocol details this state produces. The wording is local. -/ +def protocolZero : Error := .protocol "zero id" + +/-- A remote id uses the local parity. -/ +def protocolParity : Error := .protocol "wrong parity" + +/-- The id is already live, or it is retired. -/ +def protocolDuplicate : Error := .protocol "duplicate open" + +/-- The id is above the next remote id. -/ +def protocolFuture : Error := .protocol "future open" + +/-- A data, fin, or reset id was never opened. -/ +def protocolUnknown : Error := .protocol "unknown id" + +/-- The connection ended inside a partial header or body. -/ +def protocolPartial : Error := .protocol "partial frame" + +/-- `Parser.drain` rejected the buffered bytes. -/ +def protocolMalformed : Error := .protocol "malformed frame" + +/-- The next local id does not fit in `UInt32`. The session stays up. -/ +def protocolExhausted : Error := .protocol "id exhausted" + +/-- Owner close, connection end, or an IO fault ended the open. -/ +def closedError : Error := .closed "closed" + +/-! ## Write mode -/ + +/-- The local write direction of one stream. + +`applyReset` replaces `open` and `halfClosed`. `reset` stays `reset`. +`sessionClosed` stays `sessionClosed`, so a later reset does not +replace a session close. `applySessionClose` does the opposite: +a reset handle stays reset when the session closes. -/ +inductive WriteMode where + | open + | halfClosed + | reset + | sessionClosed + deriving DecidableEq, Repr, BEq + +namespace WriteMode + +/-- Reset replaces an open or half-closed write. A repeated reset stays +reset. A session close is terminal. -/ +def applyReset : WriteMode → WriteMode + | .open | .halfClosed => .reset + | .reset => .reset + | .sessionClosed => .sessionClosed + +/-- Session closure replaces an open or half-closed write. A reset +handle continues to report reset. -/ +def applySessionClose : WriteMode → WriteMode + | .open | .halfClosed => .sessionClosed + | .reset => .reset + | .sessionClosed => .sessionClosed + +/-- Bytes and fin may still leave this direction. -/ +def sends : WriteMode → Bool + | .open | .halfClosed => true + | .reset | .sessionClosed => false + +end WriteMode + +/-- The remote write direction, as this session observes it. -/ +inductive ReadMode where + | open + | fin + deriving DecidableEq, Repr, BEq + +/-- The completion-table answer for one write. This is the value +`Streams.Completions.resolve` stores. -/ +def writeAnswer (mode : WriteMode) : Except Streams.Error Unit := + match mode with + | .open => .ok () + | .reset => .error .reset + | .halfClosed | .sessionClosed => .error .closed + +/-- One admitted logical write. `tag` is the `Nat` from +`Streams.Completions.register`. `sent` counts bytes whose send has +been acknowledged. Chunks of one tag stay in order. -/ +structure Admitted where + tag : Nat + bytes : ByteArray + sent : Nat + deriving Inhabited + +/-! ## Actions and send intents -/ + +/-- A writer-visible port mode the shell publishes before it closes +the write-permit channel. `Streams.Error` also admits `timeout`, and +it uses one `closed` arm for every non-reset end, so half-close and +session close stay distinct here. A clean close and a capacity +refusal emit no `setPort`. -/ +inductive PortEnd where + | halfClosed + | reset + | sessionClosed + deriving DecidableEq, Repr, BEq + +/-- A connection write the shell has not acknowledged. +The rest of the state stays at its pre-send values until `ackSend` +or `failSend`. `failSend` stores the caught `IO.Error` and releases +the resources of this write once. -/ +inductive Outbound where + | openStream (id : UInt32) (frame : Frame) + | refuse (id : UInt32) (frame : Frame) (gotLive : Bool) (gotAccept : Bool) + | reset (id : UInt32) (frame : Frame) + | fin (id : UInt32) (frame : Frame) (turn : Nat) + | chunk (id : UInt32) (tag : Nat) (raw : ByteArray) (turn : Nat) + +/-- Work the shell performs, in list order. +`sendFrame` and `sendChunk` are intents. `ackSend` emits the +success-only resolutions and permit returns. `enqueueAccept` transfers +one accept reservation into the runtime accept envelope. The consumer +of that envelope returns the permit. `setPort` precedes +`closeWritePermits` for the same id. `closeInbox` is the read side. +The `terminal` cause's `Cause.fault?` is the `IO.Error` passed to +`Outcome.recordFault`. -/ +inductive Action where + /-- Send one control frame. The loop is the only connection writer. -/ + | sendFrame (frame : Frame) + /-- Send the next chunk of the write named by `tag`. The chunk is + nonempty and at most `testPayloadCap` bytes. -/ + | sendChunk (id : UInt32) (tag : Nat) (bytes : ByteArray) + /-- Complete a local open. `.ok` carries the issued id. -/ + | resolveOpen (result : Except Error UInt32) + /-- Complete the write named by `tag`. -/ + | resolveWrite (tag : Nat) (result : Except Streams.Error Unit) + /-- Return one live-stream permit. Each token is returned once. -/ + | returnLivePermit + /-- Return one write permit to `id`. -/ + | returnWritePermit (id : UInt32) + /-- Return one accept-backlog permit reserved for `id`. -/ + | returnAcceptPermit (id : UInt32) + /-- Publish the writer-visible mode of `id`. This precedes + `closeWritePermits` for the same id. -/ + | setPort (id : UInt32) (reason : PortEnd) + /-- Close the write-permit channel of `id`. A repeat emits nothing. -/ + | closeWritePermits (id : UInt32) + /-- Queue one inbound payload. -/ + | deliver (id : UInt32) (bytes : ByteArray) + /-- Transfer one accepted stream into the runtime accept envelope. + The envelope owns the accept reservation. -/ + | enqueueAccept (id : UInt32) + /-- Close one stream inbox. -/ + | closeInbox (id : UInt32) + /-- Close the accept channel. Queued envelopes stay readable. This + step does not return their reservations. -/ + | closeAccept + /-- The claim that left `running`. The runtime applies `Outcome`. -/ + | terminal (cause : Cause) + +/-! ## Live streams and session state -/ + +/-- One live stream. Retirement is not stored. An id is retired when +it has its opener's parity, lies below that opener's high water, and +is absent from the live table. Zero is never retired, because zero is +never issued. The accept reservation is not a field of this record. -/ +structure Live where + write : WriteMode + read : ReadMode + admitted : Array Admitted + finPending : Bool + finSent : Bool + livePermitHeld : Bool + inboxOpen : Bool + /-- `true` until the first `closeWritePermits` for this stream. -/ + writePermitsOpen : Bool + +/-- A new local stream. It holds its live permit. Its write-permit +channel is still open. -/ +def freshLive : Live where + write := .open + read := .open + admitted := #[] + finPending := false + finSent := false + livePermitHeld := true + inboxOpen := true + writePermitsOpen := true + +/-- The owner-loop state. `nextLocal` and `nextRemote` are `Nat` high +water marks. They step by two and do not wrap through `UInt32`. +`outbound` is the one unacknowledged connection write. The accept +backlog lives in the runtime channel, not in this record. -/ +structure State where + role : Role + parser : Parser + nextLocal : Nat + nextRemote : Nat + live : Std.HashMap UInt32 Live + outbound : Option Outbound + cause : Cause + order : Array UInt32 + turnIndex : Nat + +/-- The result of one transition. -/ +structure Step where + state : State + actions : List Action + +/-- An internal invariant failure. The shell throws `toIOError`. +A peer protocol error is `Core.Error`. A connection fault is the +`IO.Error` stored on the terminal cause. -/ +inductive InvariantError where + | noOutbound + | sendAfterTerminal + | missingStream (id : UInt32) + | emptyWriteQueue (id : UInt32) + | writeTagMismatch (id : UInt32) (intentTag : Nat) (queueTag : Nat) + | noRemainingChunk (id : UInt32) + | openAlreadyLive (id : UInt32) + | resetUnchanged (id : UInt32) + | sendInFlight + deriving DecidableEq, Repr, BEq + +/-- The diagnostic the shell throws. The text names the case and any +stream id or completion tag the case carries. -/ +def InvariantError.message : InvariantError → String + | .noOutbound => "mux: no outbound send" + | .sendAfterTerminal => "mux: send completion after the session ended" + | .missingStream id => s!"mux: missing stream {id}" + | .emptyWriteQueue id => s!"mux: empty write queue {id}" + | .writeTagMismatch id intentTag queueTag => + s!"mux: write tag mismatch {id} intent {intentTag} queue {queueTag}" + | .noRemainingChunk id => s!"mux: no remaining chunk {id}" + | .openAlreadyLive id => s!"mux: open already live {id}" + | .resetUnchanged id => s!"mux: reset unchanged {id}" + | .sendInFlight => "mux: send in flight" + +/-- The `IO.Error` the shell throws for `e`. -/ +def InvariantError.toIOError (e : InvariantError) : IO.Error := + IO.userError e.message + +namespace State + +/-- A session with empty tables and the first id of `role`. -/ +def empty (role : Role) : State where + role := role + parser := Parser.empty + nextLocal := firstLocal role + nextRemote := firstRemote role + live := {} + outbound := none + cause := .running + order := #[] + turnIndex := 0 + +/-- Replace the parser. The runtime feeds slices before it stores one. -/ +def setParser (s : State) (p : Parser) : State := + { s with parser := p } + +private def setLive (s : State) (id : UInt32) (st : Live) : State := + { s with live := s.live.insert id st } + +private def insertLive (s : State) (id : UInt32) (st : Live) : State := + let s := s.setLive id st + if s.order.any (· == id) then + s + else + { s with order := s.order.push id } + +private def eraseLive (s : State) (id : UInt32) : State := + { s with + live := s.live.erase id + order := s.order.filter (· != id) + turnIndex := 0 } + +/-- `true` when `id` is retired. Zero is not retired. -/ +def retired (s : State) (id : UInt32) : Bool := + if id == 0 then + false + else if (s.live.get? id).isSome then + false + else + let n := id.toNat + let odd := oddNat n + (odd == localIsOdd s.role && n < s.nextLocal) || + (odd == remoteIsOdd s.role && n < s.nextRemote) + +/-- The local write mode of a live id. Absence is not a mode. A retired +id has no write mode here. The port cell answers an old handle. -/ +def writeMode? (s : State) (id : UInt32) : Option WriteMode := + (s.live.get? id).map fun st => st.write + +/-- The remote write mode of a live id. -/ +def readMode? (s : State) (id : UInt32) : Option ReadMode := + (s.live.get? id).map fun st => st.read + +/-- Admitted writes stored for `id`. -/ +def admittedCount (s : State) (id : UInt32) : Nat := + match s.live.get? id with + | some st => st.admitted.size + | none => 0 + +/-- Admitted payloads, oldest first. -/ +def admittedBytes (s : State) (id : UInt32) : List ByteArray := + match s.live.get? id with + | some st => st.admitted.toList.map fun a => a.bytes + | none => [] + +/-! ## Frames -/ + +private def control (k : Kind) (id : UInt32) : Option Frame := + if hk : k = Kind.data then + none + else if hid : id = 0 then + none + else + some (Frame.ofControl k id hk hid) + +private def chunkOf (a : Admitted) : Option (ByteArray × Admitted) := + if a.sent < a.bytes.size then + -- `size - sent` is at least one, and the cap is at least one, + -- so the extracted chunk is nonempty. + let n := min testPayloadCap (a.bytes.size - a.sent) + let raw := a.bytes.extract a.sent (a.sent + n) + some (raw, { a with sent := a.sent + n }) + else + none + +/-! ## Session seal -/ + +private def sealOne (id : UInt32) (st : Live) : Live × List Action := + let mode := WriteMode.applySessionClose st.write + let admission := + if mode == .sessionClosed then + [Action.setPort id .sessionClosed] ++ + (if st.writePermitsOpen then [Action.closeWritePermits id] else []) + else + [] + let writeActions := + if mode == .sessionClosed then + st.admitted.toList.map (fun a => Action.resolveWrite a.tag (.error .closed)) ++ + st.admitted.toList.map (fun _ => Action.returnWritePermit id) + else + [] + let inbox := if st.inboxOpen then [Action.closeInbox id] else [] + let permit := if st.livePermitHeld then [Action.returnLivePermit] else [] + let st : Live := { + st with + write := mode + admitted := if mode == .sessionClosed then #[] else st.admitted + finPending := false + livePermitHeld := false + inboxOpen := false + writePermitsOpen := false } + (st, admission ++ writeActions ++ inbox ++ permit) + +private def sealAll (s : State) : State × List Action := + s.order.foldl (fun acc id => + let (s, acts) := acc + match s.live.get? id with + | none => (s, acts) + | some st => + let (st, more) := sealOne id st + (s.setLive id st, acts ++ more)) (s, []) + +/-- Permits acquired for one remote open and not yet transferred. -/ +private def releaseOpen (id : UInt32) (gotLive gotAccept : Bool) : List Action := + (if gotLive then [Action.returnLivePermit] else []) ++ + (if gotAccept then [Action.returnAcceptPermit id] else []) + +/-- Release a send that never entered the live table. A chunk, fin, or +local reset still sits on its live stream, and seal releases that +stream. -/ +private def releaseOutbound (s : State) : State × List Action := + match s.outbound with + | none => (s, []) + | some (.openStream _ _) => + ({ s with outbound := none }, + [Action.resolveOpen (.error closedError), Action.returnLivePermit]) + | some (.refuse id _ gotLive gotAccept) => + ({ s with outbound := none }, releaseOpen id gotLive gotAccept) + | some (.reset ..) | some (.fin ..) | some (.chunk ..) => + ({ s with outbound := none }, []) + +/-- The first claim out of `running` wins. A later claim leaves the +winner and the action list unchanged. An in-flight open or refusal is +released here. `closeAccept` does not return a reservation that +`enqueueAccept` already transferred. -/ +def claim (s : State) (c : Cause) : Step := + match s.cause, c with + | .running, .running => ⟨s, []⟩ + | .running, c => + let (s, extra) := releaseOutbound s + let (s, acts) := sealAll s + ⟨{ s with cause := c }, extra ++ acts ++ [Action.closeAccept, Action.terminal c]⟩ + | _, _ => ⟨s, []⟩ + +/-! ## Send acknowledgement -/ + +private def advanceTurn (s : State) (i : Nat) : State := + let n := s.order.size + if n = 0 then + { s with turnIndex := 0 } + else + { s with turnIndex := (i + 1) % n } + +private def commitChunk (s : State) (id : UInt32) (tag : Nat) (turn : Nat) : + Except InvariantError Step := + match s.live.get? id with + | none => .error (.missingStream id) + | some st => + if st.admitted.isEmpty then + .error (.emptyWriteQueue id) + else + let a := st.admitted[0]! + if a.tag != tag then + .error (.writeTagMismatch id tag a.tag) + else + match chunkOf a with + | none => .error (.noRemainingChunk id) + | some (_, a') => + let done := a'.bytes.size ≤ a'.sent + let rest := st.admitted.extract 1 st.admitted.size + let admitted := if done then rest else #[a'] ++ rest + let st := { st with admitted } + let s := advanceTurn ({ s with outbound := none }.setLive id st) turn + let tail := + if done then + [Action.resolveWrite tag (.ok ()), Action.returnWritePermit id] + else + [] + .ok ⟨s, tail⟩ + +/-- Finish a reset whose frame has succeeded, or a received reset, +which sends nothing. The accept envelope is left with its consumer. +A missing stream, or a mode that cannot change, returns +`InvariantError` and leaves `s` unchanged. -/ +private def commitReset (s : State) (id : UInt32) : Except InvariantError Step := + match s.live.get? id with + | none => .error (.missingStream id) + | some st => + let mode := WriteMode.applyReset st.write + if mode == st.write then + .error (.resetUnchanged id) + else + let s := + match s.outbound with + | some (.reset i _) => if i == id then { s with outbound := none } else s + | _ => s + let resolves := st.admitted.toList.map fun a => + Action.resolveWrite a.tag (.error .reset) + let permits := st.admitted.toList.map fun _ => Action.returnWritePermit id + let admission := + [Action.setPort id .reset] ++ + (if st.writePermitsOpen then [Action.closeWritePermits id] else []) + let inbox := if st.inboxOpen then [Action.closeInbox id] else [] + let live := if st.livePermitHeld then [Action.returnLivePermit] else [] + .ok ⟨eraseLive s id, admission ++ resolves ++ permits ++ inbox ++ live⟩ + +private def commitFin (s : State) (id : UInt32) (turn : Nat) : + Except InvariantError Step := + match s.live.get? id with + | none => .error (.missingStream id) + | some st => + let s := { s with outbound := none } + let st : Live := { st with finPending := false, finSent := true } + if st.read == .fin then + let permit := if st.livePermitHeld then [Action.returnLivePermit] else [] + .ok ⟨eraseLive (s.setLive id st) id, permit⟩ + else + .ok ⟨advanceTurn (s.setLive id st) turn, []⟩ + +private def commit (s : State) (intent : Outbound) : Except InvariantError Step := + match intent with + | .openStream id _ => + match s.live.get? id with + | some _ => .error (.openAlreadyLive id) + | none => + let s := insertLive { s with outbound := none, nextLocal := s.nextLocal + 2 } id freshLive + .ok ⟨s, [Action.resolveOpen (.ok id)]⟩ + | .refuse id _ gotLive gotAccept => + .ok ⟨{ s with outbound := none, nextRemote := s.nextRemote + 2 }, + releaseOpen id gotLive gotAccept⟩ + | .reset id _ => commitReset s id + | .fin id _ turn => commitFin s id turn + | .chunk id tag _ turn => commitChunk s id tag turn + +/-- Commit the in-flight send. The connection write has succeeded. +No send in flight, and a send that outlives `running`, return +`InvariantError`. The caller keeps `s`. -/ +def ackSend (s : State) : Except InvariantError Step := + match s.outbound with + | none => .error .noOutbound + | some intent => + if s.cause.isRunning then + commit s intent + else + .error .sendAfterTerminal + +/-- The connection write threw `e`. The writer fault wins if the +session is still running, and this step releases the in-flight send +once. A later claim adds no second release. No send in flight, and a +send that outlives `running`, return `InvariantError`. The caller +keeps `s`. -/ +def failSend (s : State) (e : IO.Error) : Except InvariantError Step := + match s.outbound, s.cause.isRunning with + | none, _ => .error .noOutbound + | some _, false => .error .sendAfterTerminal + | some _, true => .ok (claim s (.writerFault e)) + +/-! ## Opens -/ + +private def openError (s : State) : Error := + match s.cause with + | .protocol e => e + | _ => closedError + +/-- Returned to the opener when `openLocal` runs during a send. -/ +def busyOpen : Error := + .closed "send in flight" + +/-- Issue one local id. The live permit is already held. The open +frame is an intent: `nextLocal` and the live table change in `ackSend`. +No id is taken when the session is finished or `nextLocal` does not +convert. A call during a send already holds that opener's permit, so +it returns the permit and completes the opener with `busyOpen`. The +in-flight send stays. -/ +def openLocal (s : State) : Step := + if !s.cause.isRunning then + ⟨s, [Action.resolveOpen (.error (openError s)), Action.returnLivePermit]⟩ + else if s.outbound.isSome then + ⟨s, [Action.resolveOpen (.error busyOpen), Action.returnLivePermit]⟩ + else + match issueId s.nextLocal (localIsOdd s.role) with + | none => + ⟨s, [Action.resolveOpen (.error protocolExhausted), Action.returnLivePermit]⟩ + | some id => + match control Kind.open id with + | none => + ⟨s, [Action.resolveOpen (.error protocolExhausted), Action.returnLivePermit]⟩ + | some frame => + ⟨{ s with outbound := some (.openStream id frame) }, [Action.sendFrame frame]⟩ + +/-- Whether `id` may be admitted. The shell calls this before it reserves +a live permit or an accept permit. A failure here consumes neither. -/ +def classifyRemote (s : State) (id : UInt32) : Except Error Unit := + if id == 0 then + .error protocolZero + else if oddNat id.toNat != remoteIsOdd s.role then + .error protocolParity + else if (s.live.get? id).isSome then + .error protocolDuplicate + else if retired s id then + .error protocolDuplicate + else if id.toNat > s.nextRemote then + .error protocolFuture + else + .ok () + +private def failRemote (s : State) (id : UInt32) (e : Error) (gotLive gotAccept : Bool) : Step := + let sealed := claim s (.protocol e) + ⟨sealed.state, releaseOpen id gotLive gotAccept ++ sealed.actions⟩ + +/-- Commit one remote open that `classifyRemote` has already accepted. +`gotLive` and `gotAccept` are the permits the shell reserved. The +shell passes `gotAccept` only when the accept channel has a free +slot. Both install the stream and transfer the accept reservation +into the accept envelope. Any other valid open is a reset intent. +`ackSend` retires that id and returns the permits the refusal still +holds. An id that fails classification advances nothing. A call +while a send is open returns `sendInFlight` and does not take +`gotLive` or `gotAccept`. A correct shell does not make that call. +If it does, the caller still owns those permits and releases them +while the shell throws `InvariantError.toIOError`. -/ +def openRemote (s : State) (id : UInt32) (gotLive gotAccept : Bool) : + Except InvariantError Step := + if !s.cause.isRunning then + .ok ⟨s, releaseOpen id gotLive gotAccept⟩ + else if s.outbound.isSome then + .error .sendInFlight + else + match classifyRemote s id with + | .error e => .ok (failRemote s id e gotLive gotAccept) + | .ok () => + if gotLive && gotAccept then + let s := insertLive { s with nextRemote := s.nextRemote + 2 } id freshLive + .ok ⟨s, [Action.enqueueAccept id]⟩ + else + match control Kind.reset id with + | none => .ok ⟨s, releaseOpen id gotLive gotAccept⟩ + | some frame => + .ok ⟨{ s with outbound := some (.refuse id frame gotLive gotAccept) }, + [Action.sendFrame frame]⟩ + +/-! ## Inbound frames -/ + +/-- Deliver or ignore one remote data payload. A call while a send is +open returns `sendInFlight`. A correct shell does not make that call. -/ +def dataRemote (s : State) (id : UInt32) (payload : List Nat) : + Except InvariantError Step := + if s.outbound.isSome then + .error .sendInFlight + else if !s.cause.isRunning then + .ok ⟨s, []⟩ + else + match s.live.get? id with + | some st => + if st.read == .fin || !st.inboxOpen then + .ok ⟨s, []⟩ + else + .ok ⟨s, [Action.deliver id (Bytes.ofNatList payload)]⟩ + | none => + if retired s id then + .ok ⟨s, []⟩ + else + .ok (claim s (.protocol protocolUnknown)) + +/-- A received fin closes the remote write only. A second fin is ignored. +The live entry remains while the local fin is still unsent. An +accept reservation already transferred stays with its envelope. A +call while a send is open returns `sendInFlight`. A correct shell +does not make that call. -/ +def finRemote (s : State) (id : UInt32) : Except InvariantError Step := + if s.outbound.isSome then + .error .sendInFlight + else if !s.cause.isRunning then + .ok ⟨s, []⟩ + else + match s.live.get? id with + | some st => + if st.read == .fin then + .ok ⟨s, []⟩ + else + let close := if st.inboxOpen then [Action.closeInbox id] else [] + let st : Live := { st with read := .fin, inboxOpen := false } + if st.finSent then + let permit := if st.livePermitHeld then [Action.returnLivePermit] else [] + .ok ⟨eraseLive (s.setLive id st) id, close ++ permit⟩ + else + .ok ⟨s.setLive id st, close⟩ + | none => + if retired s id then + .ok ⟨s, []⟩ + else + .ok (claim s (.protocol protocolUnknown)) + +/-- A local reset. The reset frame is an intent. Admitted writes, the +live permit, and the port cell change in `ackSend`. A second reset, +and a reset of `sessionClosed`, send nothing. A reset while a send +is open returns `sendInFlight`. A correct shell does not make that +call. The reset event stays queued until the send finishes. -/ +def resetLocal (s : State) (id : UInt32) : Except InvariantError Step := + if s.outbound.isSome then + .error .sendInFlight + else + match s.live.get? id with + | none => .ok ⟨s, []⟩ + | some st => + if WriteMode.applyReset st.write == st.write then + .ok ⟨s, []⟩ + else + match control Kind.reset id with + | none => .ok ⟨s, []⟩ + | some frame => + .ok ⟨{ s with outbound := some (.reset id frame) }, [Action.sendFrame frame]⟩ + +/-- A received reset. The id retires when it is live. No reset frame is +sent back, so the reset commits in this step. A retired id is ignored. +An unknown id is a protocol failure. The accept envelope stays with +its consumer. A reset while a send is open returns `sendInFlight`. +A correct shell does not make that call. -/ +def resetRemote (s : State) (id : UInt32) : Except InvariantError Step := + if s.outbound.isSome then + .error .sendInFlight + else if !s.cause.isRunning then + .ok ⟨s, []⟩ + else if (s.live.get? id).isSome then + commitReset s id + else if retired s id then + .ok ⟨s, []⟩ + else + .ok (claim s (.protocol (if id == 0 then protocolZero else protocolUnknown))) + +/-- Apply one decoded data, fin, or reset frame. An open frame is not +admitted here. The shell classifies it, reserves permits, and calls +`openRemote`. A call while a send is open returns `sendInFlight`. +A correct shell does not make that call. -/ +def apply (s : State) (f : Frame) : Except InvariantError Step := + if s.outbound.isSome then + .error .sendInFlight + else + match f.kind with + | .open => .ok ⟨s, []⟩ + | .data => dataRemote s f.streamId f.payload + | .fin => finRemote s f.streamId + | .reset => resetRemote s f.streamId + +/-! ## Parser -/ + +/-- One parser budget. `frames` are not admitted. `actions` is empty, +or the single protocol claim when `drain` fails. A complete frame kept +past the budget stays in `state.parser`. -/ +structure Pull where + state : State + frames : List Frame + actions : List Action + +/-- Take one parser budget. This does not read capacity and does not +commit a remote open. -/ +def pull (s : State) : Pull := + if !s.cause.isRunning then + { state := s, frames := [], actions := [] } + else + match s.parser.drain with + | .error _ => + let step := claim s (.protocol protocolMalformed) + { state := step.state, frames := [], actions := step.actions } + | .ok (frames, p) => + { state := { s with parser := p }, frames, actions := [] } + +/-- Select the end cause after the shell has pulled every complete frame. +A parser that still has a complete frame stays `running`. An empty gap +selects connection end. A partial header or body selects a protocol +failure. -/ +def inboundEnd (s : State) : Step := + if !s.cause.isRunning || s.parser.hasComplete then + ⟨s, []⟩ + else + match s.parser.gap with + | .empty => claim s .connectionEnd + | .partialHeader | .partialBody => claim s (.protocol protocolPartial) + +/-! ## Writes -/ + +/-- An empty write. `Stream.write` has already registered `tag`. +The write takes no write permit and enters no admitted queue. +`mode` is the port-cell mode the shell just read, so a retired reset +handle still resolves `reset`. -/ +def writeEmpty (s : State) (tag : Nat) (mode : WriteMode) : Step := + ⟨s, [Action.resolveWrite tag (writeAnswer mode)]⟩ + +private def refuseWrite (s : State) (id : UInt32) (tag : Nat) + (result : Except Streams.Error Unit) : Step := + ⟨s, [Action.resolveWrite tag result, Action.returnWritePermit id]⟩ + +/-- Store one nonempty write that already holds a write permit. +`tag` is that write's completion tag. An empty buffer is not a queued +write and takes no permit: the shell uses `writeEmpty` with the port +mode. The queue stops at `admittedWriteCap`. A further call is an +invariant guard. It is not stored, its tag resolves `closed`, and its +permit returns once. -/ +def admitWrite (s : State) (id : UInt32) (tag : Nat) (bytes : ByteArray) : Step := + if bytes.isEmpty then + match writeMode? s id with + | some mode => ⟨s, [Action.resolveWrite tag (writeAnswer mode)]⟩ + | none => ⟨s, [Action.resolveWrite tag (.error .closed)]⟩ + else + match s.live.get? id with + | none => + refuseWrite s id tag (.error .closed) + | some st => + if !s.cause.isRunning || st.write ≠ .open then + let result := + match st.write with + | .reset => writeAnswer .reset + | .open | .halfClosed | .sessionClosed => writeAnswer .halfClosed + refuseWrite s id tag result + else if st.admitted.size ≥ admittedWriteCap then + refuseWrite s id tag (.error .closed) + else + let st := { st with admitted := st.admitted.push { tag, bytes, sent := 0 } } + ⟨s.setLive id st, []⟩ + +/-- Stop local admission. Admitted bytes stay queued. Fin is not sent +in this step. `pump` sends it after those bytes are gone. This step +publishes `halfClosed` and then closes the write-permit channel. +A second half-close emits neither. A fifth writer is not stored here. +The shell wakes it by performing those two actions in order. -/ +def halfClose (s : State) (id : UInt32) : Step := + if !s.cause.isRunning then + ⟨s, []⟩ + else + match s.live.get? id with + | none => ⟨s, []⟩ + | some st => + if st.write ≠ .open then + ⟨s, []⟩ + else + let permits := + if st.writePermitsOpen then [Action.closeWritePermits id] else [] + let st := { + st with + write := .halfClosed + finPending := !st.finSent + writePermitsOpen := false } + ⟨s.setLive id st, [Action.setPort id .halfClosed] ++ permits⟩ + +/-! ## Outbound turn -/ + +private def queueDrained (st : Live) : Bool := + st.admitted.all fun a => a.bytes.size ≤ a.sent + +private def emitData (s : State) (i : Nat) (id : UInt32) (st : Live) : Option Step := + if st.admitted.isEmpty then + none + else + let a := st.admitted[0]! + match chunkOf a with + | none => none + | some (raw, _) => + some ⟨{ s with outbound := some (.chunk id a.tag raw i) }, + [Action.sendChunk id a.tag raw]⟩ + +private def emitFin (s : State) (i : Nat) (id : UInt32) (st : Live) : Option Step := + if st.finPending && !st.finSent && queueDrained st && st.write == .halfClosed then + match control Kind.fin id with + | none => none + | some frame => + some ⟨{ s with outbound := some (.fin id frame i) }, [Action.sendFrame frame]⟩ + else + none + +private def tryEmit (s : State) (i : Nat) : Option Step := + if hi : i < s.order.size then + let id := s.order[i] + match s.live.get? id with + | none => none + | some st => + if !WriteMode.sends st.write then + none + else + match emitData s i id st with + | some step => some step + | none => emitFin s i id st + else + none + +private def scan (s : State) (seen : Nat) : Step := + if seen < s.order.size then + let n := s.order.size + let i := (s.turnIndex + seen) % n + match tryEmit s i with + | some step => step + | none => scan s (seen + 1) + else + ⟨s, []⟩ +termination_by s.order.size - seen + +/-- Emit one outbound intent, or one fin intent when that stream's +admitted queue is empty. The state does not record the send as +finished. `ackSend` advances the turn, so streams with admitted bytes +take turns. A session with an unacknowledged send emits nothing. -/ +def pump (s : State) : Step := + if !s.cause.isRunning || s.outbound.isSome || s.order.isEmpty then + ⟨s, []⟩ + else + scan s 0 + +end State + +end Libp2p.Mux.Test.State diff --git a/packages/libp2p/mux/docs/ARCHITECTURE.md b/packages/libp2p/mux/docs/ARCHITECTURE.md index 755fdd83..ea7d4ce3 100644 --- a/packages/libp2p/mux/docs/ARCHITECTURE.md +++ b/packages/libp2p/mux/docs/ARCHITECTURE.md @@ -23,6 +23,7 @@ same node code ran under both muxers in-process and over loopback TCP | `Libp2p/Mux/Mplex.lean` | the mplex message codec and `Mux.mplex` (P2, §8); the spike reference is `Conn/Mplex.lean` (§10 row 15, §7 row 10). Planned | | `Libp2p/Mux/Test.lean` | `Mux.test : Muxer`, the independent test session under `Mux.testId`. Planned | | `Libp2p/Mux/Test/Wire.lean` | the private header, frame, and incremental parser. Present. Internal, and tests import it directly | +| `Libp2p/Mux/Test/State.lean` | the pure owner-loop state and its transitions. Present. Internal, and tests import it directly. A transition returns actions for the runtime shell | | `Libp2p/Mux/Mock/Core/ProtocolId.lean` | the temporary `ProtocolId`. Present. `Constants.lean` imports only this module, so the constants stay free of `Conc` and `Streams` | | `Libp2p/Mux/Mock/Core.lean` | the temporary `Role`, `Error`, `Muxer`, and `MuxSession` that let W5.4 compile before W5.2. Present. The public root does not import it | @@ -32,9 +33,9 @@ live in `Libp2p.Mux.Test.Wire`. `Libp2p/Mux.lean`, `Libp2p/Mux/Constants.lean`, `Libp2p/Mux/Mock/Core/ProtocolId.lean`, `Libp2p/Mux/Mock/Core.lean`, -and `Libp2p/Mux/Test/Wire.lean` are in the tree. `Yamux.lean`, -`Mplex.lean`, and `Test.lean` are the remaining modules of this -package. +`Libp2p/Mux/Test/Wire.lean`, and `Libp2p/Mux/Test/State.lean` are in +the tree. `Yamux.lean`, `Mplex.lean`, and `Test.lean` are the +remaining modules of this package. ## Public surface @@ -88,6 +89,65 @@ both mock modules. The imports then move to the `Libp2p.Core` modules that own the same names. The private test protocol is not part of the mock. +### Owner-loop state + +`Test/State.lean` is the pure state the owner loop steps. It holds +the parser, `nextLocal`, `nextRemote`, the live table, one +unacknowledged send, the admitted write queues, and the terminal +cause. A transition returns that state and an ordered list of +actions. The runtime shell performs the list in order. + +`sendFrame` and `sendChunk` are intents. The pre-send state stays in +place until `ackSend` or `failSend`. `ackSend` commits the successful +write and emits its resolutions and permit returns. `failSend` claims +the writer fault with the caught `IO.Error`. It releases that send's +resources once. A later claim does not release them again. An +impossible acknowledgement or scheduler step returns `InvariantError` +and leaves the caller's state in place. The shell throws +`InvariantError.toIOError`. A peer error is `Core.Error`. A +connection fault is the `IO.Error` stored on the terminal cause. +Scheduling and writes states the one-send rule. + +Each admitted write stores the `Nat` tag from `Streams.Completions`. +A chunk intent and a write resolution both carry that tag. The answer +is `Except Streams.Error Unit`, the completion-table value. A +write-permit action names the stream. An empty write registers a tag, +takes no write permit, enters no queue, and resolves from the port +mode the shell just read. + +The shell calls `classifyRemote` before it reserves a live permit or +an accept permit. It reserves both only for an open that classifies +as the next remote id. It passes `gotAccept` only when the accept +channel has a free slot. Both reservations install the stream. +`enqueueAccept` transfers the accept reservation into the runtime +accept envelope. The consumer of that envelope returns the permit, +including after `closeAccept` and after the owner has returned. +Reset, fin, and removal from the live table do not return it. A +refusal is a reset intent. `ackSend` retires that id and returns each +permit the refusal still holds. A zero, wrong-parity, duplicate, or +future open is classified first, so that open acquires no permit. +`pull` returns at most one parser budget of frames and does not admit +them. `inboundEnd` does not run while the parser still holds a +complete frame. + +A reader or writer fault stores its `IO.Error`. The terminal action +carries that cause. `Cause.fault?` is the value passed to +`Outcome.recordFault`. A later fault does not replace it. + +`setPort` publishes `halfClosed`, `reset`, or `sessionClosed` before +`closeWritePermits` for the same id. `closeInbox` is the read side +and stays a separate action. A clean close and a capacity refusal +emit no `setPort`. A port that is already `reset` stays `reset` when +the session closes, and that close does not close the write-permit +channel again. Absence from the live table is retirement. + +The state does not store a waiting caller, a channel, a task, or an +atomic cell. A local open in a transition already holds a live permit. +A write already holds a write permit. `reset` replaces an open or +half-closed write mode, and a repeated reset stays reset. +`sessionClosed` does not become `reset`. A reset mode stays reset +when the session closes. + ### Private frame The header is nine bytes. The payload follows it. @@ -136,12 +196,16 @@ odd id `2^32 - 1` and the last even id `2^32 - 2` may each be issued once. After `nextLocal` or `nextRemote` exceeds `UInt32.max`, the session issues no new id and accepts no new id. -When a remote open matches that next id, the session adds two to -`nextRemote`. If the accept backlog and the live-stream cap both have -room, the stream enters the live table. If either cap is full, the -session sends reset and does not add a live stream. That id is then -retired. The new value of `nextRemote` remains the next id a remote -open may match. +When a remote open matches that next id, the owner loop reserves one +live permit and one accept permit. The accept permit comes from a +channel of `acceptBacklogCap` tokens, so a full backlog never reserves +it. If both succeed, the stream enters the live table, `nextRemote` +advances by two, and `enqueueAccept` transfers the accept reservation +into the runtime accept envelope. If either reservation fails, the +session sends reset. After that send returns, `nextRemote` advances +by two and each acquired permit is returned. The refused id stays out +of the live table. The live cap and the accept cap are consulted only +for a valid remote open. A local open that is waiting for admission does not read or advance `nextLocal`. It exposes no id. The session takes the id only when @@ -164,8 +228,11 @@ No new write succeeds. The session does not echo a received reset. Admitted writes complete with `Streams.Error.reset`. Bytes already accepted into the inbox stay readable. After those bytes drain, one read returns `Streams.Error.reset`. The following read returns -`.ok none`. The stream handle may still drain that inbox after the -id has retired. +`.ok none`. The session records reset on the stream port before it +closes the inbox. The stream handle may still drain that inbox after +the id has retired. Session close records session close on the port +before the inbox closes. A port that already records reset stays +reset. A clean close and a refused remote open leave the port unset. A received fin closes the remote write direction. The inbox closes at that fin. Bytes already queued stay readable. The application @@ -283,20 +350,42 @@ ready, keeps the loop runnable when no new event is queued. The loop parks only when a queued event, a complete buffered frame, and a ready outbound chunk are all absent. +The owner performs one `sendFrame` or `sendChunk`. It then calls +`ackSend` or `failSend`. Only then does it dequeue and apply another +event. The event stays queued while the send is open. `pump` emits +nothing during that send. `resetLocal`, `resetRemote`, `openRemote`, +`dataRemote`, `finRemote`, and `apply` return +`InvariantError.sendInFlight` if they are called in that window. A +correct shell never observes that error. If it does, the shell +throws `InvariantError.toIOError`. + +`openLocal` is the caller-owned exception. The call already holds a +live permit, so it completes that opener with `closed` and returns +the permit. The in-flight send stays. `openRemote` is reserved before +the call. `sendInFlight` from `openRemote` is unreachable when the +shell follows this order. When that error is returned, the caller +still owns those permits and releases them while the invariant is +thrown. + Streams that have admitted bytes take turns. Writes on one stream keep their admission order. One logical write is split into private -chunks. It resolves only after its last `Connection.write` returns. -An empty write on an open stream succeeds at once and registers no -completion. +chunks. The state records each chunk only after `ackSend`. The write +resolves when its last chunk is acknowledged. An empty write registers +a completion tag, takes no write permit, and resolves at once from the +port mode. A stream admits at most the admitted-write cap of writes. A further -writer waits, and the call stays open. A local half-close stops -admission. Writes already admitted flush. The session sends fin -after that flush returns. A flush that throws follows the -`Connection.write` rule below, and no fin is sent. A write that is -still waiting for admission completes with `Streams.Error.closed`. -It does not stay parked. A write that arrives after the half-close, -including an empty write, completes with `Streams.Error.closed`. +writer waits outside the admitted queue, on the write-permit channel. +Half-close, reset, and session shutdown publish the port mode and +then close that channel. The actions are `setPort` and then +`closeWritePermits`. A later close of the same stream emits neither +again. The waiting writer wakes and completes from the port cell: +`closed` after half-close or session shutdown, and `reset` after +reset. A local half-close stops admission. Writes already admitted +flush. The session sends fin after that flush is acknowledged. A +flush that throws follows the `Connection.write` rule below, and no +fin is sent. A write that arrives after the half-close, including an +empty write, completes with `Streams.Error.closed`. `Connection.write` throws `IO.Error` and tears the connection down. The logical `Stream.write` has no arm for that error. The loop @@ -315,9 +404,17 @@ leaves the live table. ### Accept and backpressure -The accept backlog queues at most its cap of inbound streams. Each -live stream queues at most the inbound frame credit of unread frames. -The owner loop does not park on a stream inbox. +The accept backlog holds at most its cap of inbound streams. The +runtime accept channel is that backlog. `enqueueAccept` puts one +envelope on it, and the envelope owns the accept reservation. +`accept` receives the envelope and returns the reservation. +`Chan.close` leaves queued envelopes readable, so `accept` can still +receive them after `closeAccept` and after the owner has returned. A +drain of that channel returns each envelope's reservation once. +Reset, fin, and ordinary stream teardown do not return it and do not +remove the envelope. Each live stream queues at most the inbound +frame credit of unread frames. The owner loop does not park on a +stream inbox. A full inbox resets that live stream only. A remote open that finds the accept backlog or the live-stream cap full follows the refusal @@ -325,7 +422,9 @@ rule in Stream ids. The session stays up. A local open past the live-stream cap waits. Stream ids records that this wait takes no id. After terminal shutdown has linearized, `accept` returns no newly -accepted stream. A stream accepted immediately before that point may +accepted stream. Entries already buffered stay readable. The caller +that receives one, or that drains the channel, returns its +reservation. A stream accepted immediately before that point may still be returned. The caller then sees its inbox close. ### Resources From cda78fbfe42259b1f21d8246ece5b772accd865e Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 16:58:15 +0530 Subject: [PATCH 071/125] feat(mux): add the test session shell Give the private test session one owner for the pure state and one reader for capped slices. Bound each turn to one event, one parser budget, and one outbound chunk so neither caller traffic nor retained peer frames can starve the other. --- Tests/Mux.lean | 1059 ++++++++++++++++- .../libp2p/mux/Libp2p/Mux/Test/Session.lean | 863 ++++++++++++++ packages/libp2p/mux/docs/ARCHITECTURE.md | 98 +- 3 files changed, 1991 insertions(+), 29 deletions(-) create mode 100644 packages/libp2p/mux/Libp2p/Mux/Test/Session.lean diff --git a/Tests/Mux.lean b/Tests/Mux.lean index f8b2cdb5..d8b2ff6a 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -3,13 +3,17 @@ import Libp2p.Mux import Libp2p.Mux.Mock.Core import Libp2p.Mux.Test.Wire import Libp2p.Mux.Test.State +import Libp2p.Mux.Test.Session +import Conc +import Streams +import Streams.Mock.Multiaddr /-! # Mux suite (unit W5.4) The constant checks, the temporary core seam, the private -test-wire codec and parser, and the pure session transitions -(`packages/libp2p/mux/docs/ARCHITECTURE.md`). +test-wire codec and parser, the pure session transitions, and the +runtime shell (`packages/libp2p/mux/docs/ARCHITECTURE.md`). -/ set_option autoImplicit false @@ -1592,6 +1596,1056 @@ def run (r : Runner) : IO Unit := do end Tests.Mux.State +namespace Tests.Mux.Session + +open Libp2p.Mux.Mock.Core +open Libp2p.Mux.Test.Session +open Libp2p.Mux.Test.State +open Libp2p.Mux.Test.Wire +open Conc +open Streams +open Streams.Mock.Multiformats +open Streams.Sim + +private def pid : ProtocolId := ⟨"/leanp2p/test-mux/1.0.0"⟩ + +private def ma (s : String) : Async Multiaddr := do + match Multiaddr.parse? s with + | .ok a => return a + | .error _ => throw <| IO.userError s!"test address {s} does not parse" + +private structure Ends where + dialer : Connection + peer : Connection + +private def ends (n : Nat) : Async Ends := do + let net ← Net.new + let l ← net.listen (← ma s!"/ip4/127.0.0.1/tcp/{4000 + n}") + let some dialer ← net.dial l.addr | throw <| IO.userError "dial failed" + let some peer ← l.accept | throw <| IO.userError "accept failed" + return { dialer, peer } + +private def drain (c : Connection) (owner : TaskSet) : BaseIO Unit := do + owner.spawn do + repeat + match ← c.read with + | none => break + | some _ => pure () + +private theorem id1 : (1 : UInt32) ≠ 0 := by decide + +private theorem openNotData : Kind.open ≠ Kind.data := by decide + +private theorem resetNotData : Kind.reset ≠ Kind.data := by decide + +private theorem finNotData : Kind.fin ≠ Kind.data := by decide + +private def control (k : Kind) (hk : k ≠ Kind.data) : ByteArray := + Frame.encodeB (Frame.ofControl k 1 hk id1) + +private def oneData (b : UInt8) : ByteArray := + Header.encodeB { kind := .data, streamId := 1, length := 1 } ++ ⟨#[b]⟩ + +private def isOk (r : Except Streams.Error Unit) : Bool := + match r with + | .ok () => true + | _ => false + +private def isClosedW (r : Except Streams.Error Unit) : Bool := + match r with + | .error .closed => true + | _ => false + +private def isResetW (r : Except Streams.Error Unit) : Bool := + match r with + | .error .reset => true + | _ => false + +private def holdData (base : Connection) : BaseIO (Connection × Chan Unit) := do + let hold ← Chan.new 1 + let conn : Connection := { base with + write := fun bytes => do + let openFrame := + match bytes[0]? with + | some b => b == 1 + | none => false + if openFrame then + base.write bytes + else + match ← hold.recv with + | none => throw <| IO.userError "write held" + | some _ => base.write bytes } + return (conn, hold) + +private def mustOpen (sess : MuxSession) : Async Streams.Stream := do + match ← (sess.openStream pid).run with + | .ok stream => return stream + | .error e => throw <| IO.userError s!"open failed: {repr e}" + +/-- The text `join` throws, or `ok` when it returns. -/ +private def joinText (sess : MuxSession) : Async String := do + try + let _ ← sess.join + pure "ok" + catch e => + pure e.toString + +/-- Hook refs with the force flags clear unless a test sets them. -/ +private def testHooks (invariant : Bool := false) (missing : Bool := false) : + Async TestHooks := do + return { + registrySize := ← IO.mkRef 0 + forceInvariant := ← IO.mkRef invariant + forceMissingPort := ← IO.mkRef missing + acceptedEvent := ← IO.mkRef none + appliedFrames := ← IO.mkRef 0 + captureOpenFrames := ← IO.mkRef none + pauseAfterBudget := ← IO.mkRef none + beforeOutcome := ← IO.mkRef none } + +/-- Terminal-event order, teardown races, owner faults, and the port map. -/ +private def runCorrections (r : Runner) : Async Unit := do + -- EOF dequeued with a second chunk ready does not start that write. + do + let pair ← ends 15 + let hold : Chan Unit ← Chan.new 1 + let entered : Chan Unit ← Chan.new 1 + let phase ← IO.mkRef 0 + let conn : Connection := { pair.dialer with + write := fun bytes => do + let isData := + match bytes[0]? with + | some b => b == 2 + | none => false + if isData then + let n ← phase.get + phase.set (n + 1) + if n == 0 then + discard <| entered.send () + discard <| hold.recv + pair.dialer.write bytes } + let readGate : Chan Unit ← Chan.new 1 + let eofConn : Connection := { conn with + read := do + match ← readGate.recv with + | none => return none + | some _ => conn.read } + let sess ← start .initiator eofConn + let stream ← mustOpen sess + let writer ← TaskSet.new + let wrote : Chan (Except Streams.Error Unit) ← Chan.new 1 + writer.spawn do + let result ← stream.write ⟨Array.replicate (testPayloadCap + 1) 7⟩ + discard <| wrote.send result + discard <| entered.recv + readGate.close + hold.close + writer.joinAll + match ← wrote.recv with + | some result => + r.check "no write starts after EOF is dequeued" + ((← phase.get) == 1 && isClosedW result) + | none => r.check "no write starts after EOF is dequeued" false + match ← sess.join with + | .ok () => r.check "EOF join stays a success" true + | .error _ => r.check "EOF join stays a success" false + + -- A reader fault is dequeued before any later write can start. + do + let pair ← ends 16 + let hold : Chan Unit ← Chan.new 1 + let entered : Chan Unit ← Chan.new 1 + let phase ← IO.mkRef 0 + let boom := IO.userError "reader boom" + let later := IO.userError "later write" + let conn : Connection := { pair.dialer with + write := fun bytes => do + let isData := + match bytes[0]? with + | some b => b == 2 + | none => false + if isData then + let n ← phase.get + phase.set (n + 1) + if n == 0 then + discard <| entered.send () + discard <| hold.recv + else + throw later + pair.dialer.write bytes } + let readGate : Chan Unit ← Chan.new 1 + let faultConn : Connection := { conn with + read := do + match ← readGate.recv with + | none => throw boom + | some _ => conn.read } + let sess ← start .initiator faultConn + let stream ← mustOpen sess + let writer ← TaskSet.new + let wrote : Chan (Except Streams.Error Unit) ← Chan.new 1 + writer.spawn do + let result ← stream.write ⟨Array.replicate (testPayloadCap + 1) 7⟩ + discard <| wrote.send result + discard <| entered.recv + readGate.close + hold.close + writer.joinAll + match ← wrote.recv with + | some result => + r.check "no write starts after a reader fault is dequeued" + ((← phase.get) == 1 && isClosedW result) + | none => r.check "no write starts after a reader fault is dequeued" false + let readerJoin ← joinText sess + let readerAgain ← joinText sess + r.check "the reader fault wins and join throws it" (readerJoin == boom.toString) + r.check "a second join throws the same reader fault" + (readerAgain == boom.toString) + + -- Opens and writes in flight at teardown all wake once. + do + let pair ← ends 17 + let hold : Chan Unit ← Chan.new 1 + let entered : Chan Unit ← Chan.new 1 + let conn : Connection := { pair.dialer with + write := fun bytes => do + let isData := + match bytes[0]? with + | some b => b == 2 + | none => false + if isData then + discard <| entered.send () + match ← hold.recv with + | none => throw <| IO.userError "write held" + | some _ => pair.dialer.write bytes + else + pair.dialer.write bytes } + let sess ← start .initiator conn + let stream ← mustOpen sess + let box : Chan Bool ← Chan.new 8 + let ready : Chan Unit ← Chan.new 7 + let workers ← TaskSet.new + -- Pin the owner inside this write before the other callers run. + workers.spawn do + let result ← stream.write ⟨#[9]⟩ + discard <| box.send (isClosedW result) + discard <| entered.recv + for _ in List.range 3 do + workers.spawn do + discard <| ready.send () + let result ← stream.write ⟨#[9]⟩ + discard <| box.send (isClosedW result) + for _ in List.range 4 do + workers.spawn do + discard <| ready.send () + match ← (sess.openStream pid).run with + | .error e => discard <| box.send (e == closedError) + | .ok extra => + extra.reset + discard <| box.send false + for _ in List.range 7 do + discard <| ready.recv + sess.close + hold.close + workers.joinAll + let mut woke := 0 + for _ in List.range 8 do + match ← box.recv with + | some true => woke := woke + 1 + | _ => pure () + r.check "owner close answers every racing open and write" (woke == 8) + match ← sess.join with + | .ok () => r.check "owner close join stays a success" true + | .error _ => r.check "owner close join stays a success" false + + -- The owner throws the invariant, then join reports that same error. + do + let pair ← ends 18 + let hooks ← testHooks true + let sess ← startWithHooks .initiator pair.dialer .empty hooks + let box : Chan Bool ← Chan.new 1 + let opener ← TaskSet.new + opener.spawn do + match ← (sess.openStream pid).run with + | .error _ => discard <| box.send true + | .ok extra => + extra.reset + discard <| box.send false + pair.peer.write ⟨#[1]⟩ + match ← box.recv with + | some true => r.check "an owner invariant wakes the opener" true + | _ => r.check "an owner invariant wakes the opener" false + opener.joinAll + let want := InvariantError.message .sendInFlight + let ownerJoin ← joinText sess + let ownerAgain ← joinText sess + r.check "an owner invariant reaches join" (ownerJoin == want) + r.check "a second join throws the same owner invariant" (ownerAgain == want) + + -- A missing port throws from the owner and join returns that text. + do + let pair ← ends 19 + let hooks ← testHooks false true + let sess ← startWithHooks .initiator pair.dialer .empty hooks + pair.peer.write ⟨#[1]⟩ + let missingJoin ← joinText sess + let missingAgain ← joinText sess + r.check "a missing port reaches join" (missingJoin == "mux: missing port 0") + r.check "a second join throws the same missing port" + (missingAgain == "mux: missing port 0") + + -- Retired handles stay usable, and the map keeps only live ports. + do + let pair ← ends 20 + let drainOwner ← TaskSet.new + drain pair.peer drainOwner + let hooks ← testHooks + let sess ← startWithHooks .initiator pair.dialer .empty hooks + let mut first : Option Streams.Stream := none + let mut grew := false + for i in List.range (liveStreamCap + 8) do + let stream ← mustOpen sess + if (← hooks.registrySize.get) != 1 then grew := true + if i == 0 then first := some stream + stream.reset + let extra ← mustOpen sess + r.check "the port map drops retired streams" + (!grew && (← hooks.registrySize.get) == 1) + match first with + | some stream => + r.check "an old reset handle still reports reset" + (isResetW (← stream.write ⟨#[1]⟩)) + | none => r.check "an old reset handle still reports reset" false + extra.reset + let _ ← mustOpen sess + r.check "another open proceeds after the churn" + ((← hooks.registrySize.get) == 1) + sess.close + discard <| sess.join + drainOwner.joinAll + + -- Two joins wait together and both throw the stored owner error. + do + let pair ← ends 21 + let hooks ← testHooks true + let sess ← startWithHooks .initiator pair.dialer .empty hooks + let box : Chan Bool ← Chan.new 1 + let opener ← TaskSet.new + opener.spawn do + match ← (sess.openStream pid).run with + | .error _ => discard <| box.send true + | .ok extra => + extra.reset + discard <| box.send false + pair.peer.write ⟨#[1]⟩ + discard <| box.recv + opener.joinAll + let want := InvariantError.message .sendInFlight + let answers : Chan String ← Chan.new 2 + let joins ← TaskSet.new + joins.spawn do + discard <| answers.send (← joinText sess) + joins.spawn do + discard <| answers.send (← joinText sess) + joins.joinAll + let left ← answers.recv + let right ← answers.recv + r.check "two concurrent joins throw the owner invariant" + (left == some want && right == some want) + + -- Queued opens and writes are inside the channel. The pinned write + -- throws, failSend claims the writer fault, and finishCause closes + -- that still-open channel. + do + let pair ← ends 22 + let hold : Chan Unit ← Chan.new 1 + let entered : Chan Unit ← Chan.new 1 + let boom := IO.userError "writer boom" + let conn : Connection := { pair.dialer with + write := fun bytes => do + let isData := + match bytes[0]? with + | some b => b == 2 + | none => false + if isData then + discard <| entered.send () + match ← hold.recv with + | none => throw boom + | some _ => pair.dialer.write bytes + else + pair.dialer.write bytes } + let accepted : Chan Unit ← Chan.new 7 + let hooks ← testHooks + let sess ← startWithHooks .initiator conn .empty hooks + let stream ← mustOpen sess + let box : Chan Bool ← Chan.new 8 + let workers ← TaskSet.new + workers.spawn do + let result ← stream.write ⟨#[9]⟩ + discard <| box.send (isClosedW result) + discard <| entered.recv + hooks.acceptedEvent.set (some accepted) + for _ in List.range 3 do + workers.spawn do + let result ← stream.write ⟨#[9]⟩ + discard <| box.send (isClosedW result) + for _ in List.range 4 do + workers.spawn do + match ← (sess.openStream pid).run with + | .error e => discard <| box.send (e == closedError) + | .ok extra => + extra.reset + discard <| box.send false + for _ in List.range 7 do + discard <| accepted.recv + hold.close + workers.joinAll + let mut answered := 0 + for _ in List.range 8 do + match ← box.recv with + | some true => answered := answered + 1 + | _ => pure () + r.check "a writer fault drains every accepted open and write" (answered == 8) + let writerJoin ← joinText sess + let writerAgain ← joinText sess + r.check "repeated joins throw that writer fault" + (writerJoin == boom.toString && writerAgain == boom.toString) + + +/-- More than two parser budgets stay queued behind one local open. -/ +private def runBudgetFairness (r : Runner) : Async Unit := do + let pair ← ends 23 + let entered : Chan Unit ← Chan.new 1 + let release : Chan Unit ← Chan.new 1 + let accepted : Chan Unit ← Chan.new 1 + let hooks ← testHooks + hooks.pauseAfterBudget.set (some { entered, release }) + let sess ← startWithHooks .initiator pair.dialer .empty hooks + let mut blob : ByteArray := .empty + for i in List.range (parserFrameBudget * 3) do + discard <| mustOpen sess + let id : UInt32 := 1 + 2 * UInt32.ofNat i + let frame := + Header.encodeB { kind := .data, streamId := id, length := 1 } ++ ⟨#[7]⟩ + blob := blob ++ frame + pair.peer.write blob + discard <| entered.recv + let saw : Chan Nat ← Chan.new 1 + hooks.captureOpenFrames.set (some saw) + hooks.acceptedEvent.set (some accepted) + let opener ← TaskSet.new + opener.spawn do + discard <| mustOpen sess + discard <| accepted.recv + discard <| release.send () + opener.joinAll + match ← saw.recv with + | some n => + r.check "a local open runs after one parser budget" (n == parserFrameBudget) + | none => r.check "a local open runs after one parser budget" false + sess.close + discard <| sess.join + +/-- Queued opens cannot hold a retained parser suffix. -/ +private def runInverseFairness (r : Runner) : Async Unit := do + let pair ← ends 30 + let entered : Chan Unit ← Chan.new 1 + let release : Chan Unit ← Chan.new 1 + let accepted : Chan Unit ← Chan.new 1 + let hooks ← testHooks + hooks.pauseAfterBudget.set (some { entered, release }) + let sess ← startWithHooks .initiator pair.dialer .empty hooks + let mut blob : ByteArray := .empty + for i in List.range (parserFrameBudget * 3) do + discard <| mustOpen sess + let id : UInt32 := 1 + 2 * UInt32.ofNat i + let frame := + Header.encodeB { kind := .data, streamId := id, length := 1 } ++ ⟨#[7]⟩ + blob := blob ++ frame + pair.peer.write blob + discard <| entered.recv + let saw : Chan Nat ← Chan.new 4 + hooks.captureOpenFrames.set (some saw) + hooks.acceptedEvent.set (some accepted) + let openers ← TaskSet.new + for _ in List.range 3 do + openers.spawn do + discard <| mustOpen sess + discard <| accepted.recv + discard <| release.send () + openers.joinAll + let mut counts : List Nat := [] + for _ in List.range 3 do + match ← saw.recv with + | some n => counts := counts ++ [n] + | none => counts := counts ++ [0] + let want := [parserFrameBudget, parserFrameBudget * 2, parserFrameBudget * 3] + r.check "queued opens advance one parser budget each" (counts == want) + sess.close + discard <| sess.join + +/-- Caller events queued behind a deferred terminal cannot hold the parser. -/ +private def runDeferredPressure (r : Runner) (n : Nat) (fault : Bool) : Async Unit := do + let pair ← ends n + let entered : Chan Unit ← Chan.new 1 + let release : Chan Unit ← Chan.new 1 + let readGate : Chan Unit ← Chan.new 1 + let accepted : Chan Unit ← Chan.new 1 + let phase ← IO.mkRef 0 + let reads ← IO.mkRef 0 + let boom := IO.userError "deferred reader boom" + let conn : Connection := { pair.dialer with + write := fun bytes => do + let isData := + match bytes[0]? with + | some b => b == 2 + | none => false + if isData then + phase.set ((← phase.get) + 1) + pair.dialer.write bytes + read := do + let k ← reads.get + reads.set (k + 1) + if k == 0 then + pair.dialer.read + else if fault then + match ← readGate.recv with + | none => throw boom + | some _ => pair.dialer.read + else + match ← readGate.recv with + | none => return none + | some _ => pair.dialer.read } + let hooks ← testHooks + hooks.pauseAfterBudget.set (some { entered, release }) + let sess ← startWithHooks .initiator conn .empty hooks + let mut target : Option Streams.Stream := none + let mut blob : ByteArray := .empty + for i in List.range (parserFrameBudget * 4) do + let stream ← mustOpen sess + if i == 0 then target := some stream + let id : UInt32 := 1 + 2 * UInt32.ofNat i + let frame := + Header.encodeB { kind := .data, streamId := id, length := 1 } ++ ⟨#[7]⟩ + blob := blob ++ frame + pair.peer.write blob + discard <| entered.recv + hooks.acceptedEvent.set (some accepted) + readGate.close + discard <| accepted.recv + let enteredB : Chan Unit ← Chan.new 1 + let releaseB : Chan Unit ← Chan.new 1 + hooks.pauseAfterBudget.set (some { entered := enteredB, release := releaseB }) + discard <| release.send () + discard <| enteredB.recv + let framesAtDefer ← hooks.appliedFrames.get + let saw : Chan Nat ← Chan.new 4 + hooks.captureOpenFrames.set (some saw) + let stream ← match target with + | some stream => pure stream + | none => throw <| IO.userError "pressure test opened no stream" + let writers ← TaskSet.new + let closed ← IO.mkRef true + for _ in List.range 3 do + writers.spawn do + let result ← stream.write ⟨#[1]⟩ + if !(isClosedW result) then closed.set false + discard <| accepted.recv + let enteredC : Chan Unit ← Chan.new 1 + let releaseC : Chan Unit ← Chan.new 1 + hooks.pauseAfterBudget.set (some { entered := enteredC, release := releaseC }) + discard <| releaseB.send () + discard <| enteredC.recv + let framesNext ← hooks.appliedFrames.get + let firstRelease ← saw.recv + let extra ← (saw.tryRecv : BaseIO (Option Nat)) + discard <| releaseC.send () + writers.joinAll + let secondRelease ← saw.recv + let chunks ← phase.get + let answersClosed ← closed.get + let stepOk := + framesAtDefer == parserFrameBudget * 2 + && framesNext == parserFrameBudget * 3 + && firstRelease == some (parserFrameBudget * 2) + && extra == none + && secondRelease == some (parserFrameBudget * 3) + && chunks == 0 + && answersClosed + if fault then + let first ← joinText sess + let second ← joinText sess + r.check "a deferred reader fault advances under queued writes" + (stepOk && first == boom.toString && second == boom.toString) + else + let first ← joinText sess + let second ← joinText sess + r.check "a deferred EOF advances under queued writes" + (stepOk && first == "ok" && second == "ok") + +/-- Retained complete frames, then EOF, a partial tail, or a reader fault. -/ +private def runRetained (r : Runner) (n : Nat) (trailing fault : Bool) : Async Unit := do + let pair ← ends n + let entered : Chan Unit ← Chan.new 1 + let release : Chan Unit ← Chan.new 1 + let readGate : Chan Unit ← Chan.new 1 + let accepted : Chan Unit ← Chan.new 1 + let phase ← IO.mkRef 0 + let reads ← IO.mkRef 0 + let boom := IO.userError "retained reader boom" + let conn : Connection := { pair.dialer with + write := fun bytes => do + let isData := + match bytes[0]? with + | some b => b == 2 + | none => false + if isData then + phase.set ((← phase.get) + 1) + pair.dialer.write bytes + read := do + let k ← reads.get + reads.set (k + 1) + if k == 0 then + pair.dialer.read + else if fault then + match ← readGate.recv with + | none => throw boom + | some _ => pair.dialer.read + else + match ← readGate.recv with + | none => return none + | some _ => pair.dialer.read } + let hooks ← testHooks + hooks.pauseAfterBudget.set (some { entered, release }) + let sess ← startWithHooks .initiator conn .empty hooks + let mut target : Option Streams.Stream := none + let mut blob : ByteArray := .empty + for i in List.range (parserFrameBudget * 3) do + let stream ← mustOpen sess + if i == 0 then target := some stream + let id : UInt32 := 1 + 2 * UInt32.ofNat i + let frame := + Header.encodeB { kind := .data, streamId := id, length := 1 } ++ ⟨#[7]⟩ + blob := blob ++ frame + if trailing then blob := blob ++ ⟨#[0, 0, 0, 0]⟩ + pair.peer.write blob + discard <| entered.recv + let stream ← match target with + | some stream => pure stream + | none => throw <| IO.userError "retained test opened no stream" + hooks.acceptedEvent.set (some accepted) + let writer ← TaskSet.new + writer.spawn do + discard <| stream.write ⟨Array.replicate (testPayloadCap + 1) 7⟩ + discard <| accepted.recv + readGate.close + discard <| accepted.recv + discard <| release.send () + writer.joinAll + let chunks ← phase.get + if fault then + let first ← joinText sess + let second ← joinText sess + r.check "retained frames keep the reader fault" + (first == boom.toString && second == boom.toString) + r.check "no chunk follows a deferred reader fault" (chunks == 1) + else if trailing then + let once ← sess.join + let twice ← sess.join + match once, twice with + | .error a, .error b => + r.check "a partial frame at EOF is a protocol error" + (a == protocolPartial && b == protocolPartial) + | _, _ => r.check "a partial frame at EOF is a protocol error" false + r.check "no chunk follows a deferred partial EOF" (chunks == 1) + else + let first ← joinText sess + let second ← joinText sess + r.check "retained frames then EOF join as connection end" + (first == "ok" && second == "ok") + r.check "no chunk follows a deferred EOF" (chunks == 1) + +/-- The second chunk of a large write leaves without a later event. -/ +private def runSecondChunk (r : Runner) : Async Unit := do + let pair ← ends 29 + let phase ← IO.mkRef 0 + let conn : Connection := { pair.dialer with + write := fun bytes => do + let isData := + match bytes[0]? with + | some b => b == 2 + | none => false + if isData then + phase.set ((← phase.get) + 1) + pair.dialer.write bytes } + let sess ← start .initiator conn + let stream ← mustOpen sess + let wrote ← stream.write ⟨Array.replicate (testPayloadCap + 1) 7⟩ + let chunks ← phase.get + r.check "a large write sends its second chunk" (isOk wrote && chunks == 2) + sess.close + discard <| sess.join + +private def runArbitration (r : Runner) : Async Unit := do + runBudgetFairness r + runInverseFairness r + runSecondChunk r + runRetained r 24 false false + runRetained r 25 true false + runRetained r 26 false true + runDeferredPressure r 31 false + runDeferredPressure r 32 true + -- A throwing transport close still closes the event channel. + do + let pair ← ends 27 + let hold : Chan Unit ← Chan.new 1 + let entered : Chan Unit ← Chan.new 1 + let conn : Connection := { pair.dialer with + write := fun bytes => do + let isData := + match bytes[0]? with + | some b => b == 2 + | none => false + if isData then + discard <| entered.send () + match ← hold.recv with + | none => throw <| IO.userError "write held" + | some _ => pair.dialer.write bytes + else + pair.dialer.write bytes + close := do + pair.dialer.close + throw <| IO.userError "close boom" } + let sess ← start .initiator conn + let stream ← mustOpen sess + let box : Chan Bool ← Chan.new 1 + let workers ← TaskSet.new + workers.spawn do + let result ← stream.write ⟨#[9]⟩ + discard <| box.send (isClosedW result) + discard <| entered.recv + let returned ← try + sess.close + pure true + catch _ => + pure false + r.check "close returns when the transport close throws" returned + match ← (sess.openStream pid).run with + | .error e => r.check "close shuts the event channel" (e == closedError) + | .ok extra => + extra.reset + r.check "close shuts the event channel" false + hold.close + workers.joinAll + match ← box.recv with + | some true => r.check "a pinned writer wakes after a throwing close" true + | _ => r.check "a pinned writer wakes after a throwing close" false + let first ← joinText sess + let second ← joinText sess + r.check "repeated joins stay an owner close" (first == "ok" && second == "ok") + -- The reader fault has won. Close during the outcome pause keeps it. + do + let pair ← ends 28 + let entered : Chan Unit ← Chan.new 1 + let release : Chan Unit ← Chan.new 1 + let hooks ← testHooks + hooks.beforeOutcome.set (some { entered, release }) + let boom := IO.userError "reader boom" + let conn : Connection := { pair.dialer with + read := throw boom } + let sess ← startWithHooks .initiator conn .empty hooks + discard <| entered.recv + sess.close + discard <| release.send () + let first ← joinText sess + let second ← joinText sess + r.check "a later close keeps the winning reader fault" + (first == boom.toString && second == boom.toString) + +private def checks (r : Runner) : Async Unit := do + -- Startup, join, and a second join. + do + let pair ← ends 1 + let sess ← start .initiator pair.dialer + sess.close + match ← sess.join with + | .ok () => r.check "startup and clean shutdown join once" true + | .error _ => r.check "startup and clean shutdown join once" false + match ← sess.join with + | .ok () => r.check "a second join returns the same success" true + | .error _ => r.check "a second join returns the same success" false + match ← pair.peer.read with + | none => r.check "closing the session ends the peer read" true + | some _ => r.check "closing the session ends the peer read" false + + -- One acknowledged data frame. + do + let pair ← ends 2 + let sess ← start .initiator pair.dialer + let stream ← mustOpen sess + let wrote ← stream.write ⟨#[9]⟩ + let openBytes ← pair.peer.readExactly 9 + let dataBytes ← pair.peer.readExactly 10 + let payloadOk := + match dataBytes with + | some (frame, rest) => + frame.size == 10 && frame[9]? == some 9 && rest.isEmpty + | none => false + r.check "one outbound data frame is written and acknowledged" + (isOk wrote && openBytes.isSome && payloadOk) + sess.close + match ← sess.join with + | .ok () => r.check "the reader and the owner both join" true + | .error _ => r.check "the reader and the owner both join" false + + -- A failed write keeps the IO.Error. + do + let pair ← ends 3 + let boom := IO.userError "boom" + let conn : Connection := { pair.dialer with write := fun _ => throw boom } + let sess ← start .initiator conn + match ← (sess.openStream pid).run with + | .ok _ => r.check "a failed open write resolves closed" false + | .error e => + r.check "a failed open write resolves closed" (e == closedError) + let first ← joinText sess + let second ← joinText sess + r.check "a failed connection write keeps its IO.Error" (first == boom.toString) + r.check "a second join throws the same connection error" (second == boom.toString) + + -- Malformed bytes are a protocol failure. + do + let pair ← ends 4 + let sess ← start .initiator pair.dialer + pair.peer.write ⟨#[0, 0, 0, 0, 1, 0, 0, 0, 0]⟩ + match ← sess.join with + | .error e => + r.check "a malformed frame is a protocol failure" (e == protocolMalformed) + | .ok () => + r.check "a malformed frame is a protocol failure" false + + -- A frame split across reads. + do + let pair ← ends 5 + let sess ← start .initiator pair.dialer + let stream ← mustOpen sess + let _ ← pair.peer.readExactly 9 + let frame := oneData 7 + pair.peer.write (frame.extract 0 4) + pair.peer.write (frame.extract 4 frame.size) + match ← stream.read with + | .ok (some chunk) => + r.check "a frame split across reads is delivered" (chunk == ⟨#[7]⟩) + | _ => r.check "a frame split across reads is delivered" false + sess.close + discard <| sess.join + + -- A payload that crosses the reader's slice boundary. + do + let pair ← ends 14 + let sess ← start .initiator pair.dialer + let stream ← mustOpen sess + let _ ← pair.peer.readExactly 9 + let payload : ByteArray := ⟨Array.replicate testPayloadCap 7⟩ + let frame := + Header.encodeB { + kind := .data + streamId := 1 + length := UInt32.ofNat testPayloadCap } ++ payload + pair.peer.write frame + match ← stream.read with + | .ok (some chunk) => + r.check "a frame that crosses a slice boundary is delivered" (chunk == payload) + | _ => + r.check "a frame that crosses a slice boundary is delivered" false + sess.close + discard <| sess.join + + -- More frames than one parser budget arrive in one read. + -- Each frame belongs to a different stream. + do + let pair ← ends 6 + let reads ← IO.mkRef 0 + let conn : Connection := { pair.dialer with + read := do + reads.modify (· + 1) + pair.dialer.read } + let sess ← start .initiator conn + let mut burst : Array Streams.Stream := #[] + for _ in List.range 10 do + burst := burst.push (← mustOpen sess) + let mut bulk : ByteArray := .empty + for i in List.range 10 do + let id := UInt32.ofNat (1 + 2 * i) + bulk := bulk ++ Header.encodeB { + kind := .data + streamId := id + length := 1 } ++ ⟨#[UInt8.ofNat i]⟩ + pair.peer.write bulk + let mut got : Array UInt8 := #[] + for stream in burst do + match ← stream.read with + | .ok (some chunk) => + match chunk[0]? with + | some b => got := got.push b + | none => pure () + | _ => pure () + let n ← reads.get + r.check "a budget of frames drains without a read per frame" + (got == (List.range 10).toArray.map UInt8.ofNat && 1 ≤ n && n ≤ 3) + sess.close + discard <| sess.join + + -- Reset publishes the port before it closes admission. + do + let pair ← ends 7 + let sess ← start .initiator pair.dialer + let stream ← mustOpen sess + let _ ← pair.peer.readExactly 9 + pair.peer.write (control .reset resetNotData) + match ← stream.read with + | .error .reset => + let mut resetCount := 0 + for _ in List.range 5 do + if isResetW (← stream.write ⟨#[1]⟩) then + resetCount := resetCount + 1 + r.check "five writes after reset observe the reset port" (resetCount == 5) + | _ => r.check "five writes after reset observe the reset port" false + sess.close + discard <| sess.join + + -- A reset frame write wakes a blocked fifth writer after the port update. + do + let pair ← ends 12 + let drainOwner ← TaskSet.new + drain pair.peer drainOwner + let gate : Chan Unit ← Chan.new 1 + let conn : Connection := { pair.dialer with + write := fun bytes => do + let openFrame := + match bytes[0]? with + | some b => b == 1 + | none => false + if openFrame then + pair.dialer.write bytes + else + let _ ← gate.recv + pair.dialer.write bytes } + let sess ← start .initiator conn + let stream ← mustOpen sess + stream.reset + let box ← Chan.new 5 + let writers ← TaskSet.new + for _ in List.range 5 do + writers.spawn do + let result ← stream.write ⟨#[3]⟩ + discard <| box.send result + gate.close + writers.joinAll + let mut resetCount := 0 + for _ in List.range 5 do + match ← box.recv with + | some result => + if isResetW result then resetCount := resetCount + 1 + | none => pure () + r.check "a blocked fifth writer wakes with reset" (resetCount == 5) + sess.close + discard <| sess.join + drainOwner.joinAll + + -- Negotiation bytes are frames before the first read. + do + let pair ← ends 13 + let sess ← start .responder pair.dialer (control .open openNotData) + match ← sess.accept with + | some _ => r.check "a leftover open frame is accepted" true + | none => r.check "a leftover open frame is accepted" false + sess.close + discard <| sess.join + + -- Half-close closes admission. + do + let pair ← ends 8 + let drainOwner ← TaskSet.new + drain pair.peer drainOwner + let sess ← start .initiator pair.dialer + let stream ← mustOpen sess + stream.close + let wrote ← stream.write ⟨#[1]⟩ + r.check "a write after half-close completes with closed" (isClosedW wrote) + sess.close + discard <| sess.join + drainOwner.joinAll + + -- Five writers wake on shutdown. + do + let pair ← ends 9 + let (conn, hold) ← holdData pair.dialer + let sess ← start .initiator conn + let stream ← mustOpen sess + let box ← Chan.new 5 + let writers ← TaskSet.new + for _ in List.range 5 do + writers.spawn do + let result ← stream.write ⟨#[3]⟩ + discard <| box.send result + sess.close + hold.close + writers.joinAll + let mut closedCount := 0 + for _ in List.range 5 do + match ← box.recv with + | some result => + if isClosedW result then closedCount := closedCount + 1 + | none => pure () + r.check "five blocked writers wake with closed on shutdown" (closedCount == 5) + match ← sess.join with + | .ok () => r.check "shutdown join stays a success" true + | .error _ => r.check "shutdown join stays a success" false + + -- Accept envelopes survive reset. + do + let pair ← ends 10 + let sess ← start .responder pair.dialer + pair.peer.write (control .open openNotData) + pair.peer.write (control .reset resetNotData) + match ← sess.accept with + | none => r.check "an accepted stream survives reset" false + | some stream => + match ← stream.read with + | .error .reset => + r.check "an accepted stream survives reset" true + | _ => r.check "an accepted stream survives reset" false + sess.close + discard <| sess.join + + -- Live permits return, so the cap can be filled twice. + do + let pair ← ends 11 + let drainOwner ← TaskSet.new + drain pair.peer drainOwner + let sess ← start .initiator pair.dialer + let mut streams : Array Streams.Stream := #[] + for _ in List.range liveStreamCap do + streams := streams.push (← mustOpen sess) + for stream in streams do + stream.reset + let extra ← mustOpen sess + extra.reset + r.check "a live permit returns and another open proceeds" true + sess.close + discard <| sess.join + drainOwner.joinAll + + runCorrections r + runArbitration r + +/-- The runtime shell over the sim connection. -/ +def run (r : Runner) : IO Unit := do + r.section "mux test session" "packages/libp2p/mux/Libp2p/Mux/Test/Session.lean" + Async.run (checks r) + +end Tests.Mux.Session + namespace Tests open Libp2p.Mux @@ -1613,5 +2667,6 @@ def Mux.run (r : Runner) : IO Unit := do Mux.Wire.run r Mux.Seam.run r Mux.State.run r + Mux.Session.run r end Tests diff --git a/packages/libp2p/mux/Libp2p/Mux/Test/Session.lean b/packages/libp2p/mux/Libp2p/Mux/Test/Session.lean new file mode 100644 index 00000000..ee6dba74 --- /dev/null +++ b/packages/libp2p/mux/Libp2p/Mux/Test/Session.lean @@ -0,0 +1,863 @@ +import Libp2p.Mux.Mock.Core +import Libp2p.Mux.Test.State +import Libp2p.Mux.Test.Wire +import Conc +import Streams + +/-! +# Runtime shell for the test session + +One owner task steps `Libp2p.Mux.Test.State`. One reader task calls +`Connection.read` and posts slices. Callers post opens, writes, and +resets on the same event channel. The owner performs each outbound +`Connection.write`, then `ackSend` or `failSend`, and only then takes +another event. + +A turn takes at most one queued event, then at most one parser budget, +then at most one outbound chunk. A byte event's `feed` is that budget. +A non-byte event is followed by one retained pull in the same turn. +A dequeued connection end or reader fault is held while a complete +frame remains. Each later turn answers one caller event and takes one +budget, and no chunk starts after that dequeue. +Teardown closes the event channel before it drains. An owner that +throws stores that `IO.Error`, wakes every waiter, and throws the +same error. Every later `join` throws the stored error. +-/ + +set_option autoImplicit false + +namespace Libp2p.Mux.Test.Session + +open Libp2p.Mux.Mock.Core +open Libp2p.Mux.Test.State +open Libp2p.Mux.Test.Wire +open Conc hiding Error + +/-- Events waiting for the owner. The architecture policy table is +this capacity. -/ +def eventChannelCap : Nat := 32 + +/-- Unread frames queued on one live stream. A further frame resets +that stream. -/ +def inboundFrameCredit : Nat := 4 + +/-- A one-shot barrier. The owner sends on `entered` and then waits +on `release`. -/ +structure TurnGate where + entered : Chan Unit + release : Chan Unit + +/-- Refs a test installs through `startWithHooks`. `start` does not +take this structure, and `Libp2p.Mux` does not import it. With no +hooks the owner reads no hook ref. -/ +structure TestHooks where + /-- Live entries in the port map, written after each retirement sweep. -/ + registrySize : IO.Ref Nat + /-- The owner throws `InvariantError.sendInFlight` on the next event. -/ + forceInvariant : IO.Ref Bool + /-- The owner throws `mux: missing port 0` on the next event. -/ + forceMissingPort : IO.Ref Bool + /-- One unit after an open, a write, or a reader event is accepted. + `none` sends nothing. -/ + acceptedEvent : IO.Ref (Option (Chan Unit)) + /-- Remote frames the owner has applied. -/ + appliedFrames : IO.Ref Nat + /-- Each resolved local open, and each caller event a deferred turn + releases, sends `appliedFrames` here. `none` sends nothing. -/ + captureOpenFrames : IO.Ref (Option (Chan Nat)) + /-- Pause once after a budget that leaves a complete frame. -/ + pauseAfterBudget : IO.Ref (Option TurnGate) + /-- Pause once after a cause has won and before `Outcome` is written. -/ + beforeOutcome : IO.Ref (Option TurnGate) + +/-- Throw `e` as `InvariantError.toIOError`. The owner uses this for +every pure-step failure. It does not continue with the old state. -/ +private def throwInvariant {α : Type} (e : InvariantError) : Async α := + throw e.toIOError + +/-! ## Ports, events, runtime -/ + +private structure Port where + mode : OnceRef (Option PortEnd) + inbox : Chan (Except Streams.Error ByteArray) + permits : Chan Unit + +/-- `write` carries the handle's port. A retired id leaves the map, +and a late write answers from that port. `halfClose` and `reset` +name the stream id. -/ +private inductive Event where + | openLocal (reply : Chan (Except Error Streams.Stream)) + | write (port : Port) (id : UInt32) (tag : Nat) (bytes : ByteArray) (heldPermit : Bool) + | halfClose (id : UInt32) + | reset (id : UInt32) + | bytes (chunk : ByteArray) + | ended + | readerFault (error : IO.Error) + +/-- A dequeued connection end or reader fault. The owner stores it +while a complete frame remains, and clears it when that cause is +claimed or dropped. -/ +private inductive DeferredTerminal where + | ended + | readerFault (error : IO.Error) + +private structure Runtime where + conn : Streams.Connection + events : Chan Event + livePermits : Chan Unit + acceptPermits : Chan Unit + acceptQ : Chan Streams.Stream + writes : Streams.Completions Unit + ports : OnceRef (Std.HashMap UInt32 Port) + state : OnceRef State + outcome : Outcome Error + tasks : TaskSet + closing : OnceRef Bool + /-- Set when normal or exceptional teardown begins. A second entry + does not record another result. -/ + teardownStarted : OnceRef Bool + /-- The first `IO.Error` that escaped the owner. `join` throws it + after `TaskSet.joinAll` has consumed its report. A later store + leaves this value in place. -/ + ownerFault : OnceRef (Option IO.Error) + /-- See `DeferredTerminal`. The owner is the only task that reads + or writes this cell. -/ + deferredTerminal : OnceRef (Option DeferredTerminal) + /-- The opener whose `openLocal` step is running. `resolveOpen` + publishes on it and clears it. -/ + pendingOpen : OnceRef (Option (Chan (Except Error Streams.Stream))) + hooks : Option TestHooks + +private def preload (c : Chan Unit) (n : Nat) : BaseIO Unit := do + for _ in List.range n do + discard <| c.trySend () + +private def returnLivePermit (rt : Runtime) : BaseIO Unit := do + discard <| rt.livePermits.trySend () + +private def returnWritePermit (p : Port) : BaseIO Unit := do + discard <| p.permits.trySend () + +private def returnAcceptPermit (rt : Runtime) : BaseIO Unit := do + discard <| rt.acceptPermits.trySend () + +private def newPort : BaseIO Port := do + let permits ← Chan.new State.admittedWriteCap + preload permits State.admittedWriteCap + return { + mode := ← OnceRef.new none + inbox := ← Chan.new inboundFrameCredit + permits } + +private def portMode (p : Port) : BaseIO WriteMode := do + match ← p.mode.get with + | some .halfClosed => return .halfClosed + | some .reset => return .reset + | some .sessionClosed => return .sessionClosed + | none => return .open + +private def lookup (rt : Runtime) (id : UInt32) : BaseIO (Option Port) := do + return (← rt.ports.get).get? id + +/-- The port an active action names. Absence is an internal failure. +Terminal cleanup walks the map itself and does not call this. -/ +private def requirePort (rt : Runtime) (id : UInt32) : Async Port := do + match ← lookup rt id with + | some p => return p + | none => throw <| IO.userError s!"mux: missing port {id}" + +private def publishRegistry (rt : Runtime) : BaseIO Unit := do + if let some hooks := rt.hooks then + hooks.registrySize.set (← rt.ports.get).size + +/-- Tell a test that an event entered the channel. No hook, and an +empty watch channel, send nothing. -/ +private def noteAccepted (rt : Runtime) : Async Unit := do + let some hooks := rt.hooks | return + let some ch ← (hooks.acceptedEvent.get : BaseIO (Option (Chan Unit))) | return + discard <| ch.send () + +/-- Send `appliedFrames` while a test is watching opens or deferred releases. -/ +private def noteOpenFrames (rt : Runtime) : Async Unit := do + let some hooks := rt.hooks | return + let some ch ← (hooks.captureOpenFrames.get : BaseIO _) | return + discard <| ch.send (← hooks.appliedFrames.get) + +/-- One use of a test barrier. The slot is cleared before the wait. -/ +private def waitGate (slot : IO.Ref (Option TurnGate)) : Async Unit := do + let some gate ← (slot.get : BaseIO (Option TurnGate)) | return + slot.set none + discard <| gate.entered.send () + discard <| gate.release.recv + +/-- After a budget that left a complete frame, let a test queue an event. -/ +private def pauseAfterBudget (rt : Runtime) : Async Unit := do + let s ← (rt.state.get : BaseIO State) + if !s.parser.hasComplete then return + let some hooks := rt.hooks | return + waitGate hooks.pauseAfterBudget + +/-- After the pure cause has won, and before `Outcome` changes. -/ +private def pauseBeforeOutcome (rt : Runtime) : Async Unit := do + let some hooks := rt.hooks | return + waitGate hooks.beforeOutcome + +private def chunkWire (id : UInt32) (raw : ByteArray) : ByteArray := + Header.encodeB { + kind := .data + streamId := id + length := UInt32.ofNat raw.size } ++ raw + +private def failReply (reply : Chan (Except Error Streams.Stream)) (e : Error) : + BaseIO Unit := do + discard <| reply.trySend (.error e) + reply.close + +/-- Answer one event that teardown owns. The caller answers an event +whose send failed, and does not call this. -/ +private def releaseQueued (rt : Runtime) (ev : Event) : BaseIO Unit := do + match ev with + | .openLocal reply => + failReply reply closedError + returnLivePermit rt + | .write port _ tag _ held => + rt.writes.resolve tag (.error .closed) + if held then returnWritePermit port + | _ => pure () + +private def writeStream (rt : Runtime) (id : UInt32) (p : Port) (bytes : ByteArray) : + Async (Except Streams.Error Unit) := do + if !bytes.isEmpty then + match ← p.permits.recv with + | none => + match ← (portMode p : BaseIO WriteMode) with + | .reset => return .error .reset + | _ => return .error .closed + | some _ => pure () + let some (tag, slot) ← (rt.writes.register : BaseIO _) + | do + if !bytes.isEmpty then returnWritePermit p + return .error .closed + match ← rt.events.send (.write p id tag bytes (!bytes.isEmpty)) with + | .error _ => + if !bytes.isEmpty then returnWritePermit p + rt.writes.resolve tag (.error .closed) + return .error .closed + | .ok () => + noteAccepted rt + match ← slot.recv with + | some answer => return answer + | none => return .error .closed + +private def mint (rt : Runtime) (id : UInt32) : Async Streams.Stream := do + let p ← (newPort : BaseIO Port) + rt.ports.update fun m => m.insert id p + publishRegistry rt + let base ← Streams.Stream.ofPort { + inbox := p.inbox + endedBy := do + match ← (p.mode.get : BaseIO (Option PortEnd)) with + | some .reset => return some .reset + | _ => return none + completions := rt.writes + postWrite := fun _ _ => pure (.ok ()) + postConsumed := fun _ => pure (.ok ()) + postClose := rt.events.send (.halfClose id) + postReset := rt.events.send (.reset id) } + return { base with write := writeStream rt id p } + +/-! ## Owner steps -/ + +private def claimClose (rt : Runtime) : Async (List Action) := do + let s ← (rt.state.get : BaseIO State) + if s.cause.isRunning then + let step := State.claim s .ownerClose + rt.state.set step.state + return step.actions + else + return [] + +private def doWrite (rt : Runtime) (bytes : ByteArray) : Async (List Action) := do + -- The pure state still holds the outbound tag until this write + -- returns. The closing flag is read again here, after it may have + -- changed during the previous action. The `try` covers the write alone. + if ← (rt.closing.get : BaseIO Bool) then + return ← claimClose rt + let wrote : Except IO.Error Unit ← + try + rt.conn.write bytes + pure (.ok ()) + catch e => + pure (.error e) + let s ← (rt.state.get : BaseIO State) + match wrote with + | .ok () => + match State.ackSend s with + | .ok step => + rt.state.set step.state + return step.actions + | .error e => throwInvariant e + | .error e => + if ← (rt.closing.get : BaseIO Bool) then + claimClose rt + else if s.cause.isRunning then + match State.failSend s e with + | .ok step => + rt.state.set step.state + return step.actions + | .error inv => throwInvariant inv + else + throwInvariant .sendAfterTerminal + +/-- Close the event channel, then take every event that entered before +that close. Each queued open and write is answered here. -/ +private def rejectQueued (rt : Runtime) : BaseIO Unit := do + rt.events.close + for ev in ← (rt.events.drain : BaseIO (List Event)) do + releaseQueued rt ev + +/-- Wake admission, completions, and every port still in the map. +Closes are idempotent. This path does not record an outcome. A missing +map entry is a port already dropped after its retirement actions. -/ +private def wakeWaiters (rt : Runtime) : Async Unit := do + if let some reply ← (rt.pendingOpen.get : BaseIO _) then + failReply reply closedError + rt.pendingOpen.set none + rt.writes.closeAll + rt.livePermits.close + rt.acceptPermits.close + rt.acceptQ.close + let ports ← (rt.ports.get : BaseIO (Std.HashMap UInt32 Port)) + for (_, p) in ports.toList do + p.inbox.close + p.permits.close + rt.ports.set ({} : Std.HashMap UInt32 Port) + rt.conn.closeQuietly + +private def finishCause (rt : Runtime) (c : Cause) : Async Unit := do + if ← (rt.teardownStarted.get : BaseIO Bool) then return + rt.teardownStarted.set true + rejectQueued rt + -- The pure cause has already won. Close may set the request flag + -- during this wait. `markClosing` runs only for owner close. + pauseBeforeOutcome rt + match c with + | .running => + throw <| IO.userError "mux: teardown while running" + | .ownerClose => + rt.outcome.markClosing + rt.outcome.finish (.ok ()) + | .connectionEnd => + rt.outcome.finish (.ok ()) + | .readerFault e | .writerFault e => + rt.outcome.recordFault e + rt.outcome.finish (.ok ()) + | .protocol e => + rt.outcome.finish (.error e) + wakeWaiters rt + +/-- The owner threw. Wake the same waiters. Do not store a successful +result, and do not replace the error the caller rethrows. -/ +private def teardownOnExit (rt : Runtime) : Async Unit := do + rt.teardownStarted.set true + rejectQueued rt + try + wakeWaiters rt + catch _ => + pure () + +/-- Drop ids whose retirement actions have already run. The handle +keeps its `Port`. -/ +private def forgetRetired (rt : Runtime) : BaseIO Unit := do + let live := (← (rt.state.get : BaseIO State)).live + rt.ports.update fun m => + m.toList.foldl (init := ({} : Std.HashMap UInt32 Port)) fun acc (id, p) => + if (live.get? id).isSome then acc.insert id p else acc + publishRegistry rt + +private def perform (rt : Runtime) (acts : List Action) : Async Unit := do + let mut queue := acts + repeat + match queue with + | [] => break + | act :: rest => + queue := rest + match act with + | .sendFrame frame => + let more ← doWrite rt (Frame.encodeB frame) + queue := more ++ queue + | .sendChunk id _ bytes => + let more ← doWrite rt (chunkWire id bytes) + queue := more ++ queue + | .resolveOpen result => + match ← (rt.pendingOpen.get : BaseIO _) with + | none => throw <| IO.userError "mux: open reply missing" + | some reply => + rt.pendingOpen.set none + match result with + | .error e => failReply reply e + | .ok id => + noteOpenFrames rt + let stream ← mint rt id + discard <| reply.trySend (.ok stream) + reply.close + | .resolveWrite tag result => + rt.writes.resolve tag result + | .returnLivePermit => + returnLivePermit rt + | .returnWritePermit id => + returnWritePermit (← requirePort rt id) + | .returnAcceptPermit _ => + returnAcceptPermit rt + | .setPort id reason => + let p ← requirePort rt id + p.mode.set (some reason) + | .closeWritePermits id => + let p ← requirePort rt id + p.permits.close + | .deliver id bytes => + let p ← requirePort rt id + let sent ← (p.inbox.trySend (.ok bytes) : BaseIO Bool) + if !sent && !(← (p.inbox.isClosed : BaseIO Bool)) then + let s ← (rt.state.get : BaseIO State) + match State.resetLocal s id with + | .ok step => + rt.state.set step.state + queue := step.actions ++ queue + | .error e => throwInvariant e + | .enqueueAccept id => + let stream ← mint rt id + if !(← (rt.acceptQ.trySend stream : BaseIO Bool)) then + throw <| IO.userError s!"mux: accept enqueue failed {id}" + | .closeInbox id => + let p ← requirePort rt id + p.inbox.close + | .closeAccept => + rt.acceptQ.close + | .terminal c => + finishCause rt c + forgetRetired rt + +/-- Claim owner close when the session is still running, and perform +that claim. An earlier winner leaves the action list empty. -/ +private def performClose (rt : Runtime) : Async Unit := do + let actions ← claimClose rt + if !actions.isEmpty then + perform rt actions + +private def enact (rt : Runtime) (step : Step) : Async Unit := do + rt.state.set step.state + perform rt step.actions + +private def enactExcept (rt : Runtime) (x : Except InvariantError Step) : Async Unit := do + match x with + | .ok step => enact rt step + | .error e => throwInvariant e + +private def applyFrame (rt : Runtime) (f : Frame) : Async Unit := do + if let some hooks := rt.hooks then + hooks.appliedFrames.modify (· + 1) + let s ← (rt.state.get : BaseIO State) + if !s.cause.isRunning then return + if f.kind == Kind.open then + match State.classifyRemote s f.streamId with + | .error _ => + enactExcept rt (State.openRemote s f.streamId false false) + | .ok () => + let gotLive ← (rt.livePermits.tryRecv : BaseIO (Option Unit)) + let gotAccept ← (rt.acceptPermits.tryRecv : BaseIO (Option Unit)) + match State.openRemote s f.streamId gotLive.isSome gotAccept.isSome with + | .error e => + if gotLive.isSome then returnLivePermit rt + if gotAccept.isSome then returnAcceptPermit rt + throwInvariant e + | .ok step => enact rt step + else + enactExcept rt (State.apply s f) + +private def pullOnce (rt : Runtime) : Async Unit := do + let s ← (rt.state.get : BaseIO State) + let pulled := State.pull s + rt.state.set pulled.state + perform rt pulled.actions + for f in pulled.frames do + if (← (rt.state.get : BaseIO State)).cause.isRunning then + applyFrame rt f + pauseAfterBudget rt + +private def feed (rt : Runtime) (chunk : ByteArray) : Async Unit := do + let s ← (rt.state.get : BaseIO State) + if !s.cause.isRunning then return + match Parser.feedB s.parser chunk with + | .error _ => + enact rt (State.claim s (.protocol protocolMalformed)) + | .ok (frames, p) => + rt.state.set (State.setParser s p) + for f in frames do + if (← (rt.state.get : BaseIO State)).cause.isRunning then + applyFrame rt f + pauseAfterBudget rt + +/-- A dequeued write carries the handle's port. Admission reads that +port. It does not invent a mode when the map no longer has the id. +After the session has left `running`, the same port returns the permit. -/ +private def onWrite (rt : Runtime) (p : Port) (id : UInt32) (tag : Nat) + (bytes : ByteArray) (held : Bool) : Async Unit := do + let s ← (rt.state.get : BaseIO State) + if !s.cause.isRunning then + rt.writes.resolve tag (.error .closed) + if held then returnWritePermit p + return + let mode ← portMode p + if bytes.isEmpty then + enact rt (State.writeEmpty s tag mode) + else if mode ≠ .open then + rt.writes.resolve tag (writeAnswer mode) + if held then returnWritePermit p + else + enact rt (State.admitWrite s id tag bytes) + +/-- `true` when the caller may pump. Connection end and a reader fault +return `false`. A close request wins while the session is still running. +A byte event's `true` result means `feed` already spent the budget. -/ +private def dispatch (rt : Runtime) (ev : Event) : Async Bool := do + if ← (rt.closing.get : BaseIO Bool) then + releaseQueued rt ev + performClose rt + return false + match ev with + | .ended => + let s ← (rt.state.get : BaseIO State) + if s.cause.isRunning then + enact rt (State.inboundEnd s) + return false + | .readerFault e => + let s ← (rt.state.get : BaseIO State) + if s.cause.isRunning then + enact rt (State.claim s (.readerFault e)) + return false + | .openLocal reply => + rt.pendingOpen.set (some reply) + let s ← (rt.state.get : BaseIO State) + enact rt (State.openLocal s) + return true + | .write port id tag bytes held => + onWrite rt port id tag bytes held + return true + | .halfClose id => + let s ← (rt.state.get : BaseIO State) + enact rt (State.halfClose s id) + return true + | .reset id => + let s ← (rt.state.get : BaseIO State) + enactExcept rt (State.resetLocal s id) + return true + | .bytes chunk => + feed rt chunk + return true + +/-- Send one ready chunk. `false` means the session had nothing to +send, or a terminal cell or the close request already owns the turn. -/ +private def pumpOne (rt : Runtime) : Async Bool := do + if (← rt.deferredTerminal.get).isSome then return false + if ← (rt.closing.get : BaseIO Bool) then return false + let s ← (rt.state.get : BaseIO State) + if !s.cause.isRunning then return false + if ← (rt.closing.get : BaseIO Bool) then return false + let step := State.pump s + if step.actions.isEmpty then return false + if ← (rt.closing.get : BaseIO Bool) then return false + enact rt step + return true + +/-- The test hook throws before the event is applied. The event is +answered first, so the waiter does not stay parked. -/ +private def takeTestFault (rt : Runtime) (ev : Event) : Async Unit := do + let some hooks := rt.hooks | return + if ← hooks.forceInvariant.get then + releaseQueued rt ev + throwInvariant .sendInFlight + if ← hooks.forceMissingPort.get then + releaseQueued rt ev + discard <| requirePort rt 0 + +private def teardownStarted (rt : Runtime) : BaseIO Bool := + rt.teardownStarted.get + +/-- Store EOF or a reader fault when complete frames remain. The caller +does not pump after a `true` result. -/ +private def deferTerminal (rt : Runtime) (ev : Event) : Async Bool := do + let s ← (rt.state.get : BaseIO State) + let closing ← (rt.closing.get : BaseIO Bool) + let hold := s.cause.isRunning && s.parser.hasComplete && !closing + match ev with + | .ended => + if hold then + rt.deferredTerminal.set (some .ended) + return true + return false + | .readerFault e => + if hold then + rt.deferredTerminal.set (some (.readerFault e)) + return true + return false + | _ => return false + +/-- The event step's effect on the rest of this turn. -/ +private structure TurnBudget where + /-- `feed` already took the parser budget. -/ + spent : Bool + /-- A terminal observation forbids the outbound chunk. -/ + suppressChunk : Bool + +/-- Apply one event. A byte slice that `feed` accepts spends the budget. +Deferring EOF or a reader fault does not, and it forbids the chunk. -/ +private def applyEvent (rt : Runtime) (ev : Event) : Async TurnBudget := do + if ← deferTerminal rt ev then + return { spent := false, suppressChunk := true } + takeTestFault rt ev + let mayPump ← dispatch rt ev + let spent := + match ev with + | .bytes _ => mayPump + | _ => false + return { spent, suppressChunk := !mayPump } + +/-- The budget and the chunk that follow the event. A spent budget is +not taken again. A suppressed chunk is not sent. -/ +private def completeTurn (rt : Runtime) (turn : TurnBudget) : Async Unit := do + if ← (teardownStarted rt : BaseIO Bool) then return + if !(← (rt.state.get : BaseIO State)).cause.isRunning then return + if ← (rt.closing.get : BaseIO Bool) then return + if !turn.spent && (← (rt.state.get : BaseIO State)).parser.hasComplete then + pullOnce rt + if turn.suppressChunk || (← rt.deferredTerminal.get).isSome then return + if ← (teardownStarted rt : BaseIO Bool) then return + if !(← (rt.state.get : BaseIO State)).cause.isRunning then return + if ← (rt.closing.get : BaseIO Bool) then return + if (← rt.deferredTerminal.get).isSome then return + discard <| pumpOne rt + +/-- Claim the stored cause once no complete frame remains. A close +request that is already set claims owner close instead. -/ +private def applyDeferred (rt : Runtime) : Async Unit := do + let some d ← rt.deferredTerminal.get | return + rt.deferredTerminal.set none + let s ← (rt.state.get : BaseIO State) + if !s.cause.isRunning then return + if ← (rt.closing.get : BaseIO Bool) then + performClose rt + return + match d with + | .ended => enact rt (State.inboundEnd s) + | .readerFault e => enact rt (State.claim s (.readerFault e)) + +/-- One deferred turn. Answer one caller event and take one budget. +`feed` of a queued byte slice is that budget. No chunk is sent. The +first stored cause stays until no complete frame remains. -/ +private def runDeferredTurn (rt : Runtime) : Async Unit := do + if !(← (rt.state.get : BaseIO State)).parser.hasComplete then + applyDeferred rt + return + if ← (rt.closing.get : BaseIO Bool) then + rt.deferredTerminal.set none + performClose rt + return + let spent ← match ← (rt.events.tryRecv : BaseIO (Option Event)) with + | none => pure false + | some ev => + match ev with + | .bytes chunk => + feed rt chunk + pure true + | .ended | .readerFault _ => + -- The first terminal stays in the cell. + pure false + | _ => + releaseQueued rt ev + noteOpenFrames rt + pure false + if ← (teardownStarted rt : BaseIO Bool) then return + if !(← (rt.state.get : BaseIO State)).cause.isRunning then return + if ← (rt.closing.get : BaseIO Bool) then + rt.deferredTerminal.set none + performClose rt + return + if !spent && (← (rt.state.get : BaseIO State)).parser.hasComplete then + pullOnce rt + +/-- Run the event step, then the same turn's budget and chunk. -/ +private def turnOn (rt : Runtime) (ev : Option Event) : Async Unit := do + match ev with + | some ev => completeTurn rt (← applyEvent rt ev) + | none => completeTurn rt { spent := false, suppressChunk := false } + +private def ownerLoop (rt : Runtime) : Async Unit := do + repeat + if ← (teardownStarted rt : BaseIO Bool) then break + let s ← (rt.state.get : BaseIO State) + if !s.cause.isRunning then + if !(← (teardownStarted rt : BaseIO Bool)) then + throw <| IO.userError "mux: session ended without teardown" + break + if (← rt.deferredTerminal.get).isSome then + runDeferredTurn rt + continue + match ← (rt.events.tryRecv : BaseIO (Option Event)) with + | some ev => turnOn rt (some ev) + | none => + if ← (rt.events.isClosed : BaseIO Bool) then + if ← (rt.closing.get : BaseIO Bool) then + performClose rt + else if (← (rt.state.get : BaseIO State)).cause.isRunning then + throw <| IO.userError "mux: reader ended without a terminal event" + break + else if ← (rt.closing.get : BaseIO Bool) then + performClose rt + else if s.parser.hasComplete then + turnOn rt none + else if ← pumpOne rt then + pure () + else + match ← rt.events.recv with + | none => continue + | some ev => turnOn rt (some ev) + +/-- Keep the first owner error. Cleanup runs after this store, and +the rethrow uses the same `IO.Error`. -/ +private def keepOwnerFault (rt : Runtime) (e : IO.Error) : BaseIO Unit := do + rt.ownerFault.update fun kept => kept <|> some e + +private def owner (rt : Runtime) : Async Unit := do + try + ownerLoop rt + catch e => + keepOwnerFault rt e + teardownOnExit rt + throw e + +/-! ## Reader -/ + +private def postSlices (rt : Runtime) (chunk : ByteArray) : Async Bool := do + let mut i := 0 + while i < chunk.size do + let j := min chunk.size (i + testPayloadCap) + match ← rt.events.send (.bytes (chunk.extract i j)) with + | .ok () => + noteAccepted rt + i := j + | .error _ => return false + return true + +private def reader (rt : Runtime) (leftover : ByteArray) : Async Unit := do + try + if !(← postSlices rt leftover) then return + repeat + match ← rt.conn.read with + | none => + match ← rt.events.send .ended with + | .ok () => noteAccepted rt + | .error _ => pure () + break + | some chunk => + if !(← postSlices rt chunk) then break + catch e => + match ← rt.events.send (.readerFault e) with + | .ok () => noteAccepted rt + | .error _ => pure () + +/-! ## Session surface -/ + +private def openStream (rt : Runtime) (_id : ProtocolId) : + ExceptT Error Async Streams.Stream := do + match ← rt.livePermits.recv with + | none => throw closedError + | some _ => + let reply ← Chan.new 1 + match ← rt.events.send (.openLocal reply) with + | .error _ => + returnLivePermit rt + throw closedError + | .ok () => + noteAccepted rt + match ← reply.recv with + | some (.ok stream) => return stream + | some (.error e) => throw e + | none => throw closedError + +private def accept (rt : Runtime) : Async (Option Streams.Stream) := do + match ← rt.acceptQ.recv with + | none => return none + | some stream => + returnAcceptPermit rt + return some stream + +private def close (rt : Runtime) : IO Unit := do + -- The flag is the request. `markClosing` waits until owner close wins. + rt.closing.set true + rt.conn.closeQuietly + rt.events.close + +/-- `TaskSet.joinAll` hands its task error to one join. The stored +owner error stays, so every join throws that same `IO.Error`. A +connection fault and a protocol error still come from `Outcome`. -/ +private def join (rt : Runtime) : Async (Except Error Unit) := do + let fromTasks ← try + rt.tasks.joinAll + pure (none : Option IO.Error) + catch e => + pure (some e) + match ← (rt.ownerFault.get : BaseIO (Option IO.Error)) with + | some e => throw e + | none => + match fromTasks with + | some e => throw e + | none => rt.outcome.answer + +private def startRuntime (role : Role) (conn : Streams.Connection) + (leftover : ByteArray) (hooks : Option TestHooks) : Async MuxSession := do + let livePermits ← Chan.new State.liveStreamCap + preload livePermits State.liveStreamCap + let acceptPermits ← Chan.new State.acceptBacklogCap + preload acceptPermits State.acceptBacklogCap + let events ← Chan.new eventChannelCap + let acceptQ ← Chan.new State.acceptBacklogCap + let writes ← Streams.Completions.new + let rt : Runtime := { + conn + events + livePermits + acceptPermits + acceptQ + writes + ports := ← OnceRef.new ({} : Std.HashMap UInt32 Port) + state := ← OnceRef.new (State.empty role) + outcome := ← Outcome.new + tasks := ← TaskSet.new + closing := ← OnceRef.new false + teardownStarted := ← OnceRef.new false + ownerFault := ← OnceRef.new (none : Option IO.Error) + deferredTerminal := ← OnceRef.new (none : Option DeferredTerminal) + pendingOpen := ← OnceRef.new none + hooks } + rt.tasks.spawn (reader rt leftover) + rt.tasks.spawn (owner rt) + return { + openStream := openStream rt + accept := accept rt + close := close rt + join := join rt } + +/-- The reader and the owner for `role` on `conn`. `leftover` is the +negotiation suffix. The tasks belong to the session `TaskSet`. -/ +def start (role : Role) (conn : Streams.Connection) (leftover : ByteArray := .empty) : + Async MuxSession := + startRuntime role conn leftover none + +/-- `start` with test refs. `Libp2p.Mux` does not import this function. +The owner reads a hook only through this entry. -/ +def startWithHooks (role : Role) (conn : Streams.Connection) + (leftover : ByteArray := .empty) (hooks : TestHooks) : Async MuxSession := + startRuntime role conn leftover (some hooks) + +end Libp2p.Mux.Test.Session diff --git a/packages/libp2p/mux/docs/ARCHITECTURE.md b/packages/libp2p/mux/docs/ARCHITECTURE.md index ea7d4ce3..dfd4b07f 100644 --- a/packages/libp2p/mux/docs/ARCHITECTURE.md +++ b/packages/libp2p/mux/docs/ARCHITECTURE.md @@ -24,6 +24,7 @@ same node code ran under both muxers in-process and over loopback TCP | `Libp2p/Mux/Test.lean` | `Mux.test : Muxer`, the independent test session under `Mux.testId`. Planned | | `Libp2p/Mux/Test/Wire.lean` | the private header, frame, and incremental parser. Present. Internal, and tests import it directly | | `Libp2p/Mux/Test/State.lean` | the pure owner-loop state and its transitions. Present. Internal, and tests import it directly. A transition returns actions for the runtime shell | +| `Libp2p/Mux/Test/Session.lean` | the runtime shell for that state. Present. Internal, and tests import it directly. One reader and one owner loop | | `Libp2p/Mux/Mock/Core/ProtocolId.lean` | the temporary `ProtocolId`. Present. `Constants.lean` imports only this module, so the constants stay free of `Conc` and `Streams` | | `Libp2p/Mux/Mock/Core.lean` | the temporary `Role`, `Error`, `Muxer`, and `MuxSession` that let W5.4 compile before W5.2. Present. The public root does not import it | @@ -33,9 +34,10 @@ live in `Libp2p.Mux.Test.Wire`. `Libp2p/Mux.lean`, `Libp2p/Mux/Constants.lean`, `Libp2p/Mux/Mock/Core/ProtocolId.lean`, `Libp2p/Mux/Mock/Core.lean`, -`Libp2p/Mux/Test/Wire.lean`, and `Libp2p/Mux/Test/State.lean` are in -the tree. `Yamux.lean`, `Mplex.lean`, and `Test.lean` are the -remaining modules of this package. +`Libp2p/Mux/Test/Wire.lean`, `Libp2p/Mux/Test/State.lean`, and +`Libp2p/Mux/Test/Session.lean` are in the tree. `Yamux.lean`, +`Mplex.lean`, and `Test.lean` are the remaining modules of this +package. ## Public surface @@ -256,6 +258,13 @@ retired id is a duplicate open and a protocol error. A data, fin, or reset frame for an id that was never opened is a protocol error. An id is never reused. +The runtime port map keeps an id while a later action of the retiring +step still names it. The step then removes the entry. The stream +handle keeps the port value. An empty write and a late write read +that value. An active action that names a missing port throws +`mux: missing port {id}`. Terminal cleanup closes the ports still +in the map. It skips an entry the retiring step already removed. + ### Parser and codec The header codec and the frame codec cover values their types can @@ -280,13 +289,30 @@ distinguish a peer FIN, a peer RST, and a local close. ### Ownership -One reader task and one owner loop serve the session. The loop is the -only task that steps the pure session state, and it is the only task -that writes a frame. Callers post events on the bounded event channel. -The loop is the only caller of `Outcome.finish`. `TaskSet.joinAll` -returns before `Outcome.answer`. The reader and the loop catch an -`IO.Error` from the connection and return. `joinAll` therefore does -not throw that error a second time. +One reader task and one owner loop serve the session. +`Test/Session.lean` is that pair. The loop is the only task that +steps the pure session state, and it is the only task that writes a +frame. Callers post events on the bounded event channel. The reader +posts slices of at most `testPayloadCap`. The loop calls `ackSend` +or `failSend` before it applies another event. It reads the close +request again immediately before `Connection.write`. Scheduling and +writes is the rest of the turn. +The loop is the only caller of `Outcome.finish`. `join` waits for +`TaskSet.joinAll`, then reads a stored owner error or `Outcome.answer`. +The reader and the loop catch an `IO.Error` from the connection and +return. That fault stays in `Outcome`, and every `join` throws it. +A protocol failure stays a returned `Core.Error`. An owner that throws +any other `IO.Error`, including `InvariantError.toIOError`, stores +that error first. It then wakes the reader, the callers, and admission, +and throws the same error. It does not store a successful result. +Every later `join` throws the stored error. +`start` takes the role, the connection, and the leftover bytes. +`startWithHooks` is the test entry. `Libp2p.Mux` does not import it. + +Teardown closes the event channel before it drains. The drain answers +every open and write that entered before the close. A send that finds +the channel closed fails, and that caller releases the permit it +still holds. The owner does not cancel either task. Shutdown closes the connection and the event channel, and that close wakes a parked task. Stream @@ -301,10 +327,16 @@ an IO fault that carries its `IO.Error`, and a protocol failure that carries its `Core.Error`. The first atomic claim out of running wins. A later cause leaves the -winner in place. Only a winning owner close calls -`Outcome.markClosing`. No claimant writes fault state into `Outcome`. -Teardown records a winning IO fault once, through `Outcome.recordFault`, -before `Outcome.finish`. +winner in place. `close` sets the close request. It then closes the +connection with `Connection.closeQuietly` and closes the event +channel. It does not call `Outcome.markClosing`. `finishCause` calls +`markClosing` only for a winning owner close, immediately before +`Outcome.finish (.ok ())`. A reader fault, a writer fault, a protocol +error, or a connection end that already won stays the result if close +is requested before that projection. A read or a write that fails +after the close request, while the session is still running, is owner +close. Teardown records a winning IO fault once, through +`Outcome.recordFault`, before `Outcome.finish`. `Core.Error` is the seam error named in the root architecture (§4.9). This package does not declare it. @@ -340,15 +372,28 @@ Teardown closes every live inbox and every inbox still queued for ### Scheduling and writes -A turn processes at most one available event. It then takes at most -the parser frame budget of complete frames, then one outbound chunk. -One chunk is at most `testPayloadCap` bytes. A large write cannot -hold the connection for the rest of its bytes. - -A complete frame already in the parser, or an outbound chunk that is -ready, keeps the loop runnable when no new event is queued. The loop -parks only when a queued event, a complete buffered frame, and a -ready outbound chunk are all absent. +A turn takes at most one queued event. It then takes at most one +parser budget, unless that event was a byte slice and `feed` already +spent the budget. It then sends at most one ready chunk. One chunk is +at most `testPayloadCap` bytes. A terminal observation sends no chunk. +The next turn starts after those steps. + +`feed` of a byte event is the budget. A non-byte event leaves the +budget for one retained pull in the same turn, so a full event channel +cannot hold the parser. With no queued event, a complete frame still +costs one pull, and a ready chunk is one send. When the channel is +empty, the close request runs before that pull or send. The loop parks +when the channel has no event, the parser has no complete frame, no +chunk is ready, and the close request is clear. + +The owner stores the first dequeued connection end or reader fault in +`deferredTerminal` while a complete frame remains. The owner alone +writes that cell. A later terminal does not replace it. No chunk +starts after that dequeue. While the cell is set and a complete frame +remains, the turn answers one queued caller event and takes one +parser budget. A queued byte slice counts as that budget. The owner +applies the stored cause when no complete frame remains. A close +request claims owner close, and the stored cause is not claimed. The owner performs one `sendFrame` or `sendChunk`. It then calls `ackSend` or `failSend`. Only then does it dequeue and apply another @@ -392,10 +437,9 @@ The logical `Stream.write` has no arm for that error. The loop resolves that write, and every write still waiting, with `Streams.Error.closed`. `Connection.write` does not say whether the peer received the bytes. When the IO fault won the terminal claim, -`Outcome.answer` throws the stored `IO.Error`. When owner close -already won, `markClosing` has ended the outcome, the fault is not -recorded, and `answer` returns success. The stream write is -`Streams.Error.closed` in both cases. +`Outcome.answer` throws the stored `IO.Error`. When owner close won, +that write error is not recorded, and `answer` returns success. The +stream write is `Streams.Error.closed` in both cases. A local reset sends one reset frame. Admitted writes, and writes still waiting for admission, complete with `Streams.Error.reset`. From 61efa19f4fc714738f619ecf0bfbc3b0a658d0fa Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 17:25:24 +0530 Subject: [PATCH 072/125] feat(mux): expose the test muxer Expose the independent repository-only test session through the Muxer seam. Forward role, connection, and negotiation leftovers unchanged, and exercise the value through its public lifecycle. --- Tests/Mux.lean | 184 ++++++++++++++++++ packages/libp2p/mux/Libp2p/Mux.lean | 9 +- packages/libp2p/mux/Libp2p/Mux/Test.lean | 27 +++ .../libp2p/mux/Libp2p/Mux/Test/Session.lean | 10 +- packages/libp2p/mux/docs/ARCHITECTURE.md | 48 +++-- 5 files changed, 250 insertions(+), 28 deletions(-) create mode 100644 packages/libp2p/mux/Libp2p/Mux/Test.lean diff --git a/Tests/Mux.lean b/Tests/Mux.lean index d8b2ff6a..657a25a5 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -2646,6 +2646,189 @@ def run (r : Runner) : IO Unit := do end Tests.Mux.Session +namespace Tests.Mux.Public + +open Libp2p.Mux +open Libp2p.Mux.Mock.Core +open Libp2p.Mux.Test.Wire +open Conc +open Streams +open Streams.Mock.Multiformats +open Streams.Sim + +private structure Ends where + dialer : Connection + peer : Connection + +private def ends (n : Nat) : Async Ends := do + let net ← Net.new + let addr ← match Multiaddr.parse? s!"/ip4/127.0.0.1/tcp/{4000 + n}" with + | .ok a => pure a + | .error _ => throw <| IO.userError s!"test address {n} does not parse" + let l ← net.listen addr + let some dialer ← net.dial l.addr | throw <| IO.userError "dial failed" + let some peer ← l.accept | throw <| IO.userError "accept failed" + return { dialer, peer } + +/-- The session `Mux.test.open` returns. -/ +private def openSession (role : Role) (conn : Connection) + (leftover : ByteArray := .empty) : Async MuxSession := do + match ← (test.open role conn leftover).run with + | .ok sess => return sess + | .error e => throw <| IO.userError s!"public open failed: {repr e}" + +private def readPayload (stream : Stream) : Async ByteArray := do + match ← stream.read with + | .ok (some bytes) => return bytes + | .ok none => throw <| IO.userError "public read ended" + | .error e => throw <| IO.userError s!"public read failed: {repr e}" + +private def takeHeader (c : Connection) (pending : ByteArray) : + Async (Header × ByteArray) := do + match ← c.readExactly headerSize pending with + | none => throw <| IO.userError "header ended" + | some (frame, rest) => + match Header.decodeB? frame with + | .ok (h, suffix) => + if suffix.isEmpty then return (h, rest) + else throw <| IO.userError "header left a suffix" + | .error _ => throw <| IO.userError "header did not decode" + +private def openFrame (id : UInt32) : ByteArray := + Header.encodeB { kind := .open, streamId := id, length := 0 } + +private def dataFrame (id : UInt32) (b : UInt8) : ByteArray := + Header.encodeB { kind := .data, streamId := id, length := 1 } ++ ⟨#[b]⟩ + +/-- Two concurrent opens. The peer reads both open frames. -/ +private def runParity (r : Runner) (n : Nat) (role : Role) + (low high : UInt32) (title : String) : Async Unit := do + let pair ← ends n + let sess ← openSession role pair.dialer + let other : ProtocolId := ⟨"/leanp2p/not-on-wire/1"⟩ + let workers ← TaskSet.new + workers.spawn do + match ← (sess.openStream testId).run with + | .ok _ => pure () + | .error e => throw <| IO.userError s!"open failed: {repr e}" + workers.spawn do + match ← (sess.openStream other).run with + | .ok _ => pure () + | .error e => throw <| IO.userError s!"open failed: {repr e}" + workers.joinAll + let (h1, pending) ← takeHeader pair.peer .empty + let (h2, _) ← takeHeader pair.peer pending + let ids := (h1.streamId, h2.streamId) + let framesOk := + (ids == (low, high) || ids == (high, low)) + && h1.kind == Kind.open && h2.kind == Kind.open + && h1.length == 0 && h2.length == 0 + r.check title framesOk + sess.close + discard <| sess.join + +/-- One public open, then bytes in both directions. -/ +private def runRoundTrip (r : Runner) : Async Unit := do + let pair ← ends 42 + let dial ← openSession .initiator pair.dialer + let peer ← openSession .responder pair.peer + match ← (dial.openStream testId).run with + | .error _ => + r.check "the peer accepts a public open" false + r.check "bytes round-trip in both directions" false + | .ok outbound => + let wrote ← outbound.write ⟨#[7]⟩ + match ← peer.accept with + | none => + r.check "the peer accepts a public open" false + r.check "bytes round-trip in both directions" false + | some remote => + r.check "the peer accepts a public open" true + let got ← readPayload remote + let replied ← remote.write ⟨#[8]⟩ + let back ← readPayload outbound + let wroteOk := + match wrote with + | .ok () => true + | .error _ => false + let repliedOk := + match replied with + | .ok () => true + | .error _ => false + r.check "bytes round-trip in both directions" + (wroteOk && repliedOk && got == ⟨#[7]⟩ && back == ⟨#[8]⟩) + dial.close + peer.close + discard <| dial.join + discard <| peer.join + +/-- Leftover bytes are the reader's first input. -/ +private def runLeftover (r : Runner) : Async Unit := do + let pair ← ends 43 + let leftover := openFrame 2 ++ dataFrame 2 1 + let later := openFrame 4 ++ dataFrame 4 2 + pair.peer.write later + let sess ← openSession .initiator pair.dialer leftover + match ← sess.accept with + | none => r.check "a leftover arrives before later connection bytes" false + | some first => + match ← sess.accept with + | none => r.check "a leftover arrives before later connection bytes" false + | some second => + let early ← readPayload first + let next ← readPayload second + r.check "a leftover arrives before later connection bytes" + (early == ⟨#[1]⟩ && next == ⟨#[2]⟩) + sess.close + discard <| sess.join + +/-- Close, repeated join, and a parked accept. -/ +private def runClose (r : Runner) : Async Unit := do + let pair ← ends 44 + let sess ← openSession .initiator pair.dialer + let started : Chan Unit ← Chan.new 1 + let box : Chan Bool ← Chan.new 1 + let workers ← TaskSet.new + workers.spawn do + discard <| started.send () + match ← sess.accept with + | none => discard <| box.send true + | some stream => + stream.reset + discard <| box.send false + discard <| started.recv + sess.close + workers.joinAll + match ← box.recv with + | some true => r.check "a closed session wakes accept" true + | _ => r.check "a closed session wakes accept" false + match ← sess.join with + | .ok () => r.check "public close joins once" true + | .error _ => r.check "public close joins once" false + match ← sess.join with + | .ok () => r.check "a second public join returns the same success" true + | .error _ => r.check "a second public join returns the same success" false + match ← pair.peer.read with + | none => r.check "public close ends the peer read" true + | some _ => r.check "public close ends the peer read" false + +private def checks (r : Runner) : Async Unit := do + r.check "Mux.test.id is Mux.testId" (decide (test.id = testId)) + r.check "the test protocol id is /leanp2p/test-mux/1.0.0" + (test.id.toString == "/leanp2p/test-mux/1.0.0") + runParity r 40 .initiator 1 3 "an initiator open pair uses distinct odd ids" + runParity r 41 .responder 2 4 "a responder open pair uses distinct even ids" + runRoundTrip r + runLeftover r + runClose r + +/-- The public `Mux.test` boundary over the sim connection. -/ +def run (r : Runner) : IO Unit := do + r.section "mux test value" "packages/libp2p/mux/Libp2p/Mux/Test.lean" + Async.run (checks r) + +end Tests.Mux.Public + namespace Tests open Libp2p.Mux @@ -2668,5 +2851,6 @@ def Mux.run (r : Runner) : IO Unit := do Mux.Seam.run r Mux.State.run r Mux.Session.run r + Mux.Public.run r end Tests diff --git a/packages/libp2p/mux/Libp2p/Mux.lean b/packages/libp2p/mux/Libp2p/Mux.lean index 78275d37..d74c8f08 100644 --- a/packages/libp2p/mux/Libp2p/Mux.lean +++ b/packages/libp2p/mux/Libp2p/Mux.lean @@ -1,11 +1,12 @@ import Libp2p.Mux.Constants +import Libp2p.Mux.Test /-! # Libp2p.Mux The mux subpackage: the `Muxer` values over the muxer sessions -(ARCHITECTURE §4.9). This root re-exports the public submodules. -`Constants.lean` is the first of them. The `Muxer` values arrive with -the later units of W5.4 (`docs/PLAN.md`). The package docs live under -`docs/` beside this file. +(ARCHITECTURE §4.9). This root imports each public submodule. +`Constants.lean` holds the ids. `Test.lean` holds `Mux.test`. +`Mux.yamux` and `Mux.mplex` are later values. The package docs live +under `docs/` beside this file. -/ diff --git a/packages/libp2p/mux/Libp2p/Mux/Test.lean b/packages/libp2p/mux/Libp2p/Mux/Test.lean new file mode 100644 index 00000000..69683713 --- /dev/null +++ b/packages/libp2p/mux/Libp2p/Mux/Test.lean @@ -0,0 +1,27 @@ +import Libp2p.Mux.Constants +import Libp2p.Mux.Mock.Core +import Libp2p.Mux.Test.Session + +/-! +# The public test muxer + +`Mux.test` is the `Muxer` for the private test session. `open` passes +the role, the connection, and the negotiation leftover to +`Session.start`. `ExceptT.lift` returns that session on the seam. +The lift adds no error arm. +-/ + +set_option autoImplicit false + +namespace Libp2p.Mux + +open Mock.Core + +/-- The independent test muxer. `id` is `testId`. `open` starts the +private session. The leftover bytes are the reader's first input. -/ +def test : Muxer where + id := testId + «open» := fun role connection leftover => + ExceptT.lift (Test.Session.start role connection leftover) + +end Libp2p.Mux diff --git a/packages/libp2p/mux/Libp2p/Mux/Test/Session.lean b/packages/libp2p/mux/Libp2p/Mux/Test/Session.lean index ee6dba74..d09bc50a 100644 --- a/packages/libp2p/mux/Libp2p/Mux/Test/Session.lean +++ b/packages/libp2p/mux/Libp2p/Mux/Test/Session.lean @@ -48,8 +48,9 @@ structure TurnGate where release : Chan Unit /-- Refs a test installs through `startWithHooks`. `start` does not -take this structure, and `Libp2p.Mux` does not import it. With no -hooks the owner reads no hook ref. -/ +take this structure. `TestHooks` is an unsupported testing detail +under `Libp2p.Mux.Test.Session`. With no hooks the owner reads no +hook ref. -/ structure TestHooks where /-- Live entries in the port map, written after each retirement sweep. -/ registrySize : IO.Ref Nat @@ -854,8 +855,9 @@ def start (role : Role) (conn : Streams.Connection) (leftover : ByteArray := .em Async MuxSession := startRuntime role conn leftover none -/-- `start` with test refs. `Libp2p.Mux` does not import this function. -The owner reads a hook only through this entry. -/ +/-- `startWithHooks` is an unsupported testing detail under +`Libp2p.Mux.Test.Session`. The owner reads a hook only through this +entry. -/ def startWithHooks (role : Role) (conn : Streams.Connection) (leftover : ByteArray := .empty) (hooks : TestHooks) : Async MuxSession := startRuntime role conn leftover (some hooks) diff --git a/packages/libp2p/mux/docs/ARCHITECTURE.md b/packages/libp2p/mux/docs/ARCHITECTURE.md index dfd4b07f..44ab9513 100644 --- a/packages/libp2p/mux/docs/ARCHITECTURE.md +++ b/packages/libp2p/mux/docs/ARCHITECTURE.md @@ -17,27 +17,28 @@ same node code ran under both muxers in-process and over loopback TCP | Module | Holds, and why | |---|---| -| `Libp2p/Mux.lean` | the root module; one import line per public submodule. It does not re-export the mock or the private test wire | +| `Libp2p/Mux.lean` | the root module. It imports `Constants.lean` and `Test.lean`. The `Test.lean` import loads the session implementation under `Libp2p.Mux.Test` | | `Libp2p/Mux/Constants.lean` | `Mux.yamuxId` (`/yamux/1.0.0`), `Mux.mplexId` (`/mplex/6.7.0`), `Mux.testId` (`/leanp2p/test-mux/1.0.0`), and `Mux.mplexMessageCap` (1 MiB); the named ids of §3.4 and the cap of §5.1, one home per fact (§3.8) | | `Libp2p/Mux/Yamux.lean` | `Mux.yamux`, the yamux value over the session of `net/yamux`; the `Role` conversion below. Planned | | `Libp2p/Mux/Mplex.lean` | the mplex message codec and `Mux.mplex` (P2, §8); the spike reference is `Conn/Mplex.lean` (§10 row 15, §7 row 10). Planned | -| `Libp2p/Mux/Test.lean` | `Mux.test : Muxer`, the independent test session under `Mux.testId`. Planned | -| `Libp2p/Mux/Test/Wire.lean` | the private header, frame, and incremental parser. Present. Internal, and tests import it directly | -| `Libp2p/Mux/Test/State.lean` | the pure owner-loop state and its transitions. Present. Internal, and tests import it directly. A transition returns actions for the runtime shell | -| `Libp2p/Mux/Test/Session.lean` | the runtime shell for that state. Present. Internal, and tests import it directly. One reader and one owner loop | +| `Libp2p/Mux/Test.lean` | `Mux.test : Muxer`. Present. `open` calls `Session.start` and lifts the session onto the seam | +| `Libp2p/Mux/Test/Wire.lean` | the header, frame, and incremental parser. Present. An unsupported implementation detail under `Libp2p.Mux.Test.Wire`. Tests import it directly | +| `Libp2p/Mux/Test/State.lean` | the pure owner-loop state and its transitions. Present. An unsupported implementation detail under `Libp2p.Mux.Test.State`. A transition returns actions for the runtime shell | +| `Libp2p/Mux/Test/Session.lean` | the runtime shell, `TestHooks`, and `startWithHooks`. Present. Unsupported implementation and testing details under `Libp2p.Mux.Test.Session`. One reader and one owner loop | | `Libp2p/Mux/Mock/Core/ProtocolId.lean` | the temporary `ProtocolId`. Present. `Constants.lean` imports only this module, so the constants stay free of `Conc` and `Streams` | -| `Libp2p/Mux/Mock/Core.lean` | the temporary `Role`, `Error`, `Muxer`, and `MuxSession` that let W5.4 compile before W5.2. Present. The public root does not import it | +| `Libp2p/Mux/Mock/Core.lean` | the temporary `Role`, `Error`, `Muxer`, and `MuxSession` that let W5.4 compile before W5.2. Present. `Test.lean` imports it, so the root loads the seam with `Mux.test` | -Public declarations live in the `Libp2p.Mux` namespace, and the root -module re-exports the public submodules. The private wire declarations -live in `Libp2p.Mux.Test.Wire`. +The supported names in `Libp2p.Mux` are `Mux.test`, `Mux.testId`, +and the other constants, together with the `Muxer` and `MuxSession` +fields. The frame, the parser, the state, and the session shell live +under `Libp2p.Mux.Test.*`. `Libp2p.Mux` defines no alias for them. `Libp2p/Mux.lean`, `Libp2p/Mux/Constants.lean`, `Libp2p/Mux/Mock/Core/ProtocolId.lean`, `Libp2p/Mux/Mock/Core.lean`, -`Libp2p/Mux/Test/Wire.lean`, `Libp2p/Mux/Test/State.lean`, and -`Libp2p/Mux/Test/Session.lean` are in the tree. `Yamux.lean`, -`Mplex.lean`, and `Test.lean` are the remaining modules of this -package. +`Libp2p/Mux/Test/Wire.lean`, `Libp2p/Mux/Test/State.lean`, +`Libp2p/Mux/Test/Session.lean`, and `Libp2p/Mux/Test.lean` are in +the tree. `Yamux.lean` and `Mplex.lean` are the remaining modules +of this package. ## Public surface @@ -52,8 +53,11 @@ package. - The error mapping happens once, in `Yamux.lean`: the value lifts the session failure into `Core.Error` with one `Except.mapError` (§3.5). The upgrader and the host match on `Core.Error` alone. -- `Mux.test : Muxer` is the test session below. Callers use the - `Muxer` and `MuxSession` seams. Its id is `Mux.testId`. +- `Mux.test : Muxer` carries `id := Mux.testId`. `open` calls + `Test.Session.start` with the role, the connection, and the + leftover bytes. `ExceptT.lift` returns that session on the seam. + The lift adds no error arm. `openStream` still takes a + `ProtocolId`. The private frame does not carry it. - `Mux.mplex : Muxer` is the P2 surface: `id := Mux.mplexId`, the pure message codec, and a session that keys streams by `(id, initiator)`. The declared message length is capped at @@ -74,10 +78,12 @@ Its id is `Mux.testId`, and ### Boundary -The public value is `Mux.test : Muxer`. A caller sees the W5.2 -`Muxer` and `MuxSession` seams. Frame types, parser state, stream ids, -terminal state, admission gates, and scheduling state are private. -They do not appear in public Lean declarations. +The supported surface is `Mux.test`, `Mux.testId`, and the `Muxer` +and `MuxSession` fields. `Libp2p/Mux.lean` imports `Test.lean`. +That import loads the frame, the parser, the state, the session +shell, `TestHooks`, and `startWithHooks` under `Libp2p.Mux.Test.*`. +Those declarations are unsupported implementation and testing +details. `Libp2p.Mux` defines no alias for them. Temporary W5.2 types live only under `Libp2p/Mux/Mock/`. `Mock/Core/ProtocolId.lean` holds `ProtocolId`, and `Constants.lean` @@ -307,7 +313,9 @@ that error first. It then wakes the reader, the callers, and admission, and throws the same error. It does not store a successful result. Every later `join` throws the stored error. `start` takes the role, the connection, and the leftover bytes. -`startWithHooks` is the test entry. `Libp2p.Mux` does not import it. +`TestHooks` and `startWithHooks` are unsupported testing details +under `Libp2p.Mux.Test.Session`. `Mux.test.open` calls `start`. +`Libp2p.Mux` defines no alias for them. Teardown closes the event channel before it drains. The drain answers every open and write that entered before the close. A send that finds From fa077a9f921166a38efb591297af9f4af220b451 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 17:57:38 +0530 Subject: [PATCH 073/125] test(mux): synchronize reader terminal events --- Tests/Mux.lean | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Tests/Mux.lean b/Tests/Mux.lean index 657a25a5..255b270b 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -1710,6 +1710,7 @@ private def runCorrections (r : Runner) : Async Unit := do let pair ← ends 15 let hold : Chan Unit ← Chan.new 1 let entered : Chan Unit ← Chan.new 1 + let accepted : Chan Unit ← Chan.new 1 let phase ← IO.mkRef 0 let conn : Connection := { pair.dialer with write := fun bytes => do @@ -1730,7 +1731,8 @@ private def runCorrections (r : Runner) : Async Unit := do match ← readGate.recv with | none => return none | some _ => conn.read } - let sess ← start .initiator eofConn + let hooks ← testHooks + let sess ← startWithHooks .initiator eofConn .empty hooks let stream ← mustOpen sess let writer ← TaskSet.new let wrote : Chan (Except Streams.Error Unit) ← Chan.new 1 @@ -1738,7 +1740,13 @@ private def runCorrections (r : Runner) : Async Unit := do let result ← stream.write ⟨Array.replicate (testPayloadCap + 1) 7⟩ discard <| wrote.send result discard <| entered.recv + -- The barrier waits for the reader's successful event post + -- before the blocked write is released. + hooks.acceptedEvent.set (some accepted) readGate.close + match ← accepted.recv with + | some _ => pure () + | none => throw <| IO.userError "the eof terminal was not accepted" hold.close writer.joinAll match ← wrote.recv with @@ -1755,6 +1763,7 @@ private def runCorrections (r : Runner) : Async Unit := do let pair ← ends 16 let hold : Chan Unit ← Chan.new 1 let entered : Chan Unit ← Chan.new 1 + let accepted : Chan Unit ← Chan.new 1 let phase ← IO.mkRef 0 let boom := IO.userError "reader boom" let later := IO.userError "later write" @@ -1779,7 +1788,8 @@ private def runCorrections (r : Runner) : Async Unit := do match ← readGate.recv with | none => throw boom | some _ => conn.read } - let sess ← start .initiator faultConn + let hooks ← testHooks + let sess ← startWithHooks .initiator faultConn .empty hooks let stream ← mustOpen sess let writer ← TaskSet.new let wrote : Chan (Except Streams.Error Unit) ← Chan.new 1 @@ -1787,7 +1797,13 @@ private def runCorrections (r : Runner) : Async Unit := do let result ← stream.write ⟨Array.replicate (testPayloadCap + 1) 7⟩ discard <| wrote.send result discard <| entered.recv + -- The barrier waits for the reader's successful event post + -- before the blocked write is released. + hooks.acceptedEvent.set (some accepted) readGate.close + match ← accepted.recv with + | some _ => pure () + | none => throw <| IO.userError "the reader fault was not accepted" hold.close writer.joinAll match ← wrote.recv with From 703557bc4979486b7ce23b62363cf5bb2fca6d28 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 18:41:07 +0530 Subject: [PATCH 074/125] test(mux): cover the public session lifecycle Two fresh sessions of each role both issue that role's first stream id. Both ends open together and exchange distinct payloads. An error from either open fails the checks. Two closes return, accept returns none, and both joins succeed. --- Tests/Mux.lean | 129 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 107 insertions(+), 22 deletions(-) diff --git a/Tests/Mux.lean b/Tests/Mux.lean index 255b270b..73df3507 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -2798,35 +2798,118 @@ private def runLeftover (r : Runner) : Async Unit := do sess.close discard <| sess.join -/-- Close, repeated join, and a parked accept. -/ +/-- Two public closes. `accept` returns `none`, both joins succeed, and the peer read ends. -/ private def runClose (r : Runner) : Async Unit := do let pair ← ends 44 let sess ← openSession .initiator pair.dialer - let started : Chan Unit ← Chan.new 1 - let box : Chan Bool ← Chan.new 1 - let workers ← TaskSet.new - workers.spawn do - discard <| started.send () - match ← sess.accept with - | none => discard <| box.send true - | some stream => - stream.reset - discard <| box.send false - discard <| started.recv sess.close - workers.joinAll - match ← box.recv with - | some true => r.check "a closed session wakes accept" true - | _ => r.check "a closed session wakes accept" false + sess.close + match ← sess.accept with + | none => r.check "accept after repeated public close returns none" true + | some stream => + stream.reset + r.check "accept after repeated public close returns none" false match ← sess.join with - | .ok () => r.check "public close joins once" true - | .error _ => r.check "public close joins once" false + | .ok () => r.check "repeated public close joins once" true + | .error _ => r.check "repeated public close joins once" false match ← sess.join with - | .ok () => r.check "a second public join returns the same success" true - | .error _ => r.check "a second public join returns the same success" false + | .ok () => r.check "a second join after repeated close succeeds" true + | .error _ => r.check "a second join after repeated close succeeds" false match ← pair.peer.read with - | none => r.check "public close ends the peer read" true - | some _ => r.check "public close ends the peer read" false + | none => r.check "repeated public close ends the peer read" true + | some _ => r.check "repeated public close ends the peer read" false + +/-- The first open id from one fresh public session, after a clean join. -/ +private def firstOpenId (n : Nat) (role : Role) : Async (Option UInt32) := do + let pair ← ends n + let sess ← openSession role pair.dialer + let id ← + match ← (sess.openStream testId).run with + | .error _ => pure (none : Option UInt32) + | .ok _ => + let (h, _) ← takeHeader pair.peer .empty + if h.kind == Kind.open && h.length == 0 then + pure (some h.streamId) + else + pure none + sess.close + match ← sess.join with + | .ok () => return id + | .error _ => return none + +/-- Two fresh sessions of one role both issue that role's first id. -/ +private def runIdRestart (r : Runner) : Async Unit := do + let initiatorA ← firstOpenId 45 .initiator + let initiatorB ← firstOpenId 46 .initiator + r.check "two initiator sessions both open stream id 1" + (initiatorA == some 1 && initiatorB == some 1) + let responderA ← firstOpenId 47 .responder + let responderB ← firstOpenId 48 .responder + r.check "two responder sessions both open stream id 2" + (responderA == some 2 && responderB == some 2) + +/-- Both ends open together. Each accepts the other stream and reads its payload. -/ +private def runConcurrentOpens (r : Runner) : Async Unit := do + let pair ← ends 49 + let initiator ← openSession .initiator pair.dialer + let responder ← openSession .responder pair.peer + let initiatorOpen : Chan (Except Mock.Core.Error Stream) ← Chan.new 1 + let responderOpen : Chan (Except Mock.Core.Error Stream) ← Chan.new 1 + let workers ← TaskSet.new + workers.spawn do + let result ← (initiator.openStream testId).run + match ← initiatorOpen.send result with + | .ok () => pure () + | .error _ => throw <| IO.userError "initiator open result was not delivered" + workers.spawn do + let result ← (responder.openStream testId).run + match ← responderOpen.send result with + | .ok () => pure () + | .error _ => throw <| IO.userError "responder open result was not delivered" + workers.joinAll + match ← initiatorOpen.recv, ← responderOpen.recv with + | some (.ok openedByInitiator), some (.ok openedByResponder) => + r.check "both ends open a stream together" true + match ← initiator.accept, ← responder.accept with + | some acceptedByInitiator, some acceptedByResponder => + r.check "each end accepts the opposite open" true + let wroteInitiator ← openedByInitiator.write ⟨#[3]⟩ + let wroteResponder ← openedByResponder.write ⟨#[4]⟩ + let gotResponder ← acceptedByResponder.read + let gotInitiator ← acceptedByInitiator.read + let initiatorOk := + match wroteInitiator with + | .ok () => true + | .error _ => false + let responderOk := + match wroteResponder with + | .ok () => true + | .error _ => false + let readResponder := + match gotResponder with + | .ok (some bytes) => bytes == ⟨#[3]⟩ + | _ => false + let readInitiator := + match gotInitiator with + | .ok (some bytes) => bytes == ⟨#[4]⟩ + | _ => false + r.check "concurrent streams carry distinct payloads" + (initiatorOk && responderOk && readResponder && readInitiator) + | _, _ => + r.check "each end accepts the opposite open" false + r.check "concurrent streams carry distinct payloads" false + | some (.error _), _ + | _, some (.error _) + | none, _ + | _, none => + r.check "both ends open a stream together" false + r.check "each end accepts the opposite open" false + r.check "concurrent streams carry distinct payloads" false + initiator.close + responder.close + match ← initiator.join, ← responder.join with + | .ok (), .ok () => r.check "both public sessions join after the exchange" true + | _, _ => r.check "both public sessions join after the exchange" false private def checks (r : Runner) : Async Unit := do r.check "Mux.test.id is Mux.testId" (decide (test.id = testId)) @@ -2837,6 +2920,8 @@ private def checks (r : Runner) : Async Unit := do runRoundTrip r runLeftover r runClose r + runIdRestart r + runConcurrentOpens r /-- The public `Mux.test` boundary over the sim connection. -/ def run (r : Runner) : IO Unit := do From bc1bf95ea03527f0e55e6a8ae7b117e2835d6f5e Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 18:53:18 +0530 Subject: [PATCH 075/125] test(mux): cover public reader-fault propagation Construct the session through Mux.test.open and release a gated connection read failure. Both joins throw the same stored IO error, rather than returning success or a Core.Error. --- Tests/Mux.lean | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/Tests/Mux.lean b/Tests/Mux.lean index 73df3507..8315ed4e 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -2911,6 +2911,48 @@ private def runConcurrentOpens (r : Runner) : Async Unit := do | .ok (), .ok () => r.check "both public sessions join after the exchange" true | _, _ => r.check "both public sessions join after the exchange" false +/-- A reader fault through `Mux.test.open` stays the connection `IO.Error`. +Both joins throw that same error. -/ +private def runReaderFault (r : Runner) : Async Unit := do + let pair ← ends 50 + let boom := IO.userError "reader boom" + -- The reader parks on this gate. Closing it throws after `open` returns. + let gate : Chan Unit ← Chan.new 1 + let conn : Connection := { pair.dialer with + read := do + match ← gate.recv with + | none => throw boom + | some _ => pair.dialer.read } + match ← (test.open .initiator conn .empty).run with + | .error _ => + gate.close + pair.dialer.close + pair.peer.close + r.check "Mux.test.open returns before the reader fault" false + r.check "the public reader fault is the connection error" false + r.check "a second public join throws the same reader fault" false + | .ok sess => + r.check "Mux.test.open returns before the reader fault" true + gate.close + let first ← + try + match ← sess.join with + | .ok () => pure "returned ok" + | .error _ => pure "returned core error" + catch e => + pure e.toString + let second ← + try + match ← sess.join with + | .ok () => pure "returned ok" + | .error _ => pure "returned core error" + catch e => + pure e.toString + r.check "the public reader fault is the connection error" (first == boom.toString) + r.check "a second public join throws the same reader fault" + (second == boom.toString && second == first) + pair.peer.close + private def checks (r : Runner) : Async Unit := do r.check "Mux.test.id is Mux.testId" (decide (test.id = testId)) r.check "the test protocol id is /leanp2p/test-mux/1.0.0" @@ -2922,12 +2964,18 @@ private def checks (r : Runner) : Async Unit := do runClose r runIdRestart r runConcurrentOpens r + runReaderFault r /-- The public `Mux.test` boundary over the sim connection. -/ def run (r : Runner) : IO Unit := do r.section "mux test value" "packages/libp2p/mux/Libp2p/Mux/Test.lean" Async.run (checks r) +/-- The public reader-fault path through `Mux.test.open`. -/ +def runFault (r : Runner) : IO Unit := do + r.section "mux test value" "packages/libp2p/mux/Libp2p/Mux/Test.lean" + Async.run (runReaderFault r) + end Tests.Mux.Public namespace Tests From 9bf06b375c4cb9833e8a43ea4ca27b8054b876de Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 19:16:22 +0530 Subject: [PATCH 076/125] test(mux): cover public malformed-frame handling Send one malformed private frame through a session created by Mux.test.open. Both joins return the same protocol error, while unexpected IO errors are rethrown only after peer cleanup. --- Tests/Mux.lean | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/Tests/Mux.lean b/Tests/Mux.lean index 8315ed4e..c1844e16 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -2953,6 +2953,45 @@ private def runReaderFault (r : Runner) : Async Unit := do (second == boom.toString && second == first) pair.peer.close +/-- A kind-zero peer header through `Mux.test.open`. +Both joins return the same protocol error. -/ +private def runMalformed (r : Runner) : Async Unit := do + let pair ← ends 51 + match ← (test.open .initiator pair.dialer .empty).run with + | .error _ => + pair.dialer.close + pair.peer.close + r.check "a public malformed frame returns a protocol error" false + r.check "a second public join returns the same protocol error" false + | .ok sess => + let observed : Except IO.Error + (Except Mock.Core.Error Unit × Except Mock.Core.Error Unit) ← + try + pair.peer.write ⟨#[0, 0, 0, 0, 1, 0, 0, 0, 0]⟩ + pure (.ok (← sess.join, ← sess.join)) + catch e => + pure (.error e) + pair.peer.close + match observed with + | .error e => throw e + | .ok (first, second) => + match first, second with + | .error e1, .error e2 => + let firstProtocol := + match e1 with + | .protocol _ => true + | _ => false + let secondProtocol := + match e2 with + | .protocol _ => true + | _ => false + r.check "a public malformed frame returns a protocol error" firstProtocol + r.check "a second public join returns the same protocol error" + (secondProtocol && decide (e1 = e2)) + | _, _ => + r.check "a public malformed frame returns a protocol error" false + r.check "a second public join returns the same protocol error" false + private def checks (r : Runner) : Async Unit := do r.check "Mux.test.id is Mux.testId" (decide (test.id = testId)) r.check "the test protocol id is /leanp2p/test-mux/1.0.0" @@ -2965,6 +3004,7 @@ private def checks (r : Runner) : Async Unit := do runIdRestart r runConcurrentOpens r runReaderFault r + runMalformed r /-- The public `Mux.test` boundary over the sim connection. -/ def run (r : Runner) : IO Unit := do @@ -2976,6 +3016,11 @@ def runFault (r : Runner) : IO Unit := do r.section "mux test value" "packages/libp2p/mux/Libp2p/Mux/Test.lean" Async.run (runReaderFault r) +/-- The public malformed-frame path through `Mux.test.open`. -/ +def runHostile (r : Runner) : IO Unit := do + r.section "mux test value" "packages/libp2p/mux/Libp2p/Mux/Test.lean" + Async.run (runMalformed r) + end Tests.Mux.Public namespace Tests From 73678baf117cb204a3975b8fb55ea0579f93269d Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 19:27:25 +0530 Subject: [PATCH 077/125] test(mux): cover unknown inbound stream ids Data, fin, and reset on a fresh never-opened remote id each select protocolUnknown. The transitions leave the id counters and stream state unchanged and emit no stream-specific actions. --- Tests/Mux.lean | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Tests/Mux.lean b/Tests/Mux.lean index c1844e16..d0077278 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -673,6 +673,29 @@ private def require {α : Type} (op : String) (x : Except InvariantError α) : E | .ok a => pure a | .error e => throw s!"{op}: {e.message}" +/-- The accept close and the terminal claim. -/ +private def sessionTerminal (a : Action) : Bool := + match a with + | .closeAccept => true + | .terminal _ => true + | _ => false + +/-- An unknown-id claim on a fresh session. The counters, the live +table, the order, and the outbound slot stay as they were. Every +action is session-level. -/ +private def unknownFresh (fresh : State) (id : UInt32) (step : Step) : Bool := + isProtocol step.state.cause protocolUnknown && + step.state.nextLocal == fresh.nextLocal && + step.state.nextRemote == fresh.nextRemote && + (step.state.live.get? id).isNone && + step.state.live.isEmpty && + !(step.state.retired id) && + step.state.order.isEmpty && + step.state.outbound.isNone && + step.state.turnIndex == fresh.turnIndex && + Parser.gap step.state.parser == .empty && + step.actions.all sessionTerminal + /-- Append `ackSend` when the step left an intent. -/ private def acknowledged (step : Step) : Except InvariantError Step := if step.state.outbound.isNone then @@ -1073,6 +1096,23 @@ private def runLifecycle (r : Runner) : ExceptT String IO Unit := do (remoteFin.state.live.get? localId).isNone && (remoteFin.state.writeMode? localId).isNone) +/-- Data, fin, and reset for a remote id that was never opened. +Each call starts from a fresh initiator session. -/ +private def runUnknown (r : Runner) : ExceptT String IO Unit := do + let id : UInt32 := 2 + let dataFresh := State.empty .initiator + let dataStep ← require "dataRemote" (State.dataRemote dataFresh id oneByte) + r.check "data for a never-opened id selects protocolUnknown" + (unknownFresh dataFresh id dataStep) + let finFresh := State.empty .initiator + let finStep ← require "finRemote" (State.finRemote finFresh id) + r.check "fin for a never-opened id selects protocolUnknown" + (unknownFresh finFresh id finStep) + let resetFresh := State.empty .initiator + let resetStep ← require "resetRemote" (State.resetRemote resetFresh id) + r.check "reset for a never-opened id selects protocolUnknown" + (unknownFresh resetFresh id resetStep) + /-- Terminal-cause arbitration. -/ private def runTerminal (r : Runner) : ExceptT String IO Unit := do let initiator := State.empty .initiator @@ -1588,6 +1628,7 @@ def run (r : Runner) : IO Unit := do finish r (runAdmission r) finish r (runWrites r) finish r (runLifecycle r) + finish r (runUnknown r) finish r (runTerminal r) finish r (runParser r) finish r (runAccept r) From 377d43155eb6998e3645030b18084af858a979b9 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 19:43:03 +0530 Subject: [PATCH 078/125] test(mux): cover inbox-credit overflow --- Tests/Mux.lean | 127 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 1 deletion(-) diff --git a/Tests/Mux.lean b/Tests/Mux.lean index d0077278..501cf525 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -1684,8 +1684,22 @@ private theorem finNotData : Kind.fin ≠ Kind.data := by decide private def control (k : Kind) (hk : k ≠ Kind.data) : ByteArray := Frame.encodeB (Frame.ofControl k 1 hk id1) +private def dataFrame (id : UInt32) (b : UInt8) : ByteArray := + Header.encodeB { kind := .data, streamId := id, length := 1 } ++ ⟨#[b]⟩ + private def oneData (b : UInt8) : ByteArray := - Header.encodeB { kind := .data, streamId := 1, length := 1 } ++ ⟨#[b]⟩ + dataFrame 1 b + +/-- One decoded header, and the bytes read past it. -/ +private def takeHeader (c : Connection) (pending : ByteArray) : + Async (Option (Header × ByteArray)) := do + match ← c.readExactly headerSize pending with + | none => return none + | some (frame, rest) => + match Header.decodeB? frame with + | .ok (h, suffix) => + if suffix.isEmpty then return some (h, rest) else return none + | .error _ => return none private def isOk (r : Except Streams.Error Unit) : Bool := match r with @@ -2418,6 +2432,111 @@ private def runArbitration (r : Runner) : Async Unit := do r.check "a later close keeps the winning reader fault" (first == boom.toString && second == boom.toString) +/-- A full inbox resets that stream. Buffered bytes stay readable. +The other stream and the session stay up. -/ +private def runInboxCredit (r : Runner) : Async Unit := do + let pair ← ends 33 + let sess ← start .initiator pair.dialer + let firstStream ← mustOpen sess + let secondStream ← mustOpen sess + let mut pending : ByteArray := .empty + let mut opensOk := false + match ← takeHeader pair.peer pending with + | some (firstOpen, rest1) => + match ← takeHeader pair.peer rest1 with + | some (secondOpen, rest2) => + pending := rest2 + opensOk := + firstOpen.kind == Kind.open && firstOpen.streamId == 1 && + firstOpen.length == 0 && + secondOpen.kind == Kind.open && secondOpen.streamId == 3 && + secondOpen.length == 0 + | none => pure () + | none => pure () + if !opensOk then + r.check "a full inbox sends one reset for that stream" false + r.check "buffered inbox bytes drain before reset" false + r.check "inbox reset is reported once before eof" false + r.check "writes after inbox overflow report reset" false + r.check "another stream remains usable after inbox overflow" false + r.check "inbox overflow leaves the session healthy" false + sess.close + discard <| sess.join + pair.peer.close + return + let queued := (List.range inboundFrameCredit).toArray.map fun i => + UInt8.ofNat (i + 1) + let mut blob : ByteArray := .empty + for b in queued do + blob := blob ++ dataFrame 1 b + blob := blob ++ dataFrame 1 (UInt8.ofNat (inboundFrameCredit + 1)) + pair.peer.write blob + match ← takeHeader pair.peer pending with + | none => + r.check "a full inbox sends one reset for that stream" false + r.check "buffered inbox bytes drain before reset" false + r.check "inbox reset is reported once before eof" false + r.check "writes after inbox overflow report reset" false + r.check "another stream remains usable after inbox overflow" false + r.check "inbox overflow leaves the session healthy" false + sess.close + discard <| sess.join + pair.peer.close + | some (resetHeader, rest) => + let resetOk := + resetHeader.kind == Kind.reset && resetHeader.streamId == 1 && + resetHeader.length == 0 && rest.isEmpty + r.check "a full inbox sends one reset for that stream" resetOk + if !resetOk then + r.check "buffered inbox bytes drain before reset" false + r.check "inbox reset is reported once before eof" false + r.check "writes after inbox overflow report reset" false + r.check "another stream remains usable after inbox overflow" false + r.check "inbox overflow leaves the session healthy" false + sess.close + discard <| sess.join + pair.peer.close + return + let mut got : Array ByteArray := #[] + for _ in [:queued.size] do + match ← firstStream.read with + | .ok (some bytes) => got := got.push bytes + | _ => pure () + let expect := queued.map fun b => (⟨#[b]⟩ : ByteArray) + r.check "buffered inbox bytes drain before reset" (got == expect) + let resetRead ← firstStream.read + let eofRead ← firstStream.read + r.check "inbox reset is reported once before eof" + (match resetRead, eofRead with + | .error .reset, .ok none => true + | _, _ => false) + let wrote ← firstStream.write ⟨#[7]⟩ + r.check "writes after inbox overflow report reset" (isResetW wrote) + pair.peer.write (dataFrame 3 8) + let inbound ← secondStream.read + let outbound ← secondStream.write ⟨#[9]⟩ + match ← takeHeader pair.peer rest with + | some (dataHeader, dataRest) => + match ← pair.peer.readExactly 1 dataRest with + | some (body, leftover) => + let readOk := + match inbound with + | .ok (some bytes) => bytes == ⟨#[8]⟩ + | _ => false + r.check "another stream remains usable after inbox overflow" + (readOk && isOk outbound && + dataHeader.kind == Kind.data && dataHeader.streamId == 3 && + dataHeader.length == 1 && body == ⟨#[9]⟩ && leftover.isEmpty) + | none => + r.check "another stream remains usable after inbox overflow" false + | none => + r.check "another stream remains usable after inbox overflow" false + sess.close + match ← sess.join with + | .ok () => r.check "inbox overflow leaves the session healthy" true + | .error _ => r.check "inbox overflow leaves the session healthy" false + pair.peer.close + private def checks (r : Runner) : Async Unit := do -- Startup, join, and a second join. do @@ -2693,6 +2812,7 @@ private def checks (r : Runner) : Async Unit := do discard <| sess.join drainOwner.joinAll + runInboxCredit r runCorrections r runArbitration r @@ -2701,6 +2821,11 @@ def run (r : Runner) : IO Unit := do r.section "mux test session" "packages/libp2p/mux/Libp2p/Mux/Test/Session.lean" Async.run (checks r) +/-- The inbox-credit boundary through `Session.start`. -/ +def runInbox (r : Runner) : IO Unit := do + r.section "mux test session" "packages/libp2p/mux/Libp2p/Mux/Test/Session.lean" + Async.run (runInboxCredit r) + end Tests.Mux.Session namespace Tests.Mux.Public From c3ed4ff183bec1f7658cfc1d4da405f17ae93d33 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 20:03:49 +0530 Subject: [PATCH 079/125] test(mux): cover the live accept backlog --- Tests/Mux.lean | 155 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/Tests/Mux.lean b/Tests/Mux.lean index 501cf525..0761335f 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -2865,6 +2865,12 @@ private def readPayload (stream : Stream) : Async ByteArray := do | .ok none => throw <| IO.userError "public read ended" | .error e => throw <| IO.userError s!"public read failed: {repr e}" +/-- The next chunk is the one-byte form of `id`. -/ +private def readIdByte (stream : Stream) (id : UInt32) : Async Bool := do + match ← stream.read with + | .ok (some bytes) => return bytes == ⟨#[UInt8.ofNat id.toNat]⟩ + | _ => return false + private def takeHeader (c : Connection) (pending : ByteArray) : Async (Header × ByteArray) := do match ← c.readExactly headerSize pending with @@ -2882,6 +2888,18 @@ private def openFrame (id : UInt32) : ByteArray := private def dataFrame (id : UInt32) (b : UInt8) : ByteArray := Header.encodeB { kind := .data, streamId := id, length := 1 } ++ ⟨#[b]⟩ +/-- The next peer frame is one data byte `b` for `id`. -/ +private def peerReadsData (c : Connection) (pending : ByteArray) (id : UInt32) + (b : UInt8) : Async (Bool × ByteArray) := do + let (h, rest) ← takeHeader c pending + match ← c.readExactly 1 rest with + | none => return (false, rest) + | some (body, leftover) => + let ok := + h.kind == Kind.data && h.streamId == id && h.length == 1 && + body == ⟨#[b]⟩ && leftover.isEmpty + return (ok, leftover) + /-- Two concurrent opens. The peer reads both open frames. -/ private def runParity (r : Runner) (n : Nat) (role : Role) (low high : UInt32) (title : String) : Async Unit := do @@ -3158,6 +3176,137 @@ private def runMalformed (r : Runner) : Async Unit := do r.check "a public malformed frame returns a protocol error" false r.check "a second public join returns the same protocol error" false +/-- A full accept backlog resets the next remote open. +One dequeue admits the following id. The session stays up. -/ +private def runAcceptBacklog (r : Runner) : Async Unit := do + let pair ← ends 52 + let cap := Libp2p.Mux.Test.State.acceptBacklogCap + let remoteId (i : Nat) : UInt32 := UInt32.ofNat (2 * (i + 1)) + let refused := remoteId cap + let later := remoteId (cap + 1) + let mut opened : Option MuxSession := none + let outcome : Except IO.Error Unit ← + try + match ← (test.open .initiator pair.dialer .empty).run with + | .error _ => + r.check "a full accept backlog resets the overflow open" false + r.check "the first accepted stream carries the first remote id" false + r.check "one queued stream stays usable after the refusal" false + r.check "accepted streams stay in open order" false + r.check "the next remote id is accepted after one dequeue" false + r.check "the later accepted stream is usable" false + r.check "accept backlog overflow leaves the session healthy" false + pure (.ok ()) + | .ok sess => + opened := some sess + let mut blob : ByteArray := .empty + for i in List.range cap do + let id := remoteId i + blob := blob ++ openFrame id ++ dataFrame id (UInt8.ofNat id.toNat) + blob := blob ++ openFrame refused + pair.peer.write blob + let (resetHeader, rest) ← takeHeader pair.peer .empty + let resetOk := + resetHeader.kind == Kind.reset && resetHeader.streamId == refused && + resetHeader.length == 0 && rest.isEmpty + r.check "a full accept backlog resets the overflow open" resetOk + let mut firstStream : Option Stream := none + let mut firstOk := false + let mut stop := !resetOk + if resetOk then + match ← sess.accept with + | none => stop := true + | some stream => + firstStream := some stream + firstOk := ← readIdByte stream (remoteId 0) + r.check "the first accepted stream carries the first remote id" firstOk + let usable ← + match firstStream with + | none => pure false + | some stream => + match ← stream.write ⟨#[1]⟩ with + | .error _ => pure false + | .ok () => + let (frameOk, _) ← peerReadsData pair.peer rest (remoteId 0) 1 + pure frameOk + r.check "one queued stream stays usable after the refusal" usable + if resetOk && firstOk then + pair.peer.write (openFrame later ++ dataFrame later (UInt8.ofNat later.toNat)) + let mut orderOk := resetOk && firstOk + for i in List.range (cap - 1) do + if !stop && orderOk then + match ← sess.accept with + | none => + orderOk := false + stop := true + | some stream => + let ok ← readIdByte stream (remoteId (i + 1)) + orderOk := orderOk && ok + r.check "accepted streams stay in open order" orderOk + let mut laterStream : Option Stream := none + let laterOk ← + if stop || !orderOk then + pure false + else + match ← sess.accept with + | none => pure false + | some stream => + laterStream := some stream + readIdByte stream later + r.check "the next remote id is accepted after one dequeue" laterOk + let laterUsable ← + match laterStream with + | none => pure false + | some stream => + match ← stream.write ⟨#[3]⟩ with + | .error _ => pure false + | .ok () => + let (frameOk, _) ← peerReadsData pair.peer .empty later 3 + pure frameOk + r.check "the later accepted stream is usable" laterUsable + sess.close + match ← sess.join with + | .ok () => r.check "accept backlog overflow leaves the session healthy" true + | .error _ => r.check "accept backlog overflow leaves the session healthy" false + pure (.ok ()) + catch e => + pure (.error e) + match outcome with + | .error _ => + match opened with + | some sess => + try + sess.close + catch _ => + pure () + try + discard <| sess.join + catch _ => + pure () + | none => + try + pair.dialer.close + catch _ => + pure () + | .ok () => + match opened with + | some _ => pure () + | none => + try + pair.dialer.close + catch _ => + pure () + let peerClosed : Except IO.Error Unit ← + try + pair.peer.close + pure (.ok ()) + catch e => + pure (.error e) + match outcome, peerClosed with + | .error e, _ => throw e + | .ok (), .error e => throw e + | .ok (), .ok () => pure () + private def checks (r : Runner) : Async Unit := do r.check "Mux.test.id is Mux.testId" (decide (test.id = testId)) r.check "the test protocol id is /leanp2p/test-mux/1.0.0" @@ -3171,6 +3320,7 @@ private def checks (r : Runner) : Async Unit := do runConcurrentOpens r runReaderFault r runMalformed r + runAcceptBacklog r /-- The public `Mux.test` boundary over the sim connection. -/ def run (r : Runner) : IO Unit := do @@ -3187,6 +3337,11 @@ def runHostile (r : Runner) : IO Unit := do r.section "mux test value" "packages/libp2p/mux/Libp2p/Mux/Test.lean" Async.run (runMalformed r) +/-- The live accept-backlog path through `Mux.test.open`. -/ +def runAccept (r : Runner) : IO Unit := do + r.section "mux test value" "packages/libp2p/mux/Libp2p/Mux/Test.lean" + Async.run (runAcceptBacklog r) + end Tests.Mux.Public namespace Tests From 430e5e71cc2f05d7a2a386969d39533fb233b04c Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 21:06:57 +0530 Subject: [PATCH 080/125] test(mux): cover the public peer-reset lifecycle --- Tests/Mux.lean | 125 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 6 deletions(-) diff --git a/Tests/Mux.lean b/Tests/Mux.lean index 0761335f..e4322181 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -2778,23 +2778,23 @@ private def checks (r : Runner) : Async Unit := do | .ok () => r.check "shutdown join stays a success" true | .error _ => r.check "shutdown join stays a success" false - -- Accept envelopes survive reset. + -- The queued stream is still accepted. Its read reports reset. do let pair ← ends 10 let sess ← start .responder pair.dialer pair.peer.write (control .open openNotData) pair.peer.write (control .reset resetNotData) match ← sess.accept with - | none => r.check "an accepted stream survives reset" false + | none => r.check "accept returns the queued stream and its read reports reset" false | some stream => match ← stream.read with | .error .reset => - r.check "an accepted stream survives reset" true - | _ => r.check "an accepted stream survives reset" false + r.check "accept returns the queued stream and its read reports reset" true + | _ => r.check "accept returns the queued stream and its read reports reset" false sess.close discard <| sess.join - -- Live permits return, so the cap can be filled twice. + -- A full live set is reset. The next open throws if no permit returns. do let pair ← ends 11 let drainOwner ← TaskSet.new @@ -2807,7 +2807,6 @@ private def checks (r : Runner) : Async Unit := do stream.reset let extra ← mustOpen sess extra.reset - r.check "a live permit returns and another open proceeds" true sess.close discard <| sess.join drainOwner.joinAll @@ -2888,6 +2887,10 @@ private def openFrame (id : UInt32) : ByteArray := private def dataFrame (id : UInt32) (b : UInt8) : ByteArray := Header.encodeB { kind := .data, streamId := id, length := 1 } ++ ⟨#[b]⟩ +/-- One zero-length reset for `id`. -/ +private def resetFrame (id : UInt32) : ByteArray := + Header.encodeB { kind := .reset, streamId := id, length := 0 } + /-- The next peer frame is one data byte `b` for `id`. -/ private def peerReadsData (c : Connection) (pending : ByteArray) (id : UInt32) (b : UInt8) : Async (Bool × ByteArray) := do @@ -3307,6 +3310,110 @@ private def runAcceptBacklog (r : Runner) : Async Unit := do | .ok (), .error e => throw e | .ok (), .ok () => pure () +/-- A peer reset ends stream 1 after its buffered byte. +A second reset of id 1 draws no answer. Stream 3 stays usable. -/ +private def runPeerReset (r : Runner) : Async Unit := do + let pair ← ends 53 + let mut opened : Option MuxSession := none + let outcome : Except IO.Error Unit ← + try + let mut opensOk := false + let mut readOk := false + let mut writeReset := false + let mut siblingRead := false + let mut outboundOk := false + let mut healthy := false + match ← (test.open .initiator pair.dialer .empty).run with + | .error _ => pure () + | .ok sess => + opened := some sess + match ← (sess.openStream testId).run with + | .error _ => pure () + | .ok stream1 => + match ← (sess.openStream testId).run with + | .error _ => pure () + | .ok stream3 => + let (h1, rest1) ← takeHeader pair.peer .empty + let (h2, rest2) ← takeHeader pair.peer rest1 + opensOk := + h1.kind == Kind.open && h1.streamId == 1 && h1.length == 0 && + h2.kind == Kind.open && h2.streamId == 3 && h2.length == 0 && + rest2.isEmpty + if opensOk then + pair.peer.write (dataFrame 1 5 ++ resetFrame 1) + let dataRead ← stream1.read + let resetRead ← stream1.read + let eofRead ← stream1.read + readOk := + match dataRead, resetRead, eofRead with + | .ok (some bytes), .error .reset, .ok none => bytes == ⟨#[5]⟩ + | _, _, _ => false + if opensOk && readOk then + match ← stream1.write ⟨#[7]⟩ with + | .error .reset => writeReset := true + | _ => pure () + if opensOk && readOk && writeReset then + pair.peer.write (resetFrame 1) + pair.peer.write (dataFrame 3 8) + siblingRead := + match ← stream3.read with + | .ok (some bytes) => bytes == ⟨#[8]⟩ + | _ => false + match ← stream3.write ⟨#[9]⟩ with + | .error _ => pure () + | .ok () => + let (frameOk, _) ← peerReadsData pair.peer .empty 3 9 + outboundOk := frameOk + let finished := opensOk && readOk && writeReset && siblingRead && outboundOk + sess.close + match ← sess.join with + | .ok () => healthy := finished + | .error _ => pure () + r.check "the initiator opens streams 1 and 3" opensOk + r.check "stream 1 reads its byte, then reset, then eof" readOk + r.check "a write on stream 1 returns reset" writeReset + r.check "stream 3 reads its byte after the second reset" siblingRead + r.check "the next outbound frame is stream 3 data" outboundOk + r.check "a peer reset leaves the session healthy" healthy + pure (.ok ()) + catch e => + pure (.error e) + match outcome with + | .error _ => + match opened with + | some sess => + try + sess.close + catch _ => + pure () + try + discard <| sess.join + catch _ => + pure () + | none => + try + pair.dialer.close + catch _ => + pure () + | .ok () => + match opened with + | some _ => pure () + | none => + try + pair.dialer.close + catch _ => + pure () + let peerClosed : Except IO.Error Unit ← + try + pair.peer.close + pure (.ok ()) + catch e => + pure (.error e) + match outcome, peerClosed with + | .error e, _ => throw e + | .ok (), .error e => throw e + | .ok (), .ok () => pure () + private def checks (r : Runner) : Async Unit := do r.check "Mux.test.id is Mux.testId" (decide (test.id = testId)) r.check "the test protocol id is /leanp2p/test-mux/1.0.0" @@ -3321,6 +3428,7 @@ private def checks (r : Runner) : Async Unit := do runReaderFault r runMalformed r runAcceptBacklog r + runPeerReset r /-- The public `Mux.test` boundary over the sim connection. -/ def run (r : Runner) : IO Unit := do @@ -3342,6 +3450,11 @@ def runAccept (r : Runner) : IO Unit := do r.section "mux test value" "packages/libp2p/mux/Libp2p/Mux/Test.lean" Async.run (runAcceptBacklog r) +/-- The public peer-reset path through `Mux.test.open`. -/ +def runReset (r : Runner) : IO Unit := do + r.section "mux test value" "packages/libp2p/mux/Libp2p/Mux/Test.lean" + Async.run (runPeerReset r) + end Tests.Mux.Public namespace Tests From 031c403ee666772b2e138a07c164ff2c1901e2df Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 21:21:12 +0530 Subject: [PATCH 081/125] test(mux): cover the public half-close lifecycle --- Tests/Mux.lean | 148 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/Tests/Mux.lean b/Tests/Mux.lean index e4322181..c36b336c 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -2891,6 +2891,10 @@ private def dataFrame (id : UInt32) (b : UInt8) : ByteArray := private def resetFrame (id : UInt32) : ByteArray := Header.encodeB { kind := .reset, streamId := id, length := 0 } +/-- One zero-length fin for `id`. -/ +private def finFrame (id : UInt32) : ByteArray := + Header.encodeB { kind := .fin, streamId := id, length := 0 } + /-- The next peer frame is one data byte `b` for `id`. -/ private def peerReadsData (c : Connection) (pending : ByteArray) (id : UInt32) (b : UInt8) : Async (Bool × ByteArray) := do @@ -3414,6 +3418,144 @@ private def runPeerReset (r : Runner) : Async Unit := do | .ok (), .error e => throw e | .ok (), .ok () => pure () +/-- Local half-close sends stream 1 data, then one fin. +A second close draws no fin. The inbound side stays open until the peer fin. -/ +private def runHalfClose (r : Runner) : Async Unit := do + let pair ← ends 54 + let mut opened : Option MuxSession := none + let outcome : Except IO.Error Unit ← + try + let mut opensOk := false + let mut wireOk := false + let mut writeClosed := false + let mut silenceOk := false + let mut inboundOk := false + let mut eofOk := false + let mut eofStable := false + let mut siblingRead := false + let mut healthy := false + match ← (test.open .initiator pair.dialer .empty).run with + | .error _ => pure () + | .ok sess => + opened := some sess + match ← (sess.openStream testId).run with + | .error _ => pure () + | .ok stream1 => + match ← (sess.openStream testId).run with + | .error _ => pure () + | .ok stream3 => + let (h1, rest1) ← takeHeader pair.peer .empty + let (h2, rest2) ← takeHeader pair.peer rest1 + opensOk := + h1.kind == Kind.open && h1.streamId == 1 && h1.length == 0 && + h2.kind == Kind.open && h2.streamId == 3 && h2.length == 0 && + rest2.isEmpty + if opensOk then + match ← stream1.write ⟨#[5]⟩ with + | .error _ => pure () + | .ok () => + stream1.close + let (dataHeader, dataRest) ← takeHeader pair.peer .empty + match ← pair.peer.readExactly 1 dataRest with + | none => pure () + | some (body, afterData) => + let dataOk := + dataHeader.kind == Kind.data && dataHeader.streamId == 1 && + dataHeader.length == 1 && body == ⟨#[5]⟩ + if dataOk then + let (finHeader, finRest) ← takeHeader pair.peer afterData + wireOk := + finHeader.kind == Kind.fin && finHeader.streamId == 1 && + finHeader.length == 0 && finRest.isEmpty + if opensOk && wireOk then + match ← stream1.write ⟨#[7]⟩ with + | .error .closed => writeClosed := true + | _ => pure () + if opensOk && wireOk && writeClosed then + stream1.close + match ← stream3.write ⟨#[9]⟩ with + | .error _ => pure () + | .ok () => + let (frameOk, _) ← peerReadsData pair.peer .empty 3 9 + silenceOk := frameOk + if opensOk && wireOk && writeClosed && silenceOk then + pair.peer.write (dataFrame 1 6) + inboundOk := + match ← stream1.read with + | .ok (some bytes) => bytes == ⟨#[6]⟩ + | _ => false + if opensOk && wireOk && writeClosed && silenceOk && inboundOk then + pair.peer.write (finFrame 1) + match ← stream1.read with + | .ok none => + eofOk := true + eofStable := + match ← stream1.read with + | .ok none => true + | _ => false + | _ => pure () + if opensOk && wireOk && writeClosed && silenceOk && inboundOk && + eofOk && eofStable then + pair.peer.write (dataFrame 3 8) + siblingRead := + match ← stream3.read with + | .ok (some bytes) => bytes == ⟨#[8]⟩ + | _ => false + let finished := + opensOk && wireOk && writeClosed && silenceOk && inboundOk && + eofOk && eofStable && siblingRead + sess.close + match ← sess.join with + | .ok () => healthy := finished + | .error _ => pure () + r.check "the initiator opens streams 1 and 3" opensOk + r.check "stream 1 sends its payload and then one fin" wireOk + r.check "a write after half-close returns closed" writeClosed + r.check "the next frame after a second close is stream 3 data" silenceOk + r.check "stream 1 reads inbound data after half-close" inboundOk + r.check "the peer fin ends stream 1 with eof" eofOk + r.check "a second read on stream 1 stays at eof" eofStable + r.check "stream 3 reads its byte after the peer fin" siblingRead + r.check "half-close leaves the session healthy" healthy + pure (.ok ()) + catch e => + pure (.error e) + match outcome with + | .error _ => + match opened with + | some sess => + try + sess.close + catch _ => + pure () + try + discard <| sess.join + catch _ => + pure () + | none => + try + pair.dialer.close + catch _ => + pure () + | .ok () => + match opened with + | some _ => pure () + | none => + try + pair.dialer.close + catch _ => + pure () + let peerClosed : Except IO.Error Unit ← + try + pair.peer.close + pure (.ok ()) + catch e => + pure (.error e) + match outcome, peerClosed with + | .error e, _ => throw e + | .ok (), .error e => throw e + | .ok (), .ok () => pure () + private def checks (r : Runner) : Async Unit := do r.check "Mux.test.id is Mux.testId" (decide (test.id = testId)) r.check "the test protocol id is /leanp2p/test-mux/1.0.0" @@ -3429,6 +3571,7 @@ private def checks (r : Runner) : Async Unit := do runMalformed r runAcceptBacklog r runPeerReset r + runHalfClose r /-- The public `Mux.test` boundary over the sim connection. -/ def run (r : Runner) : IO Unit := do @@ -3455,6 +3598,11 @@ def runReset (r : Runner) : IO Unit := do r.section "mux test value" "packages/libp2p/mux/Libp2p/Mux/Test.lean" Async.run (runPeerReset r) +/-- The public half-close path through `Mux.test.open`. -/ +def runFin (r : Runner) : IO Unit := do + r.section "mux test value" "packages/libp2p/mux/Libp2p/Mux/Test.lean" + Async.run (runHalfClose r) + end Tests.Mux.Public namespace Tests From 306927d5b0282ca08a876e6c31324df86d356e45 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Thu, 5 Nov 2026 21:48:54 +0530 Subject: [PATCH 082/125] test(mux): cover the public local-reset lifecycle --- Tests/Mux.lean | 139 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/Tests/Mux.lean b/Tests/Mux.lean index c36b336c..0f211c84 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -3556,6 +3556,139 @@ private def runHalfClose (r : Runner) : Async Unit := do | .ok (), .error e => throw e | .ok (), .ok () => pure () +/-- Local reset sends one reset after stream 1 has a buffered byte. +A second reset draws no frame. Data for the retired id draws no outbound frame. -/ +private def runLocalReset (r : Runner) : Async Unit := do + let pair ← ends 55 + let mut opened : Option MuxSession := none + let outcome : Except IO.Error Unit ← + try + let mut opensOk := false + let mut buffered := false + let mut wireOk := false + let mut readOk := false + let mut writeReset := false + let mut silenceOk := false + let mut siblingRead := false + let mut retiredSilent := false + let mut healthy := false + match ← (test.open .initiator pair.dialer .empty).run with + | .error _ => pure () + | .ok sess => + opened := some sess + match ← (sess.openStream testId).run with + | .error _ => pure () + | .ok stream1 => + match ← (sess.openStream testId).run with + | .error _ => pure () + | .ok stream3 => + let (h1, rest1) ← takeHeader pair.peer .empty + let (h2, rest2) ← takeHeader pair.peer rest1 + opensOk := + h1.kind == Kind.open && h1.streamId == 1 && h1.length == 0 && + h2.kind == Kind.open && h2.streamId == 3 && h2.length == 0 && + rest2.isEmpty + if opensOk then + -- Stream 3's byte follows stream 1's byte in one write. + -- Its read is the barrier that stream 1's byte is queued. + pair.peer.write (dataFrame 1 5 ++ dataFrame 3 8) + buffered := + match ← stream3.read with + | .ok (some bytes) => bytes == ⟨#[8]⟩ + | _ => false + if opensOk && buffered then + stream1.reset + let (resetHeader, resetRest) ← takeHeader pair.peer .empty + wireOk := + resetHeader.kind == Kind.reset && resetHeader.streamId == 1 && + resetHeader.length == 0 && resetRest.isEmpty + if opensOk && buffered && wireOk then + let dataRead ← stream1.read + let resetRead ← stream1.read + let eofRead ← stream1.read + readOk := + match dataRead, resetRead, eofRead with + | .ok (some bytes), .error .reset, .ok none => bytes == ⟨#[5]⟩ + | _, _, _ => false + if opensOk && buffered && wireOk && readOk then + match ← stream1.write ⟨#[7]⟩ with + | .error .reset => writeReset := true + | _ => pure () + if opensOk && buffered && wireOk && readOk && writeReset then + stream1.reset + match ← stream3.write ⟨#[9]⟩ with + | .error _ => pure () + | .ok () => + let (frameOk, _) ← peerReadsData pair.peer .empty 3 9 + silenceOk := frameOk + if opensOk && buffered && wireOk && readOk && writeReset && silenceOk then + pair.peer.write (dataFrame 1 6 ++ dataFrame 3 4) + siblingRead := + match ← stream3.read with + | .ok (some bytes) => bytes == ⟨#[4]⟩ + | _ => false + if opensOk && buffered && wireOk && readOk && writeReset && + silenceOk && siblingRead then + match ← stream3.write ⟨#[2]⟩ with + | .error _ => pure () + | .ok () => + let (frameOk, _) ← peerReadsData pair.peer .empty 3 2 + retiredSilent := frameOk + let finished := + opensOk && buffered && wireOk && readOk && writeReset && + silenceOk && siblingRead && retiredSilent + sess.close + match ← sess.join with + | .ok () => healthy := finished + | .error _ => pure () + r.check "the initiator opens streams 1 and 3" opensOk + r.check "stream 3 reads the byte sent after stream 1 data" buffered + r.check "the peer reads one zero-length reset for stream 1" wireOk + r.check "stream 1 reads its buffered byte, then reset, then eof" readOk + r.check "a write on stream 1 returns reset" writeReset + r.check "the next frame after a second reset is stream 3 data" silenceOk + r.check "stream 3 reads its byte after data for the retired id" siblingRead + r.check "the next frame after retired data is stream 3 data" retiredSilent + r.check "a local reset leaves the session healthy" healthy + pure (.ok ()) + catch e => + pure (.error e) + match outcome with + | .error _ => + match opened with + | some sess => + try + sess.close + catch _ => + pure () + try + discard <| sess.join + catch _ => + pure () + | none => + try + pair.dialer.close + catch _ => + pure () + | .ok () => + match opened with + | some _ => pure () + | none => + try + pair.dialer.close + catch _ => + pure () + let peerClosed : Except IO.Error Unit ← + try + pair.peer.close + pure (.ok ()) + catch e => + pure (.error e) + match outcome, peerClosed with + | .error e, _ => throw e + | .ok (), .error e => throw e + | .ok (), .ok () => pure () + private def checks (r : Runner) : Async Unit := do r.check "Mux.test.id is Mux.testId" (decide (test.id = testId)) r.check "the test protocol id is /leanp2p/test-mux/1.0.0" @@ -3572,6 +3705,7 @@ private def checks (r : Runner) : Async Unit := do runAcceptBacklog r runPeerReset r runHalfClose r + runLocalReset r /-- The public `Mux.test` boundary over the sim connection. -/ def run (r : Runner) : IO Unit := do @@ -3603,6 +3737,11 @@ def runFin (r : Runner) : IO Unit := do r.section "mux test value" "packages/libp2p/mux/Libp2p/Mux/Test.lean" Async.run (runHalfClose r) +/-- The public local-reset path through `Mux.test.open`. -/ +def runLocal (r : Runner) : IO Unit := do + r.section "mux test value" "packages/libp2p/mux/Libp2p/Mux/Test.lean" + Async.run (runLocalReset r) + end Tests.Mux.Public namespace Tests From ea079ce1366b1a0144ee9bbd948bd727c212a8f0 Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Fri, 6 Nov 2026 17:51:19 +0700 Subject: [PATCH 083/125] test(mux): parse test addresses with the real Multiaddr --- Tests/Mux.lean | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/Mux.lean b/Tests/Mux.lean index 0f211c84..253b3cfc 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -6,7 +6,7 @@ import Libp2p.Mux.Test.State import Libp2p.Mux.Test.Session import Conc import Streams -import Streams.Mock.Multiaddr +import Multiformats /-! # Mux suite (unit W5.4) @@ -1645,7 +1645,7 @@ open Libp2p.Mux.Test.State open Libp2p.Mux.Test.Wire open Conc open Streams -open Streams.Mock.Multiformats +open Multiformats open Streams.Sim private def pid : ProtocolId := ⟨"/leanp2p/test-mux/1.0.0"⟩ @@ -2834,7 +2834,7 @@ open Libp2p.Mux.Mock.Core open Libp2p.Mux.Test.Wire open Conc open Streams -open Streams.Mock.Multiformats +open Multiformats open Streams.Sim private structure Ends where From a34c80e94e3331be6b124de355603fe530f2d5a7 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:52:36 +0800 Subject: [PATCH 084/125] Fix four compile errors in gossipsub tests --- Tests/Gossipsub/Identity.lean | 4 ++-- Tests/Gossipsub/Node.lean | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/Gossipsub/Identity.lean b/Tests/Gossipsub/Identity.lean index da2a8b39..c36b6a4b 100644 --- a/Tests/Gossipsub/Identity.lean +++ b/Tests/Gossipsub/Identity.lean @@ -15,11 +15,11 @@ namespace Tests.Gossipsub.Identity /-- A validated identity multihash for one transcript peer. -/ def peer (n : Nat) : Libp2p.Identity.PeerId := Libp2p.Identity.PeerId.inline (ByteArray.mk #[n.toUInt8]) (by - simp [Libp2p.Identity.maxInlineKeyBytes]) + simp [ByteArray.size, Libp2p.Identity.maxInlineKeyBytes]) /-- A sized public key for policy tests that control verification. -/ def publicKey (n : Nat) : Libp2p.Identity.PubKey := Libp2p.Identity.PubKey.ofEd25519 - ⟨ByteArray.mk (Array.replicate 32 n.toUInt8), by simp⟩ + ⟨ByteArray.mk (Array.replicate 32 n.toUInt8), by simp [ByteArray.size]⟩ end Tests.Gossipsub.Identity diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index d04e15b9..0a03622f 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -46,8 +46,8 @@ private def receive {α : Type} (c : Chan α) : Async (Option α) := do private partial def receiveWhere {α : Type} (c : Chan α) (wanted : α → Bool) : Async (Option α) := do match ← receive c with - | some value => if wanted value then some value else receiveWhere c wanted - | none => none + | some value => if wanted value then pure (some value) else receiveWhere c wanted + | none => pure none private def marker : Rpc.RPC := subOptRpc ⟨"node-suite-barrier"⟩ false From d1eb82e361b3d457e1a1929def4b114a81105b9f Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Fri, 6 Nov 2026 18:23:40 +0700 Subject: [PATCH 085/125] docs(security): record the units done in the package plan --- packages/libp2p/security/docs/PLAN.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/libp2p/security/docs/PLAN.md b/packages/libp2p/security/docs/PLAN.md index 8a2a243c..29555b42 100644 --- a/packages/libp2p/security/docs/PLAN.md +++ b/packages/libp2p/security/docs/PLAN.md @@ -20,7 +20,10 @@ The split lets the pure side close on transcripts alone. The core needs W3.3 for the facade and W5.2 for the `SecureConn` inputs; W4.2 gates only the framed IO path and the seam values, which unit 1b carries. -Unit 0 is done. +Units 0, 1a, and 1b are done: the suite is `Tests/Noise.lean`, and the +interop check of the vector stance runs through +`scripts/noise-interop.sh`. The package docs record the mock of W5.2 +and the integration that drops it ("The mock" in the ARCHITECTURE). ## Consumers From 8d6d7dd722a13d8864d77be2ed79bc761690d7ce Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:31:56 +0800 Subject: [PATCH 086/125] test(yamux): wait for the data frame before teardown --- Tests/Yamux.lean | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Tests/Yamux.lean b/Tests/Yamux.lean index b2113e02..436eb1db 100644 --- a/Tests/Yamux.lean +++ b/Tests/Yamux.lean @@ -953,29 +953,35 @@ private def leftoverChecks (r : Runner) : Async Unit := do /-- A write whose frame goes out after the loop has ended answers `.ok`. The client's connection reads park until the test closes `fin`, then -end, as after a peer FIN, while its writes still reach the server. Once -`armed` is set, the writes wait at `gate`, so the write's frame is still -queued when the teardown runs. -/ +end, as after a peer FIN, while its writes still reach the server. The +DATA frame signals entry, then waits at `gate` until teardown ends. The +stream-opening frame passes the gate so it cannot block the DATA frame. -/ private def writeAcrossEndChecks (r : Runner) : Async Unit := do let (c, s) ← pair let fin : Chan Unit ← Chan.new 1 let gate : Chan Unit ← Chan.new 1 - let armed : OnceRef Bool ← OnceRef.new false + let entered : Chan Unit ← Chan.new 1 let c' : Connection := { c with read := do discard fin.recv; return none write := fun bytes => do - if ← (armed.get : BaseIO Bool) then discard gate.recv + match Header.decodeB? bytes with + | .ok (h, _) => + if h.type == .data && h.streamId == 1 then + discard <| entered.send () + discard gate.recv + | .error _ => pure () c.write bytes } let client ← Session.new .client c' let server ← Session.new .server s let .ok cs ← client.openStream | throw <| IO.userError "open failed" - armed.set true let tasks ← TaskSet.new let wrote : OnceRef Bool ← OnceRef.new false tasks.spawn do if let .ok () ← cs.write (b "late") then wrote.set true + -- Wait until the target DATA frame has entered the wrapped write. -- The end of the reads ends the loop; the teardown closes the accept -- queue, so this accept answers `none` only after the teardown. + discard entered.recv fin.close r.check "session: the reader's end ends the session" ((← client.acceptStream).isNone) gate.close From 4e0461a0a7440a14d3ccf863405500b1bf5e885d Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Fri, 6 Nov 2026 22:27:33 +0700 Subject: [PATCH 087/125] docs(protocols): drop the streams mock axiom from the frame-step note The base swap removed `Streams.Mock.Varint`, so the laws of `Streams.Framing` no longer carry its axiom. The audit at this head shows the standard axioms only. --- packages/libp2p/protocols/Libp2p/Protocols/Identify.lean | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean index 2c87479c..3284a1ec 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean @@ -575,9 +575,9 @@ def respondHandler (key : Mock.Identity.PubKey) (addrs : List (List Nat)) `clientCoreFrame` pins the frame that `clientCore` validates. `clientCoreOverCap` pins the cap check before the body. `clientCoreEmptyFrame` pins the zero-length rule. Each consumes a law of -`Streams.Framing`, which inherits the axiom -`Streams.Mock.Varint.decodeEncodeSuffix`. None takes a result of this -package's mocks as a premise. -/ +`Streams.Framing`, and the laws rest on no mock axiom: the base +replaced the streams mocks with the real encoders. None takes a result +of this package's mocks as a premise. -/ /-- The frame step reads a whole frame back from its encoding, with any rest after it. -/ From 76e51369c803288afb0618fa7d3c2fc376052966 Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Fri, 6 Nov 2026 22:51:28 +0700 Subject: [PATCH 088/125] chore(protocols): drop the dead Multiaddr mock Protocols.Mock.Multiaddr has no importer, and issue #71 says the unused copy goes with the mock batch. The receiveWhere docstring now states what the loop does with the values it skips, and why it is partial. --- Tests/Gossipsub/Node.lean | 5 ++++- .../Libp2p/Protocols/Mock/Multiaddr.lean | 21 ------------------- 2 files changed, 4 insertions(+), 22 deletions(-) delete mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index 0a03622f..7cbe5bb5 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -42,7 +42,10 @@ private def receive {α : Type} (c : Chan α) : Async (Option α) := do let timer ← (Park.sleep patience none : BaseIO (Park (Option α))) select #[Park.recv c id, timer] -/-- Wait for one matching value while preserving unrelated observations. -/ +/-- Wait for one matching value, and drop the values it reads before +it. The loop is `partial` because termination depends on the channel +contents, as with the IO read loops; the deadline of `receive` bounds +every wait. -/ private partial def receiveWhere {α : Type} (c : Chan α) (wanted : α → Bool) : Async (Option α) := do match ← receive c with diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean deleted file mode 100644 index 089d3947..00000000 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Multiaddr.lean +++ /dev/null @@ -1,21 +0,0 @@ -import Std - -/-! -# Multiaddr mock - -`Multiformats` (issue #8, PR #36, merged) owns the real -`Multiaddr.ofByteArray?`. This mock declares the minimal validity -predicate that the identify validate needs. The real check replaces -it at integration. --/ - -set_option autoImplicit false - -namespace Libp2p.Protocols.Mock.Multiaddr - -/-- Executable stand-in: the empty address is invalid, every other -buffer is valid. -/ -opaque validBytes (bs : List Nat) : Bool := - !bs.isEmpty - -end Libp2p.Protocols.Mock.Multiaddr From 2f424b3cc5fb9d0e32b3b6d7f8ff048ff1ed5f71 Mon Sep 17 00:00:00 2001 From: Wenbobobo Date: Sat, 7 Nov 2026 01:16:26 +0800 Subject: [PATCH 089/125] refactor(negotiate): take the peer id from libp2p/identity --- Tests/Upgrade.lean | 15 ++++++++--- .../libp2p/negotiate/Libp2p/Negotiate.lean | 2 +- .../negotiate/Libp2p/Negotiate/Mock/Core.lean | 10 +++---- .../Libp2p/Negotiate/Mock/Identity.lean | 24 ----------------- .../negotiate/Libp2p/Negotiate/Upgrade.lean | 26 +++++++++---------- 5 files changed, 30 insertions(+), 47 deletions(-) delete mode 100644 packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Identity.lean diff --git a/Tests/Upgrade.lean b/Tests/Upgrade.lean index 6e2df5c1..91134525 100644 --- a/Tests/Upgrade.lean +++ b/Tests/Upgrade.lean @@ -1,5 +1,6 @@ import Tests.Support import Libp2p.Negotiate +import Libp2p.Identity import Conc import Bytes import Streams.Conn @@ -88,11 +89,17 @@ private def markerI : ByteArray := Bytes.ofNatList [1, 1, 1, 1] /-- The responder's marker of the test handshake. -/ private def markerR : ByteArray := Bytes.ofNatList [2, 2, 2, 2] +/-- A validated inline peer id, one per suite peer. An identity +multihash needs no native crypto, so the suite stays portable. -/ +private def peerOf (n : Nat) : Libp2p.Identity.PeerId := + Libp2p.Identity.PeerId.inline (ByteArray.mk #[n.toUInt8]) (by + simp [ByteArray.size, Libp2p.Identity.maxInlineKeyBytes]) + /-- The fixed peer id of the dialing side. -/ -private def dialerId : Mock.Identity.PeerId := ⟨"12D3KooWTestDialer"⟩ +private def dialerId : Libp2p.Identity.PeerId := peerOf 1 /-- The fixed peer id of the listening side. -/ -private def listenerId : Mock.Identity.PeerId := ⟨"12D3KooWTestListener"⟩ +private def listenerId : Libp2p.Identity.PeerId := peerOf 2 /-- The fixed-key plaintext test security value. It replaces the Noise milestone (#16, unmerged). The handshake writes its marker, reads the @@ -187,7 +194,7 @@ private def recordWrites (conn : Streams.Connection) (ref : Conc.OnceRef (List B /-- The answer of one upgrade run. -/ private abbrev UpAnswer := - Option (Except Mock.Core.Error (Mock.Core.MuxSession × Mock.Identity.PeerId)) + Option (Except Mock.Core.Error (Mock.Core.MuxSession × Libp2p.Identity.PeerId)) /-! ## The pipeline checks -/ @@ -390,7 +397,7 @@ any muxer byte moves, and the teardown close ends the responder's parked negotiation with `closed`. -/ private def checkIdentityMismatch (r : Runner) : Async Unit := do let (iside, rside) ← newPair - let wrongId : Mock.Identity.PeerId := ⟨"12D3KooWWrongPeer"⟩ + let wrongId : Libp2p.Identity.PeerId := peerOf 3 let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ let dRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate.lean b/packages/libp2p/negotiate/Libp2p/Negotiate.lean index fea468b5..25f5d964 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate.lean @@ -9,5 +9,5 @@ import Libp2p.Negotiate.Upgrade The negotiate subpackage: multistream-select 1.0.0 and the upgrader (ARCHITECTURE §4.9). One import line per public submodule. The root names no Mock module directly. Its submodules still expose `Mock.Core` -and `Mock.Identity` until W5.1 and W5.2 land. +until W5.2 lands. The identity field types name `Libp2p.Identity`. -/ diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean index 891be73b..f18ddd1f 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean @@ -2,7 +2,7 @@ import Std import Conc import Streams.Conn import Streams.Stream -import Libp2p.Negotiate.Mock.Identity +import Libp2p.Identity /-! # Core mocks for units 1a and 1b @@ -19,8 +19,8 @@ joins the derived classes per root §3.2, so `State`, `Event`, and `SecurityProtocol` and `Muxer` mention the streams `Connection`, and `SecureConn` mentions the identity `PeerId`, so this module imports -`Streams.Conn`, `Streams.Stream`, and the identity mock. The identity -mock stays until W5.1 lands. +`Streams.Conn`, `Streams.Stream`, and `Libp2p.Identity`. The identity +package landed as W5.1, so the field types name the real one. -/ set_option autoImplicit false @@ -56,8 +56,8 @@ ARCHITECTURE, "Public surface"). The `leftover` is every byte the handshake read past its end; it seeds the reader of the next stage. -/ structure SecureConn where conn : Streams.Connection - localPeer : Mock.Identity.PeerId - remotePeer : Mock.Identity.PeerId + localPeer : Libp2p.Identity.PeerId + remotePeer : Libp2p.Identity.PeerId leftover : ByteArray close : BaseIO Unit diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Identity.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Identity.lean deleted file mode 100644 index 90719d9f..00000000 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Identity.lean +++ /dev/null @@ -1,24 +0,0 @@ -import Std - -/-! -# Identity mocks for unit 1b - -`PeerId` lives in `libp2p/identity` (#11, not merged yet). The real -type is a proof-carrying newtype over a validated multihash -(identity ARCHITECTURE, "Public surface"). The upgrader compares -`PeerId` values only, so this mock keeps the recorded one-field form -and the derived classes (plan.md, "Mock surface"). The import swaps -to `Libp2p.Identity` when the identity package lands. --/ - -set_option autoImplicit false - -namespace Libp2p.Negotiate.Mock.Identity - -/-- The one-field mock of the validated multihash. The upgrader -compares values only. -/ -structure PeerId where - toString : String - deriving DecidableEq, Repr, Hashable - -end Libp2p.Negotiate.Mock.Identity diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean index ab8ff907..1f8c34c2 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean @@ -1,7 +1,7 @@ import Libp2p.Negotiate.Step import Libp2p.Negotiate.Message import Libp2p.Negotiate.Mock.Core -import Libp2p.Negotiate.Mock.Identity +import Libp2p.Identity import Streams.Conn import Streams.BufReader import Streams.Constants @@ -167,8 +167,8 @@ def mapNegotiateError : Error → Mock.Core.Error peer. The dial path compares the derived id against the expectation and answers `authentication` on a difference, before any muxer byte moves (negotiate ARCHITECTURE, "Decisions"). -/ -def checkIdentity (expected : Option Mock.Identity.PeerId) - (remote : Mock.Identity.PeerId) : Except Mock.Core.Error Unit := +def checkIdentity (expected : Option Libp2p.Identity.PeerId) + (remote : Libp2p.Identity.PeerId) : Except Mock.Core.Error Unit := match expected with | none => .ok () | some e => @@ -373,19 +373,19 @@ theorem mapNegotiateErrorSpec {e : Bytes.DecodeError} : /-- A differing derived id is authentication, before any muxer byte moves. -/ -theorem checkIdentityReject {expected remote : Mock.Identity.PeerId} +theorem checkIdentityReject {expected remote : Libp2p.Identity.PeerId} (h : expected ≠ remote) : checkIdentity (some expected) remote = .error (.authentication "remote peer id differs from the expected peer id") := by simp [checkIdentity, h] /-- The dial to the expected peer passes. -/ -theorem checkIdentityAccept {e : Mock.Identity.PeerId} : +theorem checkIdentityAccept {e : Libp2p.Identity.PeerId} : checkIdentity (some e) e = .ok () := by simp [checkIdentity] /-- The listen path accepts any peer. -/ -theorem checkIdentityNone {r : Mock.Identity.PeerId} : +theorem checkIdentityNone {r : Libp2p.Identity.PeerId} : checkIdentity none r = .ok () := rfl /-- The deadline wiring of contract 6: the race's expiry is the @@ -483,9 +483,9 @@ def negotiate (role : Mock.Core.Role) (reader : Streams.BufReader) check the identity, negotiate the muxer, open the session. Each lookup is total; a miss after a selection is `protocol`. -/ private def upgradeCore (role : Mock.Core.Role) (conn : Streams.Connection) - (leftover : ByteArray) (expected : Option Mock.Identity.PeerId) + (leftover : ByteArray) (expected : Option Libp2p.Identity.PeerId) (secs : List Mock.Core.SecurityProtocol) (muxers : List Mock.Core.Muxer) : - Conc.Async (Except Mock.Core.Error (Mock.Core.MuxSession × Mock.Identity.PeerId)) := do + Conc.Async (Except Mock.Core.Error (Mock.Core.MuxSession × Libp2p.Identity.PeerId)) := do let reader := Streams.BufReader.new conn match ← negotiate role reader conn leftover (secs.map (·.id)) with | .error e => return .error e @@ -520,7 +520,7 @@ private def boundaryClosed {α : Type} : Except Conc.Error α → Bool /-- Close a session that finished after the timer won. No caller receives that session. -/ private def releaseLate (r : Except Mock.Core.Error - (Mock.Core.MuxSession × Mock.Identity.PeerId)) : BaseIO Unit := + (Mock.Core.MuxSession × Libp2p.Identity.PeerId)) : BaseIO Unit := match r with | .ok pair => pair.1.close | .error _ => pure () @@ -532,9 +532,9 @@ The clock is a seam, so the deadline test can advance it. -/ def upgradeWith (clock : Conc.Clock) (role : Mock.Core.Role) (conn : Streams.Connection) (leftover : ByteArray) - (expected : Option Mock.Identity.PeerId) + (expected : Option Libp2p.Identity.PeerId) (secs : List Mock.Core.SecurityProtocol) (muxers : List Mock.Core.Muxer) : - ExceptT Mock.Core.Error Conc.Async (Mock.Core.MuxSession × Mock.Identity.PeerId) := do + ExceptT Mock.Core.Error Conc.Async (Mock.Core.MuxSession × Libp2p.Identity.PeerId) := do let answer ← Conc.withinDeadline clock Streams.unauthDeadlineMs (Streams.Connection.closeQuietly conn) (upgradeCore role conn leftover expected secs muxers) @@ -553,9 +553,9 @@ def upgradeWith (clock : Conc.Clock) (role : Mock.Core.Role) system clock here and stays a seam in `upgradeWith` for the deadline test. -/ def upgrade (role : Mock.Core.Role) (conn : Streams.Connection) - (leftover : ByteArray) (expected : Option Mock.Identity.PeerId) + (leftover : ByteArray) (expected : Option Libp2p.Identity.PeerId) (secs : List Mock.Core.SecurityProtocol) (muxers : List Mock.Core.Muxer) : - ExceptT Mock.Core.Error Conc.Async (Mock.Core.MuxSession × Mock.Identity.PeerId) := + ExceptT Mock.Core.Error Conc.Async (Mock.Core.MuxSession × Libp2p.Identity.PeerId) := upgradeWith Conc.Clock.system role conn leftover expected secs muxers end Libp2p.Negotiate From 43a34ef5ae7f2906d48fc351f76766f8a761bc13 Mon Sep 17 00:00:00 2001 From: Wenbobobo Date: Sat, 7 Nov 2026 01:20:35 +0800 Subject: [PATCH 090/125] refactor(protocols): take keys and peer ids from libp2p/identity --- Tests/Protocols.lean | 59 +++++++++----- .../libp2p/protocols/Libp2p/Protocols.lean | 1 + .../protocols/Libp2p/Protocols/Identify.lean | 77 +++++++++---------- .../protocols/Libp2p/Protocols/Identity.lean | 42 ++++++++++ .../protocols/Libp2p/Protocols/Mock/Host.lean | 10 +-- .../Libp2p/Protocols/Mock/Identity.lean | 57 -------------- 6 files changed, 124 insertions(+), 122 deletions(-) create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Identity.lean delete mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean diff --git a/Tests/Protocols.lean b/Tests/Protocols.lean index 552eec3e..6f3be97e 100644 --- a/Tests/Protocols.lean +++ b/Tests/Protocols.lean @@ -7,7 +7,7 @@ import Streams.Error import Streams.Framing import Streams.Stream import Libp2p.Protocols -import Libp2p.Protocols.Mock.Identity +import Libp2p.Protocols.Identity import Libp2p.Protocols.Mock.Core import Libp2p.Protocols.Mock.Host @@ -17,10 +17,10 @@ import Libp2p.Protocols.Mock.Host Mirrors `vendor/specs/ping/ping.md` (32-byte echo, protocol id `/ipfs/ping/1.0.0`) and the captured go-libp2p Identify protobuf (`identify-go.json`). The suite replays the vector and drives the -pure `Identify.validate` and `Ping.checkEcho` checks. The mock key -and host stand-ins run their executable bodies here, and the address -checks run the real Multiformats decoder, so every validate outcome -appears in a check. +pure `Identify.validate` and `Ping.checkEcho` checks. The real key +fixtures and the host stand-in run here, and the address checks run the +real Multiformats decoder, so every validate outcome appears in a +check. The exchange cases cover the identify client against the responder handler, the push handler against the in-memory peerstore double, the @@ -35,7 +35,17 @@ set_option autoImplicit false namespace Tests.Protocols open Libp2p.Protocols -open Libp2p.Protocols.Mock.Identity +open Libp2p.Identity + +/-- A real Ed25519 public key for the suite. A 32-byte key needs no +native call, and the key round trips through the wire form. -/ +private def testKey (tag : UInt8) : PubKey := + PubKey.ofEd25519 ⟨ByteArray.mk (Array.replicate 32 tag), by simp [ByteArray.size]⟩ + +/-- A validated inline peer id, one per suite peer. -/ +private def testPeer (tag : UInt8) : PeerId := + PeerId.inline (ByteArray.mk #[tag]) (by + simp [ByteArray.size, Libp2p.Identity.maxInlineKeyBytes]) /-- The model decode over the byte list. -/ private def decodeModel (bs : List Nat) : Except Pb.Error (Identify × List Nat) := @@ -122,7 +132,7 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do do let pair ← mkPair let tc ← Conc.TestClock.new - let key : PubKey := ⟨.ed25519, [1, 2]⟩ + let key := testKey 1 let pid : PeerId := peerIdOfPubKey key let addr := [4, 127, 0, 0, 1, 6, 138, 43] let agent := "leanp2p" @@ -135,7 +145,7 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do r.check "identify e2e responder round trip accepted" (match res with | .ok (some (m, got)) => - got == pid && m.agentVersion == some agent && m.protocols == protocols + decide (got = pid) && m.agentVersion == some agent && m.protocols == protocols | _ => false) tc.close @@ -143,7 +153,7 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do do let pair ← mkPair let tc ← Conc.TestClock.new - let key : PubKey := ⟨.ed25519, [3, 4]⟩ + let key := testKey 3 let pid : PeerId := peerIdOfPubKey key let agent := "leanp2p" let ts ← Conc.TaskSet.new @@ -164,7 +174,7 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do let pair ← mkPair let tc ← Conc.TestClock.new let store ← Mock.Host.inMemory - let key : PubKey := ⟨.ed25519, [1, 2]⟩ + let key := testKey 1 let pid : PeerId := peerIdOfPubKey key let addr := [4, 127, 0, 0, 1, 6, 138, 43] let msg := Identify.respond key [addr] [] none "" none @@ -187,7 +197,7 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do let pair ← mkPair let tc ← Conc.TestClock.new let store ← Mock.Host.inMemory - let key : PubKey := ⟨.ed25519, [1, 2]⟩ + let key := testKey 1 let pid : PeerId := peerIdOfPubKey key let addr := [4, 127, 0, 0, 1, 6, 138, 43] let msg := Identify.respond key [addr] [] none "" none @@ -215,7 +225,7 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do let pair ← mkPair let tc ← Conc.TestClock.new let store ← Mock.Host.inMemory - let key : PubKey := ⟨.ed25519, [7, 8]⟩ + let key := testKey 7 let pid : PeerId := peerIdOfPubKey key let addr := [4, 127, 0, 0, 1, 6, 138, 43] let ts ← Conc.TaskSet.new @@ -426,6 +436,7 @@ def run (r : Runner) : IO Unit := do let wantProtocols ← jStrs "identify-go.json" j "protocols" let wantAgent ← jStr "identify-go.json" j "agentVersion" let jsonVersion ← jStr "identify-go.json" j "protocolVersion" + let wantPeerId ← jStr "identify-go.json" j "peerId" match Bytes.ofHex? respHex with | none => @@ -473,6 +484,14 @@ def run (r : Runner) : IO Unit := do -- the ed25519 key type, field 2 holds the 32 key bytes r.check "identify vector publicKey equals JSON publicKeyHex" (m.publicKey == some ([8, 1, 18, 32] ++ Bytes.toNatList pkBytes)) + -- the derivation runs the real identity package: parse the + -- serialized key, derive its peer id, compare the text form + match unmarshalPubKey? ([8, 1, 18, 32] ++ Bytes.toNatList pkBytes) with + | some k => + r.check "identify vector peer id matches the JSON peerId" + ((peerIdOfPubKey k).toString == wantPeerId) + | none => + r.check "identify vector peer id matches the JSON peerId" false r.check "identify vector protocols equal JSON protocols in order" (m.protocols == wantProtocols) r.check "identify vector agentVersion equals JSON agentVersion" @@ -490,10 +509,8 @@ def run (r : Runner) : IO Unit := do -- /ipfs/1.0.0; the suite asserts the wire value r.check "identify vector protocolVersion wire delta recorded" (m.protocolVersion == some "" && jsonVersion == "/ipfs/1.0.0") - r.skip "identify vector peer id derivation skipped" - "the peer id derivation is an opaque mock; identity #11 replaces it" - -- validate: rejection and acceptance, through the mock key and the + -- validate: rejection and acceptance, over a real key and the -- real address decoder r.check "identify over-cap payload rejected before decode" (failsWith Identify.Error.overCap @@ -506,26 +523,26 @@ def run (r : Runner) : IO Unit := do let badKeyMsg := { emptyIdentify with publicKey := some [] } r.check "identify undecodable publicKey rejected" (failsWith Identify.Error.badKey (Identify.validate (msgPayload badKeyMsg) none)) - let otherId : PeerId := ⟨[], "other"⟩ - let mismatchMsg := { emptyIdentify with publicKey := some [1, 2] } + let otherId : PeerId := testPeer 9 + let mismatchMsg := { emptyIdentify with publicKey := some (marshalPubKey (testKey 1)) } r.check "identify key mismatch rejected" (failsWith Identify.Error.keyMismatch (Identify.validate (msgPayload mismatchMsg) (some otherId))) -- `[4]` is an ip4 tag with no address bytes: the real decoder -- rejects it. The empty list is a valid empty address, so it cannot -- stand in for a bad address. - let badListen := { emptyIdentify with publicKey := some [1, 2], listenAddrs := [[4]] } + let badListen := { emptyIdentify with publicKey := some (marshalPubKey (testKey 1)), listenAddrs := [[4]] } r.check "identify invalid listen address rejected" (failsWith Identify.Error.badAddress (Identify.validate (msgPayload badListen) none)) - let badObs := { emptyIdentify with publicKey := some [1, 2], observedAddr := some [4] } + let badObs := { emptyIdentify with publicKey := some (marshalPubKey (testKey 1)), observedAddr := some [4] } r.check "identify invalid observed address rejected" (failsWith Identify.Error.badAddress (Identify.validate (msgPayload badObs) none)) -- a continuation byte with no terminator: truncated framing r.check "identify malformed protobuf rejected" (failsWith (Identify.Error.badMessage (.framing .truncated)) (Identify.validate [0x80] none)) - let good := { emptyIdentify with publicKey := some [1, 2] } - let pid : PeerId := peerIdOfPubKey ⟨.ed25519, [1, 2]⟩ + let good := { emptyIdentify with publicKey := some (marshalPubKey (testKey 1)) } + let pid : PeerId := peerIdOfPubKey (testKey 1) r.check "identify accepted with matching derivation" (succeedsWith (good, pid) (Identify.validate (msgPayload good) (some pid))) r.check "identify accepted with no expected id" diff --git a/packages/libp2p/protocols/Libp2p/Protocols.lean b/packages/libp2p/protocols/Libp2p/Protocols.lean index 87cb602c..6b30b1ca 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols.lean @@ -1,4 +1,5 @@ import Libp2p.Protocols.Constants +import Libp2p.Protocols.Identity import Libp2p.Protocols.Identify import Libp2p.Protocols.Ping diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean index 3284a1ec..f211d720 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean @@ -5,7 +5,7 @@ import Streams.Error import Streams.Framing import Streams.Stream import Multiformats.Multiaddr.Binary -import Libp2p.Protocols.Mock.Identity +import Libp2p.Protocols.Identity import Libp2p.Protocols.Mock.Host import Libp2p.Protocols.Constants @@ -56,7 +56,6 @@ structure Identify where namespace Identify -open Mock.Identity /-- The identify protocol id. -/ def protocolId : Mock.Core.ProtocolId := Constants.identifyId @@ -212,8 +211,8 @@ def validAddress (bytes : List Nat) : Bool := /-- The address checks: every listening address and the observed address, when present, must decode as a multiaddr. -/ -private def checkAddrs (m : Identify) (k : Mock.Identity.PubKey) : - Except Error (Identify × Mock.Identity.PeerId) := +private def checkAddrs (m : Identify) (k : Libp2p.Identity.PubKey) : + Except Error (Identify × Libp2p.Identity.PeerId) := if ∀ a ∈ m.listenAddrs, validAddress a then if ∀ a ∈ m.observedAddr.toList, validAddress a then .ok (m, peerIdOfPubKey k) @@ -224,8 +223,8 @@ private def checkAddrs (m : Identify) (k : Mock.Identity.PubKey) : presence and parse of `publicKey`, the derived id when the caller supplies one, and every listen and observed address through `Multiformats.Multiaddr.ofBytes?`. -/ -def validate (payload : List Nat) (expected : Option Mock.Identity.PeerId) : - Except Error (Identify × Mock.Identity.PeerId) := +def validate (payload : List Nat) (expected : Option Libp2p.Identity.PeerId) : + Except Error (Identify × Libp2p.Identity.PeerId) := if Constants.identifyCap < payload.length then .error .overCap else match (Pb.decodeMessage? payload : Except Pb.Error (Identify × List Nat)) with | .error e => .error (.badMessage e) @@ -244,8 +243,8 @@ def validate (payload : List Nat) (expected : Option Mock.Identity.PeerId) : /-- The address check accepts exactly the messages whose addresses are all valid. -/ -private theorem checkAddrsSound (m : Identify) (k : Mock.Identity.PubKey) - (m' : Identify) (pid : Mock.Identity.PeerId) +private theorem checkAddrsSound (m : Identify) (k : Libp2p.Identity.PubKey) + (m' : Identify) (pid : Libp2p.Identity.PeerId) (h : checkAddrs m k = .ok (m', pid)) : (∀ a ∈ m.listenAddrs, validAddress a) ∧ (∀ a ∈ m.observedAddr.toList, validAddress a) ∧ @@ -263,7 +262,7 @@ private theorem checkAddrsSound (m : Identify) (k : Mock.Identity.PubKey) cases h /-- An over-cap payload fails before any decode. -/ -theorem validateOverCap (payload : List Nat) (expected : Option Mock.Identity.PeerId) +theorem validateOverCap (payload : List Nat) (expected : Option Libp2p.Identity.PeerId) (h : Constants.identifyCap < payload.length) : validate payload expected = .error .overCap := by simp [validate, h] @@ -276,14 +275,14 @@ theorem decodeEmpty : /-- The empty message has no key, so `validate` rejects it. -/ theorem validateEmptyNoKey : validate [] none = .error .missingKey := rfl -/-- Acceptance, conditional on the opaque mock results. -/ +/-- Acceptance, conditional on the parsed key. -/ theorem validateAccepts (payload : List Nat) (m : Identify) (kb : List Nat) - (k : Mock.Identity.PubKey) (expected : Option Mock.Identity.PeerId) + (k : Libp2p.Identity.PubKey) (expected : Option Libp2p.Identity.PeerId) (hsize : payload.length ≤ Constants.identifyCap) (hdec : Pb.decodeMessage? (α := Identify) payload = .ok (m, [])) (hpub : m.publicKey = some kb) - (hun : Mock.Identity.unmarshalPubKey? kb = some k) - (hderive : ∀ e, expected = some e → Mock.Identity.peerIdOfPubKey k = e) + (hun : unmarshalPubKey? kb = some k) + (hderive : ∀ e, expected = some e → peerIdOfPubKey k = e) (haddrs : ∀ a ∈ m.listenAddrs, validAddress a) (hobs : ∀ a ∈ m.observedAddr.toList, validAddress a) : validate payload expected = .ok (m, peerIdOfPubKey k) := by @@ -294,19 +293,19 @@ theorem validateAccepts (payload : List Nat) (m : Identify) (kb : List Nat) · simp only [checkAddrs] rw [ite_eq_left haddrs, ite_eq_left hobs] · rename_i e - have hk : Mock.Identity.peerIdOfPubKey k = e := hderive e rfl + have hk : peerIdOfPubKey k = e := hderive e rfl rw [ite_eq_left hk] simp only [checkAddrs] rw [ite_eq_left haddrs, ite_eq_left hobs] /-- `validate` rejects a derived id that differs from the expected id. -/ theorem validateKeyMismatch (payload : List Nat) (m : Identify) (kb : List Nat) - (k : Mock.Identity.PubKey) (e : Mock.Identity.PeerId) + (k : Libp2p.Identity.PubKey) (e : Libp2p.Identity.PeerId) (hsize : payload.length ≤ Constants.identifyCap) (hdec : Pb.decodeMessage? (α := Identify) payload = .ok (m, [])) (hpub : m.publicKey = some kb) - (hun : Mock.Identity.unmarshalPubKey? kb = some k) - (hne : Mock.Identity.peerIdOfPubKey k ≠ e) : + (hun : unmarshalPubKey? kb = some k) + (hne : peerIdOfPubKey k ≠ e) : validate payload (some e) = .error .keyMismatch := by unfold validate rw [ite_eq_right (by omega)] @@ -320,13 +319,13 @@ theorem validateKeyMismatch (payload : List Nat) (m : Identify) (kb : List Nat) facts, the derivation match, and the address validity. The statement does not tie `m` to the decode of `payload`; it omits the conjunct `∃ rest, Pb.decodeMessage? (α := Identify) payload = .ok (m, rest)`. -/ -theorem validateSound (payload : List Nat) (expected : Option Mock.Identity.PeerId) - (m : Identify) (pid : Mock.Identity.PeerId) +theorem validateSound (payload : List Nat) (expected : Option Libp2p.Identity.PeerId) + (m : Identify) (pid : Libp2p.Identity.PeerId) (h : validate payload expected = .ok (m, pid)) : payload.length ≤ Constants.identifyCap ∧ - ∃ kb k, m.publicKey = some kb ∧ Mock.Identity.unmarshalPubKey? kb = some k ∧ - pid = Mock.Identity.peerIdOfPubKey k ∧ - (∀ e, expected = some e → Mock.Identity.peerIdOfPubKey k = e) ∧ + ∃ kb k, m.publicKey = some kb ∧ unmarshalPubKey? kb = some k ∧ + pid = peerIdOfPubKey k ∧ + (∀ e, expected = some e → peerIdOfPubKey k = e) ∧ (∀ a ∈ m.listenAddrs, validAddress a) ∧ (∀ a ∈ m.observedAddr.toList, validAddress a) := by unfold validate at h @@ -347,7 +346,7 @@ theorem validateSound (payload : List Nat) (expected : Option Mock.Identity.Peer cases h | some kb => simp only [hm] at h - cases hu : Mock.Identity.unmarshalPubKey? kb with + cases hu : unmarshalPubKey? kb with | none => simp only [hu] at h cases h @@ -364,7 +363,7 @@ theorem validateSound (payload : List Nat) (expected : Option Mock.Identity.Peer cases he' | some e => simp only [he] at h - by_cases hkey : Mock.Identity.peerIdOfPubKey k = e + by_cases hkey : peerIdOfPubKey k = e · rw [ite_eq_left hkey] at h have hs := checkAddrsSound m' k m pid h rcases hs with ⟨ha, ho, rfl, rfl⟩ @@ -384,7 +383,7 @@ the entries do not fit the wire model: such a message would encode to an empty one and write a zero-length frame. -/ /-- Build one identify response from the host data. -/ -def respond (key : Mock.Identity.PubKey) (addrs : List (List Nat)) +def respond (key : Libp2p.Identity.PubKey) (addrs : List (List Nat)) (protocols : List String) (observed : Option (List Nat)) (agent : String) (spr : Option (List Nat) := none) : Identify := { protocolVersion := some Constants.identifyProtocolVersion, @@ -397,7 +396,7 @@ def respond (key : Mock.Identity.PubKey) (addrs : List (List Nat)) /-- Build the response, and fail closed when its entries do not fit the wire model. -/ -def respond? (key : Mock.Identity.PubKey) (addrs : List (List Nat)) +def respond? (key : Libp2p.Identity.PubKey) (addrs : List (List Nat)) (protocols : List String) (observed : Option (List Nat)) (agent : String) (spr : Option (List Nat) := none) : Except Error Identify := let m := respond key addrs protocols observed agent spr @@ -437,8 +436,8 @@ caller supplies all the bytes at once, so a buffer that ends inside a frame is the `.truncated` framing error. An empty buffer is also `.truncated`, and the core never answers `.ok none`, while `client` answers `.ok none` on a clean end. -/ -def clientCore (bytes : List Nat) (expected : Option Mock.Identity.PeerId) : - Except Error (Option (Identify × Mock.Identity.PeerId)) := +def clientCore (bytes : List Nat) (expected : Option Libp2p.Identity.PeerId) : + Except Error (Option (Identify × Libp2p.Identity.PeerId)) := match decodeFrame? bytes with | .error e => .error e | .ok none => .error (.badMessage (.framing .truncated)) @@ -476,8 +475,8 @@ private def resetQuietly (s : Streams.Stream) : BaseIO Unit := /-- Read one response under the cap and the deadline, then validate it. `.ok none` means that the stream ended before any byte arrived. -/ def client (s : Streams.Stream) (clock : Conc.Clock) (timeoutMs : Nat) - (expected : Option Mock.Identity.PeerId) : - Conc.Async (Except Error (Option (Identify × Mock.Identity.PeerId))) := do + (expected : Option Libp2p.Identity.PeerId) : + Conc.Async (Except Error (Option (Identify × Libp2p.Identity.PeerId))) := do match ← Conc.withinDeadline clock timeoutMs (resetQuietly s) (readFrame s) with | .error .deadlineExpired => return .error .timeout | .error .closed => return .error (.stream .closed) @@ -497,7 +496,7 @@ def mapIdentifyError : Error → Mock.Core.Error An empty read leaves the peerstore unchanged. This is a delta from go, whose `consumeMessage` runs on the empty message and clears the peer's protocols. -/ -def push (store : Mock.Host.Peerstore) (expected : Mock.Identity.PeerId) +def push (store : Mock.Host.Peerstore) (expected : Libp2p.Identity.PeerId) (clock : Conc.Clock) (timeoutMs : Nat) (s : Streams.Stream) : Conc.Async (Except Error Unit) := do match ← Conc.withinDeadline clock timeoutMs (resetQuietly s) (readFrame s) with @@ -513,7 +512,7 @@ def push (store : Mock.Host.Peerstore) (expected : Mock.Identity.PeerId) return .ok () /-- The push handler value. -/ -def pushHandler (store : Mock.Host.Peerstore) (expected : Mock.Identity.PeerId) +def pushHandler (store : Mock.Host.Peerstore) (expected : Libp2p.Identity.PeerId) (clock : Conc.Clock) (timeoutMs : Nat) : Mock.Core.StreamHandler := { id := pushId, handle := fun s => ExceptT.mk ((Except.mapError mapIdentifyError) <$> @@ -522,7 +521,7 @@ def pushHandler (store : Mock.Host.Peerstore) (expected : Mock.Identity.PeerId) /-- Write one length-delimited message and half-close. The responder and the push sender share this body: both announce the same record, and only the protocol id of the stream differs. -/ -private def writeRecord (key : Mock.Identity.PubKey) (addrs : List (List Nat)) +private def writeRecord (key : Libp2p.Identity.PubKey) (addrs : List (List Nat)) (protocols : List String) (observed : Option (List Nat)) (agent : String) (spr : Option (List Nat)) (s : Streams.Stream) : Conc.Async (Except Mock.Core.Error Unit) := do @@ -541,7 +540,7 @@ private def writeRecord (key : Mock.Identity.PubKey) (addrs : List (List Nat)) /-- The responder body. Write one length-delimited message and half-close. -/ -def respondTo (key : Mock.Identity.PubKey) (addrs : List (List Nat)) +def respondTo (key : Libp2p.Identity.PubKey) (addrs : List (List Nat)) (protocols : List String) (observed : Option (List Nat)) (agent : String) (spr : Option (List Nat) := none) (s : Streams.Stream) : Conc.Async (Except Mock.Core.Error Unit) := @@ -557,14 +556,14 @@ skips a connection that already carries the current record (the spike's `vendor/go-libp2p/p2p/protocol/identify/id.go`, the `loop` and `sendPushes` functions). This package sends one record per call and starts no timer. -/ -def pushTo (key : Mock.Identity.PubKey) (addrs : List (List Nat)) +def pushTo (key : Libp2p.Identity.PubKey) (addrs : List (List Nat)) (protocols : List String) (observed : Option (List Nat)) (agent : String) (spr : Option (List Nat) := none) (s : Streams.Stream) : Conc.Async (Except Mock.Core.Error Unit) := writeRecord key addrs protocols observed agent spr s /-- The responder handler value. -/ -def respondHandler (key : Mock.Identity.PubKey) (addrs : List (List Nat)) +def respondHandler (key : Libp2p.Identity.PubKey) (addrs : List (List Nat)) (protocols : List String) (observed : Option (List Nat)) (agent : String) (spr : Option (List Nat) := none) : Mock.Core.StreamHandler := { id := protocolId, @@ -593,7 +592,7 @@ private theorem decodeFrame?Encode /-- The client validates the payload bytes of the frame that it read. -/ theorem clientCoreFrame (f : Streams.Framing.Frame .uvarint Constants.identifyCap) - (rest : List Nat) (expected : Option Mock.Identity.PeerId) + (rest : List Nat) (expected : Option Libp2p.Identity.PeerId) (h : 0 < f.payload.length) : clientCore (Streams.Framing.Frame.encode f ++ rest) expected = some <$> validate f.payload expected := by @@ -617,7 +616,7 @@ private theorem decodeFrame?OverCap (len : Nat) (rest : List Nat) /-- `clientCore` rejects a length over the cap before it reads the body. The statement has no premise about the bytes after the prefix. -/ theorem clientCoreOverCap (len : Nat) (rest : List Nat) - (expected : Option Mock.Identity.PeerId) + (expected : Option Libp2p.Identity.PeerId) (hlen : len ≤ Streams.Framing.Prefix.maxLen .uvarint) (hcap : Constants.identifyCap < len) : clientCore (Streams.Framing.encodeLength .uvarint len ++ rest) expected = @@ -635,7 +634,7 @@ private theorem decodeFrame?EncodeLengthZero : simp [Bytes.take?] /-- The zero-length rule on `clientCore`, the spike's rule. -/ -theorem clientCoreEmptyFrame (expected : Option Mock.Identity.PeerId) : +theorem clientCoreEmptyFrame (expected : Option Libp2p.Identity.PeerId) : clientCore (Streams.Framing.encodeLength .uvarint 0) expected = .error .emptyFrame := by unfold clientCore diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Identity.lean b/packages/libp2p/protocols/Libp2p/Protocols/Identity.lean new file mode 100644 index 00000000..2a2bd3e9 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Identity.lean @@ -0,0 +1,42 @@ +import Bytes +import Libp2p.Identity + +/-! +# The boundary to `libp2p/identity` + +Identify carries its fields over the `List Nat` byte model, and the +identity package speaks `ByteArray`. This module holds the three +conversions: parse the `publicKey` field, derive the peer id of a key, +and serialize a key back to the wire. + +`libp2p/identity` landed as W5.1, so the conversions call the real +package. The module replaces the earlier identity mock, and it keeps the +signatures the pure half already names. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols + +/-- Parse the `publicKey` field. The field is the serialized `PublicKey` +protobuf (`PublicKey.unmarshal?`). -/ +def unmarshalPubKey? (bs : List Nat) : Option Libp2p.Identity.PubKey := + (Libp2p.Identity.PubKey.unmarshal? (Bytes.ofNatList bs)).toOption + +/-- The peer id of a key. A key at the inline threshold gives an inline +id, and a longer key takes the facade's SHA-256, which fails only on a +crypto fault; the error arm keeps the identity of the empty key. The +validate path only reaches a key that parsed, so the error arm cannot +change an accepted record. -/ +def peerIdOfPubKey (k : Libp2p.Identity.PubKey) : Libp2p.Identity.PeerId := + match Libp2p.Identity.PeerId.ofPubKey k with + | .ok p => p + | .error _ => + Libp2p.Identity.PeerId.inline ByteArray.empty (by + simp [ByteArray.size, Libp2p.Identity.maxInlineKeyBytes]) + +/-- Serialize a key to the wire form the `publicKey` field carries. -/ +def marshalPubKey (k : Libp2p.Identity.PubKey) : List Nat := + Bytes.toNatList k.marshal + +end Libp2p.Protocols diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean index 295aa6f2..42ab3615 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean @@ -1,6 +1,6 @@ import Std import Conc -import Libp2p.Protocols.Mock.Identity +import Libp2p.Identity /-! # Host peerstore mock @@ -17,7 +17,7 @@ set_option autoImplicit false namespace Libp2p.Protocols.Mock.Host -open Libp2p.Protocols.Mock.Identity +open Libp2p.Identity /-- The peerstore seam the push handler updates: addresses by peer, deduplicated at add. -/ @@ -36,12 +36,12 @@ opaque inMemory : IO Peerstore := do let table ← Conc.OnceRef.new ([] : List (PeerId × List (List Nat))) let addAddrs : PeerId → List (List Nat) → IO Unit := fun p addrs => table.update fun cur => - let old := (cur.find? (fun e => e.1 == p)).map (·.2) |>.getD [] + let old := (cur.find? (fun e => decide (e.1 = p))).map (·.2) |>.getD [] let merged := (old ++ addrs).eraseDups - (p, merged) :: cur.filter (fun e => !(e.1 == p)) + (p, merged) :: cur.filter (fun e => !(decide (e.1 = p))) let addrsOf : PeerId → IO (List (List Nat)) := fun p => do let cur ← table.get - return (cur.find? (fun e => e.1 == p)).map (·.2) |>.getD [] + return (cur.find? (fun e => decide (e.1 = p))).map (·.2) |>.getD [] return { addAddrs, addrsOf } end Libp2p.Protocols.Mock.Host diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean deleted file mode 100644 index bcc689fd..00000000 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Identity.lean +++ /dev/null @@ -1,57 +0,0 @@ -import Std - -/-! -# Identity mock - -`Libp2p.Identity` (issue #11) is not merged. This mock declares the key -and peer id facts of `packages/libp2p/identity/docs/ARCHITECTURE.md`. -That doc freezes `KeyType` and what a key and a peer id hold, and it -leaves the signatures open. This mock fixes minimal signatures. -`PubKey` and `PeerId` drop their proof fields. The real identity package -replaces this module at integration. - -The stand-in bodies are executable, so the suite can drive both outcomes -of `unmarshalPubKey?`. The theorems take these results as premises, so a -replacement with the same signatures keeps them. --/ - -set_option autoImplicit false - -namespace Libp2p.Protocols.Mock.Identity - -/-- The closed set of key types: rsa (0), ed25519 (1), secp256k1 (2), -ecdsa (3). -/ -inductive KeyType where - | rsa | ed25519 | secp256k1 | ecdsa - deriving DecidableEq, BEq, Repr - -/-- A public key: its type and its data bytes. -/ -structure PubKey where - keyType : KeyType - data : List Nat - deriving DecidableEq, Repr - -/-- A peer id: the derived bytes and the text form. -/ -structure PeerId where - bytes : List Nat - text : String - deriving DecidableEq, BEq, Repr - -/-- Executable stand-in: a nonempty buffer parses as an ed25519 key, -an empty buffer does not parse. -/ -opaque unmarshalPubKey? (bs : List Nat) : Option PubKey := - if bs.isEmpty then none else some ⟨.ed25519, bs⟩ - -/-- Executable stand-in: the derived id carries the key bytes. -/ -opaque peerIdOfPubKey (k : PubKey) : PeerId := - ⟨k.data, ""⟩ - -/-- Executable stand-in: the raw key data. The mock `unmarshalPubKey?` -parses any nonempty buffer as `⟨.ed25519, buffer⟩`, and `peerIdOfPubKey` -reads only the buffer, so the round trip holds for a nonempty ed25519 -stand-in. The real identity package writes the serialized PublicKey -protobuf (peer-ids spec); that body replaces this one at integration. -/ -opaque marshalPubKey (k : PubKey) : List Nat := - k.data - -end Libp2p.Protocols.Mock.Identity From 14efcadb8fdfd32a26f4a3a9757fd4cfff43e3a3 Mon Sep 17 00:00:00 2001 From: Wenbobobo Date: Sat, 7 Nov 2026 10:41:04 +0800 Subject: [PATCH 091/125] test(protocols): run the interop adapter suite --- Tests/Main.lean | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/Main.lean b/Tests/Main.lean index 1c007d57..0327cbe4 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -7,6 +7,7 @@ import Tests.Gossipsub.Score import Tests.Gossipsub.Rpc import Tests.Gossipsub.Node import Tests.Gossipsub.Wire +import Tests.Gossipsub.Interop import Tests.Concurrency import Tests.Protobuf import Tests.Bytes @@ -47,6 +48,7 @@ def main : IO UInt32 := do Tests.Gossipsub.Rpc.run r Tests.Gossipsub.Node.run r Tests.Gossipsub.Wire.run r + Tests.Gossipsub.Interop.run r Tests.Concurrency.run r Tests.Protobuf.run r Bytes.runTests r From 90ab75efd5c82ece64d15b2176e426c01cc21f2f Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:23:34 +0800 Subject: [PATCH 092/125] fix(negotiate): read handshake bytes through secure connection --- Tests/Upgrade.lean | 11 ++++++++--- .../libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Tests/Upgrade.lean b/Tests/Upgrade.lean index 91134525..bdaded60 100644 --- a/Tests/Upgrade.lean +++ b/Tests/Upgrade.lean @@ -131,7 +131,12 @@ private def secValue (overread : Bool) (seedRef : Conc.OnceRef (Option ByteArray | none => throw (.closed "test handshake over-read found the connection closed") | some chunk => pure chunk else pure ByteArray.empty - return { conn := conn, + let pending ← Conc.OnceRef.new (rest ++ over) + let secureConn : Streams.Connection := { conn with + read := do + let bytes ← (pending.updateGet (fun b => (b, ByteArray.empty)) : BaseIO ByteArray) + if bytes.isEmpty then conn.read else pure (some bytes) } + return { conn := secureConn, localPeer := if role = .initiator then dialerId else listenerId, remotePeer := if role = .initiator then listenerId else dialerId, leftover := rest ++ over, @@ -281,8 +286,8 @@ private def checkNegotiationLeftover (r : Runner) : Async Unit := do (← sideConn iside).close (← sideConn rside).close -/-- The handshake over-reads the muxer header chunk, and the muxer -stage still selects from the `SecureConn` leftover. -/ +/-- The secure reader delivers bytes the handshake over-read before +the muxer stage reads the connection. -/ private def checkHandshakeLeftover (r : Runner) : Async Unit := do let (iside, rside) ← newPair let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean index 1f8c34c2..9161fcfa 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean @@ -500,7 +500,7 @@ private def upgradeCore (role : Mock.Core.Role) (conn : Streams.Connection) | .error e => return .error e | .ok () => let muxReader := Streams.BufReader.new sc.conn - match ← negotiate role muxReader sc.conn sc.leftover (muxers.map (·.id)) with + match ← negotiate role muxReader sc.conn ByteArray.empty (muxers.map (·.id)) with | .error e => return .error e | .ok stage2 => match muxers.find? (fun m => decide (m.id = stage2.proto)) with From 6cf466a3e9484a567179a7ce81f56a3ac21a33f7 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:26:01 +0800 Subject: [PATCH 093/125] fix(security): use the merged core seam --- Tests/Interop/Noise.lean | 8 ++++---- Tests/Noise.lean | 20 +++++++++---------- .../security/Libp2p/Security/Constants.lean | 6 +++--- .../security/Libp2p/Security/Handshake.lean | 6 +++--- .../security/Libp2p/Security/Noise.lean | 18 ++++++++--------- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Tests/Interop/Noise.lean b/Tests/Interop/Noise.lean index c70869ea..ee29ccc1 100644 --- a/Tests/Interop/Noise.lean +++ b/Tests/Interop/Noise.lean @@ -69,7 +69,7 @@ def keyOf (s : ByteArray) : Async PrivKey := orFail "identity" (PrivKey.ofData? .ed25519 s |>.mapError fun e => (repr e).pretty) /-- Our seam value, and the peer id the go side must prove. -/ -def setup : Async (Mock.Core.SecurityProtocol × PeerId) := do +def setup : Async (Libp2p.Core.SecurityProtocol × PeerId) := do let cfg ← orFail "static key" ((← (Noise.Config.generate (← keyOf leanSeed)).run).mapError fun e => (repr e).pretty) let goKey ← keyOf goSeed @@ -78,7 +78,7 @@ def setup : Async (Mock.Core.SecurityProtocol × PeerId) := do return (Noise.securityProtocol cfg, want) /-- Read until `n` bytes have arrived, or the end. -/ -def readN (sc : Mock.Core.SecureConn) (n : Nat) : Async ByteArray := do +def readN (sc : Libp2p.Core.SecureConn) (n : Nat) : Async ByteArray := do let mut acc := ByteArray.empty while acc.size < n do match ← sc.read with @@ -87,7 +87,7 @@ def readN (sc : Mock.Core.SecureConn) (n : Nat) : Async ByteArray := do return acc /-- The secure connection's answer, as a failure when it is an error. -/ -def joinOk (sc : Mock.Core.SecureConn) : Async Unit := do +def joinOk (sc : Libp2p.Core.SecureConn) : Async Unit := do orFail "join" (← sc.join.run) /-- The listener side: the responder, then the echo, then the peer's @@ -137,4 +137,4 @@ def main (args : List String) : IO UInt32 := do return 0 catch e => IO.eprintln s!"{e}" - return 1 + return 1 \ No newline at end of file diff --git a/Tests/Noise.lean b/Tests/Noise.lean index 14bdfedd..e00678e6 100644 --- a/Tests/Noise.lean +++ b/Tests/Noise.lean @@ -379,7 +379,7 @@ section Sim open Conc (Async OnceRef) open Streams (Connection) -open Libp2p.Security.Mock.Core (SecureConn SecurityProtocol) +open Libp2p.Core (SecureConn SecurityProtocol) /-- An address, or a failed run. -/ private def ma (s : String) : Async Multiformats.Multiaddr := do @@ -424,9 +424,9 @@ private def mapWrites {σ : Type} (c : Connection) (init : σ) /-- Run both handshakes: the responder as a task, the initiator here. The await joins the task (§4.1, rule 2). -/ private def both (pI pR : SecurityProtocol) (c s : Connection) (leftR : ByteArray := .empty) : - Async (Except Mock.Core.Error SecureConn × Except Mock.Core.Error SecureConn) := do + Async (Except Libp2p.Core.Error SecureConn × Except Libp2p.Core.Error SecureConn) := do let t ← (Conc.Wrap.spawn (pR.handshake .responder s leftR).run : - BaseIO (Task (Except IO.Error (Except Mock.Core.Error SecureConn)))) + BaseIO (Task (Except IO.Error (Except Libp2p.Core.Error SecureConn)))) let ri ← (pI.handshake .initiator c .empty).run match ← Conc.Wrap.await t with | .ok rr => return (ri, rr) @@ -442,7 +442,7 @@ private def readN (sc : SecureConn) (n : Nat) : Async ByteArray := do return acc /-- The secure connection of a result, or a failed run. -/ -private def secured (where_ : String) : Except Mock.Core.Error SecureConn → Async SecureConn +private def secured (where_ : String) : Except Libp2p.Core.Error SecureConn → Async SecureConn | .ok sc => return sc | .error e => throw <| IO.userError s!"{where_}: {e}" @@ -576,7 +576,7 @@ private def simLeftover (r : Runner) : Async Unit := do -- The negotiation's stand-in: it reads message 0 and hands it on. let some m0 ← s.read | throw <| IO.userError s!"{name}: no message 0" (pR.handshake .responder s m0).run) : - BaseIO (Task (Except IO.Error (Except Mock.Core.Error SecureConn)))) + BaseIO (Task (Except IO.Error (Except Libp2p.Core.Error SecureConn)))) let ci ← secured name (← (pI.handshake .initiator c' .empty).run) ci.write "early".toUTF8 let cr ← match ← Conc.Wrap.await t with @@ -590,14 +590,14 @@ private def simLeftover (r : Runner) : Async Unit := do /-- Start one handshake as a task. The caller awaits it, which joins it (§4.1, rule 2). -/ -private def spawnHandshake (p : SecurityProtocol) (role : Mock.Core.Role) (c : Connection) +private def spawnHandshake (p : SecurityProtocol) (role : Libp2p.Core.Role) (c : Connection) (left : ByteArray := .empty) : - BaseIO (Task (Except IO.Error (Except Mock.Core.Error SecureConn))) := + BaseIO (Task (Except IO.Error (Except Libp2p.Core.Error SecureConn))) := Conc.Wrap.spawn (p.handshake role c left).run /-- The answer of a handshake task, or a failed run. -/ -private def awaitHandshake (t : Task (Except IO.Error (Except Mock.Core.Error SecureConn))) : - Async (Except Mock.Core.Error SecureConn) := do +private def awaitHandshake (t : Task (Except IO.Error (Except Libp2p.Core.Error SecureConn))) : + Async (Except Libp2p.Core.Error SecureConn) := do match ← Conc.Wrap.await t with | .ok rr => return rr | .error e => throw e @@ -808,4 +808,4 @@ def Noise.run (r : Runner) : IO Unit := do hostile r sim r -end Tests +end Tests \ No newline at end of file diff --git a/packages/libp2p/security/Libp2p/Security/Constants.lean b/packages/libp2p/security/Libp2p/Security/Constants.lean index 0e7a6a2d..6d6b9766 100644 --- a/packages/libp2p/security/Libp2p/Security/Constants.lean +++ b/packages/libp2p/security/Libp2p/Security/Constants.lean @@ -1,5 +1,5 @@ import Crypto -import Libp2p.Security.Mock.Core +import Libp2p.Core /-! # The Noise constants @@ -18,7 +18,7 @@ set_option autoImplicit false namespace Libp2p.Security.Noise /-- The id that multistream-select negotiates for Noise. -/ -def protocolId : Mock.Core.ProtocolId := ⟨"/noise"⟩ +def protocolId : Libp2p.Core.ProtocolId := ⟨"/noise"⟩ /-- The Noise protocol name, the one cipher suite of the spec ("Noise Protocol Name"). It is exactly 32 bytes long, so the symmetric state @@ -82,4 +82,4 @@ def identitySigField : Nat := 2 /-- `optional NoiseExtensions extensions = 4`. -/ def extensionsField : Nat := 4 -end Libp2p.Security.Noise +end Libp2p.Security.Noise \ No newline at end of file diff --git a/packages/libp2p/security/Libp2p/Security/Handshake.lean b/packages/libp2p/security/Libp2p/Security/Handshake.lean index 2dab1885..3eeaf9e3 100644 --- a/packages/libp2p/security/Libp2p/Security/Handshake.lean +++ b/packages/libp2p/security/Libp2p/Security/Handshake.lean @@ -3,7 +3,7 @@ import Bytes import Proto import Streams.Framing import Libp2p.Identity -import Libp2p.Security.Mock.Core +import Libp2p.Core import Libp2p.Security.Constants /-! @@ -85,7 +85,7 @@ namespace Libp2p.Security.Noise -- `Role` comes from the mock of W5.2 (RULES.md, "Working ahead of the -- tree"). -open Bytes Libp2p.Security.Mock.Core +open Bytes Libp2p.Core /-! ## Data types @@ -543,4 +543,4 @@ def step (st : State) (ev : Event) : Except Error (State × List Action) := do unless st.phase.writesLeft && st.payload.isNone do throw .unexpectedEvent State.tryWrite { st with payload := some p } -end Libp2p.Security.Noise +end Libp2p.Security.Noise \ No newline at end of file diff --git a/packages/libp2p/security/Libp2p/Security/Noise.lean b/packages/libp2p/security/Libp2p/Security/Noise.lean index b0c62f9e..a92f2ea6 100644 --- a/packages/libp2p/security/Libp2p/Security/Noise.lean +++ b/packages/libp2p/security/Libp2p/Security/Noise.lean @@ -1,7 +1,7 @@ import Crypto import Streams import Libp2p.Identity -import Libp2p.Security.Mock.Core +import Libp2p.Core import Libp2p.Security.Constants import Libp2p.Security.Handshake import Libp2p.Security.Payload @@ -57,7 +57,7 @@ set_option autoImplicit false namespace Libp2p.Security.Noise -- The seam types come from the mock of W5.2 (RULES.md, "Working ahead --- of the tree"). At the integration, `Mock.Core` reads `Libp2p.Core` +-- of the tree"). The package now imports `Libp2p.Core` -- again. open Libp2p.Identity open Conc (Async) @@ -71,7 +71,7 @@ The key MUST then be used to validate"), and `verify` gates and decodes the payload on the way. The end of the connection inside the handshake is `closed`; every other failure breaks the spec, and is `protocol`. The detail is the rendered Noise error. -/ -def Error.toCore (e : Error) : Mock.Core.Error := +def Error.toCore (e : Error) : Libp2p.Core.Error := let detail := s!"noise: {repr e}" match e with | .payloadTooLarge _ | .payload _ | .missingIdentity | .identity _ | .badSignature => @@ -128,7 +128,7 @@ private def performAll (conn : Connection) (l : Learned) (acts : List Action) : /-- The handshake shell. The answer holds the completion, the checked payload, and the reader that holds the bytes past the last message. -/ -private def runHandshake (cfg : Config) (rng : Crypto.Rng) (role : Mock.Core.Role) +private def runHandshake (cfg : Config) (rng : Crypto.Rng) (role : Libp2p.Core.Role) (conn : Connection) (leftover : ByteArray) : ExceptT Error Async (Completion × Verified × BufReader) := do let st0 ← ofExcept (State.init role .empty cfg.static) @@ -175,8 +175,8 @@ private def runHandshake (cfg : Config) (rng : Crypto.Rng) (role : Mock.Core.Rol /-- The handshake of a seam value: the shell, then the transport over the cipher states. A failure closes the connection before the answer (root §3.5). -/ -private def handshakeWith (cfg : Config) (rng : Crypto.Rng) (role : Mock.Core.Role) - (conn : Connection) (leftover : ByteArray) : ExceptT Mock.Core.Error Async Mock.Core.SecureConn := +private def handshakeWith (cfg : Config) (rng : Crypto.Rng) (role : Libp2p.Core.Role) + (conn : Connection) (leftover : ByteArray) : ExceptT Libp2p.Core.Error Async Libp2p.Core.SecureConn := ExceptT.mk do let shaken ← try (runHandshake cfg rng role conn leftover).run catch e => do conn.closeQuietly; throw e @@ -204,7 +204,7 @@ private def handshakeWith (cfg : Config) (rng : Crypto.Rng) (role : Mock.Core.Ro /-- The production value: Noise with the configured keys, and the ephemeral keys from `Rng.system`. -/ -def securityProtocol (cfg : Config) : Mock.Core.SecurityProtocol where +def securityProtocol (cfg : Config) : Libp2p.Core.SecurityProtocol where id := protocolId handshake := handshakeWith cfg Crypto.Rng.system @@ -212,10 +212,10 @@ def securityProtocol (cfg : Config) : Mock.Core.SecurityProtocol where its ephemeral key from a fresh `Rng.seeded seed`, so with an Ed25519 identity, whose signature is deterministic (§3.6), the same peer behavior gives the same bytes on the wire. -/ -def fixedKeyProtocol (cfg : Config) (seed : ByteArray) : Mock.Core.SecurityProtocol where +def fixedKeyProtocol (cfg : Config) (seed : ByteArray) : Libp2p.Core.SecurityProtocol where id := protocolId handshake role conn leftover := ExceptT.mk do let rng ← Crypto.Rng.seeded seed (handshakeWith cfg rng role conn leftover).run -end Libp2p.Security.Noise +end Libp2p.Security.Noise \ No newline at end of file From 4b9360d012425b8ee135f10a94fa27ce59f3f48c Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:28:00 +0800 Subject: [PATCH 094/125] fix(security): remove the core mock --- .../security/Libp2p/Security/Mock/Core.lean | 84 ------------------- 1 file changed, 84 deletions(-) delete mode 100644 packages/libp2p/security/Libp2p/Security/Mock/Core.lean diff --git a/packages/libp2p/security/Libp2p/Security/Mock/Core.lean b/packages/libp2p/security/Libp2p/Security/Mock/Core.lean deleted file mode 100644 index 4315ee89..00000000 --- a/packages/libp2p/security/Libp2p/Security/Mock/Core.lean +++ /dev/null @@ -1,84 +0,0 @@ -import Conc -import Streams -import Libp2p.Identity - -/-! -# Mock: the surface of `libp2p/core` that Noise uses - -W5.2 (`libp2p/core`, etheorem/leanp2p#15) has not merged, so this -package builds against a mock of its frozen surface (RULES.md, "Working -ahead of the tree"). The types are copies of the core ARCHITECTURE, -"Public surface": `ProtocolId`, `Role`, `Core.Error` with its four arms, -and the security seam, `SecureConn` and `SecurityProtocol`, with the -handshake signature of root §4.9. The mock imports nothing from the -real package, and it states no axiom. `PeerId` comes from the merged -`libp2p/identity`. - -At the integration, the package imports `Libp2p.Core`, drops -`open Libp2p.Security.Mock`, and the module goes. --/ - -set_option autoImplicit false - -namespace Libp2p.Security.Mock.Core - -open Conc Streams Libp2p.Identity - -/-- A protocol id: the exact text that multistream-select carries -(§3.4). -/ -structure ProtocolId where - /-- The id text, exactly as it travels on the wire. -/ - toString : String - deriving DecidableEq, Hashable, Ord, Repr - -instance : ToString ProtocolId := ⟨ProtocolId.toString⟩ - -/-- The two directions of a connection (§3.4). The initiator dialed, -and the responder accepted. -/ -inductive Role where - | initiator - | responder - deriving DecidableEq, Repr, Hashable - -/-- The coarse seam error (§3.5, §4.9). Each arm carries the rendered -error of the implementation. -/ -inductive Error where - | protocol (detail : String) - | authentication (detail : String) - | timeout (detail : String) - | closed (detail : String) - deriving DecidableEq, Repr, Hashable - -/-- The log line of the error: the kind, then the rendered detail. -/ -def Error.render : Error → String - | .protocol d => s!"protocol error: {d}" - | .authentication d => s!"authentication failure: {d}" - | .timeout d => s!"timeout: {d}" - | .closed d => s!"closed: {d}" - -instance : ToString Error := ⟨Error.render⟩ - -/-- A connection after a security handshake: the secure `read`, -`write`, and `close`, the two peer ids the handshake proved, the -ciphertext the handshake over-read (§4.7), and the completion. -/ -structure SecureConn extends Connection where - /-- The peer id of our identity key. -/ - localPeer : PeerId - /-- The peer id the peer proved in the handshake. -/ - remotePeer : PeerId - /-- The ciphertext the handshake read past its last message. -/ - leftover : ByteArray - /-- Wait until the secure connection has ended, and answer how it - ended. -/ - join : ExceptT Error Async Unit - -/-- The security seam: one security protocol that multistream-select -can negotiate. -/ -structure SecurityProtocol where - /-- The id that multistream-select negotiates, such as `/noise`. -/ - id : ProtocolId - /-- Run the handshake in the given role over the connection. The - `ByteArray` holds the bytes the negotiation over-read. -/ - handshake : Role → Connection → ByteArray → ExceptT Error Async SecureConn - -end Libp2p.Security.Mock.Core From 11179b09c271e15b761a10a003a30ce069139b4e Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:30:29 +0800 Subject: [PATCH 095/125] fix(negotiate): use the merged core seam --- Tests/Negotiate.lean | 14 +-- Tests/Upgrade.lean | 106 +++++++++--------- .../libp2p/negotiate/Libp2p/Negotiate.lean | 5 +- .../negotiate/Libp2p/Negotiate/Constants.lean | 9 +- .../negotiate/Libp2p/Negotiate/Message.lean | 24 ++-- .../negotiate/Libp2p/Negotiate/Step.lean | 62 +++++----- .../negotiate/Libp2p/Negotiate/Upgrade.lean | 74 ++++++------ 7 files changed, 146 insertions(+), 148 deletions(-) diff --git a/Tests/Negotiate.lean b/Tests/Negotiate.lean index 278caee0..ce48446f 100644 --- a/Tests/Negotiate.lean +++ b/Tests/Negotiate.lean @@ -40,13 +40,13 @@ private def writesOf (actions : List Action) : List (List Nat) := | .select .. => none /-- The selects of an action list, in order. -/ -private def selectsOf (actions : List Action) : List (Mock.Core.ProtocolId × List Nat) := +private def selectsOf (actions : List Action) : List (Libp2p.Core.ProtocolId × List Nat) := actions.filterMap fun | .select p r => some (p, r) | .write _ => none /-- The responder's initial state. -/ -private def responderState (supported : List Mock.Core.ProtocolId) : State := +private def responderState (supported : List Libp2p.Core.ProtocolId) : State := { role := .responder, supported := supported, proposals := [], buffer := [], phase := .headerPending } /-- The initiator's initial state. -/ @@ -136,7 +136,7 @@ def run (r : Runner) : IO Unit := do let protos ← Tests.jStrs "mss-ls.json" reply "protocols" let bodyHex ← Tests.jStr "mss-ls.json" reply "bodyHex" let bodyBytes ← hexOf bodyHex - let ids : List Mock.Core.ProtocolId := protos.map fun s => ⟨s⟩ + let ids : List Libp2p.Core.ProtocolId := protos.map fun s => ⟨s⟩ r.check "mss ls body encode" (lsBody ids == bodyBytes) r.check "mss ls reply encode" (encodeLs ids == Varint.encode (boundedVarint bodyBytes.length) ++ bodyBytes) @@ -158,8 +158,8 @@ def run (r : Runner) : IO Unit := do let (i2rN, r2iN) ← traceHexes "mss-go-trace.json" naTrace let lazyTrace ← Tests.jArr "mss-go-trace.json" goj "lazyTrace" let (i2rL, r2iL) ← traceHexes "mss-go-trace.json" lazyTrace - let kadId : Mock.Core.ProtocolId := ⟨"/ipfs/kad/1.0.0"⟩ - let nopeId : Mock.Core.ProtocolId := ⟨"/nope/9.9.9"⟩ + let kadId : Libp2p.Core.ProtocolId := ⟨"/ipfs/kad/1.0.0"⟩ + let nopeId : Libp2p.Core.ProtocolId := ⟨"/nope/9.9.9"⟩ match replay (responderState [kadId]) (← i2rF.mapM hexOf) with | .error _ => r.check "mss go full trace" false @@ -206,7 +206,7 @@ def run (r : Runner) : IO Unit := do r.check "mss step responder unknown id writes na and stays negotiating" (s'.phase = .negotiating ∧ actions == [.write (encodeMsg (Reply.toMsg .na))]) - let sup : List Mock.Core.ProtocolId := [⟨"/ipfs/kad/1.0.0"⟩, ⟨"/ipfs/id/1.0.0"⟩] + let sup : List Libp2p.Core.ProtocolId := [⟨"/ipfs/kad/1.0.0"⟩, ⟨"/ipfs/id/1.0.0"⟩] let sSup : State := { role := .responder, supported := sup, proposals := [], buffer := [], phase := .negotiating } match step sSup (.bytes (encodeText "ls")) with | .error _ => r.check "mss step responder ls writes ls reply and stays negotiating" false @@ -256,4 +256,4 @@ def run (r : Runner) : IO Unit := do r.skip "mss generic model pin" "needs the ByteArray adapter, which is not built; the suite runs the model decoder, which is the production path" -end Tests.Negotiate +end Tests.Negotiate \ No newline at end of file diff --git a/Tests/Upgrade.lean b/Tests/Upgrade.lean index bdaded60..b794ecb2 100644 --- a/Tests/Upgrade.lean +++ b/Tests/Upgrade.lean @@ -108,7 +108,7 @@ hands the unconsumed bytes to the next stage. `overread` adds one read whose chunk joins the leftover, so a check can force the hand-over. `seedRef` records the seed the negotiation handed over. -/ private def secValue (overread : Bool) (seedRef : Conc.OnceRef (Option ByteArray)) : - Mock.Core.SecurityProtocol where + Libp2p.Core.SecurityProtocol where id := ⟨"/test/plain/1.0.0"⟩ handshake := fun role conn seed => do seedRef.set (some seed) @@ -136,25 +136,25 @@ private def secValue (overread : Bool) (seedRef : Conc.OnceRef (Option ByteArray read := do let bytes ← (pending.updateGet (fun b => (b, ByteArray.empty)) : BaseIO ByteArray) if bytes.isEmpty then conn.read else pure (some bytes) } - return { conn := secureConn, + return { toConnection := secureConn, localPeer := if role = .initiator then dialerId else listenerId, remotePeer := if role = .initiator then listenerId else dialerId, leftover := rest ++ over, - close := Streams.Connection.closeQuietly conn } + join := pure () } /-- The test muxer value. It replaces the yamux milestone (#17, unmerged). `open` records its leftover argument, the opened id, and a ran flag in shared cells, and returns a no-op session. -/ -private def muxValue (id : Mock.Core.ProtocolId) (ranRef : Conc.OnceRef Bool) +private def muxValue (id : Libp2p.Core.ProtocolId) (ranRef : Conc.OnceRef Bool) (leftRef : Conc.OnceRef (Option ByteArray)) - (openRef : Conc.OnceRef (Option Mock.Core.ProtocolId)) : Mock.Core.Muxer where + (openRef : Conc.OnceRef (Option Libp2p.Core.ProtocolId)) : Libp2p.Core.Muxer where id := id «open» := fun _ _ leftover => do ranRef.set true leftRef.set (some leftover) openRef.set (some id) - return { openStream := (throw (.protocol "the test session opens no stream") : - ExceptT Mock.Core.Error Conc.Async Streams.Stream), + return { openStream := fun _ => (throw (.protocol "the test session opens no stream") : + ExceptT Libp2p.Core.Error Conc.Async Streams.Stream), accept := pure none, close := pure (), join := pure () } @@ -199,7 +199,7 @@ private def recordWrites (conn : Streams.Connection) (ref : Conc.OnceRef (List B /-- The answer of one upgrade run. -/ private abbrev UpAnswer := - Option (Except Mock.Core.Error (Mock.Core.MuxSession × Libp2p.Identity.PeerId)) + Option (Except Libp2p.Core.Error (Libp2p.Core.MuxSession × Libp2p.Identity.PeerId)) /-! ## The pipeline checks -/ @@ -208,15 +208,15 @@ assertion. The test security value carries no advertisement field, so the wire exchange alone selects the muxer. -/ private def checkFullPipeline (r : Runner) : Async Unit := do let (iside, rside) ← newPair - let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let muxId : Libp2p.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ let dSeed : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none let lSeed : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none let dRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let dLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none let lLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none - let dOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none - let lOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let dOpen : Conc.OnceRef (Option Libp2p.Core.ProtocolId) ← Conc.OnceRef.new none + let lOpen : Conc.OnceRef (Option Libp2p.Core.ProtocolId) ← Conc.OnceRef.new none let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new @@ -233,8 +233,8 @@ private def checkFullPipeline (r : Runner) : Async Unit := do | some (.ok (ds, dp)), some (.ok (ls, lp)) => do ds.close ls.close - ds.join - ls.join + discard ds.join.run + discard ls.join.run r.check "upgrade full pipeline dial and listen" (dp == listenerId && lp == dialerId) r.check "upgrade muxer selected on the wire" @@ -254,7 +254,7 @@ responder's negotiation over-reads the marker and hands it to the handshake. -/ private def checkNegotiationLeftover (r : Runner) : Async Unit := do let (iside, rside) ← newPair - let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let muxId : Libp2p.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ let count : Conc.OnceRef Nat ← Conc.OnceRef.new 0 let dSeed : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none let lSeed : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none @@ -262,8 +262,8 @@ private def checkNegotiationLeftover (r : Runner) : Async Unit := do let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let dLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none let lLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none - let dOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none - let lOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let dOpen : Conc.OnceRef (Option Libp2p.Core.ProtocolId) ← Conc.OnceRef.new none + let lOpen : Conc.OnceRef (Option Libp2p.Core.ProtocolId) ← Conc.OnceRef.new none let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new @@ -290,13 +290,13 @@ private def checkNegotiationLeftover (r : Runner) : Async Unit := do the muxer stage reads the connection. -/ private def checkHandshakeLeftover (r : Runner) : Async Unit := do let (iside, rside) ← newPair - let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let muxId : Libp2p.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ let dRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let dLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none let lLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none - let dOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none - let lOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let dOpen : Conc.OnceRef (Option Libp2p.Core.ProtocolId) ← Conc.OnceRef.new none + let lOpen : Conc.OnceRef (Option Libp2p.Core.ProtocolId) ← Conc.OnceRef.new none let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new @@ -324,15 +324,15 @@ private def checkHandshakeLeftover (r : Runner) : Async Unit := do `Muxer.open` as its `ByteArray` argument. -/ private def checkMuxOpenLeftover (r : Runner) : Async Unit := do let (iside, rside) ← newPair - let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let muxId : Libp2p.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ let extra := Bytes.ofNatList [9, 9, 9] let count : Conc.OnceRef Nat ← Conc.OnceRef.new 0 let dRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let dLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none let lLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none - let dOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none - let lOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let dOpen : Conc.OnceRef (Option Libp2p.Core.ProtocolId) ← Conc.OnceRef.new none + let lOpen : Conc.OnceRef (Option Libp2p.Core.ProtocolId) ← Conc.OnceRef.new none let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new @@ -359,14 +359,14 @@ private def checkMuxOpenLeftover (r : Runner) : Async Unit := do supports only the second, and the chosen session is the second id. -/ private def checkNaRetry (r : Runner) : Async Unit := do let (iside, rside) ← newPair - let muxA : Mock.Core.ProtocolId := ⟨"/test/mux/a"⟩ - let muxB : Mock.Core.ProtocolId := ⟨"/test/mux/b"⟩ + let muxA : Libp2p.Core.ProtocolId := ⟨"/test/mux/a"⟩ + let muxB : Libp2p.Core.ProtocolId := ⟨"/test/mux/b"⟩ let aRan : Conc.OnceRef Bool ← Conc.OnceRef.new false - let aOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let aOpen : Conc.OnceRef (Option Libp2p.Core.ProtocolId) ← Conc.OnceRef.new none let bRan : Conc.OnceRef Bool ← Conc.OnceRef.new false - let bOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let bOpen : Conc.OnceRef (Option Libp2p.Core.ProtocolId) ← Conc.OnceRef.new none let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false - let lOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let lOpen : Conc.OnceRef (Option Libp2p.Core.ProtocolId) ← Conc.OnceRef.new none let lWrites : Conc.OnceRef (List ByteArray) ← Conc.OnceRef.new [] let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none @@ -403,7 +403,7 @@ parked negotiation with `closed`. -/ private def checkIdentityMismatch (r : Runner) : Async Unit := do let (iside, rside) ← newPair let wrongId : Libp2p.Identity.PeerId := peerOf 3 - let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let muxId : Libp2p.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ let dRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none @@ -435,7 +435,7 @@ connection, and the parked read ends on its own. -/ private def checkDeadline (r : Runner) : Async Unit := do let tc ← Conc.TestClock.new let (sc, farPipe) ← silentPeer - let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let muxId : Libp2p.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ let answer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new tasks.spawn do @@ -478,7 +478,7 @@ private def checkEmptySecurity (r : Runner) : Async Unit := do propose fails, and the teardown close reaches the responder. -/ private def checkEmptyMuxer (r : Runner) : Async Unit := do let (iside, rside) ← newPair - let muxB : Mock.Core.ProtocolId := ⟨"/test/mux/b"⟩ + let muxB : Libp2p.Core.ProtocolId := ⟨"/test/mux/b"⟩ let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none @@ -511,7 +511,7 @@ private def checkEmptyMuxer (r : Runner) : Async Unit := do private def checkEof (r : Runner) : Async Unit := do let tc ← Conc.TestClock.new let (iside, rside) ← newPair - let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let muxId : Libp2p.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ let answer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new tasks.spawn do @@ -531,8 +531,8 @@ private def checkEof (r : Runner) : Async Unit := do list and the `negotiationFailed` maps to `protocol`. -/ private def checkExhausted (r : Runner) : Async Unit := do let (iside, rside) ← newPair - let muxA : Mock.Core.ProtocolId := ⟨"/test/mux/a"⟩ - let muxB : Mock.Core.ProtocolId := ⟨"/test/mux/b"⟩ + let muxA : Libp2p.Core.ProtocolId := ⟨"/test/mux/a"⟩ + let muxB : Libp2p.Core.ProtocolId := ⟨"/test/mux/b"⟩ let dRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none @@ -567,7 +567,7 @@ private def checkExhausted (r : Runner) : Async Unit := do teardown close reaches the peer. -/ private def checkHostile (r : Runner) : Async Unit := do let (iside, rside) ← newPair - let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let muxId : Libp2p.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ let garbage := Bytes.ofNatList (List.replicate 9 255) let count : Conc.OnceRef Nat ← Conc.OnceRef.new 0 let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none @@ -619,7 +619,7 @@ private def traceHexes (file : String) (trace : List Json) : IO (List String × writes equal the recorded `r2i` bytes, byte for byte. -/ private def checkGoTraces (r : Runner) : Async Unit := do let goj ← Tests.loadJson "mss-go-trace.json" - let kadId : Mock.Core.ProtocolId := ⟨"/ipfs/kad/1.0.0"⟩ + let kadId : Libp2p.Core.ProtocolId := ⟨"/ipfs/kad/1.0.0"⟩ let fullTrace ← Tests.jArr "mss-go-trace.json" goj "fullTrace" let (i2rF, r2iF) ← traceHexes "mss-go-trace.json" fullTrace let rs : State := { role := .responder, supported := [kadId], proposals := [], @@ -656,7 +656,7 @@ private def checkGoTraces (r : Runner) : Async Unit := do /-! ## The send-side cap -/ /-- A protocol id whose frame is over the 1 KiB cap. -/ -private def overCapId : Mock.Core.ProtocolId := +private def overCapId : Libp2p.Core.ProtocolId := ⟨String.ofList (List.replicate 1100 'a')⟩ /-- A configured id that would produce an over-cap frame is refused before @@ -671,14 +671,14 @@ private def checkSendCap (r : Runner) : Async Unit := do | .error .negotiationFailed => true | _ => false) r.check "negotiate send cap admits a normal id" - (match initiatorStart [Mock.Core.ProtocolId.mk "/ipfs/kad/1.0.0"] with + (match initiatorStart [Libp2p.Core.ProtocolId.mk "/ipfs/kad/1.0.0"] with | .ok _ => true | _ => false) /-- The responder writes its header before it reads anything, so a dialer that also waits does not stall. -/ private def checkResponderFirst (r : Runner) : Async Unit := do - match responderStart [Mock.Core.ProtocolId.mk "/ipfs/kad/1.0.0"] with + match responderStart [Libp2p.Core.ProtocolId.mk "/ipfs/kad/1.0.0"] with | .ok (s, ws) => r.check "negotiate responder sends its header first" (ws == [encodeMsg header] && s.phase = .negotiating) @@ -728,7 +728,7 @@ with `readExactly`, so it accepts whatever chunks the transport chose. It replaces the Noise milestone (#16, unmerged) as `secValue` does, and it exists so the pipeline can run over the splitting sim. -/ private def secValueExact (seedRef : Conc.OnceRef (Option ByteArray)) : - Mock.Core.SecurityProtocol where + Libp2p.Core.SecurityProtocol where id := ⟨"/test/plain/1.0.0"⟩ handshake := fun role conn seed => do seedRef.set (some seed) @@ -741,29 +741,29 @@ private def secValueExact (seedRef : Conc.OnceRef (Option ByteArray)) : match ← readExactly conn seed 4 with | none => throw (.closed "test handshake read ended before the marker") | some four => - return { conn := conn, + return { toConnection := conn, localPeer := if role = .initiator then dialerId else listenerId, remotePeer := if role = .initiator then listenerId else dialerId, leftover := Bytes.ofNatList (four.drop 4), - close := Streams.Connection.closeQuietly conn } + join := pure () } /-- The multistream exchange of both roles over a splitting sim, for a supported id and for the `na` retry. Each run also asserts that the responder's reader saw one byte per chunk, which shows the sim split the writes. -/ private def checkSplitNegotiate (r : Runner) : Async Unit := do - let kadId : Mock.Core.ProtocolId := ⟨"/ipfs/kad/1.0.0"⟩ - let idId : Mock.Core.ProtocolId := ⟨"/ipfs/id/1.0.0"⟩ - let runOne (name : String) (iIds rIds : List Mock.Core.ProtocolId) - (expect : Mock.Core.ProtocolId) : Async Unit := do + let kadId : Libp2p.Core.ProtocolId := ⟨"/ipfs/kad/1.0.0"⟩ + let idId : Libp2p.Core.ProtocolId := ⟨"/ipfs/id/1.0.0"⟩ + let runOne (name : String) (iIds rIds : List Libp2p.Core.ProtocolId) + (expect : Libp2p.Core.ProtocolId) : Async Unit := do let (ic, rc) ← simPair true let sizes : Conc.OnceRef (List Nat) ← Conc.OnceRef.new [] let split := recordReadSizes rc sizes let iReader := Streams.BufReader.new ic let rReader := Streams.BufReader.new split - let iAnswer : Conc.OnceRef (Option (Except Mock.Core.Error StageResult)) ← + let iAnswer : Conc.OnceRef (Option (Except Libp2p.Core.Error StageResult)) ← Conc.OnceRef.new none - let rAnswer : Conc.OnceRef (Option (Except Mock.Core.Error StageResult)) ← + let rAnswer : Conc.OnceRef (Option (Except Libp2p.Core.Error StageResult)) ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new tasks.spawn do @@ -791,15 +791,15 @@ and the open. This is the two-party run that the definition of done of #18 requires. -/ private def checkSplitPipeline (r : Runner) : Async Unit := do let (ic, rc) ← simPair true - let muxId : Mock.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let muxId : Libp2p.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ let dSeed : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none let lSeed : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none let dRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let lRan : Conc.OnceRef Bool ← Conc.OnceRef.new false let dLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none let lLeft : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none - let dOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none - let lOpen : Conc.OnceRef (Option Mock.Core.ProtocolId) ← Conc.OnceRef.new none + let dOpen : Conc.OnceRef (Option Libp2p.Core.ProtocolId) ← Conc.OnceRef.new none + let lOpen : Conc.OnceRef (Option Libp2p.Core.ProtocolId) ← Conc.OnceRef.new none let dAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let lAnswer : Conc.OnceRef UpAnswer ← Conc.OnceRef.new none let tasks ← Conc.TaskSet.new @@ -816,8 +816,8 @@ private def checkSplitPipeline (r : Runner) : Async Unit := do | some (.ok (ds, dp)), some (.ok (ls, lp)) => do ds.close ls.close - ds.join - ls.join + discard ds.join.run + discard ls.join.run r.check "split sim upgrade pipeline, dial and listen" (dp == listenerId && lp == dialerId) r.check "split sim upgrade selects the muxer on the wire" @@ -856,4 +856,4 @@ def run (r : Runner) : IO Unit := do r.skip "upgrade noise yamux matrix over tcp" "needs the Noise value and the yamux value, which are not merged on this base (the W5.9 live gate)" -end Tests.Upgrade +end Tests.Upgrade \ No newline at end of file diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate.lean b/packages/libp2p/negotiate/Libp2p/Negotiate.lean index 25f5d964..4ca6908e 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate.lean @@ -7,7 +7,6 @@ import Libp2p.Negotiate.Upgrade # Libp2p.Negotiate The negotiate subpackage: multistream-select 1.0.0 and the upgrader -(ARCHITECTURE §4.9). One import line per public submodule. The root -names no Mock module directly. Its submodules still expose `Mock.Core` -until W5.2 lands. The identity field types name `Libp2p.Identity`. +(ARCHITECTURE §4.9). One import line per public submodule. The public +interfaces use `Libp2p.Core` and `Libp2p.Identity`. -/ diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Constants.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Constants.lean index e1659d4e..4b01ded0 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Constants.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Constants.lean @@ -1,11 +1,10 @@ -import Libp2p.Negotiate.Mock.Core +import Libp2p.Core /-! # The constants of the negotiation One home per fact (root §3.8). The multistream protocol id and the -1 KiB message cap live only here. At W5.2 integration the `Mock.Core` -import swaps to `Libp2p.Core`, and the literal stays. +1 KiB message cap live only here. -/ set_option autoImplicit false @@ -14,10 +13,10 @@ namespace Libp2p.Negotiate /-- The multistream 1.0.0 protocol id. The only protocol id literal of the package (root §3.4). -/ -def protocolId : Mock.Core.ProtocolId := ⟨"/multistream/1.0.0"⟩ +def protocolId : Libp2p.Core.ProtocolId := ⟨"/multistream/1.0.0"⟩ /-- The 1 KiB multistream cap (root §5.1, contract 1 of the package docs). -/ def maxMsg : Nat := 1024 -end Libp2p.Negotiate +end Libp2p.Negotiate \ No newline at end of file diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean index e70f841d..04df6c58 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean @@ -283,16 +283,16 @@ theorem encodeMsgLength (m : Msg) : /-- One `ls` entry is an ordinary message frame: the entry's text is the protocol id, and the frame carries the id plus its newline. -/ -def lsEntries (ids : List Mock.Core.ProtocolId) : List Nat := +def lsEntries (ids : List Libp2p.Core.ProtocolId) : List Nat := (ids.map (fun p => encodeText p.toString)).flatten /-- The body of an `ls` reply: one entry frame per id, then the final newline byte that ends the list. -/ -def lsBody (ids : List Mock.Core.ProtocolId) : List Nat := lsEntries ids ++ [10] +def lsBody (ids : List Libp2p.Core.ProtocolId) : List Nat := lsEntries ids ++ [10] /-- The full `ls` reply: the body wrapped in one uvarint length prefix, exactly like a message. -/ -def encodeLs (ids : List Mock.Core.ProtocolId) : List Nat := +def encodeLs (ids : List Libp2p.Core.ProtocolId) : List Nat := Varint.encode (boundedVarint (lsBody ids).length) ++ lsBody ids /-- Walk the entries of one complete `ls` body. The fuel counts @@ -300,7 +300,7 @@ entries; the caller passes the body's byte length, which is at least the entry count because every entry carries its newline. `[10]` alone is the terminator, not a leading byte: a leading `0x0A` can be a legitimate one-byte prefix for a nine-byte id. -/ -def decodeEntries : Nat → List Nat → Except DecodeError (List Mock.Core.ProtocolId) +def decodeEntries : Nat → List Nat → Except DecodeError (List Libp2p.Core.ProtocolId) | 0, _ => .error .malformed | fuel + 1, bs => if bs = [10] then .ok [] @@ -313,7 +313,7 @@ same split and cap as a message. An entry that ends mid-body is malformed, because only the outer prefix and length can be completed by more bytes. -/ def decodeLs? (bs : List Nat) : - Except DecodeError (List Mock.Core.ProtocolId × List Nat) := + Except DecodeError (List Libp2p.Core.ProtocolId × List Nat) := match splitFrame bs with | .error e => .error e | .ok (body, rest) => (decodeEntries body.length body).map (·, rest) @@ -324,7 +324,7 @@ theorem encodeTextLength (s : String) : (payloadOf s).length ≤ (encodeText s). /-- The entries of an encoded list read back in order, under fuel and cap bounds. -/ -theorem decodeEntriesLs (ids : List Mock.Core.ProtocolId) (fuel : Nat) +theorem decodeEntriesLs (ids : List Libp2p.Core.ProtocolId) (fuel : Nat) (hf : ids.length < fuel) (hb : (lsEntries ids).length ≤ maxMsg) : decodeEntries fuel (lsEntries ids ++ [10]) = .ok ids := by induction ids generalizing fuel with @@ -354,13 +354,13 @@ theorem decodeEntriesLs (ids : List Mock.Core.ProtocolId) (fuel : Nat) simp [lsEntries] simp only [decodeEntries, hne, ite_false] rw [hsplit, decodeMsgSuffix ⟨p.toString, hfit⟩ (lsEntries ps ++ [10])] - change (Except.map (fun x => (⟨p.toString⟩ : Mock.Core.ProtocolId) :: x) + change (Except.map (fun x => (⟨p.toString⟩ : Libp2p.Core.ProtocolId) :: x) (decodeEntries f (lsEntries ps ++ [10])) = .ok (p :: ps)) rw [ih f (by simp at hf; omega) (by omega)] rfl /-- The byte length of the entries is at least the id count. -/ -theorem lsEntriesLength (ids : List Mock.Core.ProtocolId) : +theorem lsEntriesLength (ids : List Libp2p.Core.ProtocolId) : ids.length ≤ (lsEntries ids).length := by induction ids with | nil => simp [lsEntries] @@ -371,7 +371,7 @@ theorem lsEntriesLength (ids : List Mock.Core.ProtocolId) : /-- The `ls` reply round trip. The length prefix alone delimits the entries, so no cleanliness hypothesis is needed. -/ -theorem decodeLsEncodeLs (ids : List Mock.Core.ProtocolId) +theorem decodeLsEncodeLs (ids : List Libp2p.Core.ProtocolId) (hbody : (lsBody ids).length ≤ maxMsg) : decodeLs? (encodeLs ids) = .ok (ids, []) := by have hlim : (lsBody ids).length < Varint.limit := by @@ -414,7 +414,7 @@ theorem splitFrameInv {bs body rest : List Nat} (h : splitFrame bs = .ok (body, exact hbs.symm /-- Entries that read back are exactly the encoding of the list. -/ -theorem decodeEntriesInv (f : Nat) (bs : List Nat) (ids : List Mock.Core.ProtocolId) +theorem decodeEntriesInv (f : Nat) (bs : List Nat) (ids : List Libp2p.Core.ProtocolId) (h : decodeEntries f bs = .ok ids) : lsEntries ids ++ [10] = bs := by induction f generalizing bs ids with | zero => simp [decodeEntries] at h @@ -441,7 +441,7 @@ theorem decodeEntriesInv (f : Nat) (bs : List Nat) (ids : List Mock.Core.Protoco /-- The all-input inverse of the `ls` reply: every accepted input is the encoding of the decoded list. -/ -theorem decodeLsInv {bs : List Nat} {ids : List Mock.Core.ProtocolId} {rest : List Nat} +theorem decodeLsInv {bs : List Nat} {ids : List Libp2p.Core.ProtocolId} {rest : List Nat} (h : decodeLs? bs = .ok (ids, rest)) : encodeLs ids ++ rest = bs := by unfold decodeLs? at h split at h @@ -458,4 +458,4 @@ theorem decodeLsInv {bs : List Nat} {ids : List Mock.Core.ProtocolId} {rest : Li rw [hb] exact hs' -end Libp2p.Negotiate +end Libp2p.Negotiate \ No newline at end of file diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean index 687eaeb0..04e44ae1 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean @@ -1,5 +1,5 @@ import Libp2p.Negotiate.Message -import Libp2p.Negotiate.Mock.Core +import Libp2p.Core import Bytes /-! @@ -37,9 +37,9 @@ inductive Phase where ordered list. `proposals` is the initiator's remaining list, in order. `buffer` holds the bytes no frame has consumed yet. -/ structure State where - role : Mock.Core.Role - supported : List Mock.Core.ProtocolId - proposals : List Mock.Core.ProtocolId + role : Libp2p.Core.Role + supported : List Libp2p.Core.ProtocolId + proposals : List Libp2p.Core.ProtocolId buffer : List Nat phase : Phase deriving Repr, DecidableEq @@ -48,14 +48,14 @@ structure State where is the local dialer's input. -/ inductive Event where | bytes (bs : List Nat) - | propose (p : Mock.Core.ProtocolId) + | propose (p : Libp2p.Core.ProtocolId) deriving Repr, DecidableEq /-- One output of the core. A write carries framed bytes. A select ends the negotiation with the chosen id and the leftover bytes. -/ inductive Action where | write (bs : List Nat) - | select (proto : Mock.Core.ProtocolId) (leftover : List Nat) + | select (proto : Libp2p.Core.ProtocolId) (leftover : List Nat) deriving Repr, DecidableEq /-- The replies the responder sends, the closed set of §3.4. -/ @@ -108,11 +108,11 @@ inductive Error where /-- The pure negotiation core of the spike: the first of `ours` that `theirs` also supports. -/ -def choose (ours theirs : List Mock.Core.ProtocolId) : Option Mock.Core.ProtocolId := +def choose (ours theirs : List Libp2p.Core.ProtocolId) : Option Libp2p.Core.ProtocolId := List.find? theirs.contains ours /-- A chosen id is in our list and supported by theirs. -/ -theorem chooseSpec {ours theirs : List Mock.Core.ProtocolId} {p : Mock.Core.ProtocolId} +theorem chooseSpec {ours theirs : List Libp2p.Core.ProtocolId} {p : Libp2p.Core.ProtocolId} (h : choose ours theirs = some p) : p ∈ ours ∧ theirs.contains p = true := by unfold choose at h exact ⟨List.mem_of_find?_eq_some h, List.find?_some h⟩ @@ -124,11 +124,11 @@ theorems about the chosen id reason over this classification. -/ private inductive Answer where | echo | listIds - | pick (p : Mock.Core.ProtocolId) + | pick (p : Libp2p.Core.ProtocolId) | refuse | advance - | retry (q : Mock.Core.ProtocolId) (ps : List Mock.Core.ProtocolId) - | accept (p : Mock.Core.ProtocolId) + | retry (q : Libp2p.Core.ProtocolId) (ps : List Libp2p.Core.ProtocolId) + | accept (p : Libp2p.Core.ProtocolId) | fail /-- Classify a decoded text for a state. -/ @@ -140,7 +140,7 @@ private def classify (s : State) (text : String) : Answer := | some .ls => .listIds | _ => if text = protocolId.toString then .advance - else if (⟨text⟩ : Mock.Core.ProtocolId) ∈ s.supported then .pick ⟨text⟩ + else if (⟨text⟩ : Libp2p.Core.ProtocolId) ∈ s.supported then .pick ⟨text⟩ else .refuse | .initiator, .negotiating => if text = protocolId.toString then .advance else .fail | .initiator, .awaitingAnswer => @@ -175,7 +175,7 @@ records the id and writes the coalesced header plus the first proposal frame. Any other proposal event leaves the state unchanged: `initiatorStart` seeds the ordered list before the first frame moves, and the `na` retry walks that list from `classify`. -/ -private def propose (s : State) (p : Mock.Core.ProtocolId) : Except Error (State × List Action) := +private def propose (s : State) (p : Libp2p.Core.ProtocolId) : Except Error (State × List Action) := if s.role = .initiator then if s.phase = .headerPending then if s.proposals = [] then @@ -201,7 +201,7 @@ def step (s : State) : Event → Except Error (State × List Action) /-- A step that selects came from the pick or accept answer. -/ private theorem actSelect {s : State} {rest : List Nat} {v : Answer} {s' : State} - {acts : List Action} {p : Mock.Core.ProtocolId} {r : List Nat} + {acts : List Action} {p : Libp2p.Core.ProtocolId} {r : List Nat} (h : act s rest v = .ok (s', acts)) (hm : Action.select p r ∈ acts) : v = .pick p ∨ v = .accept p := by cases v <;> simp only [act] at h @@ -211,35 +211,35 @@ private theorem actSelect {s : State} {rest : List Nat} {v : Answer} {s' : State <;> (try simp_all)) /-- A pick comes from the responder's supported list. -/ -private theorem classifyPick {s : State} {t : String} {p : Mock.Core.ProtocolId} +private theorem classifyPick {s : State} {t : String} {p : Libp2p.Core.ProtocolId} (h : classify s t = .pick p) : p ∈ s.supported := by unfold classify at h repeat' split at h all_goals simp_all /-- An accept comes from the head of the initiator's proposals. -/ -private theorem classifyAcceptInitiator {s : State} {t : String} {p : Mock.Core.ProtocolId} +private theorem classifyAcceptInitiator {s : State} {t : String} {p : Libp2p.Core.ProtocolId} (h : classify s t = .accept p) : p ∈ s.proposals := by unfold classify at h repeat' split at h all_goals simp_all /-- An accept carries the accepted text. -/ -private theorem classifyAcceptText {s : State} {t : String} {p : Mock.Core.ProtocolId} +private theorem classifyAcceptText {s : State} {t : String} {p : Libp2p.Core.ProtocolId} (h : classify s t = .accept p) : p.toString = t := by unfold classify at h repeat' split at h all_goals simp_all /-- A responder never accepts. -/ -private theorem classifyAcceptResponder {s : State} {t : String} {p : Mock.Core.ProtocolId} +private theorem classifyAcceptResponder {s : State} {t : String} {p : Libp2p.Core.ProtocolId} (hr : s.role = .responder) (h : classify s t = .accept p) : False := by unfold classify at h repeat' split at h all_goals simp_all /-- An initiator never picks. -/ -private theorem classifyPickInitiator {s : State} {t : String} {p : Mock.Core.ProtocolId} +private theorem classifyPickInitiator {s : State} {t : String} {p : Libp2p.Core.ProtocolId} (hr : s.role = .initiator) (h : classify s t = .pick p) : False := by unfold classify at h repeat' split at h @@ -277,7 +277,7 @@ private theorem classifyListIds {s : State} {t : String} /-- An unknown id in the negotiation draws the refuse answer. -/ private theorem classifyRefuse {s : State} {t : String} (hrole : s.role = .responder) (hphase : s.phase = .negotiating) - (hnot : (⟨t⟩ : Mock.Core.ProtocolId) ∉ s.supported) + (hnot : (⟨t⟩ : Libp2p.Core.ProtocolId) ∉ s.supported) (hheader : t ≠ protocolId.toString) (hreply : Reply.ofText? t = none) : classify s t = .refuse := by unfold classify @@ -286,8 +286,8 @@ private theorem classifyRefuse {s : State} {t : String} rw [ite_eq_right hheader, ite_eq_right hnot] /-- A retry pops one proposal: the new list is the tail of the old. -/ -private theorem classifyRetryTail {s : State} {t : String} {q : Mock.Core.ProtocolId} - {ps : List Mock.Core.ProtocolId} (h : classify s t = .retry q ps) : +private theorem classifyRetryTail {s : State} {t : String} {q : Libp2p.Core.ProtocolId} + {ps : List Libp2p.Core.ProtocolId} (h : classify s t = .retry q ps) : s.proposals.tail = ps := by unfold classify at h repeat' split at h @@ -341,8 +341,8 @@ private theorem actRefuse {s : State} {rest : List Nat} {s' : State} {actions : simp /-- The retry answer replaces the proposals with the remaining list. -/ -private theorem actRetryWrites {s : State} {rest : List Nat} {q : Mock.Core.ProtocolId} - {ps : List Mock.Core.ProtocolId} {s' : State} {actions : List Action} +private theorem actRetryWrites {s : State} {rest : List Nat} {q : Libp2p.Core.ProtocolId} + {ps : List Libp2p.Core.ProtocolId} {s' : State} {actions : List Action} (h : act s rest (.retry q ps) = .ok (s', actions)) : s'.proposals = ps := by unfold act at h simp only [Except.ok.injEq, Prod.mk.injEq] at h @@ -353,7 +353,7 @@ private theorem actRetryWrites {s : State} {rest : List Nat} {q : Mock.Core.Prot /-- A responder selects only from its supported list (contract 2). -/ theorem stepSelectResponderMem {s : State} {bs : List Nat} {s' : State} - {actions : List Action} {p : Mock.Core.ProtocolId} {rest : List Nat} + {actions : List Action} {p : Libp2p.Core.ProtocolId} {rest : List Nat} (hrole : s.role = .responder) (hstep : step s (.bytes bs) = .ok (s', actions)) (hmem : Action.select p rest ∈ actions) : p ∈ s.supported := by @@ -369,7 +369,7 @@ theorem stepSelectResponderMem {s : State} {bs : List Nat} {s' : State} /-- An initiator selects only from its own proposal list. -/ theorem stepSelectInitiatorMem {s : State} {bs : List Nat} {s' : State} - {actions : List Action} {p : Mock.Core.ProtocolId} {rest : List Nat} + {actions : List Action} {p : Libp2p.Core.ProtocolId} {rest : List Nat} (hrole : s.role = .initiator) (hstep : step s (.bytes bs) = .ok (s', actions)) (hmem : Action.select p rest ∈ actions) : p ∈ s.proposals := by @@ -387,7 +387,7 @@ theorem stepSelectInitiatorMem {s : State} {bs : List Nat} {s' : State} head: the new list is the old tail, so it is strictly shorter (contract 2, the `na` retry). -/ theorem stepInitiatorNaProgress {s : State} {bs : List Nat} {s' : State} - {actions : List Action} {p : Mock.Core.ProtocolId} {ps : List Mock.Core.ProtocolId} + {actions : List Action} {p : Libp2p.Core.ProtocolId} {ps : List Libp2p.Core.ProtocolId} {w : List Nat} (hrole : s.role = .initiator) (hprop : s.proposals = p :: ps) (hstep : step s (.bytes bs) = .ok (s', actions)) @@ -442,7 +442,7 @@ theorem stepResponderLsStays {s : State} {bs : List Nat} {s' : State} /-- An unknown id draws the `na` frame and stays in the negotiation (contract 2). -/ theorem stepResponderUnknownStays {s : State} {bs : List Nat} {s' : State} - {actions : List Action} {m : Msg} {rest : List Nat} {id : Mock.Core.ProtocolId} + {actions : List Action} {m : Msg} {rest : List Nat} {id : Libp2p.Core.ProtocolId} (hrole : s.role = .responder) (hphase : s.phase = .negotiating) (hdec : decodeMsg? (s.buffer ++ bs) = .ok (m, rest)) (htext : m.text = id.toString) (hnot : id ∉ s.supported) @@ -450,7 +450,7 @@ theorem stepResponderUnknownStays {s : State} {bs : List Nat} {s' : State} (hreply : Reply.ofMsg? m = none) (hstep : step s (.bytes bs) = .ok (s', actions)) : actions = [.write (encodeMsg (Reply.toMsg .na))] ∧ s'.phase = .negotiating ∧ s'.buffer = rest := by - have hnot' : (⟨m.text⟩ : Mock.Core.ProtocolId) ∉ s.supported := by + have hnot' : (⟨m.text⟩ : Libp2p.Core.ProtocolId) ∉ s.supported := by intro hm apply hnot simpa [htext] using hm @@ -469,7 +469,7 @@ theorem stepResponderUnknownStays {s : State} {bs : List Nat} {s' : State} /-- Conservation: the bytes the accepting initiator consumed, plus the leftover, equal the bytes fed in (contract 5). -/ theorem stepSelectConservation {s : State} {bs : List Nat} {s' : State} - {p : Mock.Core.ProtocolId} {rest r : List Nat} {m : Msg} + {p : Libp2p.Core.ProtocolId} {rest r : List Nat} {m : Msg} (hrole : s.role = .initiator) (hdec : decodeMsg? (s.buffer ++ bs) = .ok (m, r)) (hstep : step s (.bytes bs) = .ok (s', [Action.select p rest])) : @@ -506,4 +506,4 @@ theorem stepTruncatedWaits {s : State} {bs : List Nat} {s' : State} {actions : L rw [← hstate] simp -end Libp2p.Negotiate +end Libp2p.Negotiate \ No newline at end of file diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean index 9161fcfa..0f9dc45c 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean @@ -1,6 +1,6 @@ import Libp2p.Negotiate.Step import Libp2p.Negotiate.Message -import Libp2p.Negotiate.Mock.Core +import Libp2p.Core import Libp2p.Identity import Streams.Conn import Streams.BufReader @@ -33,8 +33,8 @@ wait, so the loop keeps the package's `repeat` form, the form of The three stages thread the leftover bytes by contract 5 of the package docs: the negotiation leftover seeds the handshake, the -`SecureConn` leftover seeds the muxer negotiation, and the muxer -negotiation leftover reaches `Muxer.open`. +secure connection delivers the handshake leftover through its reader, +and the muxer negotiation leftover reaches `Muxer.open`. -/ set_option autoImplicit false @@ -58,7 +58,7 @@ open Bytes (DecodeError) complete frame. The caller calls `drive` again with `leftover`; it must not read. -/ inductive DriveResult where - | selected (p : Mock.Core.ProtocolId) (consumed leftover : List Nat) + | selected (p : Libp2p.Core.ProtocolId) (consumed leftover : List Nat) (writes : List (List Nat)) | waiting (s : State) (consumed leftover : List Nat) (writes : List (List Nat)) | running (s : State) (consumed leftover : List Nat) (writes : List (List Nat)) @@ -87,7 +87,7 @@ def writesOf (actions : List Action) : List (List Nat) := | .select .. => none /-- The protocol an action list selects, if it selects one. -/ -def selectOf : List Action → Option Mock.Core.ProtocolId +def selectOf : List Action → Option Libp2p.Core.ProtocolId | [] => none | .write _ :: rest => selectOf rest | .select p _ :: _ => some p @@ -125,7 +125,7 @@ The write holds the header and the first proposal back-to-back, the lazy dialer's wire shape (contract 4 of the package docs). The state carries the whole ordered list, so the core's `na` retry walks it. An empty ordered list is a protocol error before any byte moves. -/ -def initiatorStart (ids : List Mock.Core.ProtocolId) : +def initiatorStart (ids : List Libp2p.Core.ProtocolId) : Except Error (State × List (List Nat)) := match ids with | [] => .error .negotiationFailed @@ -140,7 +140,7 @@ def initiatorStart (ids : List Mock.Core.ProtocolId) : at once. The spec lets either side send first, and the responder that waits for the dialer's header stalls a dialer that also waits. The state starts in `negotiating` because the header is already on the wire. -/ -def responderStart (ids : List Mock.Core.ProtocolId) : +def responderStart (ids : List Libp2p.Core.ProtocolId) : Except Error (State × List (List Nat)) := if ids.any (fun q => !fitsText q.toString) then .error .negotiationFailed else if maxMsg < (lsBody ids).length then .error .negotiationFailed @@ -159,7 +159,7 @@ def renderDecodeError : Bytes.DecodeError → String /-- Contract 8's one mapping point: every negotiation error becomes the `protocol` arm, with the detail rendered. -/ -def mapNegotiateError : Error → Mock.Core.Error +def mapNegotiateError : Error → Libp2p.Core.Error | .decode e => .protocol ("multistream decode: " ++ renderDecodeError e) | .negotiationFailed => .protocol "multistream negotiation failed" @@ -168,7 +168,7 @@ peer. The dial path compares the derived id against the expectation and answers `authentication` on a difference, before any muxer byte moves (negotiate ARCHITECTURE, "Decisions"). -/ def checkIdentity (expected : Option Libp2p.Identity.PeerId) - (remote : Libp2p.Identity.PeerId) : Except Mock.Core.Error Unit := + (remote : Libp2p.Identity.PeerId) : Except Libp2p.Core.Error Unit := match expected with | none => .ok () | some e => @@ -179,7 +179,7 @@ def checkIdentity (expected : Option Libp2p.Identity.PeerId) `.error .deadlineExpired`; the `.closed` arm is required by the match and is unreachable from `withinDeadline`. -/ def settleUpgrade {α : Type} : - Except Conc.Error (Except Mock.Core.Error α) → Except Mock.Core.Error α + Except Conc.Error (Except Libp2p.Core.Error α) → Except Libp2p.Core.Error α | .ok (.ok a) => .ok a | .ok (.error e) => .error e | .error .deadlineExpired => .error (.timeout "upgrade deadline expired") @@ -189,7 +189,7 @@ def settleUpgrade {α : Type} : the stage over-read. `ByteArray` carries no `Repr` in Lean 4.34, so the structure derives none; the suite compares the fields it needs. -/ structure StageResult where - proto : Mock.Core.ProtocolId + proto : Libp2p.Core.ProtocolId leftover : ByteArray /-! ## The driver theorems -/ @@ -244,7 +244,7 @@ theorem driveConservation {fuel : Nat} {s : State} {input : List Nat} {r : Drive /-- The selected arm: the bytes in equal the frames consumed plus the leftover handed to the next stage (contract 5). -/ theorem driveSelectedConservation {fuel : Nat} {s : State} {input : List Nat} - {p : Mock.Core.ProtocolId} {consumed leftover : List Nat} + {p : Libp2p.Core.ProtocolId} {consumed leftover : List Nat} {writes : List (List Nat)} (h : drive fuel s input = .ok (.selected p consumed leftover writes)) : input = consumed ++ leftover := by @@ -335,7 +335,7 @@ theorem handoverCompose {ε α β : Type} the first drive's leftover, the bytes fed in equal the bytes both drives consumed plus the final leftover. -/ theorem driveTwostage {fuel₁ fuel₂ : Nat} {s₁ s₂ : State} - {input₁ input₂ : List Nat} {p₁ p₂ : Mock.Core.ProtocolId} + {input₁ input₂ : List Nat} {p₁ p₂ : Libp2p.Core.ProtocolId} {c₁ c₂ l₁ l₂ : List Nat} {w₁ w₂ : List (List Nat)} (h₁ : drive fuel₁ s₁ input₁ = .ok (.selected p₁ c₁ l₁ w₁)) (h₂ : drive fuel₂ s₂ (l₁ ++ input₂) = .ok (.selected p₂ c₂ l₂ w₂)) : @@ -347,7 +347,7 @@ theorem driveTwostage {fuel₁ fuel₂ : Nat} {s₁ s₂ : State} /-- The shell's manual first write equals the core's propose step for a singleton list. The theorem does not cover a longer list. -/ -theorem initiatorStartPropose {p : Mock.Core.ProtocolId} +theorem initiatorStartPropose {p : Libp2p.Core.ProtocolId} (hfits : fitsText p.toString = true) : initiatorStart [p] = (step { role := .initiator, supported := [], proposals := [], buffer := [], @@ -391,7 +391,7 @@ theorem checkIdentityNone {r : Libp2p.Identity.PeerId} : /-- The deadline wiring of contract 6: the race's expiry is the `timeout` arm, and the seam errors pass through untouched. The `.closed` arm is required by the match over `Conc.Error`. -/ -theorem settleUpgradeSpec {α : Type} {e : Mock.Core.Error} {a : α} : +theorem settleUpgradeSpec {α : Type} {e : Libp2p.Core.Error} {a : α} : settleUpgrade (α := α) (.error .deadlineExpired) = .error (.timeout "upgrade deadline expired") ∧ settleUpgrade (α := α) (.error .closed) = @@ -406,7 +406,7 @@ theorem settleUpgradeSpec {α : Type} {e : Mock.Core.Error} {a : α} : `closed` arm. `Connection.write` throws `IO.Error`, which is outside this shell's `Core.Error` channel. -/ private def writeFrame (conn : Streams.Connection) (b : ByteArray) : - ExceptT Mock.Core.Error Conc.Async Unit := do + ExceptT Libp2p.Core.Error Conc.Async Unit := do let ok ← (try conn.write b pure true @@ -416,7 +416,7 @@ private def writeFrame (conn : Streams.Connection) (b : ByteArray) : /-- Write each frame payload through the connection, in order. A failed write is the `closed` arm: the connection is gone. -/ private def sendWrites (conn : Streams.Connection) (ws : List (List Nat)) : - ExceptT Mock.Core.Error Conc.Async Unit := do + ExceptT Libp2p.Core.Error Conc.Async Unit := do for w in ws do writeFrame conn (Bytes.ofNatList w) @@ -429,7 +429,7 @@ long as the peer stays silent, so no measure bounds the loop (root marker, like `Conc.TaskSet.joinAll`. -/ private def negotiateLoop (reader : Streams.BufReader) (conn : Streams.Connection) (s : State) (input : List Nat) : - ExceptT Mock.Core.Error Conc.Async StageResult := do + ExceptT Libp2p.Core.Error Conc.Async StageResult := do let mut s := s let mut input := input let mut reader := reader @@ -461,10 +461,10 @@ initiator writes the coalesced header plus first proposal, and the responder writes its header. The seed is the leftover of the previous stage. The end of the stream mid-negotiation is `Core.Error.closed`; a `Negotiate.Error` maps once through `mapNegotiateError`. -/ -def negotiate (role : Mock.Core.Role) (reader : Streams.BufReader) +def negotiate (role : Libp2p.Core.Role) (reader : Streams.BufReader) (conn : Streams.Connection) (seed : ByteArray) - (ids : List Mock.Core.ProtocolId) : - ExceptT Mock.Core.Error Conc.Async StageResult := do + (ids : List Libp2p.Core.ProtocolId) : + ExceptT Libp2p.Core.Error Conc.Async StageResult := do match role with | .initiator => match initiatorStart ids with @@ -482,10 +482,10 @@ def negotiate (role : Mock.Core.Role) (reader : Streams.BufReader) /-- The pipeline: negotiate the security protocol, run the handshake, check the identity, negotiate the muxer, open the session. Each lookup is total; a miss after a selection is `protocol`. -/ -private def upgradeCore (role : Mock.Core.Role) (conn : Streams.Connection) +private def upgradeCore (role : Libp2p.Core.Role) (conn : Streams.Connection) (leftover : ByteArray) (expected : Option Libp2p.Identity.PeerId) - (secs : List Mock.Core.SecurityProtocol) (muxers : List Mock.Core.Muxer) : - Conc.Async (Except Mock.Core.Error (Mock.Core.MuxSession × Libp2p.Identity.PeerId)) := do + (secs : List Libp2p.Core.SecurityProtocol) (muxers : List Libp2p.Core.Muxer) : + Conc.Async (Except Libp2p.Core.Error (Libp2p.Core.MuxSession × Libp2p.Identity.PeerId)) := do let reader := Streams.BufReader.new conn match ← negotiate role reader conn leftover (secs.map (·.id)) with | .error e => return .error e @@ -499,14 +499,14 @@ private def upgradeCore (role : Mock.Core.Role) (conn : Streams.Connection) match checkIdentity expected sc.remotePeer with | .error e => return .error e | .ok () => - let muxReader := Streams.BufReader.new sc.conn - match ← negotiate role muxReader sc.conn ByteArray.empty (muxers.map (·.id)) with + let muxReader := Streams.BufReader.new sc.toConnection + match ← negotiate role muxReader sc.toConnection ByteArray.empty (muxers.map (·.id)) with | .error e => return .error e | .ok stage2 => match muxers.find? (fun m => decide (m.id = stage2.proto)) with | none => return .error (.protocol "selected muxer is not configured") | some mux => - match ← mux.«open» role sc.conn stage2.leftover with + match ← mux.«open» role sc.toConnection stage2.leftover with | .error e => return .error e | .ok session => return .ok (session, sc.remotePeer) @@ -519,10 +519,10 @@ private def boundaryClosed {α : Type} : Except Conc.Error α → Bool /-- Close a session that finished after the timer won. No caller receives that session. -/ -private def releaseLate (r : Except Mock.Core.Error - (Mock.Core.MuxSession × Libp2p.Identity.PeerId)) : BaseIO Unit := +private def releaseLate (r : Except Libp2p.Core.Error + (Libp2p.Core.MuxSession × Libp2p.Identity.PeerId)) : BaseIO Unit := match r with - | .ok pair => pair.1.close + | .ok pair => discard pair.1.close.toBaseIO | .error _ => pure () /-- The upgrade under one `Conc.withinDeadline`. The expiry closes @@ -530,11 +530,11 @@ the connection once at the boundary (root §3.5), and the race's `deadlineExpired` maps to the `timeout` arm through `settleUpgrade`. The clock is a seam, so the deadline test can advance it. -/ -def upgradeWith (clock : Conc.Clock) (role : Mock.Core.Role) +def upgradeWith (clock : Conc.Clock) (role : Libp2p.Core.Role) (conn : Streams.Connection) (leftover : ByteArray) (expected : Option Libp2p.Identity.PeerId) - (secs : List Mock.Core.SecurityProtocol) (muxers : List Mock.Core.Muxer) : - ExceptT Mock.Core.Error Conc.Async (Mock.Core.MuxSession × Libp2p.Identity.PeerId) := do + (secs : List Libp2p.Core.SecurityProtocol) (muxers : List Libp2p.Core.Muxer) : + ExceptT Libp2p.Core.Error Conc.Async (Libp2p.Core.MuxSession × Libp2p.Identity.PeerId) := do let answer ← Conc.withinDeadline clock Streams.unauthDeadlineMs (Streams.Connection.closeQuietly conn) (upgradeCore role conn leftover expected secs muxers) @@ -552,10 +552,10 @@ def upgradeWith (clock : Conc.Clock) (role : Mock.Core.Role) /-- The frozen entry of the package docs. The clock is fixed to the system clock here and stays a seam in `upgradeWith` for the deadline test. -/ -def upgrade (role : Mock.Core.Role) (conn : Streams.Connection) +def upgrade (role : Libp2p.Core.Role) (conn : Streams.Connection) (leftover : ByteArray) (expected : Option Libp2p.Identity.PeerId) - (secs : List Mock.Core.SecurityProtocol) (muxers : List Mock.Core.Muxer) : - ExceptT Mock.Core.Error Conc.Async (Mock.Core.MuxSession × Libp2p.Identity.PeerId) := + (secs : List Libp2p.Core.SecurityProtocol) (muxers : List Libp2p.Core.Muxer) : + ExceptT Libp2p.Core.Error Conc.Async (Libp2p.Core.MuxSession × Libp2p.Identity.PeerId) := upgradeWith Conc.Clock.system role conn leftover expected secs muxers -end Libp2p.Negotiate +end Libp2p.Negotiate \ No newline at end of file From c49a2b3f84cbd2541eafdb01fa645754f11f6c8b Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:31:52 +0800 Subject: [PATCH 096/125] fix(negotiate): remove the core mock --- .../negotiate/Libp2p/Negotiate/Mock/Core.lean | 87 ------------------- 1 file changed, 87 deletions(-) delete mode 100644 packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean deleted file mode 100644 index f18ddd1f..00000000 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Mock/Core.lean +++ /dev/null @@ -1,87 +0,0 @@ -import Std -import Conc -import Streams.Conn -import Streams.Stream -import Libp2p.Identity - -/-! -# Core mocks for units 1a and 1b - -`ProtocolId` and `Role` live in `libp2p/core` (W5.2, not merged yet). -Unit 1b adds `Error`, `SecurityProtocol`, `SecureConn`, `Muxer`, and -`MuxSession`. This module follows the core ARCHITECTURE "Public -surface", so the negotiate core and the upgrader compile against the -frozen shapes. It differs in three places: `Repr` is derived, -`MuxSession.close` is `BaseIO Unit`, and `SecureConn` has no completion -to join. The import swaps to `Libp2p.Core` when W5.2 lands. `Repr` -joins the derived classes per root §3.2, so `State`, `Event`, and -`Action` can derive `Repr` per root §3.3. - -`SecurityProtocol` and `Muxer` mention the streams `Connection`, and -`SecureConn` mentions the identity `PeerId`, so this module imports -`Streams.Conn`, `Streams.Stream`, and `Libp2p.Identity`. The identity -package landed as W5.1, so the field types name the real one. --/ - -set_option autoImplicit false - -namespace Libp2p.Negotiate.Mock.Core - -/-- A wrapped-string protocol id. Peers define ids, so the set stays -open. Code compares `ProtocolId` values only (root §3.4). -/ -structure ProtocolId where - toString : String - deriving DecidableEq, Hashable, Repr - -/-- The role of one side of a negotiation (root §4.9). No spec gives -roles a wire code, so it has no `toCode`/`ofCode?` pair. -/ -inductive Role where - | initiator - | responder - deriving DecidableEq, Repr, Hashable - -/-- The coarse error of root §4.9. Each arm carries the error of the -implementation, in its rendered form. The upgrader and the host act on -this one type (root §3.5). -/ -inductive Error where - | protocol (s : String) - | authentication (s : String) - | timeout (s : String) - | closed (s : String) - deriving DecidableEq, Repr - -/-- The post-handshake connection. It holds the underlying -`Connection` because `Muxer.open` takes a `Connection` (core -ARCHITECTURE, "Public surface"). The `leftover` is every byte the -handshake read past its end; it seeds the reader of the next stage. -/ -structure SecureConn where - conn : Streams.Connection - localPeer : Libp2p.Identity.PeerId - remotePeer : Libp2p.Identity.PeerId - leftover : ByteArray - close : BaseIO Unit - -/-- The security seam. `handshake` consumes the `ByteArray` the -negotiation over-read before it reads the connection (root §4.7). -/ -structure SecurityProtocol where - id : ProtocolId - handshake : Role → Streams.Connection → ByteArray → - ExceptT Error Conc.Async SecureConn - -/-- The uniform session view of core ARCHITECTURE, "Public surface". -The upgrader returns the opened session and never calls its fields. -/ -structure MuxSession where - openStream : ExceptT Error Conc.Async Streams.Stream - accept : Conc.Async (Option Streams.Stream) - close : BaseIO Unit - join : Conc.Async Unit - -/-- The muxer seam. The field is the frozen `open` of the core docs; -`open` is a Lean keyword, so the field is declared escaped and called -`mux.«open»`. -/ -structure Muxer where - id : ProtocolId - «open» : Role → Streams.Connection → ByteArray → - ExceptT Error Conc.Async MuxSession - -end Libp2p.Negotiate.Mock.Core From 6ef2bb95f19b5af697deac5bd2011e3048355101 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:34:48 +0800 Subject: [PATCH 097/125] fix(mux): use the merged core seam --- Tests/Mux.lean | 16 ++++++++-------- packages/libp2p/mux/Libp2p/Mux/Constants.lean | 12 ++++-------- packages/libp2p/mux/Libp2p/Mux/Test.lean | 6 +++--- packages/libp2p/mux/Libp2p/Mux/Test/Session.lean | 8 ++++---- packages/libp2p/mux/Libp2p/Mux/Test/State.lean | 6 +++--- 5 files changed, 22 insertions(+), 26 deletions(-) diff --git a/Tests/Mux.lean b/Tests/Mux.lean index 253b3cfc..e2d2a008 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -1,6 +1,6 @@ import Tests.Support import Libp2p.Mux -import Libp2p.Mux.Mock.Core +import Libp2p.Core import Libp2p.Mux.Test.Wire import Libp2p.Mux.Test.State import Libp2p.Mux.Test.Session @@ -412,7 +412,7 @@ end Tests.Mux.Wire namespace Tests.Mux.Seam open Libp2p.Mux -open Libp2p.Mux.Mock.Core +open Libp2p.Core /-- The detail carried by one seam error. -/ private def detail : Error → String @@ -468,7 +468,7 @@ end Tests.Mux.Seam namespace Tests.Mux.State -open Libp2p.Mux.Mock.Core +open Libp2p.Core open Libp2p.Mux.Test.Wire open Libp2p.Mux.Test.State @@ -1639,7 +1639,7 @@ end Tests.Mux.State namespace Tests.Mux.Session -open Libp2p.Mux.Mock.Core +open Libp2p.Core open Libp2p.Mux.Test.Session open Libp2p.Mux.Test.State open Libp2p.Mux.Test.Wire @@ -2830,7 +2830,7 @@ end Tests.Mux.Session namespace Tests.Mux.Public open Libp2p.Mux -open Libp2p.Mux.Mock.Core +open Libp2p.Core open Libp2p.Mux.Test.Wire open Conc open Streams @@ -3044,8 +3044,8 @@ private def runConcurrentOpens (r : Runner) : Async Unit := do let pair ← ends 49 let initiator ← openSession .initiator pair.dialer let responder ← openSession .responder pair.peer - let initiatorOpen : Chan (Except Mock.Core.Error Stream) ← Chan.new 1 - let responderOpen : Chan (Except Mock.Core.Error Stream) ← Chan.new 1 + let initiatorOpen : Chan (Except Libp2p.Core.Error Stream) ← Chan.new 1 + let responderOpen : Chan (Except Libp2p.Core.Error Stream) ← Chan.new 1 let workers ← TaskSet.new workers.spawn do let result ← (initiator.openStream testId).run @@ -3156,7 +3156,7 @@ private def runMalformed (r : Runner) : Async Unit := do r.check "a second public join returns the same protocol error" false | .ok sess => let observed : Except IO.Error - (Except Mock.Core.Error Unit × Except Mock.Core.Error Unit) ← + (Except Libp2p.Core.Error Unit × Except Libp2p.Core.Error Unit) ← try pair.peer.write ⟨#[0, 0, 0, 0, 1, 0, 0, 0, 0]⟩ pure (.ok (← sess.join, ← sess.join)) diff --git a/packages/libp2p/mux/Libp2p/Mux/Constants.lean b/packages/libp2p/mux/Libp2p/Mux/Constants.lean index 1287a263..48060f77 100644 --- a/packages/libp2p/mux/Libp2p/Mux/Constants.lean +++ b/packages/libp2p/mux/Libp2p/Mux/Constants.lean @@ -1,4 +1,4 @@ -import Libp2p.Mux.Mock.Core.ProtocolId +import Libp2p.Core /-! # The constants of `libp2p/mux` @@ -7,18 +7,14 @@ Each fact has one home (ARCHITECTURE §3.8). This module holds the three protocol ids and the mplex length cap. The production definitions of the ids live only here (§3.4). -`ProtocolId` is the temporary copy in -`Libp2p.Mux.Mock.Core.ProtocolId`. This module imports that file -alone, so the constants do not import the session seam. W5.2 has not -merged. When W5.2 lands, this import becomes `Libp2p.Core`. The four -public names stay. +`ProtocolId` comes from `Libp2p.Core`. -/ set_option autoImplicit false namespace Libp2p.Mux -open Mock.Core +open Libp2p.Core /-- The yamux protocol id. Peers negotiate `/yamux/1.0.0` on the wire. -/ def yamuxId : ProtocolId := { toString := "/yamux/1.0.0" } @@ -35,4 +31,4 @@ The future P2 mplex implementation checks a declared length against this cap before allocation. This module names the cap. -/ def mplexMessageCap : Nat := 1024 * 1024 -end Libp2p.Mux +end Libp2p.Mux \ No newline at end of file diff --git a/packages/libp2p/mux/Libp2p/Mux/Test.lean b/packages/libp2p/mux/Libp2p/Mux/Test.lean index 69683713..2ee5de0c 100644 --- a/packages/libp2p/mux/Libp2p/Mux/Test.lean +++ b/packages/libp2p/mux/Libp2p/Mux/Test.lean @@ -1,5 +1,5 @@ import Libp2p.Mux.Constants -import Libp2p.Mux.Mock.Core +import Libp2p.Core import Libp2p.Mux.Test.Session /-! @@ -15,7 +15,7 @@ set_option autoImplicit false namespace Libp2p.Mux -open Mock.Core +open Libp2p.Core /-- The independent test muxer. `id` is `testId`. `open` starts the private session. The leftover bytes are the reader's first input. -/ @@ -24,4 +24,4 @@ def test : Muxer where «open» := fun role connection leftover => ExceptT.lift (Test.Session.start role connection leftover) -end Libp2p.Mux +end Libp2p.Mux \ No newline at end of file diff --git a/packages/libp2p/mux/Libp2p/Mux/Test/Session.lean b/packages/libp2p/mux/Libp2p/Mux/Test/Session.lean index d09bc50a..4dab94ea 100644 --- a/packages/libp2p/mux/Libp2p/Mux/Test/Session.lean +++ b/packages/libp2p/mux/Libp2p/Mux/Test/Session.lean @@ -1,4 +1,4 @@ -import Libp2p.Mux.Mock.Core +import Libp2p.Core import Libp2p.Mux.Test.State import Libp2p.Mux.Test.Wire import Conc @@ -28,7 +28,7 @@ set_option autoImplicit false namespace Libp2p.Mux.Test.Session -open Libp2p.Mux.Mock.Core +open Libp2p.Core open Libp2p.Mux.Test.State open Libp2p.Mux.Test.Wire open Conc hiding Error @@ -847,7 +847,7 @@ private def startRuntime (role : Role) (conn : Streams.Connection) openStream := openStream rt accept := accept rt close := close rt - join := join rt } + join := ExceptT.mk (join rt) } /-- The reader and the owner for `role` on `conn`. `leftover` is the negotiation suffix. The tasks belong to the session `TaskSet`. -/ @@ -862,4 +862,4 @@ def startWithHooks (role : Role) (conn : Streams.Connection) (leftover : ByteArray := .empty) (hooks : TestHooks) : Async MuxSession := startRuntime role conn leftover (some hooks) -end Libp2p.Mux.Test.Session +end Libp2p.Mux.Test.Session \ No newline at end of file diff --git a/packages/libp2p/mux/Libp2p/Mux/Test/State.lean b/packages/libp2p/mux/Libp2p/Mux/Test/State.lean index 12327ee9..30de6975 100644 --- a/packages/libp2p/mux/Libp2p/Mux/Test/State.lean +++ b/packages/libp2p/mux/Libp2p/Mux/Test/State.lean @@ -1,5 +1,5 @@ import Libp2p.Mux.Test.Wire -import Libp2p.Mux.Mock.Core +import Libp2p.Core import Streams /-! @@ -39,7 +39,7 @@ set_option autoImplicit false namespace Libp2p.Mux.Test.State -open Libp2p.Mux.Mock.Core +open Libp2p.Core open Libp2p.Mux.Test.Wire /-! ## Caps and ids -/ @@ -982,4 +982,4 @@ def pump (s : State) : Step := end State -end Libp2p.Mux.Test.State +end Libp2p.Mux.Test.State \ No newline at end of file From 622b4778bc398afc1dd091a174105d93efc1d56c Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:36:09 +0800 Subject: [PATCH 098/125] fix(mux): remove the core mocks --- packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean | 75 ------------------- .../mux/Libp2p/Mux/Mock/Core/ProtocolId.lean | 27 ------- 2 files changed, 102 deletions(-) delete mode 100644 packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean delete mode 100644 packages/libp2p/mux/Libp2p/Mux/Mock/Core/ProtocolId.lean diff --git a/packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean b/packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean deleted file mode 100644 index f1f67417..00000000 --- a/packages/libp2p/mux/Libp2p/Mux/Mock/Core.lean +++ /dev/null @@ -1,75 +0,0 @@ -import Libp2p.Mux.Mock.Core.ProtocolId -import Conc -import Streams - -/-! -# Mock: the muxer seam of `libp2p/core` - -W5.2 is not merged. This module copies the seam that `Mux.test` must -compile against (the core pair, public surface; the mux pair, public -surface). It holds no protocol logic. - -`Role` and the four `Error` arms follow the core architecture. The -`String` on each arm is a provisional rendered payload. `MuxSession.join` -is a provisional name for the completion the owner joins. Neither -spelling is a landed `Libp2p.Core` declaration. - -`Connection` and `Stream` come from `Streams`. `Async` comes from -`Conc`. This module does not copy those types. When W5.2 lands, delete -this module and `Mock/Core/ProtocolId.lean`. The imports move to the -`Libp2p.Core` modules that own the same names. --/ - -set_option autoImplicit false - -namespace Libp2p.Mux.Mock.Core - -open Conc - -/-- The local direction of a connection. The core architecture names -`initiator` and `responder`, and gives the set no wire code. -/ -inductive Role where - | initiator - | responder - deriving DecidableEq, Repr, Hashable - -/-- The coarse seam error. The four arms are the categories in the -core architecture. `detail` is a provisional `String` rendering of the -implementation error. -/ -inductive Error where - | protocol (detail : String) - | authentication (detail : String) - | timeout (detail : String) - | closed (detail : String) - deriving DecidableEq, Repr, Hashable - -/-- The session view a `Muxer` returns. `join` is the provisional -completion the owner waits on after `close`. -/ -structure MuxSession where - /-- Open one outbound stream. The argument is a `ProtocolId`. -/ - openStream : - ProtocolId → ExceptT Error Async Streams.Stream - /-- The next inbound stream. `none` means the session has closed. -/ - accept : - Async (Option Streams.Stream) - /-- Close the session. -/ - close : - IO Unit - /-- Wait until the session tasks end. `.ok ()` is an orderly end. - `.error` is the protocol result. A fault throws. -/ - join : - Async (Except Error Unit) - -/-- A muxer selected by its protocol id. `«open»` is the field `open`: -`open` is a Lean keyword. -/ -structure Muxer where - /-- The id the negotiator compares. -/ - id : ProtocolId - /-- Start a session. The `ByteArray` is the negotiation leftover. -/ - «open» : - Role → - Streams.Connection → - ByteArray → - ExceptT Error Async MuxSession - -end Libp2p.Mux.Mock.Core diff --git a/packages/libp2p/mux/Libp2p/Mux/Mock/Core/ProtocolId.lean b/packages/libp2p/mux/Libp2p/Mux/Mock/Core/ProtocolId.lean deleted file mode 100644 index 4c29041e..00000000 --- a/packages/libp2p/mux/Libp2p/Mux/Mock/Core/ProtocolId.lean +++ /dev/null @@ -1,27 +0,0 @@ -/-! -# Mock: the `ProtocolId` surface of `libp2p/core` - -W5.2 (`libp2p/core`) is not yet merged, so this package builds against -a temporary copy of the documented `ProtocolId` surface (RULES.md, -"Working ahead of the tree"; the core pair, public surface). The copy -is `structure ProtocolId where toString : String`. It derives -`DecidableEq` and `Hashable` from that surface, and `Repr` from the -standard identifier classes (ARCHITECTURE §3.2, §3.4). - -This module imports nothing. `Constants.lean` imports it alone, so the -protocol ids stay free of the session seam. The seam lives in -`Libp2p/Mux/Mock/Core.lean`. When W5.2 lands, delete both mock -modules. `Constants.lean` then imports `Libp2p.Core`. --/ - -set_option autoImplicit false - -namespace Libp2p.Mux.Mock.Core - -/-- A protocol id: an open wrapped string (ARCHITECTURE §3.4). Peers -define ids, so the set stays open. -/ -structure ProtocolId where - toString : String - deriving DecidableEq, Repr, Hashable - -end Libp2p.Mux.Mock.Core From e0f83ecf09f0752de7af4020b8f0bfe854808ef4 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:37:18 +0800 Subject: [PATCH 099/125] fix(host): use the merged core seam --- Tests/Host.lean | 4 ++-- packages/libp2p/host/Libp2p/Host/Dedup.lean | 6 +++--- packages/libp2p/host/Libp2p/Host/Error.lean | 8 +++----- packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean | 6 +++--- packages/libp2p/host/Libp2p/Host/Policy.lean | 6 +++--- packages/libp2p/host/Libp2p/Host/Registry.lean | 6 +++--- packages/libp2p/host/Libp2p/Host/Swarm.lean | 6 +++--- 7 files changed, 20 insertions(+), 22 deletions(-) diff --git a/Tests/Host.lean b/Tests/Host.lean index 0bb1e43e..7a19cd69 100644 --- a/Tests/Host.lean +++ b/Tests/Host.lean @@ -27,7 +27,7 @@ set_option autoImplicit false namespace Tests -open Conc Multiformats Libp2p.Identity Libp2p.Host.Mock.Core Libp2p.Host +open Conc Multiformats Libp2p.Identity Libp2p.Core Libp2p.Host /-! ## Helpers -/ @@ -300,4 +300,4 @@ def Host.run (r : Runner) : IO Unit := do for leg in swarmLegs do r.skip s!"host: {leg}" "needs W5.3, W5.4, and W5.5 merged" -end Tests +end Tests \ No newline at end of file diff --git a/packages/libp2p/host/Libp2p/Host/Dedup.lean b/packages/libp2p/host/Libp2p/Host/Dedup.lean index 6a88bce4..45f83ba3 100644 --- a/packages/libp2p/host/Libp2p/Host/Dedup.lean +++ b/packages/libp2p/host/Libp2p/Host/Dedup.lean @@ -1,5 +1,5 @@ import Libp2p.Identity -import Libp2p.Host.Mock.Core +import Libp2p.Core import Libp2p.Host.Constants import Libp2p.Host.Registry @@ -46,7 +46,7 @@ set_option autoImplicit false namespace Libp2p.Host -open Libp2p.Identity Libp2p.Host.Mock.Core +open Libp2p.Identity Libp2p.Core /-- The order of two peer ids by their binary form, byte by byte (module doc, "The order on peer ids"). -/ @@ -150,4 +150,4 @@ theorem dedupKeepTable {α : Type} (l r : PeerId) (c s : Entry α) (t : SessionT · simp_all · rfl -end Libp2p.Host +end Libp2p.Host \ No newline at end of file diff --git a/packages/libp2p/host/Libp2p/Host/Error.lean b/packages/libp2p/host/Libp2p/Host/Error.lean index 854042d6..fc2b87f1 100644 --- a/packages/libp2p/host/Libp2p/Host/Error.lean +++ b/packages/libp2p/host/Libp2p/Host/Error.lean @@ -1,6 +1,6 @@ import Multiformats import Libp2p.Identity -import Libp2p.Host.Mock.Core +import Libp2p.Core /-! # `Host.Error` @@ -20,9 +20,7 @@ set_option autoImplicit false namespace Libp2p.Host --- The mock of the unmerged core (RULES.md, "Working ahead of the --- tree"); `Core.Error` resolves through `Libp2p.Host.Mock`. -open Multiformats Libp2p.Host.Mock Libp2p.Identity +open Multiformats Libp2p.Identity /-- The policy checkpoint that refused a connection (§3.4: a closed set). The gater answers at the first three (`Core.ConnectionGater`), @@ -95,4 +93,4 @@ instance : ToString Error := ⟨render⟩ end Error -end Libp2p.Host +end Libp2p.Host \ No newline at end of file diff --git a/packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean b/packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean index a399021b..a83d5ffe 100644 --- a/packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean +++ b/packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean @@ -1,7 +1,7 @@ import Conc import Streams import Libp2p.Identity -import Libp2p.Host.Mock.Core +import Libp2p.Core /-! # Mock: the surface of `libp2p/negotiate` that the host uses @@ -35,7 +35,7 @@ set_option autoImplicit false namespace Libp2p.Host.Mock.Negotiate -open Conc Streams Libp2p.Identity Libp2p.Host.Mock.Core +open Conc Streams Libp2p.Identity Libp2p.Core /-- How a stream negotiation failed. The host reads two arms: the end of the stream before a selection, an orderly end, and the empty proposal @@ -88,4 +88,4 @@ opaque negotiateStream (_role : Role) (_s : Stream) (_ids : List ProtocolId) : ExceptT Error Async (ProtocolId × Stream) := throw (.failed "libp2p/negotiate (W5.5) is not integrated") -end Libp2p.Host.Mock.Negotiate +end Libp2p.Host.Mock.Negotiate \ No newline at end of file diff --git a/packages/libp2p/host/Libp2p/Host/Policy.lean b/packages/libp2p/host/Libp2p/Host/Policy.lean index 16665b84..d5ad12d1 100644 --- a/packages/libp2p/host/Libp2p/Host/Policy.lean +++ b/packages/libp2p/host/Libp2p/Host/Policy.lean @@ -1,4 +1,4 @@ -import Libp2p.Host.Mock.Core +import Libp2p.Core /-! # The policy values @@ -17,7 +17,7 @@ set_option autoImplicit false namespace Libp2p.Host.Policy -open Libp2p.Host.Mock.Core +open Libp2p.Core /-- The default gater: it allows every dial, every inbound connection, and every proved peer. -/ @@ -52,4 +52,4 @@ no delay, the form of js-libp2p's `AddressSorter` (the core pair, def keepOrder : AddressRanker where rank addrs := addrs.map fun addr => { addr, delayMs := 0 } -end Libp2p.Host.Policy +end Libp2p.Host.Policy \ No newline at end of file diff --git a/packages/libp2p/host/Libp2p/Host/Registry.lean b/packages/libp2p/host/Libp2p/Host/Registry.lean index 5954fdb3..8412abde 100644 --- a/packages/libp2p/host/Libp2p/Host/Registry.lean +++ b/packages/libp2p/host/Libp2p/Host/Registry.lean @@ -1,6 +1,6 @@ import Std.Data.HashMap import Libp2p.Identity -import Libp2p.Host.Mock.Core +import Libp2p.Core /-! # The session and handler registries @@ -44,7 +44,7 @@ set_option autoImplicit false namespace Libp2p.Host -open Libp2p.Identity Libp2p.Host.Mock.Core +open Libp2p.Identity Libp2p.Core /-! ## The session table -/ @@ -116,4 +116,4 @@ def ids (t : Handlers) : List ProtocolId := end Handlers -end Libp2p.Host +end Libp2p.Host \ No newline at end of file diff --git a/packages/libp2p/host/Libp2p/Host/Swarm.lean b/packages/libp2p/host/Libp2p/Host/Swarm.lean index 2294d866..a23b2229 100644 --- a/packages/libp2p/host/Libp2p/Host/Swarm.lean +++ b/packages/libp2p/host/Libp2p/Host/Swarm.lean @@ -2,7 +2,7 @@ import Std.Data.HashMap import Conc import Streams import Libp2p.Identity -import Libp2p.Host.Mock.Core +import Libp2p.Core import Libp2p.Host.Mock.Negotiate import Libp2p.Host.Error import Libp2p.Host.Registry @@ -72,7 +72,7 @@ namespace Libp2p.Host -- The mocks of the unmerged packages (RULES.md, "Working ahead of the -- tree"); `Negotiate.Error` resolves through `Libp2p.Host.Mock`. open Conc Streams Multiformats Libp2p.Host.Mock Libp2p.Identity - Libp2p.Host.Mock.Core Libp2p.Host.Mock.Negotiate + Libp2p.Core Libp2p.Host.Mock.Negotiate /-- A failure of a task that no caller awaits: an inbound upgrade, a session that ended badly, an inbound stream. -/ @@ -549,4 +549,4 @@ def newStream (h : Host) (peer : PeerId) (protocols : List ProtocolId) : end Host -end Libp2p.Host +end Libp2p.Host \ No newline at end of file From a8754bf2f8b0379008d08ad220a3d19dc3849a2d Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:38:03 +0800 Subject: [PATCH 100/125] fix(host): remove the core mock --- .../libp2p/host/Libp2p/Host/Mock/Core.lean | 176 ------------------ 1 file changed, 176 deletions(-) delete mode 100644 packages/libp2p/host/Libp2p/Host/Mock/Core.lean diff --git a/packages/libp2p/host/Libp2p/Host/Mock/Core.lean b/packages/libp2p/host/Libp2p/Host/Mock/Core.lean deleted file mode 100644 index 6678d411..00000000 --- a/packages/libp2p/host/Libp2p/Host/Mock/Core.lean +++ /dev/null @@ -1,176 +0,0 @@ -import Conc -import Streams -import Libp2p.Identity - -/-! -# Mock: the surface of `libp2p/core` that the host uses - -W5.2 (`libp2p/core`) has not merged, so the host builds against a mock -of its frozen surface (RULES.md, "Working ahead of the tree"). The -types are copies: the seam shapes of the core ARCHITECTURE, "Public -surface", with `MuxSession.openStream` carrying a `ProtocolId` (the mux -ARCHITECTURE, "Public surface"). The mock imports nothing from the real -package, and it states no axiom. - -## The policy shapes - -The core ARCHITECTURE fixes the three policy hooks and no signatures: -"the decision inputs beyond these are the seam's to refine as the host -lands (W5.6)". This unit fixes them, and the core ARCHITECTURE records -them. The peer of an inbound connection is unknown until the handshake -ends, so the gater and the resource manager each take a second decision -there, with the proved peer, as go-libp2p does: -`ConnectionGater.InterceptSecured` (`vendor/go-libp2p/core/connmgr/gater.go`) -and `ResourceScope.SetPeer` (`vendor/go-libp2p/core/network/rcmgr.go`). -The ranker answers a dial schedule, an address and a delay each, the -shape of go-libp2p's `AddrDelay` and `DialRanker` -(`vendor/go-libp2p/core/network/network.go`, lines 212 and 218). - -A policy answer runs in `BaseIO`: it has no fault channel, so a policy -throw cannot lose a dial. - -At the integration, the host imports `Libp2p.Core`, and the module goes. --/ - -set_option autoImplicit false - -namespace Libp2p.Host.Mock.Core - -open Conc Streams Multiformats Libp2p.Identity - -/-- A protocol id, wrapped (§3.4). -/ -structure ProtocolId where - /-- The id text. -/ - toString : String - deriving DecidableEq, Hashable, Ord, Repr - -instance : ToString ProtocolId := ⟨ProtocolId.toString⟩ - -/-- The coarse error every seam returns (§4.9). Each arm carries the -rendered error of the implementation. -/ -inductive Error where - | protocol (detail : String) - | authentication (detail : String) - | timeout (detail : String) - | closed (detail : String) - deriving DecidableEq, Repr - -/-- The log line of the error: the kind, then the rendered detail. -/ -def Error.render : Error → String - | .protocol d => s!"protocol error: {d}" - | .authentication d => s!"authentication failure: {d}" - | .timeout d => s!"timeout: {d}" - | .closed d => s!"closed: {d}" - -instance : ToString Error := ⟨Error.render⟩ - -/-- Our side of a connection (§4.9). -/ -inductive Role where - | initiator - | responder - deriving DecidableEq, Repr - -/-- The connection after the handshake: the secure connection, the two -peer ids, the bytes the handshake read past its end, and the -completion of its tasks. -/ -structure SecureConn extends Connection where - /-- Our peer id. -/ - localPeer : PeerId - /-- The peer id the handshake proved. -/ - remotePeer : PeerId - /-- The bytes the handshake read past its end (§4.7). -/ - leftover : ByteArray - /-- Wait until the secure connection's tasks end. -/ - join : ExceptT Error Async Unit - -/-- The security seam. -/ -structure SecurityProtocol where - /-- The negotiated id. -/ - id : ProtocolId - /-- Run the handshake over a connection, with the negotiation's - leftover. -/ - handshake : Role → Connection → ByteArray → ExceptT Error Async SecureConn - -/-- A muxer session. -/ -structure MuxSession where - /-- Open a stream, labeled with its protocol id. -/ - openStream : ProtocolId → ExceptT Error Async Stream - /-- The next stream the peer opened; `none` once the session ends. -/ - accept : Async (Option Stream) - /-- End the session. -/ - close : IO Unit - /-- Wait until the session loop ends. -/ - join : ExceptT Error Async Unit - -/-- The muxer seam. -/ -structure Muxer where - /-- The negotiated id. -/ - id : ProtocolId - /-- Open a session over a secure connection, with the negotiation's - leftover. -/ - «open» : Role → Connection → ByteArray → ExceptT Error Async MuxSession - -/-- What the host knows about the connection under a served stream. -/ -structure StreamContext where - /-- The peer id the security handshake proved. -/ - remotePeer : PeerId - /-- The peer's transport address, as the connection reports it. -/ - remoteAddr : Multiaddr - -/-- The callback a served stream reaches. -/ -structure StreamHandler where - /-- The id the handler serves. -/ - id : ProtocolId - /-- Serve one stream of the connection that `StreamContext` - describes. -/ - handle : StreamContext → Stream → ExceptT Error Async Unit - -/-! ## The policy hooks (module doc, "The policy shapes") -/ - -/-- The connection gater. `true` allows. -/ -structure ConnectionGater where - /-- Before an outbound dial, by the expected peer and the address. -/ - allowDial : Option PeerId → Multiaddr → BaseIO Bool - /-- After an accept, before any byte moves, by the two addresses. -/ - allowInbound : (localAddr remoteAddr : Multiaddr) → BaseIO Bool - /-- After the handshake, before the muxer stage, by the proved peer. -/ - allowSecured : Role → PeerId → (localAddr remoteAddr : Multiaddr) → BaseIO Bool - -/-- An admitted share of a limit. A second `release` does nothing. -/ -structure Reservation where - /-- Return the share. -/ - release : BaseIO Unit - -/-- The resource scope of one admitted connection. -/ -structure ConnectionScope where - /-- Name the proved peer; `false` when the connection does not fit - that peer's limits. -/ - setPeer : PeerId → BaseIO Bool - /-- Admit one more stream; `none` when it does not fit. -/ - reserveStream : BaseIO (Option Reservation) - /-- Admit this many more buffered bytes; `none` when they do not fit. -/ - reserveMemory : Nat → BaseIO (Option Reservation) - /-- Return the connection's share and every share it still holds. -/ - release : BaseIO Unit - -/-- The resource manager. -/ -structure ResourceManager where - /-- Admit a new connection, by its direction, the expected peer, and - the remote address; `none` when it does not fit. -/ - openConnection : Role → Option PeerId → Multiaddr → BaseIO (Option ConnectionScope) - -/-- One address of a dial schedule, with its delay from the start of -the dial, in milliseconds. -/ -structure AddrDelay where - /-- The address to dial. -/ - addr : Multiaddr - /-- The wait before this dial. -/ - delayMs : Nat - deriving DecidableEq, Repr - -/-- The address ranker: the dial schedule of a peer's addresses. -/ -structure AddressRanker where - /-- The schedule, in dial order. -/ - rank : List Multiaddr → List AddrDelay - -end Libp2p.Host.Mock.Core From 549b85ab66570eabacaf805f973ea058f12c9341 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:47:11 +0800 Subject: [PATCH 101/125] fix(protocols): use core handler context --- Tests/Gossipsub/Node.lean | 24 +-- Tests/Gossipsub/Switch.lean | 138 ++++++++++++++++++ .../protocols/Libp2p/Protocols/Constants.lean | 14 +- .../Libp2p/Protocols/Gossipsub/HostSeam.lean | 25 ++++ .../Libp2p/Protocols/Gossipsub/Node.lean | 15 +- .../protocols/Libp2p/Protocols/Ping.lean | 13 +- .../Libp2p/Protocols/StreamError.lean | 14 ++ 7 files changed, 212 insertions(+), 31 deletions(-) create mode 100644 Tests/Gossipsub/Switch.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/HostSeam.lean create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/StreamError.lean diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index 7cbe5bb5..d6548ed8 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -1,5 +1,6 @@ import Tests.Support import Tests.Gossipsub.Identity +import Tests.Gossipsub.Switch import Libp2p.Protocols.Gossipsub.Node /-! @@ -20,7 +21,7 @@ open Conc open Libp2p.Protocols open Libp2p.Protocols.Gossipsub open Libp2p.Protocols.Gossipsub.Node -open Libp2p.Protocols.Mock.Host (Switch HostSeam) +open Tests.Gossipsub (Switch) private def pa : Peer := Identity.peer 1 private def pb : Peer := Identity.peer 2 @@ -89,7 +90,8 @@ private def probe (sw : Switch) (me : Peer) (seen : Chan Seen) let (host, _) ← sw.host me host.setHandler { id := Constants.meshsubId - handle := fun sender stream => ExceptT.lift do + handle := fun ctx stream => ExceptT.lift do + let sender := ctx.remotePeer let mut buf : List Nat := [] let mut done := false repeat @@ -175,9 +177,9 @@ private def switchChecks (r : Tests.Runner) : Async Unit := do let (a, _) ← (sw.host pa : BaseIO (HostSeam × TaskSet)) let (b, _) ← (sw.host pb : BaseIO (HostSeam × TaskSet)) let sender ← (Chan.new 8 : BaseIO (Chan Peer)) - let id : Mock.Core.ProtocolId := ⟨"/node-test-echo"⟩ - (b.setHandler { id, handle := fun peer s => ExceptT.lift do - discard (sender.trySend peer : BaseIO Bool) + let id : Libp2p.Core.ProtocolId := ⟨"/node-test-echo"⟩ + (b.setHandler { id, handle := fun ctx s => ExceptT.lift do + discard (sender.trySend ctx.remotePeer : BaseIO Bool) repeat match ← s.read with | .ok (some bytes) => discard <| s.write bytes @@ -336,7 +338,7 @@ private def frameBoundaryChecks (r : Tests.Runner) : Async Unit := do write := fun _ => pure (.ok ()) close := pure () reset := (reset.set true : BaseIO Unit) } - let result ← ((handler events).handle pa stream).run + let result ← ((handler events).handle { remotePeer := pa, remoteAddr := Multiformats.Multiaddr.empty } stream).run r.check s!"node.framing.{name}" ((match result with | .error (.protocol _) => true | _ => false) && (← (reset.get : BaseIO Bool))) @@ -349,7 +351,7 @@ private def outboundFailureChecks (r : Tests.Runner) : Async Unit := do let closed ← (Chan.new 8 : BaseIO (Chan Unit)) let inbound ← (Pipe.new 16 : BaseIO Pipe) let opens ← (OnceRef.new 0 : BaseIO (OnceRef Nat)) - let saved ← (OnceRef.new (none : Option Mock.Core.StreamHandler) : BaseIO (OnceRef (Option Mock.Core.StreamHandler))) + let saved ← (OnceRef.new (none : Option Libp2p.Core.StreamHandler) : BaseIO (OnceRef (Option Libp2p.Core.StreamHandler))) let host : HostSeam := { setHandler := fun h => saved.set (some h) newStream := fun _ _ => do @@ -362,7 +364,7 @@ private def outboundFailureChecks (r : Tests.Runner) : Async Unit := do let tc ← (TestClock.new : BaseIO TestClock) let n ← (start host { clock := tc.clock, onRpc := fun _ _ => discard (receipts.trySend ()) } : BaseIO Node) discard (n.subscribe topic (fun m => discard <| deliveries.trySend m) : BaseIO Bool) - let some h ← (saved.get : BaseIO (Option Mock.Core.StreamHandler)) + let some h ← (saved.get : BaseIO (Option Libp2p.Core.StreamHandler)) | throw <| IO.userError "missing node handler" let owner ← (TaskSet.new : BaseIO TaskSet) let stream : Streams.Stream := { @@ -371,7 +373,7 @@ private def outboundFailureChecks (r : Tests.Runner) : Async Unit := do close := pure () reset := (inbound.close : BaseIO Unit) } owner.spawn do - discard <| (h.handle pa stream).run + discard <| (h.handle { remotePeer := pa, remoteAddr := Multiformats.Multiaddr.empty } stream).run discard <| inbound.write (frame (Core.publishRpc (message [31]))) r.check "node.failure.first-inbound-publish-delivers" ((← receive deliveries) == some (message [31])) @@ -405,7 +407,7 @@ private def readerCopyChecks (r : Tests.Runner) : Async Unit := do write := fun _ => pure (.ok ()) close := pure () reset := pure () } - let result ← ((handler events).handle pa dribble).run + let result ← ((handler events).handle { remotePeer := pa, remoteAddr := Multiformats.Multiaddr.empty } dribble).run let inputs ← (events.drain : BaseIO (List Input)) let bodies := inputs.filterMap fun | .event (.rpc _ bytes) => some bytes @@ -421,7 +423,7 @@ private def readerCopyChecks (r : Tests.Runner) : Async Unit := do let mixed := { dribble with read := do let bytes ← (pending.updateGet (fun xs => (xs.head?, xs.drop 1)) : BaseIO (Option ByteArray)) return .ok bytes } - let mixedResult ← ((handler mixedEvents).handle pa mixed).run + let mixedResult ← ((handler mixedEvents).handle { remotePeer := pa, remoteAddr := Multiformats.Multiaddr.empty } mixed).run let mixedInputs ← (mixedEvents.drain : BaseIO (List Input)) let mixedBodies := mixedInputs.filterMap fun | .event (.rpc _ bytes) => some bytes diff --git a/Tests/Gossipsub/Switch.lean b/Tests/Gossipsub/Switch.lean new file mode 100644 index 00000000..4648bc7a --- /dev/null +++ b/Tests/Gossipsub/Switch.lean @@ -0,0 +1,138 @@ +import Libp2p.Protocols.Gossipsub.HostSeam + +set_option autoImplicit false + +namespace Tests.Gossipsub + +open Libp2p.Identity Libp2p.Core Libp2p.Protocols.Gossipsub + +/-! ## The in-memory switch + +The switch stands in for the session table of the host and its serve +task. Two peers meet over a pair of pipes, with no transport. -/ + +/-- One peer of the switch. -/ +private structure Switch.Entry where + /-- The peer. -/ + peer : PeerId + /-- The handlers the peer registered, the oldest first. -/ + handlers : List Core.StreamHandler + /-- The owner of the handler tasks of the inbound streams of the peer. -/ + tasks : Conc.TaskSet + +/-- The state of a switch. -/ +private structure Switch.State where + /-- The peers. -/ + entries : List Switch.Entry + /-- Every pipe the switch opened. The list lives as long as the switch, + which a test double allows. -/ + pipes : List Conc.Pipe + /-- True after `Switch.close`. -/ + closed : Bool + +/-- The in-memory double of the session table: the peers, their handlers, +and the pipes of their streams. -/ +structure Switch where + private state : Conc.OnceRef Switch.State + +namespace Switch + +/-- A switch with no peers. -/ +def new : BaseIO Switch := + return ⟨← Conc.OnceRef.new { entries := [], pipes := [], closed := false }⟩ + +/-- A stream over a pair of pipes. `read` reads `incoming`, and `write` +writes `outgoing`. `close` closes `outgoing`, the half-close. `reset` +closes both, so the peer of a reset reads the end of the stream and its +writes fail. -/ +private def streamOf (incoming outgoing : Conc.Pipe) : Streams.Stream where + read := do + let chunk ← incoming.read + return .ok chunk + write bytes := do + match ← outgoing.write bytes with + | .ok () => return .ok () + | .error _ => return .error .closed + close := (outgoing.close : BaseIO Unit) + reset := do + (incoming.close : BaseIO Unit) + (outgoing.close : BaseIO Unit) + +/-- The first protocol of the list that the handlers serve, with its +handler. -/ +private def choose (handlers : List Core.StreamHandler) : + List Core.ProtocolId → Option (Core.ProtocolId × Core.StreamHandler) + | [] => none + | p :: ps => + match handlers.find? (fun h => decide (h.id = p)) with + | some h => some (p, h) + | none => choose handlers ps + +/-- Run one handler on an inbound stream. A handler error fails the task, +so the `joinAll` of the owner reports it. -/ +private def serve (handler : Core.StreamHandler) (peer : PeerId) + (stream : Streams.Stream) : Conc.Async Unit := do + match ← (handler.handle { remotePeer := peer, remoteAddr := Multiformats.Multiaddr.empty } stream).run with + | .ok () => return () + | .error e => throw <| IO.userError s!"handler {handler.id.toString}: {repr e}" + +/-- Open a stream from `me` to `peer`: pick the protocol, make the pipes, +spawn the handler on the task set of `peer`, and answer the initiator +stream. The call never parks. Eight chunks is far more than one RPC frame +needs, and the bound stays explicit. -/ +private def open_ (sw : Switch) (me peer : PeerId) (protocols : List Core.ProtocolId) : + BaseIO (Except Core.Error (Core.ProtocolId × Streams.Stream)) := do + let st ← sw.state.get + let some entry := st.entries.find? (fun e => decide (e.peer = peer)) + | return .error (.protocol s!"no peer {peer}") + let some (id, handler) := choose entry.handlers protocols + | return .error (.protocol s!"peer {peer} serves none of the protocols") + let toPeer ← Conc.Pipe.new 8 + let fromPeer ← Conc.Pipe.new 8 + -- One atomic step checks the flag and records the pipes, so `close` + -- sees every pipe of a stream that opens before it. + let opened ← sw.state.updateGet fun s => + if s.closed then (false, s) + else (true, { s with pipes := toPeer :: fromPeer :: s.pipes }) + if !opened then return .error (.closed "the switch closed") + entry.tasks.spawn (serve handler me (streamOf toPeer fromPeer)) + return .ok (id, streamOf fromPeer toPeer) + +/-- The seam of the peer `me`, and the task set that owns the handler +tasks of its inbound streams. Each peer starts with an empty handler +table, and a handler registered through one seam serves only that peer. -/ +def host (sw : Switch) (me : PeerId) : BaseIO (HostSeam × Conc.TaskSet) := do + let tasks ← Conc.TaskSet.new + sw.state.update fun s => + { s with + entries := { peer := me, handlers := [], tasks } :: + s.entries.filter (fun e => decide (e.peer ≠ me)) } + let seam : HostSeam := + { newStream := fun peer protocols => (sw.open_ me peer protocols : BaseIO _) + setHandler := fun handler => + sw.state.update fun s => + { s with + entries := s.entries.map fun e => + if e.peer = me then { e with handlers := e.handlers ++ [handler] } else e } } + return (seam, tasks) + +/-- End every stream the switch opened, then join the handler tasks +(ARCHITECTURE §4.1, rule 2). A close wakes every parked read and write. +A failed handler task does not stop the joins of the other peers. The call +throws the first error after the last join. A second call does nothing. -/ +def close (sw : Switch) : Conc.Async Unit := do + let st ← (sw.state.updateGet (fun s => (s, { s with closed := true, pipes := [] })) : + BaseIO Switch.State) + for p in st.pipes do + (p.close : BaseIO Unit) + let mut failure : Option IO.Error := none + for e in st.entries do + try + e.tasks.joinAll + catch err => + failure := failure <|> some err + if let some err := failure then throw err + +end Switch + +end Tests.Gossipsub diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean b/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean index f83dc9e8..0c81868d 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Constants.lean @@ -1,4 +1,4 @@ -import Libp2p.Protocols.Mock.Core +import Libp2p.Core /-! # Protocol constants @@ -16,25 +16,25 @@ set_option autoImplicit false namespace Libp2p.Protocols.Constants /-- The gossipsub v1.1 protocol id, `/meshsub/1.1.0`. -/ -def meshsubId : Mock.Core.ProtocolId := ⟨"/meshsub/1.1.0"⟩ +def meshsubId : Libp2p.Core.ProtocolId := ⟨"/meshsub/1.1.0"⟩ /-- The gossipsub v1.0 protocol id, `/meshsub/1.0.0`. A v1.1 router speaks v1.0 to a peer that offers only this id. -/ -def meshsubV10Id : Mock.Core.ProtocolId := ⟨"/meshsub/1.0.0"⟩ +def meshsubV10Id : Libp2p.Core.ProtocolId := ⟨"/meshsub/1.0.0"⟩ /-- The floodsub protocol id, `/floodsub/1.0.0`. -/ -def floodsubId : Mock.Core.ProtocolId := ⟨"/floodsub/1.0.0"⟩ +def floodsubId : Libp2p.Core.ProtocolId := ⟨"/floodsub/1.0.0"⟩ /-- The gossipsub RPC cap: 1 MiB, the go-libp2p-pubsub default. -/ def rpcCap : Nat := 1048576 /-- The identify protocol id, `/ipfs/id/1.0.0`. -/ -def identifyId : Mock.Core.ProtocolId := ⟨"/ipfs/id/1.0.0"⟩ +def identifyId : Libp2p.Core.ProtocolId := ⟨"/ipfs/id/1.0.0"⟩ /-- The identify push protocol id, `/ipfs/id/push/1.0.0`. -/ -def identifyPushId : Mock.Core.ProtocolId := ⟨"/ipfs/id/push/1.0.0"⟩ +def identifyPushId : Libp2p.Core.ProtocolId := ⟨"/ipfs/id/push/1.0.0"⟩ /-- The ping protocol id, `/ipfs/ping/1.0.0`. -/ -def pingId : Mock.Core.ProtocolId := ⟨"/ipfs/ping/1.0.0"⟩ +def pingId : Libp2p.Core.ProtocolId := ⟨"/ipfs/ping/1.0.0"⟩ /-- The identify message cap, inherited from the spike: 65536 bytes. -/ def identifyCap : Nat := 65536 diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/HostSeam.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/HostSeam.lean new file mode 100644 index 00000000..b813e922 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/HostSeam.lean @@ -0,0 +1,25 @@ +import Libp2p.Host +import Libp2p.Core + +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub + +/-- The host operations used by a gossipsub node. -/ +structure HostSeam where + /-- Open a stream to a peer. -/ + newStream : Libp2p.Identity.PeerId → List Libp2p.Core.ProtocolId → + Conc.Async (Except Libp2p.Core.Error (Libp2p.Core.ProtocolId × Streams.Stream)) + /-- Register a stream handler. -/ + setHandler : Libp2p.Core.StreamHandler → BaseIO Unit + +/-- Adapt a host to the node operations. -/ +def HostSeam.ofHost (host : Libp2p.Host.Host) : HostSeam where + setHandler := host.setHandler + newStream := fun peer ids => do + match ← host.newStream peer ids with + | .ok pair => return .ok pair + | .error (.stream e) => return .error e + | .error e => return .error (.protocol e.render) + +end Libp2p.Protocols.Gossipsub diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index 260cc548..1aa7e438 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -2,8 +2,9 @@ import Conc import Streams import Bytes import Libp2p.Protocols.Constants -import Libp2p.Protocols.Mock.Core -import Libp2p.Protocols.Mock.Host +import Libp2p.Core +import Libp2p.Protocols.StreamError +import Libp2p.Protocols.Gossipsub.HostSeam import Libp2p.Protocols.Gossipsub.Core /-! @@ -108,7 +109,6 @@ set_option autoImplicit false namespace Libp2p.Protocols.Gossipsub.Node -open Mock.Host (HostSeam) /-- A spread a caller may choose for heartbeat gossip. Each delay stays below one second. The multiplier is prime, so the delays do not repeat with a short @@ -236,7 +236,7 @@ for the capacity constructor. Each copySlice writes only the next slice at cursor, without retaining an earlier body value. EOF between frames is clean. The repeat loop uses constant stack space for any number of chunks or frames. -/ private def readFrames (events : Conc.Chan Input) (peer : Peer) - (stream : Streams.Stream) : Conc.Async (Except Mock.Core.Error Unit) := do + (stream : Streams.Stream) : Conc.Async (Except Libp2p.Core.Error Unit) := do let mut header := ByteArray.emptyWithCapacity Varint.maxBytes let mut body := ByteArray.empty let mut expected : Option Nat := none @@ -255,7 +255,7 @@ private def readFrames (events : Conc.Chan Input) (peer : Peer) if pos = chunk.size then -- Park: the peer writes, half-closes, or resets. match ← stream.read with - | .error e => return .error (Mock.Core.mapStreamError e) + | .error e => return .error (mapStreamError e) | .ok none => return if header.isEmpty && expected.isNone then .ok () else .error (.protocol "the stream ended inside an RPC frame") @@ -290,9 +290,10 @@ private def readFrames (events : Conc.Chan Input) (peer : Peer) /-- Serve every RPC of a peer's stream in arrival order. Frame errors reset both directions. A clean EOF closes our write direction. The host owns and joins this handler task. -/ -def handler (events : Conc.Chan Input) : Mock.Core.StreamHandler where +def handler (events : Conc.Chan Input) : Libp2p.Core.StreamHandler where id := Constants.meshsubId - handle peer stream := ExceptT.mk do + handle ctx stream := ExceptT.mk do + let peer := ctx.remotePeer -- The opener joins before its first RPC. Repeat joins are loop no-ops. if let .error _ ← events.send (.event (.peerJoined peer .meshsub)) then stream.close diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean b/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean index 77b3d86d..797010e8 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Ping.lean @@ -2,7 +2,8 @@ import Bytes import Conc import Streams.Error import Streams.Stream -import Libp2p.Protocols.Mock.Core +import Libp2p.Core +import Libp2p.Protocols.StreamError import Libp2p.Protocols.Constants /-! @@ -27,7 +28,7 @@ set_option autoImplicit false namespace Libp2p.Protocols.Ping /-- The ping protocol id. -/ -def protocolId : Mock.Core.ProtocolId := Constants.pingId +def protocolId : Libp2p.Core.ProtocolId := Constants.pingId /-- The ping payload size of the spec. -/ def payloadSize : Nat := Constants.pingSize @@ -138,14 +139,14 @@ def handleCore (s : Streams.Stream) : Conc.Async (Except Error Unit) := /-- One map from this protocol's error into the coarse core error, used once, at the seam value. -/ -def mapPingError : Error → Mock.Core.Error - | .stream e => Mock.Core.mapStreamError e +def mapPingError : Error → Libp2p.Core.Error + | .stream e => mapStreamError e | e => .protocol (toString (repr e)) /-- The ping handler value. -/ -def pingHandler : Mock.Core.StreamHandler := +def pingHandler : Libp2p.Core.StreamHandler := { id := protocolId, - handle := fun s => ExceptT.mk ((Except.mapError mapPingError) <$> handleCore s) } + handle := fun _ s => ExceptT.mk ((Except.mapError mapPingError) <$> handleCore s) } /-- Accumulate echo chunks until one whole payload is present. The initiator reads one 32-byte payload, so an accumulator above that size diff --git a/packages/libp2p/protocols/Libp2p/Protocols/StreamError.lean b/packages/libp2p/protocols/Libp2p/Protocols/StreamError.lean new file mode 100644 index 00000000..7aae5315 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/StreamError.lean @@ -0,0 +1,14 @@ +import Streams.Error +import Libp2p.Core.Error + +set_option autoImplicit false + +namespace Libp2p.Protocols + +/-- Map a stream failure to the core error at a protocol boundary. -/ +def mapStreamError : Streams.Error → Libp2p.Core.Error + | .closed => .closed "the stream closed" + | .reset => .protocol "the peer reset the stream" + | .timeout => .timeout "the stream deadline expired" + +end Libp2p.Protocols From 99e0d7907487538def4208c98fb89c8a9815c3d1 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:49:42 +0800 Subject: [PATCH 102/125] feat(negotiate): negotiate protocols on streams --- .../negotiate/Libp2p/Negotiate/Upgrade.lean | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean index 0f9dc45c..e2b20708 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean @@ -558,4 +558,39 @@ def upgrade (role : Libp2p.Core.Role) (conn : Streams.Connection) ExceptT Libp2p.Core.Error Conc.Async (Libp2p.Core.MuxSession × Libp2p.Identity.PeerId) := upgradeWith Conc.Clock.system role conn leftover expected secs muxers +/-- Negotiate a protocol on a muxed stream and retain any extra bytes. -/ +def negotiateStream (role : Libp2p.Core.Role) (stream : Streams.Stream) + (ids : List Libp2p.Core.ProtocolId) : + ExceptT Libp2p.Core.Error Conc.Async (Libp2p.Core.ProtocolId × Streams.Stream) := do + let (initial, writes) ← match role with + | .initiator => (initiatorStart ids).mapError mapNegotiateError + | .responder => (responderStart ids).mapError mapNegotiateError + let mut state := initial + let mut input : List Nat := [] + for bytes in writes do + if let .error e ← stream.write (Bytes.ofNatList bytes) then + throw (.closed s!"stream negotiation write failed: {repr e}") + repeat + match drive 1 state input with + | .error e => throw (mapNegotiateError e) + | .ok result => + let (next, rest, writes, selected) := match result with + | .selected p _ rest writes => (state, rest, writes, some p) + | .waiting s _ rest writes => (s, rest, writes, none) + | .running s _ rest writes => (s, rest, writes, none) + for bytes in writes do + if let .error e ← stream.write (Bytes.ofNatList bytes) then + throw (.closed s!"stream negotiation write failed: {repr e}") + if let some p := selected then + let s ← Streams.Stream.prependBytes stream (Bytes.ofNatList rest) + return (p, s) + state := next + input := rest + if result matches .waiting .. then + match ← stream.read with + | .error e => throw (.closed s!"stream negotiation read failed: {repr e}") + | .ok none => throw (.closed "stream ended during negotiation") + | .ok (some bytes) => input := input ++ Bytes.toNatList bytes + throw (.closed "stream negotiation ended without a selection") + end Libp2p.Negotiate \ No newline at end of file From 26e908db4b48a147c18abe84e3ded7f2bf5b9707 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:51:45 +0800 Subject: [PATCH 103/125] fix(host): connect the negotiation checkpoint --- packages/libp2p/host/Libp2p/Host/Swarm.lean | 17 +++---- .../negotiate/Libp2p/Negotiate/Upgrade.lean | 45 ++++++++++++++----- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/packages/libp2p/host/Libp2p/Host/Swarm.lean b/packages/libp2p/host/Libp2p/Host/Swarm.lean index a23b2229..61f15aea 100644 --- a/packages/libp2p/host/Libp2p/Host/Swarm.lean +++ b/packages/libp2p/host/Libp2p/Host/Swarm.lean @@ -3,7 +3,7 @@ import Conc import Streams import Libp2p.Identity import Libp2p.Core -import Libp2p.Host.Mock.Negotiate +import Libp2p.Negotiate import Libp2p.Host.Error import Libp2p.Host.Registry import Libp2p.Host.Dedup @@ -69,10 +69,7 @@ set_option autoImplicit false namespace Libp2p.Host --- The mocks of the unmerged packages (RULES.md, "Working ahead of the --- tree"); `Negotiate.Error` resolves through `Libp2p.Host.Mock`. -open Conc Streams Multiformats Libp2p.Host.Mock Libp2p.Identity - Libp2p.Core Libp2p.Host.Mock.Negotiate +open Conc Streams Multiformats Libp2p.Identity Libp2p.Core Libp2p.Negotiate /-- A failure of a task that no caller awaits: an inbound upgrade, a session that ended badly, an inbound stream. -/ @@ -260,7 +257,7 @@ private def upgradeConn (h : Host) (role : Role) (conn : Connection) (expected : conn.closeQuietly let unregister : BaseIO Unit := h.pending.update (·.map (·.erase key)) let r ← try - (upgradeWith h.config.clock role conn .empty expected (h.admit role scope conn) + (upgradeAdmit h.config.clock role conn .empty expected (h.admit role scope conn) h.config.security h.config.muxers).run catch e => unregister @@ -295,8 +292,8 @@ private def serveStream (h : Host) (ctx : StreamContext) (scope : ConnectionScop share.release -- The peer ended the stream before it chose a protocol: an orderly -- end, not a failure. - unless e matches .closed do - h.report (some peer) none (.stream e.toCore) + unless e matches .closed _ do + h.report (some peer) none (.stream e) | .ok (id, s) => match (← (h.handlers.get : BaseIO Handlers)).find? id with | none => @@ -508,7 +505,7 @@ owns the stream. -/ def newStream (h : Host) (peer : PeerId) (protocols : List ProtocolId) : Async (Except Error (ProtocolId × Stream)) := do let some first := protocols.head? - | return .error (.stream (Negotiate.Error.noProtocols.toCore)) + | return .error (.stream (.protocol "multistream: no protocol to propose")) if !(← (h.connected peer : BaseIO Bool)) then if let .error e ← h.dialPeer peer then return .error e let some e := SessionTable.live? (← (h.sessions.get : BaseIO (SessionTable Served))) peer @@ -524,7 +521,7 @@ def newStream (h : Host) (peer : PeerId) (protocols : List ProtocolId) : | .error err => s.reset share.release - return .error (.stream err.toCore) + return .error (.stream err) | .ok (id, s) => -- The share returns when the stream is gone: at a reset, or once -- our close and the peer's end have both happened. diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean index e2b20708..5d641e59 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean @@ -482,10 +482,22 @@ def negotiate (role : Libp2p.Core.Role) (reader : Streams.BufReader) /-- The pipeline: negotiate the security protocol, run the handshake, check the identity, negotiate the muxer, open the session. Each lookup is total; a miss after a selection is `protocol`. -/ -private def upgradeCore (role : Libp2p.Core.Role) (conn : Streams.Connection) +/-- An upgraded session and its secure connection completion. -/ +structure Upgraded where + session : Libp2p.Core.MuxSession + remotePeer : Libp2p.Identity.PeerId + join : ExceptT Libp2p.Core.Error Conc.Async Unit + +/-- The result of the post-handshake admission checkpoint. -/ +inductive UpgradeResult (ρ : Type) where + | upgraded (value : Upgraded) + | refused (reason : ρ) + +private def upgradeCore {ρ : Type} (role : Libp2p.Core.Role) (conn : Streams.Connection) (leftover : ByteArray) (expected : Option Libp2p.Identity.PeerId) + (admit : Libp2p.Identity.PeerId → BaseIO (Option ρ)) (secs : List Libp2p.Core.SecurityProtocol) (muxers : List Libp2p.Core.Muxer) : - Conc.Async (Except Libp2p.Core.Error (Libp2p.Core.MuxSession × Libp2p.Identity.PeerId)) := do + Conc.Async (Except Libp2p.Core.Error (UpgradeResult ρ)) := do let reader := Streams.BufReader.new conn match ← negotiate role reader conn leftover (secs.map (·.id)) with | .error e => return .error e @@ -499,6 +511,9 @@ private def upgradeCore (role : Libp2p.Core.Role) (conn : Streams.Connection) match checkIdentity expected sc.remotePeer with | .error e => return .error e | .ok () => + if let some reason ← (admit sc.remotePeer : BaseIO (Option ρ)) then + sc.toConnection.closeQuietly + return .ok (.refused reason) let muxReader := Streams.BufReader.new sc.toConnection match ← negotiate role muxReader sc.toConnection ByteArray.empty (muxers.map (·.id)) with | .error e => return .error e @@ -508,7 +523,7 @@ private def upgradeCore (role : Libp2p.Core.Role) (conn : Streams.Connection) | some mux => match ← mux.«open» role sc.toConnection stage2.leftover with | .error e => return .error e - | .ok session => return .ok (session, sc.remotePeer) + | .ok session => return .ok (.upgraded { session, remotePeer := sc.remotePeer, join := sc.join }) /-- Whether the deadline boundary already closed the connection. `withinDeadline` returns an error value only on expiry, after the hook @@ -519,25 +534,25 @@ private def boundaryClosed {α : Type} : Except Conc.Error α → Bool /-- Close a session that finished after the timer won. No caller receives that session. -/ -private def releaseLate (r : Except Libp2p.Core.Error - (Libp2p.Core.MuxSession × Libp2p.Identity.PeerId)) : BaseIO Unit := +private def releaseLate {ρ : Type} (r : Except Libp2p.Core.Error (UpgradeResult ρ)) : BaseIO Unit := match r with - | .ok pair => discard pair.1.close.toBaseIO - | .error _ => pure () + | .ok (.upgraded value) => discard value.session.close.toBaseIO + | _ => pure () /-- The upgrade under one `Conc.withinDeadline`. The expiry closes the connection once at the boundary (root §3.5), and the race's `deadlineExpired` maps to the `timeout` arm through `settleUpgrade`. The clock is a seam, so the deadline test can advance it. -/ -def upgradeWith (clock : Conc.Clock) (role : Libp2p.Core.Role) +def upgradeAdmit {ρ : Type} (clock : Conc.Clock) (role : Libp2p.Core.Role) (conn : Streams.Connection) (leftover : ByteArray) (expected : Option Libp2p.Identity.PeerId) + (admit : Libp2p.Identity.PeerId → BaseIO (Option ρ)) (secs : List Libp2p.Core.SecurityProtocol) (muxers : List Libp2p.Core.Muxer) : - ExceptT Libp2p.Core.Error Conc.Async (Libp2p.Core.MuxSession × Libp2p.Identity.PeerId) := do + ExceptT Libp2p.Core.Error Conc.Async (UpgradeResult ρ) := do let answer ← Conc.withinDeadline clock Streams.unauthDeadlineMs (Streams.Connection.closeQuietly conn) - (upgradeCore role conn leftover expected secs muxers) + (upgradeCore role conn leftover expected admit secs muxers) releaseLate match settleUpgrade answer with | .ok pair => return pair @@ -549,6 +564,16 @@ def upgradeWith (clock : Conc.Clock) (role : Libp2p.Core.Role) Streams.Connection.closeQuietly conn throw e +/-- Upgrade with a clock and admit every authenticated peer. -/ +def upgradeWith (clock : Conc.Clock) (role : Libp2p.Core.Role) + (conn : Streams.Connection) (leftover : ByteArray) + (expected : Option Libp2p.Identity.PeerId) + (secs : List Libp2p.Core.SecurityProtocol) (muxers : List Libp2p.Core.Muxer) : + ExceptT Libp2p.Core.Error Conc.Async (Libp2p.Core.MuxSession × Libp2p.Identity.PeerId) := do + match ← upgradeAdmit clock role conn leftover expected (fun _ => pure (none : Option Empty)) secs muxers with + | .upgraded value => return (value.session, value.remotePeer) + | .refused reason => nomatch reason + /-- The frozen entry of the package docs. The clock is fixed to the system clock here and stays a seam in `upgradeWith` for the deadline test. -/ From b85932d2d29ce69209d7e603d2af37a17c656582 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:51:55 +0800 Subject: [PATCH 104/125] fix(host): remove the negotiate mock --- .../host/Libp2p/Host/Mock/Negotiate.lean | 91 ------------------- 1 file changed, 91 deletions(-) delete mode 100644 packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean diff --git a/packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean b/packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean deleted file mode 100644 index a83d5ffe..00000000 --- a/packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean +++ /dev/null @@ -1,91 +0,0 @@ -import Conc -import Streams -import Libp2p.Identity -import Libp2p.Core - -/-! -# Mock: the surface of `libp2p/negotiate` that the host uses - -W5.5 (`libp2p/negotiate`) has not merged, so the host builds against a -mock (RULES.md, "Working ahead of the tree"). The mock imports nothing -from the real package, and it states no axiom. Both entries fail with -the name of the package: no body negotiates, runs a handshake, or opens -a session. - -The negotiate ARCHITECTURE freezes `Negotiate.upgrade`. The host needs -two calls beside it, and the negotiate ARCHITECTURE gains both in this -unit: - -* `upgradeWith`, the upgrade with a checkpoint after the handshake and - before the muxer stage. The host's gater and resource manager decide - there, by the proved peer (the core mock, "The policy shapes"); go's - upgrader calls `InterceptSecured` at the same place - (`vendor/go-libp2p/p2p/net/upgrader/upgrader.go`, line 176). A refusal is a - value, `UpgradeResult.refused`, and no `Core.Error` arm (§3.5). -* `negotiateStream`, multistream-select over one muxed stream. The host - serves each inbound stream through it, and `newStream` negotiates - each outbound one. The bytes it reads past the selecting message go - to the stream's front (§4.7, `Stream.prependBytes`). - -At the integration, the host imports `Libp2p.Negotiate`, and the module -goes. --/ - -set_option autoImplicit false - -namespace Libp2p.Host.Mock.Negotiate - -open Conc Streams Libp2p.Identity Libp2p.Core - -/-- How a stream negotiation failed. The host reads two arms: the end of -the stream before a selection, an orderly end, and the empty proposal -list. -/ -inductive Error where - /-- The stream ended before the negotiation selected a protocol. -/ - | closed - /-- The initiator has no id to propose. -/ - | noProtocols - /-- Any other failure, rendered. -/ - | failed (detail : String) - deriving DecidableEq, Repr - -/-- The failure as the coarse seam error (§3.5). -/ -def Error.toCore : Error → Core.Error - | .closed => .closed "multistream: the stream ended before a selection" - | .noProtocols => .protocol "multistream: no protocol to propose" - | .failed d => .protocol s!"multistream: {d}" - -/-- A connection that the upgrade finished. -/ -structure Upgraded where - /-- The muxer session over the secure connection. -/ - session : MuxSession - /-- The peer id that the handshake proved, and the upgrader checked. -/ - remotePeer : PeerId - /-- Wait until the session and the secure connection under it end. -/ - join : ExceptT Core.Error Async Unit - -/-- How an upgrade with a checkpoint ended, when no stage failed. -/ -inductive UpgradeResult (ρ : Type) where - /-- Every stage ran, and the checkpoint admitted the peer. -/ - | upgraded (u : Upgraded) - /-- The checkpoint refused the proved peer, for `reason`. -/ - | refused (reason : ρ) - -/-- The upgrade with a checkpoint: negotiate the security protocol, run -the handshake, check the expected peer, call `admit` with the proved -peer, negotiate the muxer, and open the session, all under the 30 s -deadline on `clock` (§5.4). -/ -opaque upgradeWith {ρ : Type} (_clock : Clock) (_role : Role) (_conn : Connection) - (_pending : ByteArray) (_expected : Option PeerId) (_admit : PeerId → BaseIO (Option ρ)) - (_secs : List SecurityProtocol) (_muxers : List Muxer) : - ExceptT Core.Error Async (UpgradeResult ρ) := - throw (.closed "libp2p/negotiate (W5.5) is not integrated") - -/-- Negotiate one of `ids` over a stream, as `role`. The answer is the -selected id and the stream, with the bytes read past the selecting -message at its front. -/ -opaque negotiateStream (_role : Role) (_s : Stream) (_ids : List ProtocolId) : - ExceptT Error Async (ProtocolId × Stream) := - throw (.failed "libp2p/negotiate (W5.5) is not integrated") - -end Libp2p.Host.Mock.Negotiate \ No newline at end of file From 714e95ce0da05505fe3b7dadb52949f2c07fee55 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:52:57 +0800 Subject: [PATCH 105/125] fix(protocols): use core identify handler types --- .../protocols/Libp2p/Protocols/Identify.lean | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean index f211d720..b06fe5d7 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean @@ -8,6 +8,7 @@ import Multiformats.Multiaddr.Binary import Libp2p.Protocols.Identity import Libp2p.Protocols.Mock.Host import Libp2p.Protocols.Constants +import Libp2p.Protocols.StreamError /-! # Identify @@ -58,10 +59,10 @@ namespace Identify /-- The identify protocol id. -/ -def protocolId : Mock.Core.ProtocolId := Constants.identifyId +def protocolId : Libp2p.Core.ProtocolId := Constants.identifyId /-- The identify push protocol id. -/ -def pushId : Mock.Core.ProtocolId := Constants.identifyPushId +def pushId : Libp2p.Core.ProtocolId := Constants.identifyPushId /-- The wire view: one entry per present singular field and per element of a repeated field, in the ascending order 1, 2, 3, 4, 5, 6, @@ -486,8 +487,8 @@ def client (s : Streams.Stream) (clock : Conc.Clock) (timeoutMs : Nat) /-- The one map from this protocol's error into the coarse core error. `pushHandler` and `writeRecord` call it at the seam. -/ -def mapIdentifyError : Error → Mock.Core.Error - | .stream e => Mock.Core.mapStreamError e +def mapIdentifyError : Error → Libp2p.Core.Error + | .stream e => mapStreamError e | .timeout => .timeout "identify: the deadline expired" | .emptyFrame => .protocol "identify: a zero-length frame" | e => .protocol (toString (repr e)) @@ -513,9 +514,9 @@ def push (store : Mock.Host.Peerstore) (expected : Libp2p.Identity.PeerId) /-- The push handler value. -/ def pushHandler (store : Mock.Host.Peerstore) (expected : Libp2p.Identity.PeerId) - (clock : Conc.Clock) (timeoutMs : Nat) : Mock.Core.StreamHandler := + (clock : Conc.Clock) (timeoutMs : Nat) : Libp2p.Core.StreamHandler := { id := pushId, - handle := fun s => ExceptT.mk ((Except.mapError mapIdentifyError) <$> + handle := fun _ s => ExceptT.mk ((Except.mapError mapIdentifyError) <$> push store expected clock timeoutMs s) } /-- Write one length-delimited message and half-close. The @@ -524,7 +525,7 @@ record, and only the protocol id of the stream differs. -/ private def writeRecord (key : Libp2p.Identity.PubKey) (addrs : List (List Nat)) (protocols : List String) (observed : Option (List Nat)) (agent : String) (spr : Option (List Nat)) (s : Streams.Stream) : - Conc.Async (Except Mock.Core.Error Unit) := do + Conc.Async (Except Libp2p.Core.Error Unit) := do match respond? key addrs protocols observed agent spr with | .error e => return .error (mapIdentifyError e) | .ok msg => @@ -533,7 +534,7 @@ private def writeRecord (key : Libp2p.Identity.PubKey) (addrs : List (List Nat)) | none => return .error (.protocol "the identify record exceeds the cap") | some frame => match ← s.write frame with - | .error e => return .error (Mock.Core.mapStreamError e) + | .error e => return .error (mapStreamError e) | .ok () => discard s.close return .ok () @@ -543,7 +544,7 @@ half-close. -/ def respondTo (key : Libp2p.Identity.PubKey) (addrs : List (List Nat)) (protocols : List String) (observed : Option (List Nat)) (agent : String) (spr : Option (List Nat) := none) (s : Streams.Stream) : - Conc.Async (Except Mock.Core.Error Unit) := + Conc.Async (Except Libp2p.Core.Error Unit) := writeRecord key addrs protocols observed agent spr s /-- The push sender: announce this peer's record on the stream that the @@ -559,15 +560,15 @@ starts no timer. -/ def pushTo (key : Libp2p.Identity.PubKey) (addrs : List (List Nat)) (protocols : List String) (observed : Option (List Nat)) (agent : String) (spr : Option (List Nat) := none) (s : Streams.Stream) : - Conc.Async (Except Mock.Core.Error Unit) := + Conc.Async (Except Libp2p.Core.Error Unit) := writeRecord key addrs protocols observed agent spr s /-- The responder handler value. -/ def respondHandler (key : Libp2p.Identity.PubKey) (addrs : List (List Nat)) (protocols : List String) (observed : Option (List Nat)) (agent : String) - (spr : Option (List Nat) := none) : Mock.Core.StreamHandler := + (spr : Option (List Nat) := none) : Libp2p.Core.StreamHandler := { id := protocolId, - handle := fun s => respondTo key addrs protocols observed agent spr s } + handle := fun _ s => respondTo key addrs protocols observed agent spr s } /-! ## The client-core theorems From 5919010549f80f9bd8b49549f943d16d0f0045ac Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:55:25 +0800 Subject: [PATCH 106/125] fix(protocols): store identify addresses in host --- Tests/Protocols.lean | 42 +++++++++++-------- .../protocols/Libp2p/Protocols/Identify.lean | 13 +++--- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/Tests/Protocols.lean b/Tests/Protocols.lean index 6f3be97e..e4c9eb10 100644 --- a/Tests/Protocols.lean +++ b/Tests/Protocols.lean @@ -8,8 +8,8 @@ import Streams.Framing import Streams.Stream import Libp2p.Protocols import Libp2p.Protocols.Identity -import Libp2p.Protocols.Mock.Core -import Libp2p.Protocols.Mock.Host +import Libp2p.Core +import Libp2p.Host /-! # Identify and ping suite @@ -62,6 +62,14 @@ private def emptyIdentify : Identify := ⟨none, [], [], none, none, none, none private def msgPayload (m : Identify) : List Nat := Pb.encodeMessage m +private def testHost : IO Libp2p.Host.Host := do + let key ← match PrivKey.ofData? .ed25519 (ByteArray.mk (Array.replicate 32 1)) with + | .ok key => pure key + | .error e => throw (IO.userError s!"test key: {repr e}") + match ← Libp2p.Host.Host.new { identity := key, transports := [], security := [], muxers := [] } with + | .ok host => pure host + | .error e => throw (IO.userError s!"test host: {repr e}") + /-! ## The private stream pair Two `Conc.Pipe` values wired like the spike's `mkSplit` @@ -112,16 +120,16 @@ private def isOkNone {ε α : Type} (x : Except ε (Option α)) : Bool := | _ => false /-- True when the handler answer is a protocol error. -/ -private def isProtocolError (x : Except Libp2p.Protocols.Mock.Core.Error Unit) : Bool := +private def isProtocolError (x : Except Libp2p.Core.Error Unit) : Bool := match x with | .error (.protocol _) => true | _ => false /-- Run the handler on `s`, and fail the task when the handler answers an error. -/ -private def runHandler (h : Libp2p.Protocols.Mock.Core.StreamHandler) - (s : Streams.Stream) : Conc.Async Unit := do - let res : Except Libp2p.Protocols.Mock.Core.Error Unit ← h.handle s +private def runHandler (h : Libp2p.Core.StreamHandler) + (s : Streams.Stream) (peer : PeerId := testPeer 1) : Conc.Async Unit := do + let res : Except Libp2p.Core.Error Unit ← h.handle { remotePeer := peer, remoteAddr := Multiformats.Multiaddr.empty } s match res with | .error e => throw (IO.userError s!"the handler answered {repr e}") | .ok () => pure () @@ -173,7 +181,7 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do do let pair ← mkPair let tc ← Conc.TestClock.new - let store ← Mock.Host.inMemory + let store ← testHost let key := testKey 1 let pid : PeerId := peerIdOfPubKey key let addr := [4, 127, 0, 0, 1, 6, 138, 43] @@ -184,19 +192,19 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do | some frame => let ts ← Conc.TaskSet.new ts.spawn do - runHandler (Identify.pushHandler store pid tc.clock 1000) pair.b + runHandler (Identify.pushHandler store tc.clock 1000) pair.b pid discard <| pair.a.write frame ts.joinAll let stored ← store.addrsOf pid r.check "identify e2e push updates the peerstore and reads back" - (stored == [addr]) + (stored.map Multiformats.Multiaddr.toBytes == [addr]) tc.close -- identify: a second push of the same addresses grows nothing do let pair ← mkPair let tc ← Conc.TestClock.new - let store ← Mock.Host.inMemory + let store ← testHost let key := testKey 1 let pid : PeerId := peerIdOfPubKey key let addr := [4, 127, 0, 0, 1, 6, 138, 43] @@ -207,30 +215,30 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do | some frame => let ts ← Conc.TaskSet.new ts.spawn do - runHandler (Identify.pushHandler store pid tc.clock 1000) pair.b + runHandler (Identify.pushHandler store tc.clock 1000) pair.b pid discard <| pair.a.write frame ts.joinAll let ts2 ← Conc.TaskSet.new ts2.spawn do - runHandler (Identify.pushHandler store pid tc.clock 1000) pair.b + runHandler (Identify.pushHandler store tc.clock 1000) pair.b pid discard <| pair.a.write frame ts2.joinAll let stored ← store.addrsOf pid r.check "identify e2e push redial adds no duplicate address" - (stored == [addr]) + (stored.map Multiformats.Multiaddr.toBytes == [addr]) tc.close -- identify: the push sender announces the record and the receiver stores it do let pair ← mkPair let tc ← Conc.TestClock.new - let store ← Mock.Host.inMemory + let store ← testHost let key := testKey 7 let pid : PeerId := peerIdOfPubKey key let addr := [4, 127, 0, 0, 1, 6, 138, 43] let ts ← Conc.TaskSet.new ts.spawn do - runHandler (Identify.pushHandler store pid tc.clock 1000) pair.b + runHandler (Identify.pushHandler store tc.clock 1000) pair.b pid let sent ← Identify.pushTo key [addr] [] none "" none { pair.a with close := pair.ab.close } ts.joinAll @@ -404,11 +412,11 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do do let pair ← mkPair let tc ← Conc.TestClock.new - let answer : Conc.OnceRef (Option (Except Libp2p.Protocols.Mock.Core.Error Unit)) ← + let answer : Conc.OnceRef (Option (Except Libp2p.Core.Error Unit)) ← Conc.OnceRef.new none let ts ← Conc.TaskSet.new ts.spawn do - answer.set (some (← Ping.pingHandler.handle pair.b)) + answer.set (some (← Ping.pingHandler.handle { remotePeer := testPeer 1, remoteAddr := Multiformats.Multiaddr.empty } pair.b)) discard <| pair.a.write (Bytes.ofNatList (List.replicate 20 7)) pair.ab.close ts.joinAll diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean index b06fe5d7..1fe2295c 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Identify.lean @@ -6,7 +6,7 @@ import Streams.Framing import Streams.Stream import Multiformats.Multiaddr.Binary import Libp2p.Protocols.Identity -import Libp2p.Protocols.Mock.Host +import Libp2p.Host import Libp2p.Protocols.Constants import Libp2p.Protocols.StreamError @@ -41,7 +41,6 @@ set_option autoImplicit false namespace Libp2p.Protocols -open Mock /-- One identify response. The field numbers are the ones of the go-libp2p v0.50.0 identify protobuf. -/ @@ -497,7 +496,7 @@ def mapIdentifyError : Error → Libp2p.Core.Error An empty read leaves the peerstore unchanged. This is a delta from go, whose `consumeMessage` runs on the empty message and clears the peer's protocols. -/ -def push (store : Mock.Host.Peerstore) (expected : Libp2p.Identity.PeerId) +def push (store : Libp2p.Host.Host) (expected : Libp2p.Identity.PeerId) (clock : Conc.Clock) (timeoutMs : Nat) (s : Streams.Stream) : Conc.Async (Except Error Unit) := do match ← Conc.withinDeadline clock timeoutMs (resetQuietly s) (readFrame s) with @@ -509,15 +508,15 @@ def push (store : Mock.Host.Peerstore) (expected : Libp2p.Identity.PeerId) match validate payload (some expected) with | .error e => return .error e | .ok (m, pid) => - store.addAddrs pid m.listenAddrs + store.addAddrs pid (m.listenAddrs.filterMap (fun a => (Multiformats.Multiaddr.ofBytes? a).toOption)) return .ok () /-- The push handler value. -/ -def pushHandler (store : Mock.Host.Peerstore) (expected : Libp2p.Identity.PeerId) +def pushHandler (store : Libp2p.Host.Host) (clock : Conc.Clock) (timeoutMs : Nat) : Libp2p.Core.StreamHandler := { id := pushId, - handle := fun _ s => ExceptT.mk ((Except.mapError mapIdentifyError) <$> - push store expected clock timeoutMs s) } + handle := fun ctx s => ExceptT.mk ((Except.mapError mapIdentifyError) <$> + push store ctx.remotePeer clock timeoutMs s) } /-- Write one length-delimited message and half-close. The responder and the push sender share this body: both announce the same From dfb40a1ccffe720756318c10b86bd9bf9d908260 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:55:38 +0800 Subject: [PATCH 107/125] fix(protocols): remove dependency mocks --- .../protocols/Libp2p/Protocols/Mock/Core.lean | 52 ----- .../protocols/Libp2p/Protocols/Mock/Host.lean | 203 ------------------ 2 files changed, 255 deletions(-) delete mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean delete mode 100644 packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean deleted file mode 100644 index c082227f..00000000 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Core.lean +++ /dev/null @@ -1,52 +0,0 @@ -import Std -import Conc -import Streams -import Libp2p.Identity - -/-! -# Core seam mock - -`Libp2p.Core` (issue #15, PR #37) is not merged. This mock declares -the frozen seam shapes from `packages/libp2p/core/docs/ARCHITECTURE.md`: -`ProtocolId`, the coarse `Error`, and `StreamHandler`. The real core -package replaces this module at integration. --/ - -set_option autoImplicit false - -namespace Libp2p.Protocols.Mock.Core - -/-- A peer-defined protocol id, an open set of strings. The code -compares `ProtocolId` values only. -/ -structure ProtocolId where - toString : String - deriving DecidableEq, Hashable - -/-- The coarse error of the core seam, with the rendered inner error. -/ -inductive Error where - | protocol (rendered : String) - | authentication (rendered : String) - | timeout (rendered : String) - | closed (rendered : String) - deriving DecidableEq, BEq, Repr - -/-- The handler seam: the id it registers under, and the callback a -served stream reaches. The host looks a handler up by the protocol id -alone, and it knows the peer at the call site, so the seam carries the -peer. A gossipsub handler needs that id, because the core scores and -filters by the sender of each RPC. -/ -structure StreamHandler where - /-- The id the handler serves. -/ - id : ProtocolId - /-- The offered stream and the peer that opened it. -/ - handle : Libp2p.Identity.PeerId → Streams.Stream → ExceptT Error Conc.Async Unit - -/-- One map from a stream error to the coarse error, shared by every -handler of this package. `closed` stays closed, `reset` is a protocol -fault, and `timeout` stays timeout. -/ -def mapStreamError : Streams.Error → Error - | .closed => .closed "the stream closed" - | .reset => .protocol "the peer reset the stream" - | .timeout => .timeout "the stream deadline expired" - -end Libp2p.Protocols.Mock.Core \ No newline at end of file diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean b/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean deleted file mode 100644 index c5db4100..00000000 --- a/packages/libp2p/protocols/Libp2p/Protocols/Mock/Host.lean +++ /dev/null @@ -1,203 +0,0 @@ -import Std -import Conc -import Streams -import Libp2p.Identity -import Libp2p.Protocols.Mock.Core - -/-! -# Host seam mock - -`Libp2p.Host` (issue #19) is not merged. This mock declares the -peerstore surface that the identify push handler updates, from -`packages/libp2p/host/docs/ARCHITECTURE.md`: `addAddrs`, deduplicated -at add, and `addrsOf`. The host doc freezes the names and the dedup -rule, and it leaves the signatures open. This mock fixes minimal -signatures. The real host package replaces this module at integration. - -The module also declares `HostSeam`, the two calls the gossipsub loop -makes, and `Switch`, an in-memory double of that seam. The field names -follow PR #61 (Host.newStream, Host.setHandler). The consumer builds the -seam from a host h with these closures: - -```lean -let seam : HostSeam := - { newStream := fun peer ids => h.newStream peer ids - setHandler := fun handler => h.setHandler handler } -``` - -The switch retains its pipes until close. It serves finite test runs. A -long-running stress test needs a double that retires closed streams. --/ - -set_option autoImplicit false - -namespace Libp2p.Protocols.Mock.Host - -open Libp2p.Identity - -/-- The peerstore seam the push handler updates: addresses by peer, -deduplicated at add. -/ -structure Peerstore where - /-- Store the addresses of one peer, deduplicated at add. -/ - addAddrs : PeerId → List (List Nat) → IO Unit - /-- The stored addresses of one peer. -/ - addrsOf : PeerId → IO (List (List Nat)) - -/-- The executable in-memory double. Each call makes a fresh table: -bind the action once, and pass the resulting value to both the handler -and `addrsOf`. A second add of the same addresses grows nothing. The -table changes through `OnceRef.update`, one atomic step per add -(ARCHITECTURE §4.1, rule 3). -/ -opaque inMemory : IO Peerstore := do - let table ← Conc.OnceRef.new ([] : List (PeerId × List (List Nat))) - let addAddrs : PeerId → List (List Nat) → IO Unit := fun p addrs => - table.update fun cur => - let old := (cur.find? (fun e => e.1 == p)).map (·.2) |>.getD [] - let merged := (old ++ addrs).eraseDups - (p, merged) :: cur.filter (fun e => !(e.1 == p)) - let addrsOf : PeerId → IO (List (List Nat)) := fun p => do - let cur ← table.get - return (cur.find? (fun e => e.1 == p)).map (·.2) |>.getD [] - return { addAddrs, addrsOf } - -/-! ## The stream seam of a loop -/ - -/-- What a protocol loop calls out to. -/ -structure HostSeam where - /-- Open and negotiate one stream to the peer, choosing among the - protocols. -/ - newStream : PeerId → List Core.ProtocolId → - Conc.Async (Except Core.Error (Core.ProtocolId × Streams.Stream)) - /-- Register the handler of one protocol id. -/ - setHandler : Core.StreamHandler → BaseIO Unit - -/-! ## The in-memory switch - -The switch stands in for the session table of the host and its serve -task. Two peers meet over a pair of pipes, with no transport. -/ - -/-- One peer of the switch. -/ -private structure Switch.Entry where - /-- The peer. -/ - peer : PeerId - /-- The handlers the peer registered, the oldest first. -/ - handlers : List Core.StreamHandler - /-- The owner of the handler tasks of the inbound streams of the peer. -/ - tasks : Conc.TaskSet - -/-- The state of a switch. -/ -private structure Switch.State where - /-- The peers. -/ - entries : List Switch.Entry - /-- Every pipe the switch opened. The list lives as long as the switch, - which a test double allows. -/ - pipes : List Conc.Pipe - /-- True after `Switch.close`. -/ - closed : Bool - -/-- The in-memory double of the session table: the peers, their handlers, -and the pipes of their streams. -/ -structure Switch where - private state : Conc.OnceRef Switch.State - -namespace Switch - -/-- A switch with no peers. -/ -def new : BaseIO Switch := - return ⟨← Conc.OnceRef.new { entries := [], pipes := [], closed := false }⟩ - -/-- A stream over a pair of pipes. `read` reads `incoming`, and `write` -writes `outgoing`. `close` closes `outgoing`, the half-close. `reset` -closes both, so the peer of a reset reads the end of the stream and its -writes fail. -/ -private def streamOf (incoming outgoing : Conc.Pipe) : Streams.Stream where - read := do - let chunk ← incoming.read - return .ok chunk - write bytes := do - match ← outgoing.write bytes with - | .ok () => return .ok () - | .error _ => return .error .closed - close := (outgoing.close : BaseIO Unit) - reset := do - (incoming.close : BaseIO Unit) - (outgoing.close : BaseIO Unit) - -/-- The first protocol of the list that the handlers serve, with its -handler. -/ -private def choose (handlers : List Core.StreamHandler) : - List Core.ProtocolId → Option (Core.ProtocolId × Core.StreamHandler) - | [] => none - | p :: ps => - match handlers.find? (fun h => decide (h.id = p)) with - | some h => some (p, h) - | none => choose handlers ps - -/-- Run one handler on an inbound stream. A handler error fails the task, -so the `joinAll` of the owner reports it. -/ -private def serve (handler : Core.StreamHandler) (peer : PeerId) - (stream : Streams.Stream) : Conc.Async Unit := do - match ← (handler.handle peer stream).run with - | .ok () => return () - | .error e => throw <| IO.userError s!"handler {handler.id.toString}: {repr e}" - -/-- Open a stream from `me` to `peer`: pick the protocol, make the pipes, -spawn the handler on the task set of `peer`, and answer the initiator -stream. The call never parks. Eight chunks is far more than one RPC frame -needs, and the bound stays explicit. -/ -private def open_ (sw : Switch) (me peer : PeerId) (protocols : List Core.ProtocolId) : - BaseIO (Except Core.Error (Core.ProtocolId × Streams.Stream)) := do - let st ← sw.state.get - let some entry := st.entries.find? (fun e => decide (e.peer = peer)) - | return .error (.protocol s!"no peer {peer}") - let some (id, handler) := choose entry.handlers protocols - | return .error (.protocol s!"peer {peer} serves none of the protocols") - let toPeer ← Conc.Pipe.new 8 - let fromPeer ← Conc.Pipe.new 8 - -- One atomic step checks the flag and records the pipes, so `close` - -- sees every pipe of a stream that opens before it. - let opened ← sw.state.updateGet fun s => - if s.closed then (false, s) - else (true, { s with pipes := toPeer :: fromPeer :: s.pipes }) - if !opened then return .error (.closed "the switch closed") - entry.tasks.spawn (serve handler me (streamOf toPeer fromPeer)) - return .ok (id, streamOf fromPeer toPeer) - -/-- The seam of the peer `me`, and the task set that owns the handler -tasks of its inbound streams. Each peer starts with an empty handler -table, and a handler registered through one seam serves only that peer. -/ -def host (sw : Switch) (me : PeerId) : BaseIO (HostSeam × Conc.TaskSet) := do - let tasks ← Conc.TaskSet.new - sw.state.update fun s => - { s with - entries := { peer := me, handlers := [], tasks } :: - s.entries.filter (fun e => decide (e.peer ≠ me)) } - let seam : HostSeam := - { newStream := fun peer protocols => (sw.open_ me peer protocols : BaseIO _) - setHandler := fun handler => - sw.state.update fun s => - { s with - entries := s.entries.map fun e => - if e.peer = me then { e with handlers := e.handlers ++ [handler] } else e } } - return (seam, tasks) - -/-- End every stream the switch opened, then join the handler tasks -(ARCHITECTURE §4.1, rule 2). A close wakes every parked read and write. -A failed handler task does not stop the joins of the other peers. The call -throws the first error after the last join. A second call does nothing. -/ -def close (sw : Switch) : Conc.Async Unit := do - let st ← (sw.state.updateGet (fun s => (s, { s with closed := true, pipes := [] })) : - BaseIO Switch.State) - for p in st.pipes do - (p.close : BaseIO Unit) - let mut failure : Option IO.Error := none - for e in st.entries do - try - e.tasks.joinAll - catch err => - failure := failure <|> some err - if let some err := failure then throw err - -end Switch - -end Libp2p.Protocols.Mock.Host \ No newline at end of file From c6867cebfa235a4158dbfe5e2b10cd2ff99b9f4f Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:56:41 +0800 Subject: [PATCH 108/125] fix(tests): name the core switch types --- Tests/Gossipsub/Switch.lean | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Tests/Gossipsub/Switch.lean b/Tests/Gossipsub/Switch.lean index 4648bc7a..a5a36cc0 100644 --- a/Tests/Gossipsub/Switch.lean +++ b/Tests/Gossipsub/Switch.lean @@ -16,7 +16,7 @@ private structure Switch.Entry where /-- The peer. -/ peer : PeerId /-- The handlers the peer registered, the oldest first. -/ - handlers : List Core.StreamHandler + handlers : List Libp2p.Core.StreamHandler /-- The owner of the handler tasks of the inbound streams of the peer. -/ tasks : Conc.TaskSet @@ -60,8 +60,8 @@ private def streamOf (incoming outgoing : Conc.Pipe) : Streams.Stream where /-- The first protocol of the list that the handlers serve, with its handler. -/ -private def choose (handlers : List Core.StreamHandler) : - List Core.ProtocolId → Option (Core.ProtocolId × Core.StreamHandler) +private def choose (handlers : List Libp2p.Core.StreamHandler) : + List Libp2p.Core.ProtocolId → Option (Libp2p.Core.ProtocolId × Libp2p.Core.StreamHandler) | [] => none | p :: ps => match handlers.find? (fun h => decide (h.id = p)) with @@ -70,7 +70,7 @@ private def choose (handlers : List Core.StreamHandler) : /-- Run one handler on an inbound stream. A handler error fails the task, so the `joinAll` of the owner reports it. -/ -private def serve (handler : Core.StreamHandler) (peer : PeerId) +private def serve (handler : Libp2p.Core.StreamHandler) (peer : PeerId) (stream : Streams.Stream) : Conc.Async Unit := do match ← (handler.handle { remotePeer := peer, remoteAddr := Multiformats.Multiaddr.empty } stream).run with | .ok () => return () @@ -80,8 +80,8 @@ private def serve (handler : Core.StreamHandler) (peer : PeerId) spawn the handler on the task set of `peer`, and answer the initiator stream. The call never parks. Eight chunks is far more than one RPC frame needs, and the bound stays explicit. -/ -private def open_ (sw : Switch) (me peer : PeerId) (protocols : List Core.ProtocolId) : - BaseIO (Except Core.Error (Core.ProtocolId × Streams.Stream)) := do +private def open_ (sw : Switch) (me peer : PeerId) (protocols : List Libp2p.Core.ProtocolId) : + BaseIO (Except Libp2p.Core.Error (Libp2p.Core.ProtocolId × Streams.Stream)) := do let st ← sw.state.get let some entry := st.entries.find? (fun e => decide (e.peer = peer)) | return .error (.protocol s!"no peer {peer}") From 4f8a7141ecff443e35b86bea0ec6990a5ccb7cb6 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:57:42 +0800 Subject: [PATCH 109/125] fix(negotiate): correct upgrade documentation --- packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean index 5d641e59..ff75843b 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean @@ -479,9 +479,6 @@ def negotiate (role : Libp2p.Core.Role) (reader : Streams.BufReader) sendWrites conn first negotiateLoop reader conn s (Bytes.toNatList seed) -/-- The pipeline: negotiate the security protocol, run the handshake, -check the identity, negotiate the muxer, open the session. Each lookup -is total; a miss after a selection is `protocol`. -/ /-- An upgraded session and its secure connection completion. -/ structure Upgraded where session : Libp2p.Core.MuxSession From 6cdf23983e58c070c2b6b39ff31da59985fb1aff Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:00:16 +0800 Subject: [PATCH 110/125] fix(negotiate): join secure upgrade tasks --- .../negotiate/Libp2p/Negotiate/Upgrade.lean | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean index ff75843b..fe8415a2 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean @@ -506,21 +506,42 @@ private def upgradeCore {ρ : Type} (role : Libp2p.Core.Role) (conn : Streams.Co | .error e => return .error e | .ok sc => match checkIdentity expected sc.remotePeer with - | .error e => return .error e + | .error e => + sc.toConnection.closeQuietly + discard sc.join.run + return .error e | .ok () => if let some reason ← (admit sc.remotePeer : BaseIO (Option ρ)) then sc.toConnection.closeQuietly + discard sc.join.run return .ok (.refused reason) let muxReader := Streams.BufReader.new sc.toConnection match ← negotiate role muxReader sc.toConnection ByteArray.empty (muxers.map (·.id)) with - | .error e => return .error e + | .error e => + sc.toConnection.closeQuietly + discard sc.join.run + return .error e | .ok stage2 => match muxers.find? (fun m => decide (m.id = stage2.proto)) with - | none => return .error (.protocol "selected muxer is not configured") + | none => + sc.toConnection.closeQuietly + discard sc.join.run + return .error (.protocol "selected muxer is not configured") | some mux => match ← mux.«open» role sc.toConnection stage2.leftover with - | .error e => return .error e - | .ok session => return .ok (.upgraded { session, remotePeer := sc.remotePeer, join := sc.join }) + | .error e => + sc.toConnection.closeQuietly + discard sc.join.run + return .error e + | .ok session => + let join : ExceptT Libp2p.Core.Error Conc.Async Unit := do + let muxResult ← session.join.run + let secureResult ← sc.join.run + match muxResult, secureResult with + | .error e, _ => throw e + | _, .error e => throw e + | .ok (), .ok () => pure () + return .ok (.upgraded { session, remotePeer := sc.remotePeer, join }) /-- Whether the deadline boundary already closed the connection. `withinDeadline` returns an error value only on expiry, after the hook From 4ef2d7be2184f56d37593a86cf40d3dc9f7052b0 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:01:19 +0800 Subject: [PATCH 111/125] fix(tests): retain split handshake bytes --- Tests/Upgrade.lean | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Tests/Upgrade.lean b/Tests/Upgrade.lean index b794ecb2..58c7c02c 100644 --- a/Tests/Upgrade.lean +++ b/Tests/Upgrade.lean @@ -741,10 +741,16 @@ private def secValueExact (seedRef : Conc.OnceRef (Option ByteArray)) : match ← readExactly conn seed 4 with | none => throw (.closed "test handshake read ended before the marker") | some four => - return { toConnection := conn, + let extra := Bytes.ofNatList (four.drop 4) + let pending ← Conc.OnceRef.new extra + let secureConn : Streams.Connection := { conn with + read := do + let bytes ← (pending.updateGet (fun b => (b, ByteArray.empty)) : BaseIO ByteArray) + if bytes.isEmpty then conn.read else pure (some bytes) } + return { toConnection := secureConn, localPeer := if role = .initiator then dialerId else listenerId, remotePeer := if role = .initiator then listenerId else dialerId, - leftover := Bytes.ofNatList (four.drop 4), + leftover := extra, join := pure () } /-- The multistream exchange of both roles over a splitting sim, for a From f63f2a82feb4251fb713be84482d4614a64301a0 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:02:33 +0800 Subject: [PATCH 112/125] test(negotiate): cover admission refusal --- Tests/Upgrade.lean | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Tests/Upgrade.lean b/Tests/Upgrade.lean index 58c7c02c..2614e4ab 100644 --- a/Tests/Upgrade.lean +++ b/Tests/Upgrade.lean @@ -203,6 +203,33 @@ private abbrev UpAnswer := /-! ## The pipeline checks -/ +/-- A refused authenticated peer must not reach the muxer. -/ +private def checkAdmission (r : Runner) : Async Unit := do + let (dial, listen) ← newPair + let muxId : Libp2p.Core.ProtocolId := ⟨"/test/mux/1.0.0"⟩ + let seen : Conc.OnceRef (Option Libp2p.Identity.PeerId) ← Conc.OnceRef.new none + let opened : Conc.OnceRef Bool ← Conc.OnceRef.new false + let left : Conc.OnceRef (Option ByteArray) ← Conc.OnceRef.new none + let selected : Conc.OnceRef (Option Libp2p.Core.ProtocolId) ← Conc.OnceRef.new none + let result : Conc.OnceRef (Option Bool) ← Conc.OnceRef.new none + let tasks ← Conc.TaskSet.new + tasks.spawn do + let answer ← (upgradeAdmit Conc.Clock.system .responder (← sideConn listen) ByteArray.empty none + (fun peer => do seen.set (some peer); pure (some ())) + [secValue false (← Conc.OnceRef.new none)] [muxValue muxId opened left selected]).run + result.set (some (match answer with | .ok (.refused ()) => true | _ => false)) + tasks.spawn do + discard <| (upgrade .initiator (← sideConn dial) ByteArray.empty (some listenerId) + [secValue false (← Conc.OnceRef.new none)] + [muxValue muxId (← Conc.OnceRef.new false) (← Conc.OnceRef.new none) + (← Conc.OnceRef.new none)]).run + tasks.joinAll + r.check "upgrade admission refuses before mux" ((← result.get) == some true && + (← seen.get) == some dialerId && !(← opened.get)) + (← sideConn dial).close + (← sideConn listen).close + + /-- The full pipeline, both directions, plus the wire-selected muxer assertion. The test security value carries no advertisement field, so the wire exchange alone selects the muxer. -/ @@ -842,6 +869,7 @@ def run (r : Runner) : IO Unit := do "packages/libp2p/negotiate/docs/ARCHITECTURE.md" let all : Conc.Async Unit := do checkFullPipeline r + checkAdmission r checkNegotiationLeftover r checkHandshakeLeftover r checkMuxOpenLeftover r From 489717a5b32bb9edf483f313755ff7bb735ef38e Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:56:28 +0800 Subject: [PATCH 113/125] fix(core): adapt session join results --- Tests/Mux.lean | 2 +- .../libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Tests/Mux.lean b/Tests/Mux.lean index e2d2a008..82b297cd 100644 --- a/Tests/Mux.lean +++ b/Tests/Mux.lean @@ -446,7 +446,7 @@ example : Muxer := { openStream := fun _ => throw (Error.closed "unused") accept := pure none close := pure () - join := pure (.ok ()) + join := pure () } } diff --git a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean index fe8415a2..949ace1e 100644 --- a/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean +++ b/packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean @@ -534,13 +534,13 @@ private def upgradeCore {ρ : Type} (role : Libp2p.Core.Role) (conn : Streams.Co discard sc.join.run return .error e | .ok session => - let join : ExceptT Libp2p.Core.Error Conc.Async Unit := do + let join : ExceptT Libp2p.Core.Error Conc.Async Unit := ExceptT.mk do let muxResult ← session.join.run let secureResult ← sc.join.run - match muxResult, secureResult with - | .error e, _ => throw e - | _, .error e => throw e - | .ok (), .ok () => pure () + return match muxResult, secureResult with + | .error e, _ => .error e + | _, .error e => .error e + | .ok (), .ok () => .ok () return .ok (.upgraded { session, remotePeer := sc.remotePeer, join }) /-- Whether the deadline boundary already closed the connection. From ad58a2ceccefdd0fe0e5a4b594bf5ec9f184b6ce Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:18:52 +0800 Subject: [PATCH 114/125] fix(tests): compare identify address bytes --- Tests/Protocols.lean | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Protocols.lean b/Tests/Protocols.lean index e4c9eb10..20d87b29 100644 --- a/Tests/Protocols.lean +++ b/Tests/Protocols.lean @@ -247,7 +247,7 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do r.check "identify e2e push sender announces the record" ((match sent with | Except.ok _ => true - | Except.error _ => false) && stored == [addr]) + | Except.error _ => false) && stored.map Multiformats.Multiaddr.toBytes == [addr]) r.check "identify e2e push sender half-closes the stream" (isOkNone ended) tc.close From dba4dfc366961df3dfd961a321a7cc40154bc62c Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:52:21 +0800 Subject: [PATCH 115/125] feat(cli): wire gossipsub socket participant --- Tests/Gossipsub/InteropDriver.lean | 42 +++++ lake-manifest.json | 7 + lakefile.toml | 12 +- packages/cli/GossipsubInterop.lean | 210 +++++++++++++++++++++ packages/cli/GossipsubSession.lean | 61 ++++++ packages/cli/Main.lean | 105 +++++++++++ packages/cli/hostname/HostnameCheck.lean | 5 + packages/cli/hostname/InteropHostname.lean | 3 + packages/cli/hostname/c/hostname.c | 12 ++ packages/cli/hostname/lake-manifest.json | 6 + packages/cli/hostname/lakefile.lean | 21 +++ packages/libp2p/mux/Libp2p/Mux.lean | 1 + packages/libp2p/mux/Libp2p/Mux/Yamux.lean | 38 ++++ 13 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 Tests/Gossipsub/InteropDriver.lean create mode 100644 packages/cli/GossipsubInterop.lean create mode 100644 packages/cli/GossipsubSession.lean create mode 100644 packages/cli/Main.lean create mode 100644 packages/cli/hostname/HostnameCheck.lean create mode 100644 packages/cli/hostname/InteropHostname.lean create mode 100644 packages/cli/hostname/c/hostname.c create mode 100644 packages/cli/hostname/lake-manifest.json create mode 100644 packages/cli/hostname/lakefile.lean create mode 100644 packages/libp2p/mux/Libp2p/Mux/Yamux.lean diff --git a/Tests/Gossipsub/InteropDriver.lean b/Tests/Gossipsub/InteropDriver.lean new file mode 100644 index 00000000..1dcb9924 --- /dev/null +++ b/Tests/Gossipsub/InteropDriver.lean @@ -0,0 +1,42 @@ +import GossipsubInterop + +/-! Host-free regression checks. Run these against the integrated tree. -/ +set_option autoImplicit false + +namespace Tests.Gossipsub.InteropDriver + +open Libp2p.Protocols.Gossipsub.Interop + +example : DelayTable.batchDelay [("a", 5), ("b", 12)] ["a", "b"] = 12 := by decide +example : DelayTable.batchDelay [("a", 5)] [] = 0 := by decide +example : DelayTable.batchDelay [("a", 5)] ["missing"] = 0 := by decide +example : (publishPayload 42 8).map ByteArray.size = .ok 8 := by decide +example : (publishPayload 43 98304).map ByteArray.size = .ok 98304 := by decide +example : (publishPayload 42 7).isError = true := by decide +example : (publishPayload 42 maxPayloadBytes).map ByteArray.size = .ok maxPayloadBytes.toNat := by decide +example : (publishPayload 42 (maxPayloadBytes + 1)).isError = true := by decide + +example : nodeId? "node0" = some 0 := by decide +example : nodeId? "node31" = some 31 := by decide +example : nodeId? "node" = none := by decide +example : nodeId? "nodeX" = none := by decide +example : nodeId? "node-1" = none := by decide + +/-- Compare against the independent Python vectors in GATE-TCP-PROBE. -/ +def checkIdentityVectors : IO Unit := do + let ids := [ + "12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN", + "12D3KooWPjceQrSwdWXPyLLeABRXmuqt69Rg3sBYbU1Nft9HyQ6X", + "12D3KooWH3uVF6wv47WnArKHk5p6cvgCJEb74UTmxztmQDc298L3", + "12D3KooWQYhTNQdmr3ArTeUHRYzFg94BKyTkoWBDWez9kSCVe2Xo"] + for (node, expected) in ids.zipIdx |>.map (fun (id, n) => (n, id)) do + let seed := peerSeed node + unless seed == node :: List.replicate 31 0 do + throw (IO.userError s!"node{node}: seed mismatch") + match identityForNode node with + | .error e => throw (IO.userError e) + | .ok (_, peer) => + unless peer.toString == expected do + throw (IO.userError s!"node{node}: peer id mismatch") + +end Tests.Gossipsub.InteropDriver diff --git a/lake-manifest.json b/lake-manifest.json index bbfa9702..f754f817 100644 --- a/lake-manifest.json +++ b/lake-manifest.json @@ -51,6 +51,13 @@ "manifestFile": "lake-manifest.json", "inherited": false, "dir": "packages/crypto/backend", + "configFile": "lakefile.lean"}, + {"type": "path", + "scope": "", + "name": "InteropHostname", + "manifestFile": "lake-manifest.json", + "inherited": false, + "dir": "packages/cli/hostname", "configFile": "lakefile.lean"}], "name": "leanp2p", "lakeDir": ".lake", diff --git a/lakefile.toml b/lakefile.toml index d1099645..af96c449 100644 --- a/lakefile.toml +++ b/lakefile.toml @@ -35,6 +35,10 @@ testDriver = "tests" # The executables need no link argument for the shim. The toolchain # links its own static OpenSSL 3 `libcrypto.a` into every executable, # and the shim archive resolves against that copy. +[[require]] +name = "InteropHostname" +path = "packages/cli/hostname" + [[require]] name = "Backend" path = "packages/crypto/backend" @@ -186,7 +190,8 @@ globs = ["Libp2p.Protocols.*"] [[lean_lib]] name = "Cli" srcDir = "packages/cli" -roots = ["Cli"] +roots = ["Cli", "GossipsubInterop", "GossipsubSession"] +globs = ["GossipsubInterop.*", "GossipsubSession.*"] # The test tree: one suite module per upstream file, with the shared # runner in `Tests/Support.lean`. The `tests` executable is the @@ -214,3 +219,8 @@ root = "Tests.Interop.Yamux" [[lean_exe]] name = "noise-interop" root = "Tests.Interop.Noise" + +[[lean_exe]] +name = "gossipsub-interop" +srcDir = "packages/cli" +root = "Main" diff --git a/packages/cli/GossipsubInterop.lean b/packages/cli/GossipsubInterop.lean new file mode 100644 index 00000000..947c4028 --- /dev/null +++ b/packages/cli/GossipsubInterop.lean @@ -0,0 +1,210 @@ +import Conc +import InteropHostname +import Libp2p.Identity +import Libp2p.Protocols.Gossipsub.Interop + +/-! Host-free participant core. Host wiring stays provisional until the green interfaces settle. -/ +set_option autoImplicit false + +namespace Libp2p.Protocols.Gossipsub.Interop + +open Libp2p.Identity + +/-- Parse the harness command line. -/ +def paramsPath (args : List String) : Except String System.FilePath := + match args with + | ["--params", path] => .ok path + | _ => .error "expected --params " + +/-- Prefer the hostname syscall. Environment variables are a fallback. -/ +def processHostname : IO String := do + let hostname ← try + InteropHostname.get + catch e => + IO.eprintln s!"gethostname failed: {e}; trying environment" + pure "" + if (nodeId? hostname).isSome then return hostname + if !hostname.isEmpty then + IO.eprintln s!"gethostname returned '{hostname}', not nodeN; trying environment" + let value ← IO.getEnv "HOSTNAME" + let fallback ← IO.getEnv "COMPUTERNAME" + if let some name := value then + if (nodeId? name).isSome then return name + if let some name := fallback then + if (nodeId? name).isSome then return name + throw (IO.userError "hostname is not nodeN in syscall or environment") + +/-- The 1 MiB RPC body leaves room for protobuf envelope overhead. -/ +def maxPayloadBytes : Int := 1048576 - 1024 + +/-- Encode an id as eight big-endian bytes, then zero-pad to the requested size. -/ +def publishPayload (messageId size : Int) : Except String ByteArray := do + if messageId < 0 || messageId > 18446744073709551615 then + throw "publish.messageID must fit UInt64" + if size < 8 then throw "publish.messageSizeBytes must be at least eight" + if size > maxPayloadBytes then throw "publish.messageSizeBytes exceeds the 1 MiB RPC budget" + let id := messageId.toNat + let head := (List.range 8).map fun i => UInt8.ofNat ((id / (2 ^ (8 * (7 - i)))) % 256) + return ByteArray.mk (head.toArray ++ Array.replicate (size.toNat - 8) 0) + +/-- Derive the deterministic identity and peer id for nodeN. -/ +def identityForNode (nodeId : Nat) : Except String (PrivKey × PeerId) := do + let seed := ByteArray.mk ((peerSeed nodeId).map UInt8.ofNat).toArray + let key ← (PrivKey.ed25519OfData? seed).mapError (fun e => reprStr e) + let pub ← key.toPublic.mapError (fun e => reprStr e) + let peer ← (PeerId.ofPubKey pub).mapError (fun e => reprStr e) + return (key, peer) + +/-- A bounded stdout owner. Observers only offer complete lines. -/ +structure RecordWriter where + records : Conc.Chan String + tasks : Conc.TaskSet + failed : Conc.OnceRef (Option String) + +namespace RecordWriter + +def start : BaseIO RecordWriter := do + let records ← Conc.Chan.new 64 + let tasks ← Conc.TaskSet.new + let failed ← Conc.OnceRef.new (none : Option String) + tasks.spawn <| do + repeat + match ← records.recv with + | none => break + | some line => + try + IO.println line + (← IO.getStdout).flush + catch e => + failed.update (fun old => old.orElse (fun _ => some (toString e))) + records.close + break + return { records, tasks, failed } + +def offer (writer : RecordWriter) (line : String) : BaseIO Bool := do + let ok ← writer.records.trySend line + if !ok then writer.failed.update (fun old => old.orElse (fun _ => some "record queue full or closed")) + return ok + +def close (writer : RecordWriter) : Conc.Async Unit := do + writer.records.close + writer.tasks.joinAll + if let some error ← writer.failed.get then throw (IO.userError error) + +end RecordWriter + +/-- Build the PeerID NDJSON record before any receipt. -/ +def peerRecord (time peer : String) (nodeId : Nat) : String := + peerIdLine time peer (Int.ofNat nodeId) + +/-- Build a pre-deduplication receipt record. -/ +def receivedRecord (time : String) (payload : List Nat) (sender topic : Option String) : String := + receivedMessageLine time (messageIdNumber payload) sender topic + +/-- Topic validation delays are updated in one atomic step. -/ +structure DelayTable where + entries : Conc.OnceRef (List (String × Nat)) + stopping : Conc.Chan Unit + +/-- Create the delay table and its shutdown signal. -/ +def DelayTable.new : BaseIO DelayTable := do + return ⟨← Conc.OnceRef.new [], ← Conc.Chan.new 1⟩ + +/-- Replace one topic's delay. -/ +def DelayTable.set (table : DelayTable) (topic : String) (delay : Nat) : BaseIO Unit := + table.entries.update fun entries => (topic, delay) :: entries.filter (fun entry => entry.1 != topic) + +/-- The delay for a batch is the largest delay of its published topics. -/ +def DelayTable.batchDelay (entries : List (String × Nat)) (topics : List String) : Nat := + topics.foldl (fun longest topic => max longest ((entries.find? (fun entry => entry.1 == topic)).map Prod.snd |>.getD 0)) 0 + +/-- Park a stream reader until its delay ends or shutdown starts. -/ +def DelayTable.wait (table : DelayTable) (clock : Conc.Clock) (topics : List String) : Conc.Async Unit := do + let delay := batchDelay (← table.entries.get) topics + if delay > 0 then + let timer ← (clock.after delay : BaseIO (Conc.Park Unit)) + Conc.select #[timer, Conc.Park.recv table.stopping (fun _ => ())] + +/-- Counted pre-deduplication receipt observer. It never parks. -/ +structure ReceiptObserver where + writer : RecordWriter + offers : Conc.OnceRef Nat + failures : Conc.OnceRef Nat + +/-- Create receipt accounting state. -/ +def ReceiptObserver.new (writer : RecordWriter) : BaseIO ReceiptObserver := do + return { writer, offers := ← Conc.OnceRef.new 0, failures := ← Conc.OnceRef.new 0 } + +/-- RFC3339 UTC time with fixed millisecond precision. -/ +def observationTime : BaseIO String := do + let millis ← IO.monoMsNow + let fraction := String.ofList (Nat.toDigits 10 (millis % 1000)) + let fixed := if fraction.length = 1 then "00" ++ fraction else if fraction.length = 2 then "0" ++ fraction else fraction + return "1970-01-01T00:00:00." ++ fixed ++ "Z" + +/-- Decode one RPC and offer one receipt for every publish entry. -/ +def ReceiptObserver.onRpc (observer : ReceiptObserver) (sender : String) (bytes : List Nat) : BaseIO Unit := do + match Core.decodeRpc bytes with + | .error _ => observer.failures.update (· + 1) + | .ok rpc => + for message in rpc.publish do + let time ← observationTime + let payload := message.data.getD [] + let line := receivedRecord time payload (some sender) (some message.topic.name) + observer.offers.update (· + 1) + if !(← observer.writer.offer line) then observer.failures.update (· + 1) + +/-- Count receipt failures. The supervisor uses this before exit 0. -/ +def ReceiptObserver.failureCount (observer : ReceiptObserver) : BaseIO Nat := observer.failures.get + +/-- Provisional future Node.Config.beforeRpc hook. The current Node has no field. -/ +def provisionalBeforeRpc (table : DelayTable) (clock : Conc.Clock) (_sender : Peer) (bytes : List Nat) : Conc.Async Unit := do + match Core.decodeRpc bytes with + | .error _ => pure () + | .ok rpc => table.wait clock (rpc.publish.map (·.topic.name)) + +/-- Provisional BaseIO callback shape for Node.Config.beforeRpc. -/ +def provisionalOnRpc (observer : ReceiptObserver) (sender : String) (bytes : List Nat) : BaseIO Unit := + observer.onRpc sender bytes + +/-- Host-free callbacks used by the script driver. -/ +structure Driver where + connect : Int → Conc.Async (Except String Unit) := fun _ => pure (.error "host wiring is provisional") + subscribe : String → Bool → BaseIO Bool := fun _ _ => pure false + publish : String → ByteArray → BaseIO Bool := fun _ _ => pure false + validationDelay : String → Nat → BaseIO Unit := fun _ _ => pure () + +/-- Execute selected instructions. waitUntil parks only this script computation. -/ +def runScript (driver : Driver) (clock : Conc.Clock) (start : Nat) (actions : List Instruction) : Conc.Async (Except String Unit) := do + for action in actions do + let result ← match action with + | .connect peers => + let mut outcome : Except String Unit := .ok () + for peer in peers do + if outcome.isOk then outcome ← driver.connect peer + pure outcome + | .subscribeToTopic topic isPartial => + if ← driver.subscribe topic isPartial then pure (.ok ()) else pure (.error "subscribe queue refused") + | .publish id size topic => do + match publishPayload id size with + | .error e => pure (.error e) + | .ok bytes => + if ← driver.publish topic bytes then pure (.ok ()) else pure (.error "publish queue refused") + | .setTopicValidationDelay topic seconds => + driver.validationDelay topic (Float.floor (seconds * 1000.0)).toUInt64.toNat + pure (.ok ()) + | .waitUntil deadline => + repeat + let now ← (clock.nowMs : BaseIO Nat) + let due := start + deadline.toNat * 1000 + if now >= due then break + let left := due - now + let timer ← (clock.after left : BaseIO (Conc.Park Unit)) + discard <| Conc.select #[timer] + pure (.ok ()) + | .initGossipSub _ => pure (.error "unexpected initGossipSub after startup") + | .ifNodeIDEquals _ _ => pure (.error "unresolved node condition") + if let .error error := result then return .error error + return .ok () + +end Libp2p.Protocols.Gossipsub.Interop diff --git a/packages/cli/GossipsubSession.lean b/packages/cli/GossipsubSession.lean new file mode 100644 index 00000000..622373d8 --- /dev/null +++ b/packages/cli/GossipsubSession.lean @@ -0,0 +1,61 @@ +import Conc +import Streams +import Libp2p.Core +import Libp2p.Host +import Libp2p.Mux +import Libp2p.Security.Noise +import Libp2p.Protocols.Gossipsub.HostSeam +import Libp2p.Protocols.Gossipsub.Node +import Libp2p.Protocols.Identify + +/-! Real socket participant wiring. This module owns host assembly only. -/ +set_option autoImplicit false + +namespace Cli.GossipsubSession + +open Conc Streams Multiformats Libp2p.Core Libp2p.Identity Libp2p.Host +open Libp2p.Protocols Libp2p.Protocols.Constants +open Libp2p.Protocols.Gossipsub Libp2p.Protocols.Gossipsub.Node + +/-- Use the production yamux session through the core muxer seam. -/ +def yamuxMuxer : Libp2p.Core.Muxer := Libp2p.Mux.yamux + +/-- The real host surface needed by the gossipsub node. -/ +structure Running where + host : Libp2p.Host.Host + node : Node + +/-- Build Noise and the real TCP host. Node.start installs meshsub. -/ +def start (identity : PrivKey) (nodeConfig : Node.Config := {}) : IO (Except String Running) := do + let generated ← (Libp2p.Security.Noise.Config.generate identity).run + let noise ← match generated with + | .error e => return .error (reprStr e) + | .ok cfg => pure cfg + let pubkey ← match identity.toPublic with + | .error e => return .error (reprStr e) + | .ok key => pure key + let config : Libp2p.Host.Config := { + identity := identity + transports := [Streams.Tcp.transport] + security := [Libp2p.Security.Noise.securityProtocol noise] + muxers := [yamuxMuxer] + handlers := [Identify.respondHandler pubkey [] [] none "leanp2p"] } + let hostResult ← BaseIO.toIO (Libp2p.Host.Host.new config) + let host ← match hostResult with + | .error e => return .error (reprStr e) + | .ok value => pure value + let node ← BaseIO.toIO (Node.start (HostSeam.ofHost host) nodeConfig) + return .ok { host, node } + +/-- Listen only after Node.start has registered meshsub. -/ +def listen (running : Running) (addr : Multiaddr) : Conc.Async (Except String Multiaddr) := do + return (← running.host.startListening addr).mapError (fun e => reprStr e) + +/-- Stop node and host concurrently, then let the caller drain records. -/ +def stop (running : Running) : Conc.Async Unit := do + let tasks ← TaskSet.new + tasks.spawn running.node.stop + tasks.spawn (Libp2p.Host.Host.close running.host) + tasks.joinAll + +end Cli.GossipsubSession diff --git a/packages/cli/Main.lean b/packages/cli/Main.lean new file mode 100644 index 00000000..aa395441 --- /dev/null +++ b/packages/cli/Main.lean @@ -0,0 +1,105 @@ +import Conc +import Libp2p.Protocols.Gossipsub.Interop +import GossipsubInterop +import GossipsubSession + +/-! The socket participant executable. -/ +set_option autoImplicit false + +namespace Cli + +open Conc Streams Multiformats Libp2p.Core Libp2p.Identity Libp2p.Host +open Libp2p.Protocols Libp2p.Protocols.Constants +open Libp2p.Protocols.Gossipsub Libp2p.Protocols.Gossipsub.Node +open Libp2p.Protocols.Gossipsub.Interop + +private def fail (message : String) : IO UInt32 := do + IO.eprintln message + return 1 + +def load (path : System.FilePath) (nodeId : Nat) : IO (Except String (GossipSubParams × List Instruction)) := do + let source ← IO.FS.readFile path + return (parseScript source).bind (prepareScript (Int.ofNat nodeId)) + +def run (args : List String) : IO UInt32 := do + let start ← Conc.Clock.system.nowMs + let path ← match paramsPath args with + | .ok value => pure value + | .error e => return ← fail e + let hostName ← try + processHostname + catch e => + return ← fail (toString e) + let some nodeId := nodeId? hostName | return ← fail s!"invalid node hostname: {hostName}" + let script ← match ← load path nodeId with + | .ok value => pure value + | .error e => return ← fail e + let (params, actions) := script + let (identity, peer) ← match identityForNode nodeId with + | .ok value => pure value + | .error e => return ← fail e + let writer ← BaseIO.toIO RecordWriter.start + let observer ← BaseIO.toIO (ReceiptObserver.new writer) + let coreParams ← match toCoreParams params with + | .ok value => pure value + | .error e => return ← fail e + let receiptCallback : Peer → List Nat → BaseIO Unit := fun sender bytes => + observer.onRpc (toString sender) bytes + let config : Node.Config := { params := coreParams, onRpc := receiptCallback } + let running ← match ← GossipsubSession.start identity config with + | .ok value => pure value + | .error e => return ← fail e + let listenAddr ← match Multiaddr.parse? "/ip4/0.0.0.0/tcp/9000" with + | .ok value => pure value + | .error e => return ← fail (reprStr e) + let listenResult ← (running.host.startListening listenAddr).run + match listenResult with + | .error e => return ← fail (reprStr e) + | .ok _ => pure () + let peerTime ← BaseIO.toIO observationTime + let peerLine ← BaseIO.toIO (RecordWriter.offer writer (peerRecord peerTime peer.toString nodeId)) + if !peerLine then return ← fail "PeerID record queue full or closed" + let driver : Driver := { + connect := fun target => do + if target < 0 then return .error "connectTo must be nonnegative" + let (_, targetPeer) ← match identityForNode target.toNat with + | .ok value => pure value + | .error e => return .error e + let addressText := s!"/ip4/172.30.246.{10 + target.toNat}/tcp/9000" + let address ← match Multiaddr.parse? addressText with + | .ok value => pure value + | .error e => return .error (reprStr e) + running.host.addAddrs targetPeer [address] + match ← running.host.dialPeer targetPeer with + | .ok joined => + if ← running.node.peerJoined joined .meshsub then pure (.ok ()) + else pure (.error "peerJoined queue refused") + | .error e => pure (.error (reprStr e)) + subscribe := fun topic _ => running.node.subscribe ⟨topic⟩ (fun _ => pure ()) + publish := fun topic payload => running.node.publish ⟨topic⟩ payload + validationDelay := fun _ _ => pure () } + let resultCode ← try + match ← (runScript driver Conc.Clock.system start actions).run with + | .ok () => pure 0 + | .error e => IO.eprintln e; pure 1 + catch e => + IO.eprintln (toString e) + pure 1 + let stopCode ← try + (GossipsubSession.stop running).run + pure 0 + catch e => + IO.eprintln (toString e) + pure 1 + let closeCode ← try + (RecordWriter.close writer).run + pure 0 + catch e => + IO.eprintln (toString e) + pure 1 + let failures ← BaseIO.toIO observer.failureCount + return if resultCode = 0 && stopCode = 0 && closeCode = 0 && failures = 0 then 0 else 1 + +end Cli + +def main (args : List String) : IO UInt32 := Cli.run args diff --git a/packages/cli/hostname/HostnameCheck.lean b/packages/cli/hostname/HostnameCheck.lean new file mode 100644 index 00000000..4df9445f --- /dev/null +++ b/packages/cli/hostname/HostnameCheck.lean @@ -0,0 +1,5 @@ +import InteropHostname + +/-- Print the hostname that the native syscall reports. -/ +def main : IO Unit := do + IO.println (← InteropHostname.get) diff --git a/packages/cli/hostname/InteropHostname.lean b/packages/cli/hostname/InteropHostname.lean new file mode 100644 index 00000000..e64c5245 --- /dev/null +++ b/packages/cli/hostname/InteropHostname.lean @@ -0,0 +1,3 @@ +/-! POSIX hostname binding for the interop participant. -/ +@[extern "interop_participant_hostname"] +opaque InteropHostname.get : IO String diff --git a/packages/cli/hostname/c/hostname.c b/packages/cli/hostname/c/hostname.c new file mode 100644 index 00000000..e35d6973 --- /dev/null +++ b/packages/cli/hostname/c/hostname.c @@ -0,0 +1,12 @@ +#define _POSIX_C_SOURCE 200809L +#include +#include + +LEAN_EXPORT lean_obj_res interop_participant_hostname(lean_obj_arg world) { + (void)world; + char name[256] = {0}; + if (gethostname(name, sizeof(name) - 1) != 0) { + return lean_io_result_mk_error(lean_mk_io_user_error(lean_mk_string("gethostname failed"))); + } + return lean_io_result_mk_ok(lean_mk_string(name)); +} diff --git a/packages/cli/hostname/lake-manifest.json b/packages/cli/hostname/lake-manifest.json new file mode 100644 index 00000000..5a1b3bd9 --- /dev/null +++ b/packages/cli/hostname/lake-manifest.json @@ -0,0 +1,6 @@ +{"version": "1.2.0", + "packagesDir": ".lake/packages", + "packages": [], + "name": "InteropHostname", + "lakeDir": ".lake", + "fixedToolchain": false} diff --git a/packages/cli/hostname/lakefile.lean b/packages/cli/hostname/lakefile.lean new file mode 100644 index 00000000..45002b3b --- /dev/null +++ b/packages/cli/hostname/lakefile.lean @@ -0,0 +1,21 @@ +import Lake +open Lake DSL System + +package InteropHostname + +target interop_hostname.o pkg : FilePath := do + let src := pkg.dir / "c" / "hostname.c" + let obj := pkg.buildDir / "c" / "hostname.o" + let weak := #["-I", (← getLeanIncludeDir).toString] + buildO obj (← inputTextFile src) weak #["-fPIC", "-O2", "-Wall", "-Werror"] "cc" getLeanTrace + +extern_lib libleanp2p_interop_hostname pkg := do + let lib := pkg.staticLibDir / nameToStaticLib "leanp2p_interop_hostname" + buildStaticLib lib #[← interop_hostname.o.fetch] + +@[default_target] +lean_lib InteropHostname where + roots := #[`InteropHostname] + +lean_exe hostname_check where + root := `HostnameCheck diff --git a/packages/libp2p/mux/Libp2p/Mux.lean b/packages/libp2p/mux/Libp2p/Mux.lean index d74c8f08..0a7d88d9 100644 --- a/packages/libp2p/mux/Libp2p/Mux.lean +++ b/packages/libp2p/mux/Libp2p/Mux.lean @@ -1,5 +1,6 @@ import Libp2p.Mux.Constants import Libp2p.Mux.Test +import Libp2p.Mux.Yamux /-! # Libp2p.Mux diff --git a/packages/libp2p/mux/Libp2p/Mux/Yamux.lean b/packages/libp2p/mux/Libp2p/Mux/Yamux.lean new file mode 100644 index 00000000..82559da1 --- /dev/null +++ b/packages/libp2p/mux/Libp2p/Mux/Yamux.lean @@ -0,0 +1,38 @@ +import Libp2p.Mux.Constants +import Libp2p.Core +import Yamux.Session +import Yamux.Constants + +set_option autoImplicit false + +namespace Libp2p.Mux + +open Libp2p.Core + +/-- Map one yamux protocol error to the core seam error. -/ +def mapStreamError (e : Streams.Error) : Libp2p.Core.Error := + match e with + | .closed => .closed "yamux: closed" + | .reset => .protocol "yamux: reset" + | .timeout => .timeout "yamux: timeout" + +def mapYamuxError (e : Yamux.ProtocolError) : Libp2p.Core.Error := + .protocol s!"yamux: {repr e}" + +/-- The production yamux muxer over the real core connection seam. -/ +def yamux : Libp2p.Core.Muxer where + id := yamuxId + «open» := fun role connection leftover => ExceptT.mk do + let yamuxRole := match role with + | .initiator => Yamux.Role.client + | .responder => Yamux.Role.server + match ← Yamux.Session.new yamuxRole connection leftover with + | session => + return .ok { + openStream := fun _ => ExceptT.mk do + return (← session.openStream).mapError mapStreamError + accept := session.acceptStream + close := session.close + join := ExceptT.mk do return (← session.join).mapError mapYamuxError } + +end Libp2p.Mux From 90d12283b12d65aa6f7450601f799a7caf69bec0 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:58:21 +0800 Subject: [PATCH 116/125] fix(cli): preserve monotonic receipt timestamps --- packages/cli/GossipsubInterop.lean | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/cli/GossipsubInterop.lean b/packages/cli/GossipsubInterop.lean index 947c4028..170890ca 100644 --- a/packages/cli/GossipsubInterop.lean +++ b/packages/cli/GossipsubInterop.lean @@ -135,12 +135,28 @@ structure ReceiptObserver where def ReceiptObserver.new (writer : RecordWriter) : BaseIO ReceiptObserver := do return { writer, offers := ← Conc.OnceRef.new 0, failures := ← Conc.OnceRef.new 0 } -/-- RFC3339 UTC time with fixed millisecond precision. -/ +/-- Format a natural number with at least two decimal digits. -/ +def pad2 (value : Nat) : String := + let text := String.ofList (Nat.toDigits 10 value) + if text.length = 1 then "0" ++ text else text + +/-- Format a natural number with at least three decimal digits. -/ +def pad3 (value : Nat) : String := + let text := String.ofList (Nat.toDigits 10 value) + if text.length = 1 then "00" ++ text + else if text.length = 2 then "0" ++ text + else text + +/-- RFC3339 UTC time from the monotonic millisecond clock. -/ def observationTime : BaseIO String := do - let millis ← IO.monoMsNow - let fraction := String.ofList (Nat.toDigits 10 (millis % 1000)) - let fixed := if fraction.length = 1 then "00" ++ fraction else if fraction.length = 2 then "0" ++ fraction else fraction - return "1970-01-01T00:00:00." ++ fixed ++ "Z" + let nowMs ← IO.monoMsNow + let secs := (nowMs / 1000) % 86400 + let ms := nowMs % 1000 + let hours := secs / 3600 + let minutes := (secs / 60) % 60 + let seconds := secs % 60 + return "1970-01-01T" ++ pad2 hours ++ ":" ++ pad2 minutes ++ ":" ++ + pad2 seconds ++ "." ++ pad3 ms ++ "Z" /-- Decode one RPC and offer one receipt for every publish entry. -/ def ReceiptObserver.onRpc (observer : ReceiptObserver) (sender : String) (bytes : List Nat) : BaseIO Unit := do From 6f25ea4093fa7e87e69ed21e1144c664e4bf847f Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:11:11 +0800 Subject: [PATCH 117/125] test(cli): cover monotonic timestamp formatting --- Tests/Gossipsub/InteropDriver.lean | 5 +++++ packages/cli/GossipsubInterop.lean | 11 +++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Tests/Gossipsub/InteropDriver.lean b/Tests/Gossipsub/InteropDriver.lean index 1dcb9924..c8fc02a2 100644 --- a/Tests/Gossipsub/InteropDriver.lean +++ b/Tests/Gossipsub/InteropDriver.lean @@ -16,6 +16,11 @@ example : (publishPayload 42 7).isError = true := by decide example : (publishPayload 42 maxPayloadBytes).map ByteArray.size = .ok maxPayloadBytes.toNat := by decide example : (publishPayload 42 (maxPayloadBytes + 1)).isError = true := by decide +example : formatObservationTime 0 = "1970-01-01T00:00:00.000Z" := by decide +example : formatObservationTime 3723004 = "1970-01-01T01:02:03.004Z" := by decide +example : formatObservationTime 86399999 = "1970-01-01T23:59:59.999Z" := by decide +example : formatObservationTime 86400000 = "1970-01-01T00:00:00.000Z" := by decide + example : nodeId? "node0" = some 0 := by decide example : nodeId? "node31" = some 31 := by decide example : nodeId? "node" = none := by decide diff --git a/packages/cli/GossipsubInterop.lean b/packages/cli/GossipsubInterop.lean index 170890ca..d6e95ff9 100644 --- a/packages/cli/GossipsubInterop.lean +++ b/packages/cli/GossipsubInterop.lean @@ -147,17 +147,20 @@ def pad3 (value : Nat) : String := else if text.length = 2 then "0" ++ text else text -/-- RFC3339 UTC time from the monotonic millisecond clock. -/ -def observationTime : BaseIO String := do - let nowMs ← IO.monoMsNow +/-- Format a monotonic millisecond value as fixed-width RFC3339 UTC text. -/ +def formatObservationTime (nowMs : Nat) : String := let secs := (nowMs / 1000) % 86400 let ms := nowMs % 1000 let hours := secs / 3600 let minutes := (secs / 60) % 60 let seconds := secs % 60 - return "1970-01-01T" ++ pad2 hours ++ ":" ++ pad2 minutes ++ ":" ++ + "1970-01-01T" ++ pad2 hours ++ ":" ++ pad2 minutes ++ ":" ++ pad2 seconds ++ "." ++ pad3 ms ++ "Z" +/-- RFC3339 UTC time from the monotonic millisecond clock. -/ +def observationTime : BaseIO String := do + return formatObservationTime (← IO.monoMsNow) + /-- Decode one RPC and offer one receipt for every publish entry. -/ def ReceiptObserver.onRpc (observer : ReceiptObserver) (sender : String) (bytes : List Nat) : BaseIO Unit := do match Core.decodeRpc bytes with From 7c0f4049038fde063b9e62fb7d028c89b069e8a5 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:18:36 +0800 Subject: [PATCH 118/125] fix(cli): retain validation delay settings --- packages/cli/Main.lean | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/Main.lean b/packages/cli/Main.lean index aa395441..7c309a19 100644 --- a/packages/cli/Main.lean +++ b/packages/cli/Main.lean @@ -40,6 +40,7 @@ def run (args : List String) : IO UInt32 := do | .error e => return ← fail e let writer ← BaseIO.toIO RecordWriter.start let observer ← BaseIO.toIO (ReceiptObserver.new writer) + let delayTable ← BaseIO.toIO DelayTable.new let coreParams ← match toCoreParams params with | .ok value => pure value | .error e => return ← fail e @@ -77,7 +78,7 @@ def run (args : List String) : IO UInt32 := do | .error e => pure (.error (reprStr e)) subscribe := fun topic _ => running.node.subscribe ⟨topic⟩ (fun _ => pure ()) publish := fun topic payload => running.node.publish ⟨topic⟩ payload - validationDelay := fun _ _ => pure () } + validationDelay := fun topic delay => delayTable.set topic delay } let resultCode ← try match ← (runScript driver Conc.Clock.system start actions).run with | .ok () => pure 0 From 2860a5f593c4c5cb56dbeaab046d005954a7b580 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:32:04 +0800 Subject: [PATCH 119/125] fix(cli): close owned resources on setup failures --- packages/cli/Main.lean | 92 ++++++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/packages/cli/Main.lean b/packages/cli/Main.lean index 7c309a19..ce6c6343 100644 --- a/packages/cli/Main.lean +++ b/packages/cli/Main.lean @@ -43,55 +43,61 @@ def run (args : List String) : IO UInt32 := do let delayTable ← BaseIO.toIO DelayTable.new let coreParams ← match toCoreParams params with | .ok value => pure value - | .error e => return ← fail e + | .error e => + (RecordWriter.close writer).run + return ← fail e let receiptCallback : Peer → List Nat → BaseIO Unit := fun sender bytes => observer.onRpc (toString sender) bytes let config : Node.Config := { params := coreParams, onRpc := receiptCallback } - let running ← match ← GossipsubSession.start identity config with - | .ok value => pure value - | .error e => return ← fail e - let listenAddr ← match Multiaddr.parse? "/ip4/0.0.0.0/tcp/9000" with - | .ok value => pure value - | .error e => return ← fail (reprStr e) - let listenResult ← (running.host.startListening listenAddr).run - match listenResult with - | .error e => return ← fail (reprStr e) - | .ok _ => pure () - let peerTime ← BaseIO.toIO observationTime - let peerLine ← BaseIO.toIO (RecordWriter.offer writer (peerRecord peerTime peer.toString nodeId)) - if !peerLine then return ← fail "PeerID record queue full or closed" - let driver : Driver := { - connect := fun target => do - if target < 0 then return .error "connectTo must be nonnegative" - let (_, targetPeer) ← match identityForNode target.toNat with - | .ok value => pure value - | .error e => return .error e - let addressText := s!"/ip4/172.30.246.{10 + target.toNat}/tcp/9000" - let address ← match Multiaddr.parse? addressText with - | .ok value => pure value - | .error e => return .error (reprStr e) - running.host.addAddrs targetPeer [address] - match ← running.host.dialPeer targetPeer with - | .ok joined => - if ← running.node.peerJoined joined .meshsub then pure (.ok ()) - else pure (.error "peerJoined queue refused") - | .error e => pure (.error (reprStr e)) - subscribe := fun topic _ => running.node.subscribe ⟨topic⟩ (fun _ => pure ()) - publish := fun topic payload => running.node.publish ⟨topic⟩ payload - validationDelay := fun topic delay => delayTable.set topic delay } - let resultCode ← try + let mut running : Option GossipsubSession.Running := none + let mut resultCode : UInt32 := 1 + try + let started ← GossipsubSession.start identity config + let active ← match started with + | .ok value => pure value + | .error e => throw (IO.userError e) + running := some active + let listenAddr ← match Multiaddr.parse? "/ip4/0.0.0.0/tcp/9000" with + | .ok value => pure value + | .error e => throw (IO.userError (reprStr e)) + match ← (active.host.startListening listenAddr).run with + | .error e => throw (IO.userError (reprStr e)) + | .ok _ => pure () + let peerTime ← BaseIO.toIO observationTime + let peerLine ← BaseIO.toIO (RecordWriter.offer writer (peerRecord peerTime peer.toString nodeId)) + unless peerLine do throw (IO.userError "PeerID record queue full or closed") + let driver : Driver := { + connect := fun target => do + if target < 0 then return .error "connectTo must be nonnegative" + let (_, targetPeer) ← match identityForNode target.toNat with + | .ok value => pure value + | .error e => return .error e + let addressText := s!"/ip4/172.30.246.{10 + target.toNat}/tcp/9000" + let address ← match Multiaddr.parse? addressText with + | .ok value => pure value + | .error e => return .error (reprStr e) + active.host.addAddrs targetPeer [address] + match ← active.host.dialPeer targetPeer with + | .ok joined => + if ← active.node.peerJoined joined .meshsub then pure (.ok ()) + else pure (.error "peerJoined queue refused") + | .error e => pure (.error (reprStr e)) + subscribe := fun topic _ => active.node.subscribe ⟨topic⟩ (fun _ => pure ()) + publish := fun topic payload => active.node.publish ⟨topic⟩ payload + validationDelay := fun topic delay => delayTable.set topic delay } match ← (runScript driver Conc.Clock.system start actions).run with - | .ok () => pure 0 - | .error e => IO.eprintln e; pure 1 + | .ok () => resultCode := 0 + | .error e => IO.eprintln e catch e => IO.eprintln (toString e) - pure 1 - let stopCode ← try - (GossipsubSession.stop running).run - pure 0 - catch e => - IO.eprintln (toString e) - pure 1 + let stopCode ← match running with + | none => pure 0 + | some active => try + (GossipsubSession.stop active).run + pure 0 + catch e => + IO.eprintln (toString e) + pure 1 let closeCode ← try (RecordWriter.close writer).run pure 0 From e72d5916d9016755c15d99d7537c74846097b22e Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:16:53 +0800 Subject: [PATCH 120/125] fix(cli): resolve Shadow peer addresses --- packages/cli/Main.lean | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/cli/Main.lean b/packages/cli/Main.lean index ce6c6343..da84731d 100644 --- a/packages/cli/Main.lean +++ b/packages/cli/Main.lean @@ -72,12 +72,28 @@ def run (args : List String) : IO UInt32 := do let (_, targetPeer) ← match identityForNode target.toNat with | .ok value => pure value | .error e => return .error e - let addressText := s!"/ip4/172.30.246.{10 + target.toNat}/tcp/9000" + let nodeName := s!"node{target.toNat}" + let ip? ← lookupHostsFile nodeName + let addressText ← match ip? with + | none => return .error s!"no /etc/hosts entry for {nodeName}" + | some ip => + match multiaddrPrefix ip with + | some value => pure value + | none => return .error s!"invalid IPv4 entry for {nodeName}" let address ← match Multiaddr.parse? addressText with | .ok value => pure value | .error e => return .error (reprStr e) active.host.addAddrs targetPeer [address] - match ← active.host.dialPeer targetPeer with + let mut dialResult := ← active.host.dialPeer targetPeer + for attempt in [1, 2, 3, 4] do + match dialResult with + | .error (.unreachable _) => + IO.eprintln s!"dial {nodeName} unreachable, retry {attempt}/5 after 200 ms" + let timer ← (Conc.Clock.system.after 200 : BaseIO (Conc.Park Unit)) + discard <| Conc.select #[timer] + dialResult ← active.host.dialPeer targetPeer + | _ => break + match dialResult with | .ok joined => if ← active.node.peerJoined joined .meshsub then pure (.ok ()) else pure (.error "peerJoined queue refused") From 3aca73c7d9878f04f51faf595a7e0c591ce3a49d Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:27:01 +0800 Subject: [PATCH 121/125] fix(gossipsub): announce subscriptions after joins --- Tests/Gossipsub/Node.lean | 25 ++++++++++++++ .../Libp2p/Protocols/Gossipsub/Node.lean | 34 +++++++++++++++---- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/Tests/Gossipsub/Node.lean b/Tests/Gossipsub/Node.lean index d6548ed8..73a63a73 100644 --- a/Tests/Gossipsub/Node.lean +++ b/Tests/Gossipsub/Node.lean @@ -451,6 +451,30 @@ private def pairChecks (r : Tests.Runner) : Async Unit := do r.check "node.pair.no-local-delivery" ((← (b.deliveries.tryRecv : BaseIO (Option Message))).isNone) finish sw [a, b] +/-- A subscription made before a peer joins is sent once to that peer. -/ +private def postJoinSubscriptionChecks (r : Tests.Runner) : Async Unit := do + let sw ← (Switch.new : BaseIO Switch) + let f ← fixture sw pa + discard (subscribe f : BaseIO Bool) + r.check "node.post-join-subscription.local-barrier" (← barrier f) + let seen ← (Chan.new 32 : BaseIO (Chan Seen)) + let ended ← (Chan.new 16 : BaseIO (Chan Unit)) + let source ← (probe sw pb seen ended : BaseIO HostSeam) + let stream ← openStream source pa + let first ← receive seen + r.check "node.post-join-subscription.receives-topic" + (first.map (fun s => s.rpc) == some (subOptRpc topic true)) + let markerTopic : TopicId := ⟨"post-join-marker"⟩ + discard (f.node.subscribe markerTopic (fun _ => pure ()) : BaseIO Bool) + r.check "node.post-join-subscription.marker-barrier" (← barrier f) + let second ← receive seen + r.check "node.post-join-subscription.exactly-one" + (second.map (fun s => s.rpc) == some (subOptRpc markerTopic true) && + (← (seen.tryRecv : BaseIO (Option Seen))).isNone) + stream.close + discard (f.node.peerLeft pb : BaseIO Bool) + finish sw [f] + /-- The loop announces the topic before grafting its already announced peer. Repeat joins emit no hello. Multiple callbacks preserve registration order. -/ private def surfaceChecks (r : Tests.Runner) : Async Unit := do @@ -720,6 +744,7 @@ def run (r : Tests.Runner) : IO Unit := do outboundFailureChecks r readerCopyChecks r pairChecks r + postJoinSubscriptionChecks r surfaceChecks r subscribeRace r trickleChecks r diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index 1aa7e438..950717d4 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -428,18 +428,37 @@ private def perform (env : Env) (gossipAt : Option Millis) (index : Nat) | none => enqueue env to rpc return (delayed.toList, gossipIndex) -/-- Tell every known peer about a subscription change (module doc). -/ +/-- Tell peers about a subscription change, or tell a new peer about all +current subscriptions. Each offer uses the bounded per-peer queue. -/ private def announce (env : Env) (st : LoopState) (event : Core.Event) : BaseIO LoopState := do - let record? := match event with - | .subscribe t => some (subOptRpc t true) - | .unsubscribe t => some (subOptRpc t false) - | _ => none - if let some rpc := record? then + match event with + | .subscribe t => + let rpc := subOptRpc t true for (peer, _) in st.router.peers do enqueue env peer rpc + | .unsubscribe t => + let rpc := subOptRpc t false + for (peer, _) in st.router.peers do + enqueue env peer rpc + | .peerJoined peer _ => + if !st.router.topics.isEmpty then + enqueue env peer (Core.helloRpc st.router.topics) + | _ => pure () return st +/-- Drop the core hello when the node already sent per-topic join records. -/ +private def withoutPeerHello (peer : Peer) (topics : List TopicId) : + List Core.Action → List Core.Action + | [] => [] + | action :: rest => + match action with + | .sendRpc target rpc => + if !topics.isEmpty && target == peer && rpc == Core.helloRpc topics then + withoutPeerHello peer topics rest + else action :: withoutPeerHello peer topics rest + | _ => action :: withoutPeerHello peer topics rest + /-- One core step, then its actions. Only an RPC that does not decode makes the core answer `.error`. That answer keeps the state, and the loop runs on. -/ private def stepAndAct (env : Env) (st : LoopState) (event : Core.Event) : BaseIO LoopState := do @@ -456,6 +475,9 @@ private def stepAndAct (env : Env) (st : LoopState) (event : Core.Event) : BaseI match Core.step env.config.params st.router event with | .error (.decode _) => return st | .ok (router, actions) => + let actions := match event with + | .peerJoined peer _ => withoutPeerHello peer st.router.topics actions + | _ => actions let gossipAt := match event with | .tick now _ => some now | _ => none From fb4907071e1937f0c4c2958743a67b46211fc8a8 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:53:46 +0800 Subject: [PATCH 122/125] chore(gossipsub): add gated RPC wire tracing --- packages/cli/GossipsubInterop.lean | 26 +++++++++++++++++++ packages/cli/Main.lean | 8 ++++-- .../Libp2p/Protocols/Gossipsub/Node.lean | 3 +++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/cli/GossipsubInterop.lean b/packages/cli/GossipsubInterop.lean index d6e95ff9..03ed0425 100644 --- a/packages/cli/GossipsubInterop.lean +++ b/packages/cli/GossipsubInterop.lean @@ -9,6 +9,7 @@ set_option autoImplicit false namespace Libp2p.Protocols.Gossipsub.Interop open Libp2p.Identity +open Libp2p.Protocols.Gossipsub /-- Parse the harness command line. -/ def paramsPath (args : List String) : Except String System.FilePath := @@ -101,6 +102,31 @@ def peerRecord (time peer : String) (nodeId : Nat) : String := def receivedRecord (time : String) (payload : List Nat) (sender topic : Option String) : String := receivedMessageLine time (messageIdNumber payload) sender topic +/-- Format the RPC fields that matter for the mixed-cell wire trace. -/ +def rpcTraceSummary (rpc : Rpc.RPC) : String := + let controlCount := match rpc.control with + | none => 0 + | some c => c.ihave.length + c.iwant.length + c.graft.length + c.prune.length + let subscriptionTopics := rpc.subscriptions.map (fun s => s.topicid) + let publishTopics := rpc.publish.map (fun m => m.topic.name) + let controlTopics := match rpc.control with + | none => [] + | some c => c.ihave.map (fun x => x.topicId) ++ + c.graft.map (fun x => x.topicId) ++ c.prune.map (fun x => x.topicId) + let topics := subscriptionTopics ++ publishTopics ++ controlTopics + s!"subscriptions={rpc.subscriptions.length} publish={rpc.publish.length} control={controlCount} topic_ids=[{String.intercalate "," topics}]" + +/-- Emit one gated inbound RPC trace to stderr. -/ +def traceInbound (enabled : Bool) (sender : String) (bytes : List Nat) : BaseIO Unit := do + if enabled then + match Core.decodeRpc bytes with + | .ok rpc => discard (IO.eprintln s!"INTEROP_TRACE inbound peer={sender} {rpcTraceSummary rpc}").toBaseIO + | .error _ => discard (IO.eprintln s!"INTEROP_TRACE inbound peer={sender} decode_error").toBaseIO + +/-- Emit one gated outbound RPC trace to stderr. -/ +def traceOutbound (enabled : Bool) (peer : Peer) (rpc : Rpc.RPC) : BaseIO Unit := do + if enabled then discard (IO.eprintln s!"INTEROP_TRACE outbound peer={peer} {rpcTraceSummary rpc}").toBaseIO + /-- Topic validation delays are updated in one atomic step. -/ structure DelayTable where entries : Conc.OnceRef (List (String × Nat)) diff --git a/packages/cli/Main.lean b/packages/cli/Main.lean index da84731d..0e0b20f2 100644 --- a/packages/cli/Main.lean +++ b/packages/cli/Main.lean @@ -41,14 +41,18 @@ def run (args : List String) : IO UInt32 := do let writer ← BaseIO.toIO RecordWriter.start let observer ← BaseIO.toIO (ReceiptObserver.new writer) let delayTable ← BaseIO.toIO DelayTable.new + let traceEnabled := (← IO.getEnv "INTEROP_TRACE").getD "" == "1" let coreParams ← match toCoreParams params with | .ok value => pure value | .error e => (RecordWriter.close writer).run return ← fail e - let receiptCallback : Peer → List Nat → BaseIO Unit := fun sender bytes => + let receiptCallback : Peer → List Nat → BaseIO Unit := fun sender bytes => do + traceInbound traceEnabled (toString sender) bytes observer.onRpc (toString sender) bytes - let config : Node.Config := { params := coreParams, onRpc := receiptCallback } + let sendCallback : Peer → Rpc.RPC → BaseIO Unit := fun peer rpc => + traceOutbound traceEnabled peer rpc + let config : Node.Config := { params := coreParams, onRpc := receiptCallback, onSend := sendCallback } let mut running : Option GossipsubSession.Running := none let mut resultCode : UInt32 := 1 try diff --git a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean index 950717d4..8a55fcb2 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/Gossipsub/Node.lean @@ -138,6 +138,8 @@ structure Config where core sees them. A harness reads it to log each received message, including a duplicate. It runs on the loop task, so it must not park. -/ onRpc : Peer → List Nat → BaseIO Unit := fun _ _ => pure () + /-- Called for every outbound RPC before it enters the peer queue. -/ + onSend : Peer → Rpc.RPC → BaseIO Unit := fun _ _ => pure () /-- A running node. Every field is a seam over the loop task, which is the only owner of the router state. -/ @@ -357,6 +359,7 @@ private structure Env where A full queue drops its frame. Each peer has one writer and one stream. -/ private def enqueue (env : Env) (peer : Peer) (rpc : Rpc.RPC) : BaseIO Unit := do if ← env.closing.get then return () + (env.config.onSend peer rpc : BaseIO Unit) let some frame := Rpc.encodeFrame? rpc | return () let table ← env.outbound.get let ob ← match table.find? (fun e => e.1 == peer) with From 9dd17ffd50d4684632178a2f29bce9c2cad29143 Mon Sep 17 00:00:00 2001 From: Wenbo <78262508+Wenbobobo@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:08:29 +0800 Subject: [PATCH 123/125] fix(gossipsub): advertise meshsub in identify --- packages/cli/GossipsubSession.lean | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/cli/GossipsubSession.lean b/packages/cli/GossipsubSession.lean index 622373d8..7b1454eb 100644 --- a/packages/cli/GossipsubSession.lean +++ b/packages/cli/GossipsubSession.lean @@ -25,6 +25,19 @@ structure Running where host : Libp2p.Host.Host node : Node +/-- The protocol ids this host serves, as the identify responder +advertises them. `Host.new` installs identify; `Node.start` installs +meshsub before `listen`, so every peer that asks after the listen sees +both ids. + +A peer that learns no served id here never opens its own gossipsub +stream to this node: go-libp2p-pubsub adds a peer only after identify +reports a protocol that the router speaks, then opens the router stream. +An empty list leaves the peer with no router stream, so it never enters +the mesh. -/ +def servedProtocols : List String := + [identifyId.toString, meshsubId.toString] + /-- Build Noise and the real TCP host. Node.start installs meshsub. -/ def start (identity : PrivKey) (nodeConfig : Node.Config := {}) : IO (Except String Running) := do let generated ← (Libp2p.Security.Noise.Config.generate identity).run @@ -39,7 +52,7 @@ def start (identity : PrivKey) (nodeConfig : Node.Config := {}) : IO (Except Str transports := [Streams.Tcp.transport] security := [Libp2p.Security.Noise.securityProtocol noise] muxers := [yamuxMuxer] - handlers := [Identify.respondHandler pubkey [] [] none "leanp2p"] } + handlers := [Identify.respondHandler pubkey [] servedProtocols none "leanp2p"] } let hostResult ← BaseIO.toIO (Libp2p.Host.Host.new config) let host ← match hostResult with | .error e => return .error (reprStr e) From d1c1ab3202246a42dde397fc9600cc23b52fb735 Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Mon, 9 Nov 2026 10:40:31 +0700 Subject: [PATCH 124/125] docs: correct the interop host wiring claims --- packages/cli/GossipsubInterop.lean | 4 +++- packages/libp2p/protocols/docs/PLAN.md | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli/GossipsubInterop.lean b/packages/cli/GossipsubInterop.lean index 03ed0425..d8a59d32 100644 --- a/packages/cli/GossipsubInterop.lean +++ b/packages/cli/GossipsubInterop.lean @@ -3,7 +3,9 @@ import InteropHostname import Libp2p.Identity import Libp2p.Protocols.Gossipsub.Interop -/-! Host-free participant core. Host wiring stays provisional until the green interfaces settle. -/ +/-! The host-free script driver of the interop participant. `Cli.Main` +supplies the driver callbacks over the real host that `Cli.GossipsubSession` +assembles; the structure's defaults name no host. -/ set_option autoImplicit false namespace Libp2p.Protocols.Gossipsub.Interop diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index d20f515e..38dfa427 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -130,7 +130,8 @@ in the codec group wrapping upstream `google/snappy`. The checks cover configured delays across heartbeats and the default zero-delay schedule. - The loop suite, `Tests/Gossipsub/Node.lean`, runs over the in-memory - switch of `Mock/Host.lean`: + switch of `Tests/Gossipsub/Switch.lean`, a double of the real + `Libp2p.Core` handler seam: - One publish crosses two nodes and reaches the callback. - Subscribe registers the callback before posting its event. A refused post takes back its own registration by id. From b3c76b31243323765e7386ce33e46452e37c4cee Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Mon, 9 Nov 2026 15:46:13 +0700 Subject: [PATCH 125/125] chore: drop dead imports, converge two host files with the tip --- packages/libp2p/host/Libp2p/Host/Dedup.lean | 2 +- packages/libp2p/host/Libp2p/Host/Registry.lean | 2 +- packages/libp2p/mux/Libp2p/Mux.lean | 1 - packages/libp2p/protocols/Libp2p/Protocols/ReqResp/Limiter.lean | 1 - packages/libp2p/protocols/Libp2p/Protocols/ReqResp/Payload.lean | 1 - 5 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/libp2p/host/Libp2p/Host/Dedup.lean b/packages/libp2p/host/Libp2p/Host/Dedup.lean index 45f83ba3..57e86bd8 100644 --- a/packages/libp2p/host/Libp2p/Host/Dedup.lean +++ b/packages/libp2p/host/Libp2p/Host/Dedup.lean @@ -150,4 +150,4 @@ theorem dedupKeepTable {α : Type} (l r : PeerId) (c s : Entry α) (t : SessionT · simp_all · rfl -end Libp2p.Host \ No newline at end of file +end Libp2p.Host diff --git a/packages/libp2p/host/Libp2p/Host/Registry.lean b/packages/libp2p/host/Libp2p/Host/Registry.lean index 8412abde..935cf7dc 100644 --- a/packages/libp2p/host/Libp2p/Host/Registry.lean +++ b/packages/libp2p/host/Libp2p/Host/Registry.lean @@ -116,4 +116,4 @@ def ids (t : Handlers) : List ProtocolId := end Handlers -end Libp2p.Host \ No newline at end of file +end Libp2p.Host diff --git a/packages/libp2p/mux/Libp2p/Mux.lean b/packages/libp2p/mux/Libp2p/Mux.lean index 612132e5..e3ee104a 100644 --- a/packages/libp2p/mux/Libp2p/Mux.lean +++ b/packages/libp2p/mux/Libp2p/Mux.lean @@ -1,7 +1,6 @@ import Libp2p.Mux.Constants import Libp2p.Mux.Yamux import Libp2p.Mux.Test -import Libp2p.Mux.Yamux /-! # Libp2p.Mux diff --git a/packages/libp2p/protocols/Libp2p/Protocols/ReqResp/Limiter.lean b/packages/libp2p/protocols/Libp2p/Protocols/ReqResp/Limiter.lean index 194f21d5..59100627 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/ReqResp/Limiter.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/ReqResp/Limiter.lean @@ -1,7 +1,6 @@ import Conc import Libp2p.Core.ProtocolId import Libp2p.Protocols.Constants -import Libp2p.Protocols.Mock.Core /-! # Req/resp: the concurrent-request limit diff --git a/packages/libp2p/protocols/Libp2p/Protocols/ReqResp/Payload.lean b/packages/libp2p/protocols/Libp2p/Protocols/ReqResp/Payload.lean index e3eb857d..33184090 100644 --- a/packages/libp2p/protocols/Libp2p/Protocols/ReqResp/Payload.lean +++ b/packages/libp2p/protocols/Libp2p/Protocols/ReqResp/Payload.lean @@ -4,7 +4,6 @@ import Libp2p.Core.Error import Libp2p.Core.ProtocolId import Libp2p.Protocols.ReqResp.Crc32c import Libp2p.Protocols.ReqResp.SnappyFrame -import Libp2p.Protocols.Mock.Core /-! # Req/resp: the payload layer