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}