Skip to main content

wire/
pull.rs

1//! Pull-event processing — pure logic shared by `wire pull` and the daemon
2//! sync loop.
3//!
4//! P0.1 (0.5.11): refuse to silently advance cursor past events the running
5//! binary cannot process. The cursor only advances to the last event in the
6//! contiguous prefix that was either successfully written or rejected for a
7//! TERMINAL reason. Events rejected for TRANSIENT reasons (unknown kind,
8//! signer not yet pinned) block the cursor — so the next pull re-sees them
9//! and a future binary version or freshly-pinned peer can pick up where we
10//! left off.
11//!
12//! Without this rule, an old daemon running against a newer relay silently
13//! ate v0.5.x `pair_drop` events (kind=1100) it could neither pin nor verify,
14//! advancing the cursor past them. Today's debug session lost ~30 min to it.
15//!
16//! Adversarial test: `tests/pull_unknown_kind.rs` synthesises a kind=9999
17//! event, runs `process_events`, and asserts the cursor stays put + the
18//! rejection carries `binary_version=` and `unknown_kind=` so the failure is
19//! loud on every retry.
20//!
21//! Cursor advancement rules:
22//!
23//! - terminal reject (bad signature, missing field, event_id mismatch,
24//!   revoked key) → advance past, retry won't help.
25//! - transient reject (unknown kind to THIS binary, signer not in trust) →
26//!   DO NOT advance past, future state may unblock.
27//! - success → advance past.
28//!
29//! The first transient reject "blocks" the cursor; subsequent events in the
30//! batch are still processed for their inbox-write side effect but cannot
31//! advance the cursor beyond the block point. Re-pull observes the same
32//! blocking event again → visible failure mode.
33
34use anyhow::Result;
35use serde_json::{Value, json};
36use std::path::Path;
37
38use crate::{config, pair_invite, signing};
39
40/// Outcome of processing a batch of pulled events.
41pub struct PullResult {
42    pub written: Vec<Value>,
43    pub rejected: Vec<Value>,
44    /// New value for `self.last_pulled_event_id`. `None` means the cursor
45    /// was not advanced (either no events processable beyond the prior
46    /// cursor, or the first event blocked).
47    pub advance_cursor_to: Option<String>,
48    /// True if at least one event in this batch is blocking cursor advance.
49    /// Surfaces to operators in `wire pull` non-JSON output so silent stall
50    /// is visible.
51    pub blocked: bool,
52    /// RFC-004: verified inbound probes seen this batch, as `(from_handle,
53    /// nonce)`. The network-capable caller (the daemon pull cycle) auto-responds
54    /// with a probe_ack — `process_events` itself stays network-free.
55    pub probes: Vec<(String, String)>,
56}
57
58/// Check whether a peer inbox file already contains an event with this
59/// `event_id`. Scan-based — O(file_size) — but inbox files are small and
60/// only the write path consults this (a few times per pull). Avoids
61/// pulling in a separate index file.
62///
63/// Returns false if the file doesn't exist yet, so the first write to a
64/// new peer's inbox is a no-op check.
65fn inbox_already_contains(path: &std::path::Path, event_id: &str) -> bool {
66    if event_id.is_empty() {
67        return false;
68    }
69    let body = match std::fs::read_to_string(path) {
70        Ok(b) => b,
71        Err(_) => return false,
72    };
73    // Quick substring screen first — if event_id isn't anywhere in the
74    // file, no point parsing every line. event_id appears once per event
75    // as a JSON string value, so the substring is a strong signal.
76    let needle = format!("\"event_id\":\"{event_id}\"");
77    if !body.contains(&needle) {
78        return false;
79    }
80    // Confirm by line-parse — defensive against an event_id substring
81    // appearing inside a body field. JSON parsing rejects that case.
82    for line in body.lines() {
83        let trimmed = line.trim();
84        if trimmed.is_empty() {
85            continue;
86        }
87        if let Ok(v) = serde_json::from_str::<Value>(trimmed)
88            && v.get("event_id").and_then(Value::as_str) == Some(event_id)
89        {
90            return true;
91        }
92    }
93    false
94}
95
96/// Is `kind` known to THIS binary? Used by P0.1 to refuse silent cursor
97/// advance past events from a future protocol version.
98///
99/// The Nostr-compat special cases (kind=1, kind=100) are handled in
100/// `signing::kind_class`; this function mirrors them.
101pub fn is_known_kind(kind: u32) -> bool {
102    if kind == 1 || kind == 100 {
103        return true;
104    }
105    signing::kinds().iter().any(|(k, _)| *k == kind)
106}
107
108/// Whether a `VerifyError` is transient (peer pinning may complete later)
109/// or terminal (retry won't help).
110fn verify_error_is_transient(err: &signing::VerifyError) -> bool {
111    matches!(
112        err,
113        signing::VerifyError::UnknownAgent(_) | signing::VerifyError::UnknownKey(_, _)
114    )
115}
116
117/// Flow-like-water mutual trust for same-machine sisters: if `event`'s signer
118/// DID matches one of THIS operator's local sister sessions, pin that sister's
119/// card into `trust` at VERIFIED and return `true`. Same machine, same uid,
120/// same disk ⇒ same operator ⇒ trusted by construction — no accept step, no
121/// SAS, no gate. Returns `false` (caller keeps the original reject) when the
122/// signer isn't a known local sister, the registry is unreadable, or the
123/// sister's card can't be loaded.
124///
125/// Matching on the full DID — not the display handle — is the safety anchor: a
126/// wire DID is a hash commitment to its key, so a remote peer cannot forge a
127/// DID that collides with a local sister's without holding that sister's key
128/// (which would mean it IS the same operator).
129fn maybe_autopin_local_sister(event: &Value, trust: &mut Value) -> bool {
130    let Some(from_did) = event.get("from").and_then(Value::as_str) else {
131        return false;
132    };
133    let Ok(sessions) = crate::session::list_sessions() else {
134        return false;
135    };
136    for s in sessions {
137        if s.did.as_deref() != Some(from_did) {
138            continue;
139        }
140        let card_path = s
141            .home_dir
142            .join("config")
143            .join("wire")
144            .join("agent-card.json");
145        let Ok(bytes) = std::fs::read(&card_path) else {
146            return false;
147        };
148        let Ok(card) = serde_json::from_slice::<Value>(&bytes) else {
149            return false;
150        };
151        // #245: refuse to auto-pin a card that collides on an existing nick with
152        // a DIFFERENT identity — reject the pair rather than overwrite the pin.
153        if let Err(e) = crate::trust::add_agent_card_pin(trust, &card, Some("VERIFIED")) {
154            eprintln!("wire pull: refusing sister auto-pin — {e}");
155            return false;
156        }
157
158        // Mutual trust must be mutual REACHABILITY: also register the sister's
159        // relay slot so our reply has somewhere to go — otherwise the receive
160        // direction works but `wire send <sister>` back fails "peer not pinned".
161        // The authoritative source is the sister's OWN relay-state self
162        // endpoints (where their `wire up` recorded the slot — for `--no-local`
163        // federation these are flat fields `self_endpoints()` synthesizes); the
164        // on-disk card carries no endpoints. Same machine, same disk ⇒ read it
165        // directly. Best-effort; failure here doesn't undo the trust pin.
166        let sister_relay_json = s.home_dir.join("config").join("wire").join("relay.json");
167        let sister_endpoints = std::fs::read(&sister_relay_json)
168            .ok()
169            .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
170            .map(|rs| crate::endpoints::self_endpoints(&rs))
171            .unwrap_or_default();
172        if !sister_endpoints.is_empty() {
173            let handle = card
174                .get("handle")
175                .and_then(Value::as_str)
176                .map(str::to_string)
177                .unwrap_or_else(|| {
178                    crate::agent_card::display_handle_from_did(from_did).to_string()
179                });
180            if let Ok(mut relay_state) = crate::config::read_relay_state()
181                && crate::endpoints::pin_peer_endpoints(
182                    &mut relay_state,
183                    &handle,
184                    &sister_endpoints,
185                )
186                .is_ok()
187            {
188                let _ = crate::config::write_relay_state(&relay_state);
189            }
190        }
191        return true;
192    }
193    false
194}
195
196/// Process a pulled-event batch. Mutates inbox files + relay state (via
197/// `pair_invite` side effects) but returns the new cursor target rather
198/// than writing it — caller persists.
199///
200/// `initial_cursor` is the pre-pull value of `self.last_pulled_event_id`.
201/// Returned `advance_cursor_to` is what the caller should write back. If
202/// the first event blocks the cursor, `advance_cursor_to == initial_cursor`
203/// (no change).
204pub fn process_events(
205    events: &[Value],
206    initial_cursor: Option<String>,
207    inbox_dir: &Path,
208) -> Result<PullResult> {
209    let binary_version = env!("CARGO_PKG_VERSION");
210    let trust_snapshot = config::read_trust()?;
211
212    let mut written = Vec::new();
213    let mut rejected = Vec::new();
214    let mut last_advanced = initial_cursor.clone();
215    let mut first_block_idx: Option<usize> = None;
216    let mut probes: Vec<(String, String)> = Vec::new();
217
218    for (idx, event) in events.iter().enumerate() {
219        let event_id = event
220            .get("event_id")
221            .and_then(Value::as_str)
222            .unwrap_or("")
223            .to_string();
224        let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0) as u32;
225
226        // P0.Z (0.5.11): if the event declares a schema_version, its major
227        // must match ours. Absent field = legacy event (pre-0.5.11), accept
228        // — we can't retroactively stamp old traffic. Mismatched major =
229        // hard reject with both incoming + supported versions in reason.
230        // Format locked with spark: `schema_mismatch=<received> binary_supports=<ours>`.
231        if let Some(declared) = event.get("schema_version").and_then(Value::as_str) {
232            let ours = signing::EVENT_SCHEMA_VERSION;
233            if signing::schema_major(declared) != signing::schema_major(ours) {
234                rejected.push(json!({
235                    "event_id": event_id,
236                    "reason": format!(
237                        "schema_mismatch={declared} binary_supports={ours}"
238                    ),
239                    "blocks_cursor": true,
240                    "transient": true,
241                    "schema_version": declared,
242                }));
243                if first_block_idx.is_none() {
244                    first_block_idx = Some(idx);
245                }
246                continue;
247            }
248        }
249
250        // P0.1: unknown kind → transient, block cursor, fail loud.
251        if !is_known_kind(kind) {
252            let reason = format!("unknown_kind={kind} binary_version={binary_version}");
253            rejected.push(json!({
254                "event_id": event_id,
255                "reason": reason,
256                "blocks_cursor": true,
257                "transient": true,
258            }));
259            if first_block_idx.is_none() {
260                first_block_idx = Some(idx);
261            }
262            continue;
263        }
264
265        // pair_drop / pair_drop_ack — pre-trust side effects that pin sender.
266        let drop_paired = match pair_invite::maybe_consume_pair_drop(event) {
267            Ok(Some(_)) => true,
268            Ok(None) => false,
269            Err(e) => {
270                // P0.2: a pair_drop that WAS recognised (kind=1100, type=pair_drop)
271                // but FAILED during consumption is exactly the silent-fail class —
272                // sender expects to be pinned but isn't, and never finds out. Log
273                // + structured record for `wire doctor`.
274                let peer_handle = event
275                    .get("from")
276                    .and_then(Value::as_str)
277                    .map(|s| crate::agent_card::display_handle_from_did(s).to_string())
278                    .unwrap_or_else(|| "<unknown>".to_string());
279                eprintln!(
280                    "wire pull: pair_drop from {peer_handle} consume FAILED: {e}. \
281                     sender will not be pinned; have them re-add or retry."
282                );
283                pair_invite::record_pair_rejection(
284                    &peer_handle,
285                    "pair_drop_consume_failed",
286                    &e.to_string(),
287                );
288                false
289            }
290        };
291        // pair_drop_ack carries the peer's relay coordinates (relay_url /
292        // slot_id / slot_token) and, on consume, OVERWRITES our pinned
293        // endpoints for that peer + stamps the durable bilateral marker.
294        // Those are machine-trusting side effects, so they must not run on
295        // an unverified event: a forged kind=1101 claiming `from: <peer>`
296        // with attacker relay coords would otherwise redirect all our
297        // outbound traffic to that peer into the attacker's relay. We pin
298        // the peer in trust at dial time (`cmd_add`), so a legitimate ack's
299        // sender is always already pinned and verifies here; an ack we
300        // can't verify (forged, or for a peer we never dialed) is dropped
301        // before it can touch relay state. Verify against fresh trust so an
302        // earlier pair_drop in this same batch that pinned the sender is
303        // visible.
304        if event.get("kind").and_then(Value::as_u64) == Some(1101) {
305            let ack_trust = config::read_trust()?;
306            match signing::verify_message_v31(event, &ack_trust) {
307                Ok(()) => {
308                    if let Err(e) = pair_invite::maybe_consume_pair_drop_ack(event) {
309                        let peer_handle = event
310                            .get("from")
311                            .and_then(Value::as_str)
312                            .map(|s| crate::agent_card::display_handle_from_did(s).to_string())
313                            .unwrap_or_else(|| "<unknown>".to_string());
314                        eprintln!(
315                            "wire pull: pair_drop_ack from {peer_handle} consume FAILED: {e}. \
316                             their slot_token NOT recorded; we cannot `wire send` to them \
317                             until they retry."
318                        );
319                        pair_invite::record_pair_rejection(
320                            &peer_handle,
321                            "pair_drop_ack_consume_failed",
322                            &e.to_string(),
323                        );
324                    }
325                }
326                Err(e) => {
327                    let peer_handle = event
328                        .get("from")
329                        .and_then(Value::as_str)
330                        .map(|s| crate::agent_card::display_handle_from_did(s).to_string())
331                        .unwrap_or_else(|| "<unknown>".to_string());
332                    eprintln!(
333                        "wire pull: DROPPING unverified pair_drop_ack from {peer_handle}: {e}. \
334                         relay endpoints NOT updated (sender not pinned, or signature forged)."
335                    );
336                    pair_invite::record_pair_rejection(
337                        &peer_handle,
338                        "pair_drop_ack_unverified",
339                        &e.to_string(),
340                    );
341                }
342            }
343        }
344        let mut active_trust = if drop_paired {
345            config::read_trust()?
346        } else {
347            trust_snapshot.clone()
348        };
349
350        // Flow-like-water: same-machine sister sessions are the same operator,
351        // same uid, same disk — mutually trusted by construction. If an inbound
352        // event is signed by a recognized local sister we haven't pinned yet,
353        // pin it VERIFIED and re-verify — no pending-inbound, no accept step, no
354        // gate. The match is on the signer's full DID against our own session
355        // registry (a DID commits to its key, so a remote peer cannot forge one
356        // that collides with a local sister's). This is what makes
357        // `wire dial <sister>` a frictionless mutual pairing: the dialer pins
358        // the target, and the target auto-pins the dialer the instant its first
359        // event arrives. Persisted so the pin outlives this pull.
360        let verify = match signing::verify_message_v31(event, &active_trust) {
361            Ok(()) => Ok(()),
362            Err(e) => {
363                if maybe_autopin_local_sister(event, &mut active_trust) {
364                    let _ = config::write_trust(&active_trust);
365                    signing::verify_message_v31(event, &active_trust)
366                } else {
367                    Err(e)
368                }
369            }
370        };
371
372        match verify {
373            Ok(()) => {
374                let from = event
375                    .get("from")
376                    .and_then(Value::as_str)
377                    .map(|s| crate::agent_card::display_handle_from_did(s).to_string())
378                    .unwrap_or_else(|| "unknown".to_string());
379                let path = inbox_dir.join(format!("{from}.jsonl"));
380
381                // P0.X (0.5.11): dedupe-on-write. Spark reported 3 duplicate
382                // pair_drop_ack events landing in their inbox same second,
383                // same event_id. Relay double-store or push retry-after-
384                // success can re-deliver. Inbox should be content-unique by
385                // event_id.
386                if inbox_already_contains(&path, &event_id) {
387                    rejected.push(json!({
388                        "event_id": event_id,
389                        "reason": "duplicate event_id already in inbox",
390                        "blocks_cursor": false,
391                        "transient": false,
392                    }));
393                    if first_block_idx.is_none() {
394                        last_advanced = Some(event_id.clone());
395                    }
396                    continue;
397                }
398
399                use std::io::Write;
400                let mut f = std::fs::OpenOptions::new()
401                    .create(true)
402                    .append(true)
403                    .open(&path)?;
404                let mut line = serde_json::to_vec(event)?;
405                line.push(b'\n');
406                f.write_all(&line)?;
407                // v0.14.3 (#14): also surface the event timestamp so the
408                // caller (run_sync_pull) can stamp
409                // `relay_state.peers[<from>].last_inbound_event_at`
410                // — sender-side staleness needs a daemon-written
411                // signal, not inbox-file mtime (mtime breaks on
412                // backup/restore/touch and has fs-specific resolution).
413                let ts = event
414                    .get("timestamp")
415                    .and_then(Value::as_str)
416                    .unwrap_or("")
417                    .to_string();
418                // RFC-004: a verified probe → record it so the caller can
419                // auto-respond (the daemon, no LLM). Trust-neutral, plaintext.
420                if let Some(nonce) = crate::probe::probe_nonce(event) {
421                    probes.push((from.clone(), nonce));
422                }
423                written.push(json!({
424                    "event_id": event_id,
425                    "from": from,
426                    "timestamp": ts,
427                }));
428                if first_block_idx.is_none() {
429                    last_advanced = Some(event_id.clone());
430                }
431            }
432            Err(e) if verify_error_is_transient(&e) => {
433                rejected.push(json!({
434                    "event_id": event_id,
435                    "reason": e.to_string(),
436                    "blocks_cursor": true,
437                    "transient": true,
438                }));
439                if first_block_idx.is_none() {
440                    first_block_idx = Some(idx);
441                }
442            }
443            Err(e) => {
444                rejected.push(json!({
445                    "event_id": event_id,
446                    "reason": e.to_string(),
447                    "blocks_cursor": false,
448                    "transient": false,
449                }));
450                if first_block_idx.is_none() {
451                    last_advanced = Some(event_id.clone());
452                }
453            }
454        }
455    }
456
457    let result = PullResult {
458        written: written.clone(),
459        rejected: rejected.clone(),
460        advance_cursor_to: last_advanced.clone(),
461        blocked: first_block_idx.is_some(),
462        probes: probes.clone(),
463    };
464
465    // P2.10: structured trace. No-op when WIRE_DIAG is not set; one line
466    // per pull when it is. Enough signal for `wire diag tail` to replay
467    // a session.
468    crate::diag::emit(
469        "pull",
470        json!({
471            "events_in": events.len(),
472            "written": result.written.len(),
473            "rejected": result.rejected.len(),
474            "blocked": result.blocked,
475            "advance_to": result.advance_cursor_to,
476        }),
477    );
478
479    Ok(result)
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use serde_json::json;
486
487    #[test]
488    fn known_kinds_recognised() {
489        // Special cases.
490        assert!(is_known_kind(1));
491        assert!(is_known_kind(100));
492        // Named v0.1 kinds.
493        assert!(is_known_kind(1000));
494        assert!(is_known_kind(1100));
495        assert!(is_known_kind(1101));
496        assert!(is_known_kind(1201));
497    }
498
499    #[test]
500    fn unknown_kinds_rejected() {
501        assert!(!is_known_kind(0));
502        assert!(!is_known_kind(9999));
503        assert!(!is_known_kind(1099));
504        assert!(!is_known_kind(50000));
505    }
506
507    #[test]
508    fn unknown_kind_rejection_carries_binary_version_and_kind() {
509        // Spark's E. rule: the silent failure must be loud. Reject reason
510        // must name both the offending kind AND the binary version so an
511        // operator running `wire pull --json` sees instantly which side is
512        // behind.
513        crate::config::test_support::with_temp_home(|| {
514            crate::config::ensure_dirs().unwrap();
515            let inbox = crate::config::inbox_dir().unwrap();
516
517            let event = json!({
518                "event_id": "deadbeef",
519                "kind": 9999u32,
520                "type": "speculation",
521                "from": "did:wire:future-peer",
522            });
523
524            let result =
525                process_events(&[event], Some("prior-cursor".to_string()), &inbox).unwrap();
526
527            assert_eq!(result.rejected.len(), 1);
528            let reason = result.rejected[0]["reason"].as_str().unwrap();
529            assert!(
530                reason.contains("unknown_kind=9999"),
531                "reason missing kind: {reason}"
532            );
533            assert!(
534                reason.contains("binary_version="),
535                "reason missing binary_version: {reason}"
536            );
537            assert_eq!(result.rejected[0]["blocks_cursor"], true);
538
539            // Cursor MUST NOT advance past unknown event.
540            assert_eq!(
541                result.advance_cursor_to,
542                Some("prior-cursor".to_string()),
543                "cursor advanced past unknown kind — silent drop regression"
544            );
545            assert!(result.blocked);
546        });
547    }
548
549    #[test]
550    fn schema_mismatch_blocks_cursor_with_reason_shape() {
551        // P0.Z lock-in: format of the rejection reason. Spark + I agreed on
552        // exact shape `schema_mismatch=v3.2 binary_supports=v3.1` so an
553        // operator running `wire pull --json | jq` can grep for it.
554        // Wrong major (v4 vs v3) -> reject.
555        crate::config::test_support::with_temp_home(|| {
556            crate::config::ensure_dirs().unwrap();
557            let inbox = crate::config::inbox_dir().unwrap();
558            let event = json!({
559                "event_id": "future-binary",
560                "schema_version": "v4.0",
561                "kind": 1000u32,
562                "type": "decision",
563                "from": "did:wire:future",
564            });
565            let result = process_events(&[event], Some("prior".to_string()), &inbox).unwrap();
566            assert_eq!(result.rejected.len(), 1);
567            let reason = result.rejected[0]["reason"].as_str().unwrap();
568            assert!(reason.contains("schema_mismatch=v4.0"));
569            assert!(reason.contains("binary_supports=v3.1"));
570            assert_eq!(result.rejected[0]["blocks_cursor"], true);
571            assert_eq!(result.advance_cursor_to, Some("prior".to_string()));
572        });
573    }
574
575    #[test]
576    fn schema_minor_bump_within_same_major_is_accepted() {
577        // v3.2 from a slightly-newer peer is still v3 major — must NOT be
578        // rejected just because the minor differs. Otherwise we lock the
579        // protocol to whoever shipped first.
580        crate::config::test_support::with_temp_home(|| {
581            crate::config::ensure_dirs().unwrap();
582            let inbox = crate::config::inbox_dir().unwrap();
583            let event = json!({
584                "event_id": "minor-bump",
585                "schema_version": "v3.2",
586                "kind": 1000u32,
587                "type": "decision",
588                "from": "did:wire:peer-not-in-trust",
589            });
590            let result = process_events(&[event], Some("prior".to_string()), &inbox).unwrap();
591            // Schema check passes, falls through to verify which rejects
592            // for trust reasons (transient blocks_cursor=true). Either way,
593            // the reason must NOT be a schema_mismatch.
594            let reason = result.rejected[0]["reason"].as_str().unwrap();
595            assert!(
596                !reason.contains("schema_mismatch"),
597                "minor bump should not be schema_mismatch: {reason}"
598            );
599        });
600    }
601
602    #[test]
603    fn legacy_event_without_schema_version_field_is_accepted() {
604        // Pre-0.5.11 events have no schema_version. Reject on absence
605        // would lock us out from every pre-existing inbox + every peer
606        // that hasn't upgraded yet. Absent field = accept (transient
607        // verify-rejection later is fine, just not a schema rejection).
608        crate::config::test_support::with_temp_home(|| {
609            crate::config::ensure_dirs().unwrap();
610            let inbox = crate::config::inbox_dir().unwrap();
611            let event = json!({
612                "event_id": "legacy",
613                "kind": 1000u32,
614                "type": "decision",
615                "from": "did:wire:legacy-peer",
616            });
617            let result = process_events(&[event], Some("prior".to_string()), &inbox).unwrap();
618            let reason = result.rejected[0]["reason"].as_str().unwrap();
619            assert!(!reason.contains("schema_mismatch"));
620        });
621    }
622
623    #[test]
624    fn forged_pair_drop_ack_does_not_mutate_relay_endpoints() {
625        // Security regression: a kind=1101 pair_drop_ack from a sender we
626        // never pinned (forged `from`, attacker relay coords) must NOT
627        // overwrite our relay endpoints. Pre-fix, the ack was consumed
628        // before signature verification, letting an attacker redirect our
629        // outbound traffic for the impersonated peer to their own relay.
630        crate::config::test_support::with_temp_home(|| {
631            crate::config::ensure_dirs().unwrap();
632            let inbox = crate::config::inbox_dir().unwrap();
633            let forged = json!({
634                "event_id": "forged-ack-0001",
635                "kind": 1101u32,
636                "type": "pair_drop_ack",
637                "from": "did:wire:victimpeer-deadbeef",
638                "body": {
639                    "relay_url": "https://attacker.example",
640                    "slot_id": "attackerslot",
641                    "slot_token": "attackertoken",
642                },
643            });
644            // Sender is NOT in trust → verify must fail → no mutation.
645            let _ = process_events(&[forged], Some("c".to_string()), &inbox).unwrap();
646            let relay_state = crate::config::read_relay_state().unwrap();
647            let peers = relay_state.get("peers").and_then(Value::as_object);
648            assert!(
649                peers.is_none_or(|m| !m.contains_key("victimpeer")),
650                "forged ack must not pin endpoints for the impersonated peer; \
651                 relay_state.peers = {peers:?}"
652            );
653        });
654    }
655
656    #[test]
657    fn inbox_dedupe_skips_duplicate_event_id() {
658        // P0.X smoke: spark's bug — same event_id arriving twice in the
659        // same inbox file produces only ONE inbox line. The pull result
660        // surfaces the duplicate as rejected[] with a clear reason so
661        // operators see what's happening (vs silently dropping).
662        let tmp = std::env::temp_dir().join(format!(
663            "wire-dedupe-test-{}-{}",
664            std::process::id(),
665            rand::random::<u32>()
666        ));
667        std::fs::create_dir_all(&tmp).unwrap();
668        let event_id = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
669        let existing_line = json!({
670            "event_id": event_id,
671            "from": "did:wire:peer",
672            "type": "claim",
673            "body": "first occurrence",
674        });
675        let path = tmp.join("peer.jsonl");
676        std::fs::write(&path, format!("{existing_line}\n")).unwrap();
677        assert!(inbox_already_contains(&path, event_id));
678        assert!(!inbox_already_contains(&path, "different-event-id"));
679        assert!(!inbox_already_contains(&path, ""));
680    }
681
682    #[test]
683    fn inbox_dedupe_substring_in_body_is_not_false_positive() {
684        // Adversarial: event_id substring inside a body field shouldn't
685        // count as the event already being present.
686        let tmp = std::env::temp_dir().join(format!(
687            "wire-dedupe-substring-{}-{}",
688            std::process::id(),
689            rand::random::<u32>()
690        ));
691        std::fs::create_dir_all(&tmp).unwrap();
692        let target_eid = "deadbeefcafebabe";
693        // Existing line has the target eid AS A STRING INSIDE the body,
694        // NOT as the event_id field.
695        let existing_line = json!({
696            "event_id": "different",
697            "from": "did:wire:peer",
698            "body": format!("the user mentioned event_id deadbeefcafebabe in passing"),
699        });
700        let path = tmp.join("peer.jsonl");
701        std::fs::write(&path, format!("{existing_line}\n")).unwrap();
702        // Substring screen sees the eid in the body, but the line-parse
703        // confirmation rejects it.
704        assert!(!inbox_already_contains(&path, target_eid));
705    }
706
707    #[test]
708    fn known_kind_after_unknown_does_not_advance_cursor() {
709        // Block rule: once first event blocks, NO later event can advance
710        // the cursor past it, even if later events would otherwise succeed.
711        // Re-pull observes both → visible.
712        crate::config::test_support::with_temp_home(|| {
713            crate::config::ensure_dirs().unwrap();
714            let inbox = crate::config::inbox_dir().unwrap();
715
716            let events = vec![
717                json!({
718                    "event_id": "evt-unknown",
719                    "kind": 9999u32,
720                    "type": "speculation",
721                    "from": "did:wire:future",
722                }),
723                json!({
724                    "event_id": "evt-known-but-untrusted",
725                    "kind": 1000u32,
726                    "type": "decision",
727                    "from": "did:wire:peer-not-in-trust",
728                }),
729            ];
730
731            let result = process_events(&events, Some("prior".to_string()), &inbox).unwrap();
732
733            assert_eq!(result.rejected.len(), 2);
734            assert_eq!(result.advance_cursor_to, Some("prior".to_string()));
735            assert!(result.blocked);
736        });
737    }
738}