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 && let Err(e) = std::fs::create_dir_all(parent)
71 {
72 eprintln!("wire: could not create {parent:?}: {e}");
73 return;
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
202 let existing = crate::endpoints::self_endpoints(&relay_state);
210 if !existing.is_empty() {
211 let ep = existing
212 .iter()
213 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
214 .cloned()
215 .unwrap_or_else(|| existing[0].clone());
216 return Ok((did, ep.relay_url, ep.slot_id, ep.slot_token));
217 }
218
219 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
220
221 if self_state.is_null() || self_state.get("slot_id").and_then(Value::as_str).is_none() {
222 let client = crate::relay_client::RelayClient::new(relay);
223 client.check_healthz()?;
224 let handle = crate::agent_card::display_handle_from_did(&did);
225 let alloc = client.allocate_slot(Some(handle))?;
226 relay_state["self"] = json!({
227 "relay_url": relay,
228 "slot_id": alloc.slot_id,
229 "slot_token": alloc.slot_token,
230 });
231 config::write_relay_state(&relay_state)?;
232 }
233
234 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
235 let relay_url = self_state["relay_url"].as_str().unwrap_or("").to_string();
236 let slot_id = self_state["slot_id"].as_str().unwrap_or("").to_string();
237 let slot_token = self_state["slot_token"].as_str().unwrap_or("").to_string();
238 if relay_url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
239 bail!("self relay state incomplete after auto-allocate");
240 }
241 Ok((did, relay_url, slot_id, slot_token))
242}
243
244pub fn mint_invite(
246 ttl_secs: Option<u64>,
247 uses: u32,
248 preferred_relay: Option<&str>,
249) -> Result<String> {
250 let (did, relay_url, slot_id, slot_token) = ensure_self_with_relay(preferred_relay)?;
251
252 let card = config::read_agent_card()?;
253 let sk_seed = config::read_private_key()?;
254
255 let mut nonce_bytes = [0u8; 32];
256 use rand::RngCore;
257 rand::thread_rng().fill_bytes(&mut nonce_bytes);
258 let nonce = hex::encode(nonce_bytes);
259
260 let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
261 let exp = now_unix() + ttl;
262
263 let payload = InvitePayload {
264 v: 1,
265 did: did.clone(),
266 card,
267 relay_url,
268 slot_id,
269 slot_token,
270 nonce: nonce.clone(),
271 exp,
272 };
273 let payload_bytes = serde_json::to_vec(&payload)?;
274
275 let mut sk_arr = [0u8; 32];
276 sk_arr.copy_from_slice(&sk_seed[..32]);
277 let sk = SigningKey::from_bytes(&sk_arr);
278 let sig = sk.sign(&payload_bytes);
279
280 let token = format!(
281 "{}.{}",
282 B64URL.encode(&payload_bytes),
283 B64URL.encode(sig.to_bytes())
284 );
285 let url = format!("wire://pair?v=1&inv={token}");
286
287 let now = time::OffsetDateTime::now_utc()
288 .format(&time::format_description::well_known::Rfc3339)
289 .unwrap_or_default();
290 let pending = PendingInvite {
291 nonce: nonce.clone(),
292 exp,
293 uses_remaining: uses.max(1),
294 accepted_by: vec![],
295 created_at: now,
296 };
297 let dir = pending_invites_dir()?;
298 std::fs::create_dir_all(&dir)?;
299 let path = dir.join(format!("{nonce}.json"));
300 std::fs::write(&path, serde_json::to_vec_pretty(&pending)?)?;
301
302 Ok(url)
303}
304
305pub fn parse_invite(url: &str) -> Result<InvitePayload> {
308 let rest = url
309 .strip_prefix("wire://pair?")
310 .ok_or_else(|| anyhow!("not a wire pair invite URL (must start with wire://pair?)"))?;
311 let mut inv = None;
312 for part in rest.split('&') {
313 if let Some(v) = part.strip_prefix("inv=") {
314 inv = Some(v);
315 }
316 }
317 let token = inv.ok_or_else(|| anyhow!("invite URL missing `inv=` parameter"))?;
318 let (payload_b64, sig_b64) = token
319 .split_once('.')
320 .ok_or_else(|| anyhow!("invite token missing `.` separator (payload.sig)"))?;
321 let payload_bytes = B64URL
322 .decode(payload_b64)
323 .map_err(|e| anyhow!("invite payload b64 decode failed: {e}"))?;
324 let sig_bytes = B64URL
325 .decode(sig_b64)
326 .map_err(|e| anyhow!("invite sig b64 decode failed: {e}"))?;
327
328 let payload: InvitePayload = serde_json::from_slice(&payload_bytes)
329 .map_err(|e| anyhow!("invite payload JSON decode failed: {e}"))?;
330
331 if payload.v != 1 {
332 bail!("invite schema version {} not supported", payload.v);
333 }
334 if now_unix() > payload.exp {
335 bail!("invite expired (exp={}, now={})", payload.exp, now_unix());
336 }
337
338 crate::agent_card::verify_agent_card(&payload.card)
340 .map_err(|e| anyhow!("invite issuer's card signature invalid: {e}"))?;
341
342 let pk_b64 = payload
343 .card
344 .get("verify_keys")
345 .and_then(Value::as_object)
346 .and_then(|m| m.values().next())
347 .and_then(|v| v.get("key"))
348 .and_then(Value::as_str)
349 .ok_or_else(|| anyhow!("issuer card missing verify_keys[*].key"))?;
350 let pk_bytes = crate::signing::b64decode(pk_b64)?;
351 let mut pk_arr = [0u8; 32];
352 if pk_bytes.len() != 32 {
353 bail!("issuer pubkey wrong length");
354 }
355 pk_arr.copy_from_slice(&pk_bytes);
356 let vk = VerifyingKey::from_bytes(&pk_arr)
357 .map_err(|e| anyhow!("issuer pubkey decode failed: {e}"))?;
358 let mut sig_arr = [0u8; 64];
359 if sig_bytes.len() != 64 {
360 bail!("invite sig wrong length");
361 }
362 sig_arr.copy_from_slice(&sig_bytes);
363 let sig = Signature::from_bytes(&sig_arr);
364 vk.verify(&payload_bytes, &sig)
365 .map_err(|_| anyhow!("invite URL signature did not verify"))?;
366
367 Ok(payload)
368}
369
370pub fn accept_invite(url: &str) -> Result<Value> {
373 let payload = parse_invite(url)?;
374
375 let (our_did, our_relay, our_slot_id, our_slot_token) =
377 ensure_self_with_relay(Some(&payload.relay_url))?;
378
379 if our_did == payload.did {
380 bail!("refusing to accept own invite (issuer DID matches self)");
381 }
382
383 let mut trust = config::read_trust()?;
385 crate::trust::add_agent_card_pin(&mut trust, &payload.card, Some("VERIFIED"));
386 config::write_trust(&trust)?;
387
388 let peer_handle = crate::agent_card::display_handle_from_did(&payload.did).to_string();
389 let mut relay_state = config::read_relay_state()?;
390 relay_state["peers"][&peer_handle] = json!({
391 "relay_url": payload.relay_url,
392 "slot_id": payload.slot_id,
393 "slot_token": payload.slot_token,
394 });
395 config::write_relay_state(&relay_state)?;
396
397 let our_card = config::read_agent_card()?;
401 let sk_seed = config::read_private_key()?;
402 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
403 let pk_b64 = our_card
404 .get("verify_keys")
405 .and_then(Value::as_object)
406 .and_then(|m| m.values().next())
407 .and_then(|v| v.get("key"))
408 .and_then(Value::as_str)
409 .ok_or_else(|| anyhow!("our agent-card missing verify_keys[*].key"))?;
410 let pk_bytes = crate::signing::b64decode(pk_b64)?;
411
412 let now = time::OffsetDateTime::now_utc()
413 .format(&time::format_description::well_known::Rfc3339)
414 .unwrap_or_default();
415 let event = json!({
416 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
417 "timestamp": now,
418 "from": our_did,
419 "to": payload.did,
420 "type": "pair_drop",
421 "kind": 1100u32,
422 "body": {
423 "card": our_card,
424 "relay_url": our_relay,
425 "slot_id": our_slot_id,
426 "slot_token": our_slot_token,
427 "pair_nonce": payload.nonce,
428 },
429 });
430 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
431 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
432
433 let client = crate::relay_client::RelayClient::new(&payload.relay_url);
434 client
435 .post_event(&payload.slot_id, &payload.slot_token, &signed)
436 .with_context(|| {
437 format!(
438 "POST pair_drop to {} slot {}",
439 payload.relay_url, payload.slot_id
440 )
441 })?;
442
443 Ok(json!({
444 "paired_with": payload.did,
445 "peer_handle": peer_handle,
446 "event_id": event_id,
447 "status": "drop_sent",
448 }))
449}
450
451pub fn maybe_consume_pair_drop(event: &Value) -> Result<Option<String>> {
456 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
457 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
458 if kind != 1100 || type_str != "pair_drop" {
459 return Ok(None);
460 }
461 let body = match event.get("body") {
462 Some(b) => b,
463 None => return Ok(None),
464 };
465
466 let nonce_opt = body
472 .get("pair_nonce")
473 .and_then(Value::as_str)
474 .map(str::to_string);
475 let mut pending: Option<PendingInvite> = None;
476 let mut invite_path: Option<std::path::PathBuf> = None;
477 if let Some(nonce) = nonce_opt.as_deref() {
478 let dir = pending_invites_dir()?;
479 let path = dir.join(format!("{nonce}.json"));
480 if path.exists() {
481 let p: PendingInvite = serde_json::from_slice(&std::fs::read(&path)?)
482 .with_context(|| format!("reading pending invite {path:?}"))?;
483 if now_unix() > p.exp {
484 if let Err(e) = std::fs::remove_file(&path) {
487 eprintln!("wire: could not delete expired invite {path:?}: {e}");
488 }
489 return Ok(None);
490 }
491 pending = Some(p);
492 invite_path = Some(path);
493 } else if !open_mode_enabled() {
494 return Ok(None);
498 }
499 } else if !open_mode_enabled() {
500 return Ok(None);
503 }
504
505 let peer_card = body
506 .get("card")
507 .cloned()
508 .ok_or_else(|| anyhow!("pair_drop body missing card"))?;
509 crate::agent_card::verify_agent_card(&peer_card)
510 .map_err(|e| anyhow!("pair_drop peer card sig invalid: {e}"))?;
511
512 let peer_did = peer_card
513 .get("did")
514 .and_then(Value::as_str)
515 .ok_or_else(|| anyhow!("peer card missing did"))?
516 .to_string();
517 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
518
519 let mut tmp_trust = config::read_trust()?;
524 crate::trust::add_agent_card_pin(&mut tmp_trust, &peer_card, Some("VERIFIED"));
525 crate::signing::verify_message_v31(event, &tmp_trust)
526 .map_err(|e| anyhow!("pair_drop event sig verify failed: {e}"))?;
527
528 let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
529 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
530 let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
531 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
532 bail!("pair_drop body missing relay_url/slot_id/slot_token");
533 }
534
535 let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
540 .get("endpoints")
541 .and_then(Value::as_array)
542 .map(|arr| {
543 arr.iter()
544 .filter_map(|e| {
545 serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
546 })
547 .collect()
548 })
549 .unwrap_or_else(|| {
550 vec![crate::endpoints::Endpoint::federation(
551 peer_relay.to_string(),
552 peer_slot_id.to_string(),
553 peer_slot_token.to_string(),
554 )]
555 });
556
557 if nonce_opt.is_some() {
575 config::write_trust(&tmp_trust)?;
577 let mut relay_state = config::read_relay_state()?;
578 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
582 config::write_relay_state(&relay_state)?;
583
584 if let (Some(pending), Some(invite_path)) = (pending, invite_path) {
586 if pending.uses_remaining <= 1 {
587 if let Err(e) = std::fs::remove_file(&invite_path) {
588 eprintln!("wire: could not delete consumed invite {invite_path:?}: {e}");
589 }
590 } else {
591 let mut updated = pending.clone();
592 updated.uses_remaining -= 1;
593 updated.accepted_by.push(peer_did.clone());
594 std::fs::write(&invite_path, serde_json::to_vec_pretty(&updated)?)?;
595 }
596 }
597 crate::os_notify::toast(
598 &format!("wire — paired with {peer_handle}"),
599 "Invite accepted. Ready to send + receive.",
600 );
601 return Ok(Some(peer_did));
602 }
603
604 let now_iso = time::OffsetDateTime::now_utc()
606 .format(&time::format_description::well_known::Rfc3339)
607 .unwrap_or_default();
608 let event_id = event
609 .get("event_id")
610 .and_then(Value::as_str)
611 .unwrap_or("")
612 .to_string();
613 let event_timestamp = event
614 .get("timestamp")
615 .and_then(Value::as_str)
616 .unwrap_or("")
617 .to_string();
618 let pending_inbound = crate::pending_inbound_pair::PendingInboundPair {
619 peer_handle: peer_handle.clone(),
620 peer_did: peer_did.clone(),
621 peer_card: peer_card.clone(),
622 peer_relay_url: peer_relay.to_string(),
623 peer_slot_id: peer_slot_id.to_string(),
624 peer_slot_token: peer_slot_token.to_string(),
625 peer_endpoints: peer_endpoints.clone(),
626 event_id,
627 event_timestamp,
628 received_at: now_iso,
629 };
630 crate::pending_inbound_pair::write_pending_inbound(&pending_inbound)?;
631 crate::os_notify::toast(
632 &format!("wire — pair request from {peer_handle}"),
633 &format!(
634 "run `wire pair-accept {peer_handle}` (or `wire add {peer_handle}@{peer_relay}`) to accept, or `wire pair-reject {peer_handle}` to refuse",
635 ),
636 );
637
638 Ok(Some(peer_did))
639}
640
641pub fn send_pair_drop_ack(
651 peer_handle: &str,
652 peer_relay: &str,
653 peer_slot_id: &str,
654 peer_slot_token: &str,
655) -> Result<()> {
656 let our_card = config::read_agent_card()?;
658 let our_did = our_card
659 .get("did")
660 .and_then(Value::as_str)
661 .ok_or_else(|| anyhow!("our card missing did"))?
662 .to_string();
663 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
664 let relay_state = config::read_relay_state()?;
665 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
666 let our_relay = self_state
667 .get("relay_url")
668 .and_then(Value::as_str)
669 .unwrap_or("")
670 .to_string();
671 let our_slot_id = self_state
672 .get("slot_id")
673 .and_then(Value::as_str)
674 .unwrap_or("")
675 .to_string();
676 let our_slot_token = self_state
677 .get("slot_token")
678 .and_then(Value::as_str)
679 .unwrap_or("")
680 .to_string();
681 if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
682 bail!("self relay state incomplete; cannot emit pair_drop_ack");
683 }
684
685 let sk_seed = config::read_private_key()?;
686 let pk_b64 = our_card
687 .get("verify_keys")
688 .and_then(Value::as_object)
689 .and_then(|m| m.values().next())
690 .and_then(|v| v.get("key"))
691 .and_then(Value::as_str)
692 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
693 let pk_bytes = crate::signing::b64decode(pk_b64)?;
694
695 let now = time::OffsetDateTime::now_utc()
696 .format(&time::format_description::well_known::Rfc3339)
697 .unwrap_or_default();
698 let our_endpoints = crate::endpoints::self_endpoints(&relay_state);
702 let mut body = json!({
703 "relay_url": our_relay,
704 "slot_id": our_slot_id,
705 "slot_token": our_slot_token,
706 });
707 if !our_endpoints.is_empty() {
708 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
709 }
710 let event = json!({
711 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
712 "timestamp": now,
713 "from": our_did,
714 "to": format!("did:wire:{peer_handle}"),
715 "type": "pair_drop_ack",
716 "kind": 1101u32,
717 "body": body,
718 });
719 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
720 let client = crate::relay_client::RelayClient::new(peer_relay);
721 client
722 .post_event(peer_slot_id, peer_slot_token, &signed)
723 .with_context(|| format!("POST pair_drop_ack to {peer_relay} slot {peer_slot_id}"))?;
724 Ok(())
725}
726
727pub fn maybe_consume_pair_drop_ack(event: &Value) -> Result<bool> {
731 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
732 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
733 if kind != 1101 || type_str != "pair_drop_ack" {
734 return Ok(false);
735 }
736 let body = match event.get("body") {
737 Some(b) => b,
738 None => return Ok(false),
739 };
740 let from = event
741 .get("from")
742 .and_then(Value::as_str)
743 .ok_or_else(|| anyhow!("ack missing 'from'"))?;
744 let peer_handle = crate::agent_card::display_handle_from_did(from).to_string();
745 let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
746 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
747 let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
748 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
749 bail!("pair_drop_ack body missing relay_url/slot_id/slot_token");
750 }
751 let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
755 .get("endpoints")
756 .and_then(Value::as_array)
757 .map(|arr| {
758 arr.iter()
759 .filter_map(|e| {
760 serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
761 })
762 .collect()
763 })
764 .unwrap_or_else(|| {
765 vec![crate::endpoints::Endpoint::federation(
766 peer_relay.to_string(),
767 peer_slot_id.to_string(),
768 peer_slot_token.to_string(),
769 )]
770 });
771 let mut relay_state = config::read_relay_state()?;
772 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
773 config::write_relay_state(&relay_state)?;
774 crate::os_notify::toast(
775 &format!("wire — pair complete with {peer_handle}"),
776 "Both sides bound. Ready to send + receive.",
777 );
778 Ok(true)
779}
780
781#[cfg(test)]
787mod tests {
788 use super::*;
789 use crate::config;
790
791 #[test]
792 fn record_pair_rejection_writes_jsonl_under_state_dir() {
793 config::test_support::with_temp_home(|| {
797 super::record_pair_rejection(
798 "slancha-spark",
799 "pair_drop_ack_send_failed",
800 "POST returned 502",
801 );
802 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
803 assert!(path.exists(), "record_pair_rejection must create {path:?}");
804 let body = std::fs::read_to_string(&path).unwrap();
805 let line = body.lines().last().expect("at least one line");
806 let parsed: Value = serde_json::from_str(line).expect("valid JSON");
807 assert_eq!(parsed["peer"], "slancha-spark");
808 assert_eq!(parsed["code"], "pair_drop_ack_send_failed");
809 assert_eq!(parsed["detail"], "POST returned 502");
810 assert!(parsed["ts"].as_u64().unwrap_or(0) > 0);
811 });
812 }
813
814 #[test]
815 fn record_pair_rejection_appends_multiple_lines() {
816 config::test_support::with_temp_home(|| {
819 super::record_pair_rejection("a", "code_a", "detail_a");
820 super::record_pair_rejection("b", "code_b", "detail_b");
821 super::record_pair_rejection("c", "code_c", "detail_c");
822 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
823 let body = std::fs::read_to_string(&path).unwrap();
824 let lines: Vec<&str> = body.lines().collect();
825 assert_eq!(lines.len(), 3, "expected 3 entries, got {}", lines.len());
826 for (i, peer) in ["a", "b", "c"].iter().enumerate() {
827 let parsed: Value = serde_json::from_str(lines[i]).unwrap();
828 assert_eq!(parsed["peer"], *peer);
829 }
830 });
831 }
832}