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 let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
530 .get("endpoints")
531 .and_then(Value::as_array)
532 .map(|arr| {
533 arr.iter()
534 .filter_map(|e| serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok())
535 .collect()
536 })
537 .unwrap_or_else(|| {
538 vec![crate::endpoints::Endpoint::federation(
539 peer_relay.to_string(),
540 peer_slot_id.to_string(),
541 peer_slot_token.to_string(),
542 )]
543 });
544
545 if nonce_opt.is_some() {
563 config::write_trust(&tmp_trust)?;
565 let mut relay_state = config::read_relay_state()?;
566 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
570 config::write_relay_state(&relay_state)?;
571
572 if let (Some(pending), Some(invite_path)) = (pending, invite_path) {
574 if pending.uses_remaining <= 1 {
575 if let Err(e) = std::fs::remove_file(&invite_path) {
576 eprintln!(
577 "wire: could not delete consumed invite {invite_path:?}: {e}"
578 );
579 }
580 } else {
581 let mut updated = pending.clone();
582 updated.uses_remaining -= 1;
583 updated.accepted_by.push(peer_did.clone());
584 std::fs::write(&invite_path, serde_json::to_vec_pretty(&updated)?)?;
585 }
586 }
587 crate::os_notify::toast(
588 &format!("wire — paired with {peer_handle}"),
589 "Invite accepted. Ready to send + receive.",
590 );
591 return Ok(Some(peer_did));
592 }
593
594 let now_iso = time::OffsetDateTime::now_utc()
596 .format(&time::format_description::well_known::Rfc3339)
597 .unwrap_or_default();
598 let event_id = event
599 .get("event_id")
600 .and_then(Value::as_str)
601 .unwrap_or("")
602 .to_string();
603 let event_timestamp = event
604 .get("timestamp")
605 .and_then(Value::as_str)
606 .unwrap_or("")
607 .to_string();
608 let pending_inbound = crate::pending_inbound_pair::PendingInboundPair {
609 peer_handle: peer_handle.clone(),
610 peer_did: peer_did.clone(),
611 peer_card: peer_card.clone(),
612 peer_relay_url: peer_relay.to_string(),
613 peer_slot_id: peer_slot_id.to_string(),
614 peer_slot_token: peer_slot_token.to_string(),
615 peer_endpoints: peer_endpoints.clone(),
616 event_id,
617 event_timestamp,
618 received_at: now_iso,
619 };
620 crate::pending_inbound_pair::write_pending_inbound(&pending_inbound)?;
621 crate::os_notify::toast(
622 &format!("wire — pair request from {peer_handle}"),
623 &format!(
624 "run `wire pair-accept {peer_handle}` (or `wire add {peer_handle}@{peer_relay}`) to accept, or `wire pair-reject {peer_handle}` to refuse",
625 ),
626 );
627
628 Ok(Some(peer_did))
629}
630
631pub fn send_pair_drop_ack(
641 peer_handle: &str,
642 peer_relay: &str,
643 peer_slot_id: &str,
644 peer_slot_token: &str,
645) -> Result<()> {
646 let our_card = config::read_agent_card()?;
648 let our_did = our_card
649 .get("did")
650 .and_then(Value::as_str)
651 .ok_or_else(|| anyhow!("our card missing did"))?
652 .to_string();
653 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
654 let relay_state = config::read_relay_state()?;
655 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
656 let our_relay = self_state
657 .get("relay_url")
658 .and_then(Value::as_str)
659 .unwrap_or("")
660 .to_string();
661 let our_slot_id = self_state
662 .get("slot_id")
663 .and_then(Value::as_str)
664 .unwrap_or("")
665 .to_string();
666 let our_slot_token = self_state
667 .get("slot_token")
668 .and_then(Value::as_str)
669 .unwrap_or("")
670 .to_string();
671 if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
672 bail!("self relay state incomplete; cannot emit pair_drop_ack");
673 }
674
675 let sk_seed = config::read_private_key()?;
676 let pk_b64 = our_card
677 .get("verify_keys")
678 .and_then(Value::as_object)
679 .and_then(|m| m.values().next())
680 .and_then(|v| v.get("key"))
681 .and_then(Value::as_str)
682 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
683 let pk_bytes = crate::signing::b64decode(pk_b64)?;
684
685 let now = time::OffsetDateTime::now_utc()
686 .format(&time::format_description::well_known::Rfc3339)
687 .unwrap_or_default();
688 let our_endpoints = crate::endpoints::self_endpoints(&relay_state);
692 let mut body = json!({
693 "relay_url": our_relay,
694 "slot_id": our_slot_id,
695 "slot_token": our_slot_token,
696 });
697 if !our_endpoints.is_empty() {
698 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
699 }
700 let event = json!({
701 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
702 "timestamp": now,
703 "from": our_did,
704 "to": format!("did:wire:{peer_handle}"),
705 "type": "pair_drop_ack",
706 "kind": 1101u32,
707 "body": body,
708 });
709 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
710 let client = crate::relay_client::RelayClient::new(peer_relay);
711 client
712 .post_event(peer_slot_id, peer_slot_token, &signed)
713 .with_context(|| format!("POST pair_drop_ack to {peer_relay} slot {peer_slot_id}"))?;
714 Ok(())
715}
716
717pub fn maybe_consume_pair_drop_ack(event: &Value) -> Result<bool> {
721 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
722 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
723 if kind != 1101 || type_str != "pair_drop_ack" {
724 return Ok(false);
725 }
726 let body = match event.get("body") {
727 Some(b) => b,
728 None => return Ok(false),
729 };
730 let from = event
731 .get("from")
732 .and_then(Value::as_str)
733 .ok_or_else(|| anyhow!("ack missing 'from'"))?;
734 let peer_handle = crate::agent_card::display_handle_from_did(from).to_string();
735 let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
736 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
737 let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
738 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
739 bail!("pair_drop_ack body missing relay_url/slot_id/slot_token");
740 }
741 let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
745 .get("endpoints")
746 .and_then(Value::as_array)
747 .map(|arr| {
748 arr.iter()
749 .filter_map(|e| serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok())
750 .collect()
751 })
752 .unwrap_or_else(|| {
753 vec![crate::endpoints::Endpoint::federation(
754 peer_relay.to_string(),
755 peer_slot_id.to_string(),
756 peer_slot_token.to_string(),
757 )]
758 });
759 let mut relay_state = config::read_relay_state()?;
760 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
761 config::write_relay_state(&relay_state)?;
762 crate::os_notify::toast(
763 &format!("wire — pair complete with {peer_handle}"),
764 "Both sides bound. Ready to send + receive.",
765 );
766 Ok(true)
767}
768
769#[cfg(test)]
775mod tests {
776 use super::*;
777 use crate::config;
778
779 #[test]
780 fn record_pair_rejection_writes_jsonl_under_state_dir() {
781 config::test_support::with_temp_home(|| {
785 super::record_pair_rejection(
786 "slancha-spark",
787 "pair_drop_ack_send_failed",
788 "POST returned 502",
789 );
790 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
791 assert!(
792 path.exists(),
793 "record_pair_rejection must create {path:?}"
794 );
795 let body = std::fs::read_to_string(&path).unwrap();
796 let line = body.lines().last().expect("at least one line");
797 let parsed: Value = serde_json::from_str(line).expect("valid JSON");
798 assert_eq!(parsed["peer"], "slancha-spark");
799 assert_eq!(parsed["code"], "pair_drop_ack_send_failed");
800 assert_eq!(parsed["detail"], "POST returned 502");
801 assert!(parsed["ts"].as_u64().unwrap_or(0) > 0);
802 });
803 }
804
805 #[test]
806 fn record_pair_rejection_appends_multiple_lines() {
807 config::test_support::with_temp_home(|| {
810 super::record_pair_rejection("a", "code_a", "detail_a");
811 super::record_pair_rejection("b", "code_b", "detail_b");
812 super::record_pair_rejection("c", "code_c", "detail_c");
813 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
814 let body = std::fs::read_to_string(&path).unwrap();
815 let lines: Vec<&str> = body.lines().collect();
816 assert_eq!(lines.len(), 3, "expected 3 entries, got {}", lines.len());
817 for (i, peer) in ["a", "b", "c"].iter().enumerate() {
818 let parsed: Value = serde_json::from_str(lines[i]).unwrap();
819 assert_eq!(parsed["peer"], *peer);
820 }
821 });
822 }
823}