1use std::path::PathBuf;
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use anyhow::{Context, Result, anyhow, bail};
27use base64::Engine as _;
28use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
29use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
30use serde::{Deserialize, Serialize};
31use serde_json::{Value, json};
32
33use crate::config;
34
35pub const DEFAULT_RELAY: &str = "https://wireup.net";
36pub const DEFAULT_TTL_SECS: u64 = 86_400; pub(crate) fn record_pair_rejection(peer_handle: &str, code: &str, detail: &str) {
46 let line = json!({
47 "ts": std::time::SystemTime::now()
48 .duration_since(std::time::UNIX_EPOCH)
49 .map(|d| d.as_secs())
50 .unwrap_or(0),
51 "peer": peer_handle,
52 "code": code,
53 "detail": detail,
54 });
55 let serialised = match serde_json::to_string(&line) {
56 Ok(s) => s,
57 Err(e) => {
58 eprintln!("wire: could not serialise pair-rejected entry: {e}");
59 return;
60 }
61 };
62 let path = match config::state_dir() {
63 Ok(d) => d.join("pair-rejected.jsonl"),
64 Err(e) => {
65 eprintln!("wire: state_dir unresolved, dropping pair-rejected log: {e}");
66 return;
67 }
68 };
69 if let Some(parent) = path.parent() {
70 if let Err(e) = std::fs::create_dir_all(parent) {
71 eprintln!("wire: could not create {parent:?}: {e}");
72 return;
73 }
74 }
75 use std::io::Write;
76 match std::fs::OpenOptions::new()
77 .create(true)
78 .append(true)
79 .open(&path)
80 {
81 Ok(mut f) => {
82 if let Err(e) = writeln!(f, "{serialised}") {
83 eprintln!("wire: could not append pair-rejected to {path:?}: {e}");
84 }
85 }
86 Err(e) => {
87 eprintln!("wire: could not open {path:?}: {e}");
88 }
89 }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct InvitePayload {
95 pub v: u32,
97 pub did: String,
99 pub card: Value,
101 pub relay_url: String,
103 pub slot_id: String,
105 pub slot_token: String,
107 pub nonce: String,
109 pub exp: u64,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct PendingInvite {
116 pub nonce: String,
117 pub exp: u64,
118 pub uses_remaining: u32,
119 pub accepted_by: Vec<String>,
121 pub created_at: String,
122}
123
124fn open_mode_enabled() -> bool {
128 let path = match config::config_dir() {
129 Ok(p) => p.join("policy.json"),
130 Err(_) => return true,
131 };
132 let bytes = match std::fs::read(&path) {
133 Ok(b) => b,
134 Err(_) => return true,
135 };
136 let v: Value = match serde_json::from_slice(&bytes) {
137 Ok(v) => v,
138 Err(_) => return true,
139 };
140 v.get("accept_unknown_pair_drops")
141 .and_then(Value::as_bool)
142 .unwrap_or(true)
143}
144
145pub fn pending_invites_dir() -> Result<PathBuf> {
146 Ok(config::state_dir()?.join("pending-invites"))
147}
148
149fn now_unix() -> u64 {
150 SystemTime::now()
151 .duration_since(UNIX_EPOCH)
152 .map(|d| d.as_secs())
153 .unwrap_or(0)
154}
155
156fn default_handle() -> String {
159 let raw = hostname::get()
160 .ok()
161 .and_then(|s| s.into_string().ok())
162 .unwrap_or_else(|| "wire-user".into());
163 let sanitized: String = raw
164 .chars()
165 .map(|c| {
166 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
167 c
168 } else {
169 '-'
170 }
171 })
172 .collect();
173 if sanitized.is_empty() {
174 "wire-user".into()
175 } else {
176 sanitized
177 }
178}
179
180pub fn ensure_self_with_relay(
183 preferred_relay: Option<&str>,
184) -> Result<(String, String, String, String)> {
185 let relay = preferred_relay.unwrap_or(DEFAULT_RELAY);
186
187 if !config::is_initialized()? {
188 let handle = default_handle();
189 crate::pair_session::init_self_idempotent(&handle, None, Some(relay))
190 .with_context(|| format!("auto-init as did:wire:{handle}"))?;
191 }
192
193 let card = config::read_agent_card()?;
194 let did = card
195 .get("did")
196 .and_then(Value::as_str)
197 .ok_or_else(|| anyhow!("agent-card missing did"))?
198 .to_string();
199
200 let mut relay_state = config::read_relay_state()?;
201 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
202
203 if self_state.is_null() || self_state.get("slot_id").and_then(Value::as_str).is_none() {
204 let client = crate::relay_client::RelayClient::new(relay);
205 client.check_healthz()?;
206 let handle = crate::agent_card::display_handle_from_did(&did);
207 let alloc = client.allocate_slot(Some(handle))?;
208 relay_state["self"] = json!({
209 "relay_url": relay,
210 "slot_id": alloc.slot_id,
211 "slot_token": alloc.slot_token,
212 });
213 config::write_relay_state(&relay_state)?;
214 }
215
216 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
217 let relay_url = self_state["relay_url"].as_str().unwrap_or("").to_string();
218 let slot_id = self_state["slot_id"].as_str().unwrap_or("").to_string();
219 let slot_token = self_state["slot_token"].as_str().unwrap_or("").to_string();
220 if relay_url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
221 bail!("self relay state incomplete after auto-allocate");
222 }
223 Ok((did, relay_url, slot_id, slot_token))
224}
225
226pub fn mint_invite(
228 ttl_secs: Option<u64>,
229 uses: u32,
230 preferred_relay: Option<&str>,
231) -> Result<String> {
232 let (did, relay_url, slot_id, slot_token) = ensure_self_with_relay(preferred_relay)?;
233
234 let card = config::read_agent_card()?;
235 let sk_seed = config::read_private_key()?;
236
237 let mut nonce_bytes = [0u8; 32];
238 use rand::RngCore;
239 rand::thread_rng().fill_bytes(&mut nonce_bytes);
240 let nonce = hex::encode(nonce_bytes);
241
242 let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
243 let exp = now_unix() + ttl;
244
245 let payload = InvitePayload {
246 v: 1,
247 did: did.clone(),
248 card,
249 relay_url,
250 slot_id,
251 slot_token,
252 nonce: nonce.clone(),
253 exp,
254 };
255 let payload_bytes = serde_json::to_vec(&payload)?;
256
257 let mut sk_arr = [0u8; 32];
258 sk_arr.copy_from_slice(&sk_seed[..32]);
259 let sk = SigningKey::from_bytes(&sk_arr);
260 let sig = sk.sign(&payload_bytes);
261
262 let token = format!(
263 "{}.{}",
264 B64URL.encode(&payload_bytes),
265 B64URL.encode(sig.to_bytes())
266 );
267 let url = format!("wire://pair?v=1&inv={token}");
268
269 let now = time::OffsetDateTime::now_utc()
270 .format(&time::format_description::well_known::Rfc3339)
271 .unwrap_or_default();
272 let pending = PendingInvite {
273 nonce: nonce.clone(),
274 exp,
275 uses_remaining: uses.max(1),
276 accepted_by: vec![],
277 created_at: now,
278 };
279 let dir = pending_invites_dir()?;
280 std::fs::create_dir_all(&dir)?;
281 let path = dir.join(format!("{nonce}.json"));
282 std::fs::write(&path, serde_json::to_vec_pretty(&pending)?)?;
283
284 Ok(url)
285}
286
287pub fn parse_invite(url: &str) -> Result<InvitePayload> {
290 let rest = url
291 .strip_prefix("wire://pair?")
292 .ok_or_else(|| anyhow!("not a wire pair invite URL (must start with wire://pair?)"))?;
293 let mut inv = None;
294 for part in rest.split('&') {
295 if let Some(v) = part.strip_prefix("inv=") {
296 inv = Some(v);
297 }
298 }
299 let token = inv.ok_or_else(|| anyhow!("invite URL missing `inv=` parameter"))?;
300 let (payload_b64, sig_b64) = token
301 .split_once('.')
302 .ok_or_else(|| anyhow!("invite token missing `.` separator (payload.sig)"))?;
303 let payload_bytes = B64URL
304 .decode(payload_b64)
305 .map_err(|e| anyhow!("invite payload b64 decode failed: {e}"))?;
306 let sig_bytes = B64URL
307 .decode(sig_b64)
308 .map_err(|e| anyhow!("invite sig b64 decode failed: {e}"))?;
309
310 let payload: InvitePayload = serde_json::from_slice(&payload_bytes)
311 .map_err(|e| anyhow!("invite payload JSON decode failed: {e}"))?;
312
313 if payload.v != 1 {
314 bail!("invite schema version {} not supported", payload.v);
315 }
316 if now_unix() > payload.exp {
317 bail!("invite expired (exp={}, now={})", payload.exp, now_unix());
318 }
319
320 crate::agent_card::verify_agent_card(&payload.card)
322 .map_err(|e| anyhow!("invite issuer's card signature invalid: {e}"))?;
323
324 let pk_b64 = payload
325 .card
326 .get("verify_keys")
327 .and_then(Value::as_object)
328 .and_then(|m| m.values().next())
329 .and_then(|v| v.get("key"))
330 .and_then(Value::as_str)
331 .ok_or_else(|| anyhow!("issuer card missing verify_keys[*].key"))?;
332 let pk_bytes = crate::signing::b64decode(pk_b64)?;
333 let mut pk_arr = [0u8; 32];
334 if pk_bytes.len() != 32 {
335 bail!("issuer pubkey wrong length");
336 }
337 pk_arr.copy_from_slice(&pk_bytes);
338 let vk = VerifyingKey::from_bytes(&pk_arr)
339 .map_err(|e| anyhow!("issuer pubkey decode failed: {e}"))?;
340 let mut sig_arr = [0u8; 64];
341 if sig_bytes.len() != 64 {
342 bail!("invite sig wrong length");
343 }
344 sig_arr.copy_from_slice(&sig_bytes);
345 let sig = Signature::from_bytes(&sig_arr);
346 vk.verify(&payload_bytes, &sig)
347 .map_err(|_| anyhow!("invite URL signature did not verify"))?;
348
349 Ok(payload)
350}
351
352pub fn accept_invite(url: &str) -> Result<Value> {
355 let payload = parse_invite(url)?;
356
357 let (our_did, our_relay, our_slot_id, our_slot_token) =
359 ensure_self_with_relay(Some(&payload.relay_url))?;
360
361 if our_did == payload.did {
362 bail!("refusing to accept own invite (issuer DID matches self)");
363 }
364
365 let mut trust = config::read_trust()?;
367 crate::trust::add_agent_card_pin(&mut trust, &payload.card, Some("VERIFIED"));
368 config::write_trust(&trust)?;
369
370 let peer_handle = crate::agent_card::display_handle_from_did(&payload.did).to_string();
371 let mut relay_state = config::read_relay_state()?;
372 relay_state["peers"][&peer_handle] = json!({
373 "relay_url": payload.relay_url,
374 "slot_id": payload.slot_id,
375 "slot_token": payload.slot_token,
376 });
377 config::write_relay_state(&relay_state)?;
378
379 let our_card = config::read_agent_card()?;
383 let sk_seed = config::read_private_key()?;
384 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
385 let pk_b64 = our_card
386 .get("verify_keys")
387 .and_then(Value::as_object)
388 .and_then(|m| m.values().next())
389 .and_then(|v| v.get("key"))
390 .and_then(Value::as_str)
391 .ok_or_else(|| anyhow!("our agent-card missing verify_keys[*].key"))?;
392 let pk_bytes = crate::signing::b64decode(pk_b64)?;
393
394 let now = time::OffsetDateTime::now_utc()
395 .format(&time::format_description::well_known::Rfc3339)
396 .unwrap_or_default();
397 let event = json!({
398 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
399 "timestamp": now,
400 "from": our_did,
401 "to": payload.did,
402 "type": "pair_drop",
403 "kind": 1100u32,
404 "body": {
405 "card": our_card,
406 "relay_url": our_relay,
407 "slot_id": our_slot_id,
408 "slot_token": our_slot_token,
409 "pair_nonce": payload.nonce,
410 },
411 });
412 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
413 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
414
415 let client = crate::relay_client::RelayClient::new(&payload.relay_url);
416 client
417 .post_event(&payload.slot_id, &payload.slot_token, &signed)
418 .with_context(|| {
419 format!(
420 "POST pair_drop to {} slot {}",
421 payload.relay_url, payload.slot_id
422 )
423 })?;
424
425 Ok(json!({
426 "paired_with": payload.did,
427 "peer_handle": peer_handle,
428 "event_id": event_id,
429 "status": "drop_sent",
430 }))
431}
432
433pub fn maybe_consume_pair_drop(event: &Value) -> Result<Option<String>> {
438 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
439 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
440 if kind != 1100 || type_str != "pair_drop" {
441 return Ok(None);
442 }
443 let body = match event.get("body") {
444 Some(b) => b,
445 None => return Ok(None),
446 };
447
448 let nonce_opt = body
454 .get("pair_nonce")
455 .and_then(Value::as_str)
456 .map(str::to_string);
457 let mut pending: Option<PendingInvite> = None;
458 let mut invite_path: Option<std::path::PathBuf> = None;
459 if let Some(nonce) = nonce_opt.as_deref() {
460 let dir = pending_invites_dir()?;
461 let path = dir.join(format!("{nonce}.json"));
462 if path.exists() {
463 let p: PendingInvite = serde_json::from_slice(&std::fs::read(&path)?)
464 .with_context(|| format!("reading pending invite {path:?}"))?;
465 if now_unix() > p.exp {
466 if let Err(e) = std::fs::remove_file(&path) {
469 eprintln!(
470 "wire: could not delete expired invite {path:?}: {e}"
471 );
472 }
473 return Ok(None);
474 }
475 pending = Some(p);
476 invite_path = Some(path);
477 } else if !open_mode_enabled() {
478 return Ok(None);
482 }
483 } else if !open_mode_enabled() {
484 return Ok(None);
487 }
488
489 let peer_card = body
490 .get("card")
491 .cloned()
492 .ok_or_else(|| anyhow!("pair_drop body missing card"))?;
493 crate::agent_card::verify_agent_card(&peer_card)
494 .map_err(|e| anyhow!("pair_drop peer card sig invalid: {e}"))?;
495
496 let peer_did = peer_card
497 .get("did")
498 .and_then(Value::as_str)
499 .ok_or_else(|| anyhow!("peer card missing did"))?
500 .to_string();
501 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
502
503 let mut tmp_trust = config::read_trust()?;
508 crate::trust::add_agent_card_pin(&mut tmp_trust, &peer_card, Some("VERIFIED"));
509 crate::signing::verify_message_v31(event, &tmp_trust)
510 .map_err(|e| anyhow!("pair_drop event sig verify failed: {e}"))?;
511
512 let peer_relay = body
513 .get("relay_url")
514 .and_then(Value::as_str)
515 .unwrap_or("");
516 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
517 let peer_slot_token = body
518 .get("slot_token")
519 .and_then(Value::as_str)
520 .unwrap_or("");
521 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
522 bail!("pair_drop body missing relay_url/slot_id/slot_token");
523 }
524
525 if nonce_opt.is_some() {
543 config::write_trust(&tmp_trust)?;
545 let mut relay_state = config::read_relay_state()?;
546 relay_state["peers"][&peer_handle] = json!({
547 "relay_url": peer_relay,
548 "slot_id": peer_slot_id,
549 "slot_token": peer_slot_token,
550 });
551 config::write_relay_state(&relay_state)?;
552
553 if let (Some(pending), Some(invite_path)) = (pending, invite_path) {
555 if pending.uses_remaining <= 1 {
556 if let Err(e) = std::fs::remove_file(&invite_path) {
557 eprintln!(
558 "wire: could not delete consumed invite {invite_path:?}: {e}"
559 );
560 }
561 } else {
562 let mut updated = pending.clone();
563 updated.uses_remaining -= 1;
564 updated.accepted_by.push(peer_did.clone());
565 std::fs::write(&invite_path, serde_json::to_vec_pretty(&updated)?)?;
566 }
567 }
568 crate::os_notify::toast(
569 &format!("wire — paired with {peer_handle}"),
570 "Invite accepted. Ready to send + receive.",
571 );
572 return Ok(Some(peer_did));
573 }
574
575 let now_iso = time::OffsetDateTime::now_utc()
577 .format(&time::format_description::well_known::Rfc3339)
578 .unwrap_or_default();
579 let event_id = event
580 .get("event_id")
581 .and_then(Value::as_str)
582 .unwrap_or("")
583 .to_string();
584 let event_timestamp = event
585 .get("timestamp")
586 .and_then(Value::as_str)
587 .unwrap_or("")
588 .to_string();
589 let pending_inbound = crate::pending_inbound_pair::PendingInboundPair {
590 peer_handle: peer_handle.clone(),
591 peer_did: peer_did.clone(),
592 peer_card: peer_card.clone(),
593 peer_relay_url: peer_relay.to_string(),
594 peer_slot_id: peer_slot_id.to_string(),
595 peer_slot_token: peer_slot_token.to_string(),
596 event_id,
597 event_timestamp,
598 received_at: now_iso,
599 };
600 crate::pending_inbound_pair::write_pending_inbound(&pending_inbound)?;
601 crate::os_notify::toast(
602 &format!("wire — pair request from {peer_handle}"),
603 &format!(
604 "run `wire pair-accept {peer_handle}` (or `wire add {peer_handle}@{peer_relay}`) to accept, or `wire pair-reject {peer_handle}` to refuse",
605 ),
606 );
607
608 Ok(Some(peer_did))
609}
610
611pub fn send_pair_drop_ack(
621 peer_handle: &str,
622 peer_relay: &str,
623 peer_slot_id: &str,
624 peer_slot_token: &str,
625) -> Result<()> {
626 let our_card = config::read_agent_card()?;
628 let our_did = our_card
629 .get("did")
630 .and_then(Value::as_str)
631 .ok_or_else(|| anyhow!("our card missing did"))?
632 .to_string();
633 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
634 let relay_state = config::read_relay_state()?;
635 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
636 let our_relay = self_state
637 .get("relay_url")
638 .and_then(Value::as_str)
639 .unwrap_or("")
640 .to_string();
641 let our_slot_id = self_state
642 .get("slot_id")
643 .and_then(Value::as_str)
644 .unwrap_or("")
645 .to_string();
646 let our_slot_token = self_state
647 .get("slot_token")
648 .and_then(Value::as_str)
649 .unwrap_or("")
650 .to_string();
651 if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
652 bail!("self relay state incomplete; cannot emit pair_drop_ack");
653 }
654
655 let sk_seed = config::read_private_key()?;
656 let pk_b64 = our_card
657 .get("verify_keys")
658 .and_then(Value::as_object)
659 .and_then(|m| m.values().next())
660 .and_then(|v| v.get("key"))
661 .and_then(Value::as_str)
662 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
663 let pk_bytes = crate::signing::b64decode(pk_b64)?;
664
665 let now = time::OffsetDateTime::now_utc()
666 .format(&time::format_description::well_known::Rfc3339)
667 .unwrap_or_default();
668 let event = json!({
669 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
670 "timestamp": now,
671 "from": our_did,
672 "to": format!("did:wire:{peer_handle}"),
673 "type": "pair_drop_ack",
674 "kind": 1101u32,
675 "body": {
676 "relay_url": our_relay,
677 "slot_id": our_slot_id,
678 "slot_token": our_slot_token,
679 },
680 });
681 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
682 let client = crate::relay_client::RelayClient::new(peer_relay);
683 client
684 .post_event(peer_slot_id, peer_slot_token, &signed)
685 .with_context(|| format!("POST pair_drop_ack to {peer_relay} slot {peer_slot_id}"))?;
686 Ok(())
687}
688
689pub fn maybe_consume_pair_drop_ack(event: &Value) -> Result<bool> {
693 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
694 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
695 if kind != 1101 || type_str != "pair_drop_ack" {
696 return Ok(false);
697 }
698 let body = match event.get("body") {
699 Some(b) => b,
700 None => return Ok(false),
701 };
702 let from = event
703 .get("from")
704 .and_then(Value::as_str)
705 .ok_or_else(|| anyhow!("ack missing 'from'"))?;
706 let peer_handle = crate::agent_card::display_handle_from_did(from).to_string();
707 let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
708 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
709 let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
710 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
711 bail!("pair_drop_ack body missing relay_url/slot_id/slot_token");
712 }
713 let mut relay_state = config::read_relay_state()?;
714 relay_state["peers"][&peer_handle] = json!({
715 "relay_url": peer_relay,
716 "slot_id": peer_slot_id,
717 "slot_token": peer_slot_token,
718 });
719 config::write_relay_state(&relay_state)?;
720 crate::os_notify::toast(
721 &format!("wire — pair complete with {peer_handle}"),
722 "Both sides bound. Ready to send + receive.",
723 );
724 Ok(true)
725}
726
727#[cfg(test)]
733mod tests {
734 use super::*;
735 use crate::config;
736
737 #[test]
738 fn record_pair_rejection_writes_jsonl_under_state_dir() {
739 config::test_support::with_temp_home(|| {
743 super::record_pair_rejection(
744 "slancha-spark",
745 "pair_drop_ack_send_failed",
746 "POST returned 502",
747 );
748 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
749 assert!(
750 path.exists(),
751 "record_pair_rejection must create {path:?}"
752 );
753 let body = std::fs::read_to_string(&path).unwrap();
754 let line = body.lines().last().expect("at least one line");
755 let parsed: Value = serde_json::from_str(line).expect("valid JSON");
756 assert_eq!(parsed["peer"], "slancha-spark");
757 assert_eq!(parsed["code"], "pair_drop_ack_send_failed");
758 assert_eq!(parsed["detail"], "POST returned 502");
759 assert!(parsed["ts"].as_u64().unwrap_or(0) > 0);
760 });
761 }
762
763 #[test]
764 fn record_pair_rejection_appends_multiple_lines() {
765 config::test_support::with_temp_home(|| {
768 super::record_pair_rejection("a", "code_a", "detail_a");
769 super::record_pair_rejection("b", "code_b", "detail_b");
770 super::record_pair_rejection("c", "code_c", "detail_c");
771 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
772 let body = std::fs::read_to_string(&path).unwrap();
773 let lines: Vec<&str> = body.lines().collect();
774 assert_eq!(lines.len(), 3, "expected 3 entries, got {}", lines.len());
775 for (i, peer) in ["a", "b", "c"].iter().enumerate() {
776 let parsed: Value = serde_json::from_str(lines[i]).unwrap();
777 assert_eq!(parsed["peer"], *peer);
778 }
779 });
780 }
781}