Skip to main content

wacore_binary/
jid.rs

1use crate::node::NodeStr;
2use compact_str::CompactString;
3use std::fmt;
4use std::str::FromStr;
5
6/// Intermediate result from fast JID parsing.
7/// This avoids allocations by returning byte indices into the original string.
8#[derive(Debug, Clone, Copy)]
9pub struct ParsedJidParts<'a> {
10    pub user: &'a str,
11    pub server: &'a str,
12    pub agent: u8,
13    pub device: u16,
14    pub integrator: u16,
15}
16
17/// Decimal-only `u16` parser for the device and agent fields.
18///
19/// Deliberately not `str::parse`: `from_str_radix` is generic over radix and
20/// signedness and carries the branches to prove it, which the JID scanner pays
21/// on every device it splits out. The accepted grammar is the same one
22/// `u16::from_str` accepts — an optional `+`, then decimal digits, rejecting
23/// empty input and anything that overflows — and `decimal_fast_path_matches_u16_from_str`
24/// holds the two together. A `-` is a non-digit here for the same reason it is
25/// one in std: the sign is only recognised for signed types.
26#[inline]
27fn parse_u16_decimal(s: &str) -> Option<u16> {
28    let mut bytes = s.as_bytes();
29    if bytes.first() == Some(&b'+') {
30        bytes = &bytes[1..];
31    }
32    if bytes.is_empty() {
33        return None;
34    }
35
36    let mut value = 0u16;
37    for &byte in bytes {
38        let digit = byte.wrapping_sub(b'0');
39        if digit > 9 {
40            return None;
41        }
42        value = value.checked_mul(10)?.checked_add(digit as u16)?;
43    }
44    Some(value)
45}
46
47/// Single-pass JID parser optimized for hot paths.
48/// Scans the input string once to find all relevant separators (@, :)
49/// and returns slices into the original string without allocation.
50///
51/// Returns `None` for JIDs that need full validation (edge cases, unknown servers, etc.)
52#[inline]
53pub fn parse_jid_fast(s: &str) -> Option<ParsedJidParts<'_>> {
54    parse_jid_scan(s).map(|(parts, _)| parts)
55}
56
57/// Shared scanner behind [`parse_jid_fast`] and [`parse_jid_ref`]. The resolved
58/// [`Server`] comes back alongside the borrowed parts because the scan already
59/// had to classify the server to know whether dots in the user part are agent
60/// separators — returning it lets `parse_jid_ref` skip a second lookup.
61///
62/// `None` in the second slot means the server suffix is not one we know; the
63/// parts are still filled in (with the generic agent/device rules), which is
64/// what `parse_jid_fast`'s callers rely on.
65#[inline]
66fn parse_jid_scan(s: &str) -> Option<(ParsedJidParts<'_>, Option<Server>)> {
67    let bytes = s.as_bytes();
68
69    // One pass over the *user* part only: everything after `@` is the server,
70    // which carries no separators we care about, so the scan stops there.
71    let mut at = usize::MAX;
72    let mut colon_pos: Option<usize> = None;
73    let mut last_dot_pos: Option<usize> = None;
74
75    for (i, &b) in bytes.iter().enumerate() {
76        match b {
77            b'@' => {
78                at = i;
79                break;
80            }
81            b':' => colon_pos = Some(i),
82            // Dots after the first colon belong to the device, not the agent.
83            b'.' if colon_pos.is_none() => last_dot_pos = Some(i),
84            _ => {}
85        }
86    }
87
88    // No `@` (server-only JID) or an empty user: let the fallback validate it.
89    if at == usize::MAX || at == 0 {
90        return None;
91    }
92
93    let user_part = &s[..at];
94    let server_str = &s[at + 1..];
95    let server = Server::parse_known(server_str);
96
97    match server {
98        // LID user parts may contain dots, which are not agent separators.
99        Some(Server::Lid) => {
100            let (user, device) = match colon_pos {
101                Some(pos) => (&s[..pos], parse_u16_decimal(&s[pos + 1..at]).unwrap_or(0)),
102                None => (user_part, 0),
103            };
104            Some((
105                ParsedJidParts {
106                    user,
107                    server: server_str,
108                    agent: 0,
109                    device,
110                    integrator: 0,
111                },
112                server,
113            ))
114        }
115        // `s.whatsapp.net` has no agent in the string form; a trailing dotted
116        // number is the legacy device spelling.
117        Some(Server::Pn) => {
118            if let Some(pos) = colon_pos {
119                return Some((
120                    ParsedJidParts {
121                        user: &s[..pos],
122                        server: server_str,
123                        agent: 0,
124                        device: parse_u16_decimal(&s[pos + 1..at]).unwrap_or(0),
125                        integrator: 0,
126                    },
127                    server,
128                ));
129            }
130            if let Some(dot_pos) = last_dot_pos
131                && let Some(device_val) = parse_u16_decimal(&s[dot_pos + 1..at])
132            {
133                return Some((
134                    ParsedJidParts {
135                        user: &s[..dot_pos],
136                        server: server_str,
137                        agent: 0,
138                        device: device_val,
139                        integrator: 0,
140                    },
141                    server,
142                ));
143            }
144            Some((
145                ParsedJidParts {
146                    user: user_part,
147                    server: server_str,
148                    agent: 0,
149                    device: 0,
150                    integrator: 0,
151                },
152                server,
153            ))
154        }
155        // Everything else (including unknown servers): `user.agent:device`.
156        _ => {
157            let (user_before_colon, device) = match colon_pos {
158                Some(pos) => (&s[..pos], parse_u16_decimal(&s[pos + 1..at]).unwrap_or(0)),
159                None => (user_part, 0),
160            };
161            // Deliberately `rfind` on the pre-colon slice rather than reusing
162            // `last_dot_pos`: the two differ for pathological users that hold a
163            // second colon (`a:1.5:2`), and this branch is cold enough that
164            // matching the historical rule is worth the extra scan.
165            let (final_user, agent) = match user_before_colon.rfind('.') {
166                Some(dot_pos) => match parse_u16_decimal(&user_before_colon[dot_pos + 1..]) {
167                    Some(agent_val) if agent_val <= u8::MAX as u16 => {
168                        (&user_before_colon[..dot_pos], agent_val as u8)
169                    }
170                    _ => (user_before_colon, 0),
171                },
172                None => (user_before_colon, 0),
173            };
174            Some((
175                ParsedJidParts {
176                    user: final_user,
177                    server: server_str,
178                    agent,
179                    device,
180                    integrator: 0,
181                },
182                server,
183            ))
184        }
185    }
186}
187
188/// Parse the allocation-free JID shapes into the same borrowed type used by
189/// the binary decoder.
190///
191/// Returns `None` when the string needs [`Jid`]'s compatibility fallback or
192/// names an unknown server. Callers that must accept those edge cases can fall
193/// back to `s.parse::<Jid>()`; normal user/group/LID/bot JIDs stay borrowed.
194#[inline]
195pub fn parse_jid_ref(s: &str) -> Option<JidRef<'_>> {
196    let (parts, server) = parse_jid_scan(s)?;
197    Some(JidRef {
198        user: NodeStr::Borrowed(parts.user),
199        server: server?,
200        agent: parts.agent,
201        device: parts.device,
202        integrator: parts.integrator,
203    })
204}
205
206/// Known WhatsApp server identifiers.
207///
208/// Maps to the wire protocol's AD_JID domain type (u8) and the `@server` suffix
209/// in JID string representation.
210#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
211#[repr(u8)]
212pub enum Server {
213    #[default]
214    Pn = 0,
215    Lid = 1,
216    Group = 2,
217    Broadcast = 3,
218    Newsletter = 4,
219    Hosted = 5,
220    HostedLid = 6,
221    Messenger = 7,
222    Interop = 8,
223    Bot = 9,
224    Legacy = 10,
225    /// `@call` call-signaling JID; not an AD server, so it round-trips via JID_PAIR.
226    Call = 11,
227}
228
229#[cfg(feature = "serde")]
230impl serde::Serialize for Server {
231    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
232        serializer.serialize_str(self.as_str())
233    }
234}
235
236#[cfg(feature = "serde")]
237impl<'de> serde::Deserialize<'de> for Server {
238    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
239        struct ServerVisitor;
240
241        impl serde::de::Visitor<'_> for ServerVisitor {
242            type Value = Server;
243
244            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
245                formatter.write_str("a known WhatsApp server identifier")
246            }
247
248            fn visit_borrowed_str<E>(self, value: &str) -> Result<Self::Value, E>
249            where
250                E: serde::de::Error,
251            {
252                Server::try_from(value).map_err(E::custom)
253            }
254
255            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
256            where
257                E: serde::de::Error,
258            {
259                Server::try_from(value).map_err(E::custom)
260            }
261
262            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
263            where
264                E: serde::de::Error,
265            {
266                Server::try_from(value.as_str()).map_err(E::custom)
267            }
268        }
269
270        deserializer.deserialize_str(ServerVisitor)
271    }
272}
273
274impl Server {
275    #[inline]
276    pub fn as_str(self) -> &'static str {
277        match self {
278            Self::Pn => "s.whatsapp.net",
279            Self::Lid => "lid",
280            Self::Group => "g.us",
281            Self::Broadcast => "broadcast",
282            Self::Newsletter => "newsletter",
283            Self::Hosted => "hosted",
284            Self::HostedLid => "hosted.lid",
285            Self::Messenger => "msgr",
286            Self::Interop => "interop",
287            Self::Bot => "bot",
288            Self::Legacy => "c.us",
289            Self::Call => "call",
290        }
291    }
292
293    /// Phone-number-namespaced servers (`@s.whatsapp.net`, `@hosted`).
294    /// The PN side of the LID↔PN mapping treats these as a single class.
295    #[inline]
296    pub fn is_pn_family(self) -> bool {
297        matches!(self, Self::Pn | Self::Hosted)
298    }
299
300    /// LID-namespaced servers (`@lid`, `@hosted.lid`).
301    #[inline]
302    pub fn is_lid_family(self) -> bool {
303        matches!(self, Self::Lid | Self::HostedLid)
304    }
305
306    /// Whether the `agent` byte is part of the rendered JID for this server.
307    /// AD-capable servers (Pn/Lid/Hosted/HostedLid) have no agent of their own —
308    /// their wire form spells the server as a domain byte, which the decoder
309    /// resolves into `server` rather than keeping — so an agent set on one is
310    /// meaningless and stays out of the rendered form. Others (e.g. `@bot`,
311    /// `@interop`) render it. Single source of truth shared by the formatter and
312    /// `Jid::is_same_chat_as`.
313    #[inline]
314    pub fn renders_agent(self) -> bool {
315        !matches!(self, Self::Pn | Self::Lid | Self::Hosted | Self::HostedLid)
316    }
317
318    /// Whether the `user` part is (or can be) a real phone number, i.e. PII that
319    /// must be redacted in tracing fields. LID-family/group/broadcast/newsletter/
320    /// bot/call users are pseudonymous or non-personal and are safe to render raw.
321    #[inline]
322    pub fn carries_phone_number(self) -> bool {
323        matches!(
324            self,
325            Self::Pn | Self::Hosted | Self::Legacy | Self::Messenger | Self::Interop
326        )
327    }
328}
329
330impl fmt::Display for Server {
331    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332        f.write_str(self.as_str())
333    }
334}
335
336impl PartialEq<str> for Server {
337    fn eq(&self, other: &str) -> bool {
338        self.as_str() == other
339    }
340}
341
342impl PartialEq<&str> for Server {
343    fn eq(&self, other: &&str) -> bool {
344        self.as_str() == *other
345    }
346}
347
348impl Server {
349    /// Allocation-free server lookup. `TryFrom<&str>` builds a `JidError` —
350    /// and therefore a `String` — for an unknown suffix, which the JID scanner
351    /// hits on every non-JID string it is asked to classify; this returns the
352    /// same answer without paying for the message nobody reads.
353    #[inline]
354    pub fn parse_known(s: &str) -> Option<Self> {
355        Some(match s {
356            "s.whatsapp.net" => Self::Pn,
357            "lid" => Self::Lid,
358            "g.us" => Self::Group,
359            "broadcast" => Self::Broadcast,
360            "newsletter" => Self::Newsletter,
361            "hosted" => Self::Hosted,
362            "hosted.lid" => Self::HostedLid,
363            "msgr" => Self::Messenger,
364            "interop" => Self::Interop,
365            "bot" => Self::Bot,
366            "c.us" => Self::Legacy,
367            "call" => Self::Call,
368            _ => return None,
369        })
370    }
371}
372
373impl TryFrom<&str> for Server {
374    type Error = JidError;
375    fn try_from(s: &str) -> Result<Self, Self::Error> {
376        Server::parse_known(s)
377            .ok_or_else(|| JidError::InvalidFormat(format!("unknown server: {s}")))
378    }
379}
380
381// Keep string constants for backward compatibility and use in non-JID contexts
382pub const DEFAULT_USER_SERVER: &str = "s.whatsapp.net";
383pub const SERVER_JID: &str = "s.whatsapp.net";
384pub const GROUP_SERVER: &str = "g.us";
385pub const LEGACY_USER_SERVER: &str = "c.us";
386pub const BROADCAST_SERVER: &str = "broadcast";
387pub const HIDDEN_USER_SERVER: &str = "lid";
388pub const NEWSLETTER_SERVER: &str = "newsletter";
389pub const HOSTED_SERVER: &str = "hosted";
390pub const HOSTED_LID_SERVER: &str = "hosted.lid";
391pub const MESSENGER_SERVER: &str = "msgr";
392pub const INTEROP_SERVER: &str = "interop";
393pub const BOT_SERVER: &str = "bot";
394pub const STATUS_BROADCAST_USER: &str = "status";
395pub const PSA_USER: &str = "0";
396
397pub type MessageId = String;
398pub type MessageServerId = i32;
399#[derive(Debug)]
400pub enum JidError {
401    // REMOVE: #[error("...")]
402    InvalidFormat(String),
403    // REMOVE: #[error("...")]
404    Parse(std::num::ParseIntError),
405}
406
407impl fmt::Display for JidError {
408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409        match self {
410            JidError::InvalidFormat(s) => write!(f, "Invalid JID format: {s}"),
411            JidError::Parse(e) => write!(f, "Failed to parse component: {e}"),
412        }
413    }
414}
415
416impl std::error::Error for JidError {
417    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
418        match self {
419            JidError::Parse(e) => Some(e),
420            _ => None,
421        }
422    }
423}
424
425// Add From impl
426impl From<std::num::ParseIntError> for JidError {
427    fn from(err: std::num::ParseIntError) -> Self {
428        JidError::Parse(err)
429    }
430}
431
432pub trait JidExt {
433    fn user(&self) -> &str;
434    fn server(&self) -> Server;
435    fn device(&self) -> u16;
436    fn integrator(&self) -> u16;
437
438    fn is_ad(&self) -> bool {
439        self.device() > 0
440            && matches!(
441                self.server(),
442                Server::Pn | Server::Lid | Server::Hosted | Server::HostedLid
443            )
444    }
445
446    fn is_interop(&self) -> bool {
447        self.server() == Server::Interop && self.integrator() > 0
448    }
449
450    fn is_messenger(&self) -> bool {
451        self.server() == Server::Messenger && self.device() > 0
452    }
453
454    fn is_group(&self) -> bool {
455        self.server() == Server::Group
456    }
457
458    fn is_broadcast_list(&self) -> bool {
459        self.server() == Server::Broadcast && self.user() != STATUS_BROADCAST_USER
460    }
461
462    fn is_status_broadcast(&self) -> bool {
463        self.server() == Server::Broadcast && self.user() == STATUS_BROADCAST_USER
464    }
465
466    /// The system/announcements account (`0@s.whatsapp.net` / `0@c.us`).
467    /// It never answers user-directed IQs, so requests must be short-circuited
468    /// client-side (WA Web excludes it via `isPSA` before hitting the server).
469    fn is_psa(&self) -> bool {
470        matches!(self.server(), Server::Pn | Server::Legacy) && self.user() == PSA_USER
471    }
472
473    fn is_bot(&self) -> bool {
474        (self.server() == Server::Pn
475            && self.device() == 0
476            && (self.user().starts_with("1313555") || self.user().starts_with("131655500")))
477            || self.server() == Server::Bot
478    }
479
480    fn is_newsletter(&self) -> bool {
481        self.server() == Server::Newsletter
482    }
483
484    /// Returns true if this is a hosted/Cloud API device.
485    /// Hosted devices have device ID 99 or use @hosted/@hosted.lid server.
486    /// These devices should be excluded from group message fanout.
487    fn is_hosted(&self) -> bool {
488        self.device() == 99 || matches!(self.server(), Server::Hosted | Server::HostedLid)
489    }
490
491    fn is_empty(&self) -> bool {
492        self.user().is_empty()
493    }
494
495    fn is_same_user_as(&self, other: &impl JidExt) -> bool {
496        self.user() == other.user()
497    }
498}
499
500/// The part of `agent` that is actually part of a JID's identity.
501///
502/// `agent` is only meaningful where the server renders it. On Pn/Lid/Hosted/
503/// HostedLid the wire spells the server as a domain byte instead, so nothing
504/// encodes an agent set there (`server_to_domain_type`), nothing prints it
505/// (`renders_agent`), and nothing hashes it (`push_phash_form_to` writes a literal `0`,
506/// matching WA Web). Two JIDs differing only there address the same device.
507///
508/// Letting it into equality anyway is what made a JID decoded from the wire
509/// unequal to the same JID read back from the store, which holds JIDs as text
510/// (see `read_ad_jid`). Equality and `Hash` both go through here so they cannot
511/// disagree, and `sort_dedup_by_device` keys on the same rule so the fan-out
512/// cannot treat one device as two.
513///
514/// `integrator` is deliberately NOT normalised here. It is only ever non-zero on
515/// interop, but `is_same_chat_as` and `jids_share_user_identity` compare it
516/// unconditionally — folding it in here would make `==` disagree with them.
517#[inline]
518fn identity_agent(server: Server, agent: u8) -> u8 {
519    if server.renders_agent() { agent } else { 0 }
520}
521
522#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
523#[derive(Debug, Clone, Default)]
524pub struct Jid {
525    pub user: CompactString,
526    pub server: Server,
527    pub agent: u8,
528    pub device: u16,
529    pub integrator: u16,
530}
531
532#[derive(Debug, Clone, yoke::Yokeable)]
533pub struct JidRef<'a> {
534    pub user: NodeStr<'a>,
535    pub server: Server,
536    pub agent: u8,
537    pub device: u16,
538    pub integrator: u16,
539}
540
541impl JidExt for Jid {
542    fn user(&self) -> &str {
543        &self.user
544    }
545    fn server(&self) -> Server {
546        self.server
547    }
548    fn device(&self) -> u16 {
549        self.device
550    }
551    fn integrator(&self) -> u16 {
552        self.integrator
553    }
554}
555
556impl Jid {
557    pub fn new(user: impl Into<CompactString>, server: Server) -> Self {
558        Self {
559            user: user.into(),
560            server,
561            ..Default::default()
562        }
563    }
564
565    /// Create a phone number JID (s.whatsapp.net)
566    pub fn pn(user: impl Into<CompactString>) -> Self {
567        Self {
568            user: user.into(),
569            server: Server::Pn,
570            ..Default::default()
571        }
572    }
573
574    /// Create a LID JID (lid server)
575    pub fn lid(user: impl Into<CompactString>) -> Self {
576        Self {
577            user: user.into(),
578            server: Server::Lid,
579            ..Default::default()
580        }
581    }
582
583    /// Creates the `status@broadcast` JID used for status/story updates.
584    pub fn status_broadcast() -> Self {
585        Self {
586            user: CompactString::from(STATUS_BROADCAST_USER),
587            server: Server::Broadcast,
588            agent: 0,
589            device: 0,
590            integrator: 0,
591        }
592    }
593
594    /// Create a group JID (g.us).
595    pub fn group(id: impl Into<CompactString>) -> Self {
596        Self {
597            user: id.into(),
598            server: Server::Group,
599            ..Default::default()
600        }
601    }
602
603    /// Create a newsletter (channel) JID (newsletter server).
604    pub fn newsletter(id: impl Into<CompactString>) -> Self {
605        Self {
606            user: id.into(),
607            server: Server::Newsletter,
608            ..Default::default()
609        }
610    }
611
612    /// Create a phone number JID with device ID
613    pub fn pn_device(user: impl Into<CompactString>, device: u16) -> Self {
614        Self {
615            user: user.into(),
616            server: Server::Pn,
617            device,
618            ..Default::default()
619        }
620    }
621
622    /// Create a LID JID with device ID
623    pub fn lid_device(user: impl Into<CompactString>, device: u16) -> Self {
624        Self {
625            user: user.into(),
626            server: Server::Lid,
627            device,
628            ..Default::default()
629        }
630    }
631
632    /// Returns true if this is a Phone Number based JID (s.whatsapp.net)
633    #[inline]
634    pub fn is_pn(&self) -> bool {
635        self.server == Server::Pn
636    }
637
638    /// Returns true if this is a LID based JID
639    #[inline]
640    pub fn is_lid(&self) -> bool {
641        self.server == Server::Lid
642    }
643
644    /// Returns the user part without the device ID suffix (e.g., "123:4" -> "123")
645    #[inline]
646    pub fn user_base(&self) -> &str {
647        if let Some((base, _)) = self.user.split_once(':') {
648            base
649        } else {
650            &self.user
651        }
652    }
653
654    /// Helper to construct a specific device JID from this one
655    pub fn with_device(&self, device_id: u16) -> Self {
656        Self {
657            user: self.user.clone(),
658            server: self.server,
659            agent: self.agent,
660            device: device_id,
661            integrator: self.integrator,
662        }
663    }
664
665    /// Construct a device JID and select the hosted variant of PN/LID when
666    /// indicated by a device-list entry.
667    ///
668    /// Non-user namespaces are left unchanged because they have no hosted
669    /// counterpart on the wire.
670    pub fn with_device_hosting(&self, device_id: u16, is_hosted: bool) -> Self {
671        let mut jid = self.with_device(device_id);
672        jid.server = match (jid.server, is_hosted) {
673            (Server::Pn | Server::Hosted, true) => Server::Hosted,
674            (Server::Pn | Server::Hosted, false) => Server::Pn,
675            (Server::Lid | Server::HostedLid, true) => Server::HostedLid,
676            (Server::Lid | Server::HostedLid, false) => Server::Lid,
677            (server, _) => server,
678        };
679        jid
680    }
681
682    pub fn to_non_ad(&self) -> Self {
683        Self {
684            user: self.user.clone(),
685            server: self.server,
686            integrator: self.integrator,
687            ..Default::default()
688        }
689    }
690
691    /// Consuming `to_non_ad`: reuses the owned `user` instead of cloning it.
692    /// Prefer this when the receiver is a throwaway owned `Jid`.
693    pub fn into_non_ad(self) -> Self {
694        Self {
695            user: self.user,
696            server: self.server,
697            integrator: self.integrator,
698            agent: 0,
699            device: 0,
700        }
701    }
702
703    /// Device-insensitive "same chat" check: two JIDs address the same chat when
704    /// they render to the same string ignoring the multi-device `device`. Compares
705    /// `user`, `server`, `integrator`, and `agent` only where the server renders
706    /// the agent (`@bot`/`@interop`); Pn/Lid/Hosted suppress it in display, so a
707    /// stray decoded agent byte there must not split the chat. Allocates nothing.
708    /// Stricter than `is_same_user_as`, which ignores `server`.
709    #[inline]
710    pub fn is_same_chat_as(&self, other: &Jid) -> bool {
711        self.user == other.user
712            && self.server == other.server
713            && self.integrator == other.integrator
714            && (!self.server.renders_agent() || self.agent == other.agent)
715    }
716
717    /// Canonical non-AD string form (`user@server`, device + agent stripped)
718    /// in a single allocation. Equivalent to `to_non_ad().to_string()` but
719    /// skips the throwaway intermediate `Jid` and its `CompactString` clone.
720    pub fn to_non_ad_string(&self) -> String {
721        let mut buf = String::with_capacity(self.user.len() + 1 + self.server.as_str().len());
722        push_jid_to_string(&self.user, self.server, 0, 0, &mut buf);
723        buf
724    }
725
726    /// [`Self::to_non_ad_string`] as a shareable `Arc<str>`, in exactly one
727    /// allocation. Going through the `String` first costs two — the buffer, then
728    /// the `Arc<str>` its bytes are copied into — and the message-secret rows
729    /// build two of these per message.
730    pub fn to_non_ad_arc_str(&self) -> std::sync::Arc<str> {
731        let mut writer = JidStackWriter::new();
732        if write_jid_fallible(&mut writer, &self.user, self.server, 0, 0).is_ok() {
733            return std::sync::Arc::from(writer.as_str());
734        }
735        // A user part too long for the stack buffer (never seen on the wire)
736        // still renders, just back through the heap.
737        std::sync::Arc::from(self.to_non_ad_string())
738    }
739
740    /// Check if this JID matches the user or their LID.
741    /// Useful for checking if a participant is "us" in group messages.
742    #[inline]
743    pub fn matches_user_or_lid(&self, user: &Jid, lid: Option<&Jid>) -> bool {
744        self.is_same_user_as(user) || lid.is_some_and(|l| self.is_same_user_as(l))
745    }
746
747    /// See [`Jid::push_phash_form_to`]. Not a general JID rendering — use
748    /// `Display`/`to_string` for that.
749    pub fn to_phash_form_string(&self) -> String {
750        let mut s = String::with_capacity(self.user.len() + 20);
751        self.push_phash_form_to(&mut s);
752        s
753    }
754
755    /// Append the form the participant hash is computed over
756    /// (`user.0:device@server`) to `buf`, for callers that batch many JIDs into
757    /// one shared buffer instead of paying a heap `String` per JID (see
758    /// `participant_list_hash`).
759    ///
760    /// **This is not a general-purpose JID rendering.** The agent position is
761    /// the literal `0`, never `self.agent`, and that is deliberate: WA Web's
762    /// `formatFull` spelling hardcodes `".0"` unconditionally — there is no
763    /// per-server carve-out, and `WAWebWid` has no agent field to read one from.
764    /// The server recomputes this exact string to validate the phash, so writing
765    /// our agent would mean a rejected hash for any JID that carried one.
766    ///
767    /// If you want the JID as it is addressed, including the agent on the servers
768    /// that render it, use `Display` / [`Jid::push_to`] instead.
769    #[inline]
770    pub fn push_phash_form_to(&self, buf: &mut String) {
771        if self.user.is_empty() {
772            buf.push_str(self.server.as_str());
773            return;
774        }
775        buf.push_str(&self.user);
776        buf.push_str(".0:");
777        buf.push_str(itoa::Buffer::new().format(self.device));
778        buf.push('@');
779        buf.push_str(self.server.as_str());
780    }
781
782    /// Append the Display representation to `buf` using direct push operations,
783    /// bypassing `fmt::Display` and `dyn Write` dispatch.
784    #[inline]
785    pub fn push_to(&self, buf: &mut String) {
786        push_jid_to_string(&self.user, self.server, self.agent, self.device, buf);
787    }
788
789    /// Write the Display representation to any [`fmt::Write`] sink without an
790    /// intermediate `String`.
791    #[inline]
792    pub fn write_display_to<W: fmt::Write + ?Sized>(&self, writer: &mut W) -> fmt::Result {
793        write_jid_fallible(writer, &self.user, self.server, self.agent, self.device)
794    }
795
796    /// Compare the display representation with `other` without allocating.
797    #[inline]
798    pub fn display_eq(&self, other: &str) -> bool {
799        jid_display_eq(&self.user, self.server, self.agent, self.device, other)
800    }
801
802    /// Compare two JIDs by the representation emitted by [`fmt::Display`].
803    #[inline]
804    pub fn display_eq_jid(&self, other: &Self) -> bool {
805        jid_displays_equal(
806            (&self.user, self.server, self.agent, self.device),
807            (&other.user, other.server, other.agent, other.device),
808        )
809    }
810
811    /// Compare device identity (user, server, device) without allocation.
812    #[inline]
813    pub fn device_eq(&self, other: &Jid) -> bool {
814        self.user == other.user && self.server == other.server && self.device == other.device
815    }
816
817    /// The `agent` as far as identity is concerned: the field itself where the
818    /// server renders it (`@bot`, `@interop`), `0` where it does not.
819    ///
820    /// Exposed so callers that build their own key over a JID — sorting,
821    /// deduplicating, indexing — can key on the same rule `==` and `Hash` use
822    /// instead of on the raw field, which would split one device in two or, in
823    /// reverse, merge two real ones.
824    #[inline]
825    pub fn identity_agent(&self) -> u8 {
826        identity_agent(self.server, self.agent)
827    }
828
829    /// Get a borrowing key for O(1) HashSet lookups by device identity.
830    #[inline]
831    pub fn device_key(&self) -> DeviceKey<'_> {
832        DeviceKey {
833            user: &self.user,
834            server: self.server,
835            device: self.device,
836        }
837    }
838}
839
840/// Borrowing key for device identity (user, server, device). Use with HashSet for O(1) lookups.
841#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
842pub struct DeviceKey<'a> {
843    pub user: &'a str,
844    pub server: Server,
845    pub device: u16,
846}
847
848impl<'a> JidExt for JidRef<'a> {
849    fn user(&self) -> &str {
850        &self.user
851    }
852    fn server(&self) -> Server {
853        self.server
854    }
855    fn device(&self) -> u16 {
856        self.device
857    }
858    fn integrator(&self) -> u16 {
859        self.integrator
860    }
861}
862
863impl<'a> JidRef<'a> {
864    pub fn to_owned(&self) -> Jid {
865        Jid {
866            user: self.user.to_compact_string(),
867            server: self.server,
868            agent: self.agent,
869            device: self.device,
870            integrator: self.integrator,
871        }
872    }
873
874    /// Compare the display representation with `other` without allocating.
875    #[inline]
876    pub fn display_eq(&self, other: &str) -> bool {
877        jid_display_eq(&self.user, self.server, self.agent, self.device, other)
878    }
879
880    /// Compare two borrowed JIDs by the representation emitted by [`fmt::Display`].
881    #[inline]
882    pub fn display_eq_jid(&self, other: &Self) -> bool {
883        jid_displays_equal(
884            (&self.user, self.server, self.agent, self.device),
885            (&other.user, other.server, other.agent, other.device),
886        )
887    }
888}
889
890impl PartialEq for Jid {
891    #[inline]
892    fn eq(&self, other: &Self) -> bool {
893        self.user == other.user
894            && self.server == other.server
895            && self.device == other.device
896            && self.integrator == other.integrator
897            // Equal raw agents are already equal identity agents — the servers
898            // match by the check above, so both sides normalise the same way.
899            // Skipping the lookup in that case is worth it because it is the
900            // overwhelmingly common one: nothing off the wire carries an agent
901            // on the AD servers. Load-bearing on the `self.server == other.server`
902            // above; reordering these would make the shortcut wrong.
903            && (self.agent == other.agent
904                || identity_agent(self.server, self.agent)
905                    == identity_agent(other.server, other.agent))
906    }
907}
908
909impl Eq for Jid {}
910
911impl std::hash::Hash for Jid {
912    #[inline]
913    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
914        self.user.hash(state);
915        self.server.hash(state);
916        self.device.hash(state);
917        self.integrator.hash(state);
918        identity_agent(self.server, self.agent).hash(state);
919    }
920}
921
922impl PartialEq for JidRef<'_> {
923    #[inline]
924    fn eq(&self, other: &Self) -> bool {
925        self.user.as_ref() == other.user.as_ref()
926            && self.server == other.server
927            && self.device == other.device
928            && self.integrator == other.integrator
929            // See `PartialEq for Jid` for why the raw compare can short-circuit.
930            && (self.agent == other.agent
931                || identity_agent(self.server, self.agent)
932                    == identity_agent(other.server, other.agent))
933    }
934}
935
936impl Eq for JidRef<'_> {}
937
938impl std::hash::Hash for JidRef<'_> {
939    #[inline]
940    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
941        self.user.as_ref().hash(state);
942        self.server.hash(state);
943        self.device.hash(state);
944        self.integrator.hash(state);
945        identity_agent(self.server, self.agent).hash(state);
946    }
947}
948
949impl PartialEq<JidRef<'_>> for Jid {
950    #[inline]
951    fn eq(&self, other: &JidRef<'_>) -> bool {
952        self.user.as_str() == other.user.as_ref()
953            && self.server == other.server
954            && self.device == other.device
955            && self.integrator == other.integrator
956            // See `PartialEq for Jid`.
957            && (self.agent == other.agent
958                || identity_agent(self.server, self.agent)
959                    == identity_agent(other.server, other.agent))
960    }
961}
962
963impl PartialEq<Jid> for JidRef<'_> {
964    #[inline]
965    fn eq(&self, other: &Jid) -> bool {
966        other == self
967    }
968}
969
970#[cfg(feature = "serde")]
971impl serde::Serialize for JidRef<'_> {
972    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
973        use serde::ser::SerializeStruct;
974        let mut s = serializer.serialize_struct("Jid", 5)?;
975        s.serialize_field("user", &*self.user)?;
976        s.serialize_field("server", &self.server)?;
977        s.serialize_field("agent", &self.agent)?;
978        s.serialize_field("device", &self.device)?;
979        s.serialize_field("integrator", &self.integrator)?;
980        s.end()
981    }
982}
983
984impl FromStr for Jid {
985    type Err = JidError;
986    fn from_str(s: &str) -> Result<Self, Self::Err> {
987        // Try fast path first for well-formed JIDs
988        if let Some(jid) = parse_jid_ref(s) {
989            return Ok(jid.to_owned());
990        }
991
992        // Fallback to original parsing for edge cases and validation
993        // Keep server as &str to avoid allocation until we need it
994        let (user_part, server) = match s.split_once('@') {
995            Some((u, s)) => (u, s),
996            None => ("", s),
997        };
998
999        if user_part.is_empty() && Server::try_from(server).is_err() {
1000            return Err(JidError::InvalidFormat(format!(
1001                "unknown server '{server}'"
1002            )));
1003        }
1004
1005        // Special handling for LID JIDs, as their user part can contain dots
1006        // that should not be interpreted as agent separators.
1007        if server == HIDDEN_USER_SERVER {
1008            let (user, device) = if let Some((u, d_str)) = user_part.rsplit_once(':') {
1009                (u, d_str.parse()?)
1010            } else {
1011                (user_part, 0)
1012            };
1013            return Ok(Jid {
1014                user: CompactString::from(user),
1015                server: Server::try_from(server)?,
1016                device,
1017                agent: 0,
1018                integrator: 0,
1019            });
1020        }
1021
1022        // Fallback to existing logic for other JID types (s.whatsapp.net, etc.)
1023        let mut user = user_part;
1024        let mut device = 0;
1025        let mut agent = 0;
1026
1027        if let Some((u, d_str)) = user_part.rsplit_once(':') {
1028            user = u;
1029            device = d_str.parse()?;
1030        }
1031
1032        if server != DEFAULT_USER_SERVER
1033            && server != HIDDEN_USER_SERVER
1034            && let Some((u, last_part)) = user.rsplit_once('.')
1035            && let Ok(num_val) = last_part.parse::<u16>()
1036        {
1037            if num_val > u8::MAX as u16 {
1038                return Err(JidError::InvalidFormat(format!(
1039                    "Agent component out of range: {num_val}"
1040                )));
1041            }
1042            user = u;
1043            agent = num_val as u8;
1044        }
1045
1046        Ok(Jid {
1047            user: CompactString::from(user),
1048            server: Server::try_from(server)?,
1049            agent,
1050            device,
1051            integrator: 0,
1052        })
1053    }
1054}
1055
1056/// Core JID formatting logic used by `fmt::Display`, `push_jid_to_string`, and
1057/// `push_jid_to_compact`. Writes `{user}[.{agent}][:{device}]@{server}`.
1058///
1059/// Two flavors via `$append`:
1060/// - **fallible** (`f.write_str(s)?`): for `fmt::Formatter` which returns `fmt::Result`
1061/// - **infallible** (`$buf.push_str(s)`): for `String`/`CompactString`
1062macro_rules! write_jid {
1063    // Infallible variant: push_str/push into a growable buffer
1064    (infallible $buf:expr, $user:expr, $server:expr, $agent:expr, $device:expr) => {{
1065        let (user, server, agent, device) = ($user, $server, $agent, $device);
1066        if user.is_empty() {
1067            $buf.push_str(server.as_str());
1068            return;
1069        }
1070        $buf.push_str(user);
1071        if agent > 0 && server.renders_agent() {
1072            $buf.push('.');
1073            $buf.push_str(itoa::Buffer::new().format(agent));
1074        }
1075        if device > 0 {
1076            $buf.push(':');
1077            $buf.push_str(itoa::Buffer::new().format(device));
1078        }
1079        $buf.push('@');
1080        $buf.push_str(server.as_str());
1081    }};
1082    // Fallible variant: write_str into fmt::Formatter
1083    (fallible $f:expr, $user:expr, $server:expr, $agent:expr, $device:expr) => {{
1084        let (user, server, agent, device) = ($user, $server, $agent, $device);
1085        if user.is_empty() {
1086            return $f.write_str(server.as_str());
1087        }
1088        $f.write_str(user)?;
1089        if agent > 0 && server.renders_agent() {
1090            $f.write_str(".")?;
1091            $f.write_str(itoa::Buffer::new().format(agent))?;
1092        }
1093        if device > 0 {
1094            $f.write_str(":")?;
1095            $f.write_str(itoa::Buffer::new().format(device))?;
1096        }
1097        $f.write_str("@")?;
1098        $f.write_str(server.as_str())
1099    }};
1100}
1101
1102/// Write the JID display representation directly into a `String`,
1103/// bypassing `fmt::Display` and `dyn Write` dispatch entirely.
1104#[inline]
1105pub fn push_jid_to_string(user: &str, server: Server, agent: u8, device: u16, buf: &mut String) {
1106    write_jid!(infallible buf, user, server, agent, device);
1107}
1108
1109/// Write the JID display representation directly into a `CompactString`,
1110/// bypassing `fmt::Display` and `dyn Write` dispatch entirely.
1111#[inline]
1112pub fn push_jid_to_compact(
1113    user: &str,
1114    server: Server,
1115    agent: u8,
1116    device: u16,
1117    buf: &mut CompactString,
1118) {
1119    write_jid!(infallible buf, user, server, agent, device);
1120}
1121
1122/// Stack writer sized for any realistic JID, so `Display` can emit a single
1123/// `write_str`: a `ToString`-backed `String` then reserves once at the exact
1124/// length instead of reallocating per fragment. Overflow errors out and the
1125/// caller falls back to direct fragment writes.
1126struct JidStackWriter {
1127    buf: [u8; 64],
1128    len: usize,
1129}
1130
1131impl JidStackWriter {
1132    #[inline]
1133    fn new() -> Self {
1134        Self {
1135            buf: [0; 64],
1136            len: 0,
1137        }
1138    }
1139
1140    #[inline]
1141    fn as_str(&self) -> &str {
1142        // SAFETY: `write_str` below is the only writer, and it is all-or-nothing
1143        // — it returns `Err` before copying anything that would not fit, so it
1144        // can never leave a partial code point behind. `buf[..len]` is therefore
1145        // always a concatenation of whole `&str` fragments. Anything that writes
1146        // raw bytes here instead of going through `write_str` breaks this.
1147        unsafe { std::str::from_utf8_unchecked(&self.buf[..self.len]) }
1148    }
1149}
1150
1151impl fmt::Write for JidStackWriter {
1152    #[inline]
1153    fn write_str(&mut self, s: &str) -> fmt::Result {
1154        let end = self.len + s.len();
1155        if end > self.buf.len() {
1156            return Err(fmt::Error);
1157        }
1158        self.buf[self.len..end].copy_from_slice(s.as_bytes());
1159        self.len = end;
1160        Ok(())
1161    }
1162}
1163
1164#[inline]
1165fn write_jid_fallible<W: fmt::Write + ?Sized>(
1166    w: &mut W,
1167    user: &str,
1168    server: Server,
1169    agent: u8,
1170    device: u16,
1171) -> fmt::Result {
1172    write_jid!(fallible w, user, server, agent, device)
1173}
1174
1175struct StrEqWriter<'a> {
1176    target: &'a [u8],
1177    position: usize,
1178    matches: bool,
1179}
1180
1181impl fmt::Write for StrEqWriter<'_> {
1182    #[inline]
1183    fn write_str(&mut self, value: &str) -> fmt::Result {
1184        if self.matches {
1185            let bytes = value.as_bytes();
1186            let end = self.position + bytes.len();
1187            if end > self.target.len() || self.target[self.position..end] != *bytes {
1188                self.matches = false;
1189            }
1190            self.position = end;
1191        }
1192        Ok(())
1193    }
1194}
1195
1196#[inline]
1197fn jid_display_eq(user: &str, server: Server, agent: u8, device: u16, other: &str) -> bool {
1198    let mut writer = StrEqWriter {
1199        target: other.as_bytes(),
1200        position: 0,
1201        matches: true,
1202    };
1203    let written = write_jid_fallible(&mut writer, user, server, agent, device).is_ok();
1204    written && writer.matches && writer.position == other.len()
1205}
1206
1207#[inline]
1208fn jid_displays_equal(left: (&str, Server, u8, u16), right: (&str, Server, u8, u16)) -> bool {
1209    let (left_user, left_server, left_agent, left_device) = left;
1210    let (right_user, right_server, right_agent, right_device) = right;
1211    if left_user.is_empty() || right_user.is_empty() {
1212        return left_user.is_empty() && right_user.is_empty() && left_server == right_server;
1213    }
1214
1215    left_user == right_user
1216        && left_server == right_server
1217        && left_device == right_device
1218        && (!left_server.renders_agent() || left_agent == right_agent)
1219}
1220
1221impl fmt::Display for Jid {
1222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1223        let mut w = JidStackWriter::new();
1224        if self.write_display_to(&mut w).is_ok() {
1225            return f.write_str(w.as_str());
1226        }
1227        self.write_display_to(f)
1228    }
1229}
1230
1231/// Privacy-aware [`Display`](fmt::Display) wrapper for a [`Jid`], for use in tracing fields.
1232///
1233/// Pseudonymous (LID) and non-personal (newsletter/bot/call, modern group ids)
1234/// JIDs render in full, so the same peer/chat correlates across spans. JIDs whose
1235/// `user` is a phone number ([`Server::carries_phone_number`]) render the user as
1236/// a `pn#<token>` instead — preserving correlation without leaking the number.
1237/// Legacy group/broadcast ids of the form `<creator-phone>-<timestamp>` get only
1238/// the numeric prefix redacted (`pn#<token>-<timestamp>`), keeping the timestamp.
1239///
1240/// The token is a keyed hash (SipHash via a process-lifetime random key), not a
1241/// plain digest of the number: the phone-number search space is small, so an
1242/// unkeyed hash would be reversible by precomputation. The random key lives only
1243/// in process memory, so exported traces cannot be brute-forced back to numbers.
1244/// It is stable within a process run (correlation works) but not across restarts
1245/// (a fresh key each start). Enable the `tracing-pii` feature to render raw
1246/// numbers (local debugging only). The token is computed only while formatting an
1247/// already-enabled span, so it costs nothing on disabled call sites.
1248pub struct ObservedJid<'a>(&'a Jid);
1249
1250impl Jid {
1251    /// Privacy-aware display for tracing spans/fields. See [`ObservedJid`].
1252    #[inline]
1253    pub fn observe(&self) -> ObservedJid<'_> {
1254        ObservedJid(self)
1255    }
1256}
1257
1258/// Per-process keyed token for a sensitive string: a SipHash with a random key
1259/// created once per process. Stable within a run (so the same value correlates
1260/// across spans) but not precomputable from the input, so exported traces cannot
1261/// be brute-forced back to the original (e.g. an E.164 phone number). Public so
1262/// other layers can redact non-`Jid` identifiers (e.g. a Signal `ProtocolAddress`
1263/// name, which embeds a phone number) with the same keyed scheme.
1264pub fn observe_token(s: &str) -> u64 {
1265    use std::hash::BuildHasher;
1266    static KEY: std::sync::OnceLock<std::collections::hash_map::RandomState> =
1267        std::sync::OnceLock::new();
1268    KEY.get_or_init(std::collections::hash_map::RandomState::new)
1269        .hash_one(s)
1270}
1271
1272/// Privacy-aware redaction of a JID supplied as a string (e.g. a group jid `&str`
1273/// in a span field). Parses it and applies [`Jid::observe`]; if it does not parse,
1274/// falls back to a keyed token so a raw number can never leak. Honors `tracing-pii`.
1275pub fn observe_str(s: &str) -> String {
1276    if cfg!(feature = "tracing-pii") {
1277        return s.to_string();
1278    }
1279    match s.parse::<Jid>() {
1280        Ok(jid) => jid.observe().to_string(),
1281        Err(_) => format!("?#{:016x}", observe_token(s)),
1282    }
1283}
1284
1285impl fmt::Display for ObservedJid<'_> {
1286    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1287        let jid = self.0;
1288        if jid.user.is_empty() || cfg!(feature = "tracing-pii") {
1289            return fmt::Display::fmt(jid, f);
1290        }
1291        // Decide the privacy-safe `user` rendering.
1292        let redacted: Option<String> = if jid.server.carries_phone_number() {
1293            // The whole user is a phone number.
1294            Some(format!("pn#{:016x}", observe_token(jid.user.as_str())))
1295        } else if matches!(jid.server, Server::Group | Server::Broadcast) {
1296            // Legacy group/broadcast ids embed the creator phone as
1297            // "<phone>-<timestamp>". Redact the numeric prefix and keep the
1298            // timestamp (not PII) so the group still correlates across spans.
1299            match jid.user.find('-') {
1300                Some(i) if i > 0 && jid.user.as_bytes()[..i].iter().all(|b| b.is_ascii_digit()) => {
1301                    Some(format!(
1302                        "pn#{:016x}{}",
1303                        observe_token(&jid.user[..i]),
1304                        &jid.user[i..]
1305                    ))
1306                }
1307                _ => None,
1308            }
1309        } else {
1310            None
1311        };
1312        let Some(user) = redacted else {
1313            // Pseudonymous (LID) or non-personal: safe to render in full.
1314            return fmt::Display::fmt(jid, f);
1315        };
1316        f.write_str(&user)?;
1317        // Preserve agent where it is part of the identity (e.g. Interop/Messenger),
1318        // mirroring the normal JID display so distinct IDs stay distinct.
1319        if jid.agent > 0 && jid.server.renders_agent() {
1320            write!(f, ".{}", jid.agent)?;
1321        }
1322        if jid.device > 0 {
1323            write!(f, ":{}", jid.device)?;
1324        }
1325        write!(f, "@{}", jid.server.as_str())
1326    }
1327}
1328
1329impl<'a> fmt::Display for JidRef<'a> {
1330    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1331        let mut w = JidStackWriter::new();
1332        if write_jid_fallible(&mut w, &self.user, self.server, self.agent, self.device).is_ok() {
1333            return f.write_str(w.as_str());
1334        }
1335        write_jid_fallible(f, &self.user, self.server, self.agent, self.device)
1336    }
1337}
1338
1339impl From<Jid> for String {
1340    fn from(jid: Jid) -> Self {
1341        jid.to_string()
1342    }
1343}
1344
1345/// Lets `impl Into<Jid>` APIs accept `&Jid` transparently: borrow-callers pay
1346/// one cheap clone (the user part is inline for typical numeric ids), owned
1347/// callers move for free.
1348impl From<&Jid> for Jid {
1349    fn from(jid: &Jid) -> Self {
1350        jid.clone()
1351    }
1352}
1353
1354impl<'a> From<JidRef<'a>> for String {
1355    fn from(jid: JidRef<'a>) -> Self {
1356        jid.to_string()
1357    }
1358}
1359
1360impl TryFrom<String> for Jid {
1361    type Error = JidError;
1362    fn try_from(value: String) -> Result<Self, Self::Error> {
1363        Jid::from_str(&value)
1364    }
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369    use super::*;
1370    use std::str::FromStr;
1371
1372    #[test]
1373    fn is_psa_matches_system_jid_in_both_user_namespaces() {
1374        assert!(Jid::from_str("0@s.whatsapp.net").unwrap().is_psa());
1375        assert!(Jid::from_str("0@c.us").unwrap().is_psa());
1376        assert!(parse_jid_ref("0@s.whatsapp.net").unwrap().is_psa());
1377    }
1378
1379    #[test]
1380    fn is_psa_rejects_regular_and_non_pn_jids() {
1381        assert!(!Jid::from_str("10@s.whatsapp.net").unwrap().is_psa());
1382        assert!(!Jid::from_str("0@lid").unwrap().is_psa());
1383        assert!(!Jid::from_str("status@broadcast").unwrap().is_psa());
1384        assert!(!Jid::from_str("0@g.us").unwrap().is_psa());
1385    }
1386
1387    /// `JidStackWriter::as_str` reads its buffer back as UTF-8 without
1388    /// validating it, so every shape that reaches it has to be exercised
1389    /// somewhere Miri can see: multi-byte users, a user that fills the buffer
1390    /// exactly, and one that overflows it into the fallback path.
1391    #[test]
1392    fn display_reads_back_every_stack_buffer_shape() {
1393        // Multi-byte code points in the user part: a byte-wise buffer must not
1394        // be able to split one, and the readback must reproduce them exactly.
1395        let emoji = Jid::new("héllo→世界", Server::Lid);
1396        assert_eq!(emoji.to_string(), "héllo→世界@lid");
1397        assert_eq!(format!("{}", emoji.observe()), "héllo→世界@lid");
1398
1399        // Exactly at the 64-byte stack buffer: "@lid" is 4 bytes, so a 60-byte
1400        // user is the longest that still renders through it.
1401        let full = Jid::new("9".repeat(60), Server::Lid);
1402        let rendered = full.to_string();
1403        assert_eq!(rendered.len(), 64);
1404        assert!(rendered.ends_with("@lid"));
1405
1406        // One byte past it: `write_str` errors and Display falls back to
1407        // fragment writes, which must produce the same string.
1408        let over = Jid::new("9".repeat(61), Server::Lid);
1409        assert_eq!(over.to_string(), format!("{}@lid", "9".repeat(61)));
1410
1411        // The borrowed formatter and the Arc<str> builder share the writer.
1412        let borrowed = parse_jid_ref("12025550111:7@s.whatsapp.net").unwrap();
1413        assert_eq!(borrowed.to_string(), "12025550111:7@s.whatsapp.net");
1414        assert_eq!(&*emoji.to_non_ad_arc_str(), "héllo→世界@lid");
1415        assert_eq!(
1416            &*over.to_non_ad_arc_str(),
1417            &*format!("{}@lid", "9".repeat(61))
1418        );
1419    }
1420
1421    /// The phash form is recomputed and validated by the server, so
1422    /// it has to match WA Web byte for byte. WA Web writes a literal `.0` in the
1423    /// agent position (`formatFull`) and its Wid carries no agent at all, so ours
1424    /// must not leak one in either — and two JIDs that compare equal must produce
1425    /// the same string, or the phash memo (keyed by JID) can serve a hash computed
1426    /// for a different one.
1427    #[test]
1428    fn phash_form_writes_the_agent_position_as_zero_like_wa_web() {
1429        let plain = Jid {
1430            user: "5511999998888".into(),
1431            server: Server::Lid,
1432            agent: 0,
1433            device: 3,
1434            integrator: 0,
1435        };
1436        assert_eq!(plain.to_phash_form_string(), "5511999998888.0:3@lid");
1437
1438        let with_agent = Jid {
1439            agent: 7,
1440            ..plain.clone()
1441        };
1442        assert_eq!(
1443            with_agent.to_phash_form_string(),
1444            "5511999998888.0:3@lid",
1445            "the agent must not reach the hashed string"
1446        );
1447
1448        // The pairing the phash memo relies on: equal JIDs, equal AD strings.
1449        assert_eq!(plain, with_agent);
1450        assert_eq!(
1451            plain.to_phash_form_string(),
1452            with_agent.to_phash_form_string()
1453        );
1454
1455        // A server-only JID still degenerates to the server, and device still counts.
1456        assert_eq!(
1457            Jid::new("", Server::Pn).to_phash_form_string(),
1458            "s.whatsapp.net"
1459        );
1460        assert_ne!(
1461            plain.to_phash_form_string(),
1462            Jid {
1463                device: 4,
1464                ..plain.clone()
1465            }
1466            .to_phash_form_string()
1467        );
1468    }
1469
1470    /// An `agent` off an agent-rendering server is inert: nothing encodes it,
1471    /// prints it, or hashes it. Two JIDs differing only there address the same
1472    /// device, so equality and `Hash` must both say so — and must agree with each
1473    /// other, or a `HashMap<Jid, _>` gets an entry it can never look up again.
1474    ///
1475    /// `integrator` stays in identity: it is only non-zero on interop, but
1476    /// `is_same_chat_as` compares it unconditionally, and `==` must not disagree.
1477    #[test]
1478    fn inert_agent_stays_out_of_identity() {
1479        use std::collections::hash_map::DefaultHasher;
1480        use std::hash::{Hash, Hasher};
1481
1482        fn hash_of(jid: &Jid) -> u64 {
1483            let mut h = DefaultHasher::new();
1484            jid.hash(&mut h);
1485            h.finish()
1486        }
1487
1488        // The four AD servers spell the server as a domain byte, so an agent set
1489        // on one is a wire artefact, not identity.
1490        for server in [Server::Pn, Server::Lid, Server::Hosted, Server::HostedLid] {
1491            let clean = Jid {
1492                user: "123456789012345".into(),
1493                server,
1494                agent: 0,
1495                device: 7,
1496                integrator: 0,
1497            };
1498            let with_agent = Jid {
1499                agent: 1,
1500                ..clean.clone()
1501            };
1502            assert_eq!(
1503                clean, with_agent,
1504                "{server:?}: agent must not split identity"
1505            );
1506            assert_eq!(
1507                hash_of(&clean),
1508                hash_of(&with_agent),
1509                "{server:?}: Hash must agree with Eq"
1510            );
1511
1512            // integrator is NOT normalised: `is_same_chat_as` compares it always,
1513            // and `==` must not disagree with it.
1514            let with_integrator = Jid {
1515                integrator: 0xBEEF,
1516                ..clean.clone()
1517            };
1518            assert_ne!(
1519                clean, with_integrator,
1520                "{server:?}: integrator stays in identity, matching is_same_chat_as"
1521            );
1522            assert_eq!(
1523                clean.is_same_chat_as(&with_integrator),
1524                clean == with_integrator,
1525                "{server:?}: == must agree with is_same_chat_as"
1526            );
1527
1528            // The borrowed form and the cross-type comparison follow the same rule.
1529            let borrowed = JidRef {
1530                user: NodeStr::Borrowed("123456789012345"),
1531                server,
1532                agent: 1,
1533                device: 7,
1534                integrator: 0,
1535            };
1536            assert_eq!(clean, borrowed, "{server:?}: owned == borrowed");
1537            assert_eq!(borrowed, clean, "{server:?}: borrowed == owned");
1538        }
1539
1540        // Where the server DOES render the agent it is identity, and must still split.
1541        let bot = Jid {
1542            user: "123456789".into(),
1543            server: Server::Interop,
1544            agent: 4,
1545            device: 0,
1546            integrator: 0,
1547        };
1548        let other_agent = Jid {
1549            agent: 5,
1550            ..bot.clone()
1551        };
1552        assert_ne!(
1553            bot, other_agent,
1554            "interop renders the agent, so it is identity"
1555        );
1556        assert_ne!(
1557            bot,
1558            Jid {
1559                integrator: 9,
1560                ..bot.clone()
1561            },
1562            "interop is where integrator is real"
1563        );
1564
1565        // Fields that are always identity keep splitting.
1566        let pn = Jid::new("123456789012345", Server::Pn);
1567        assert_ne!(
1568            pn,
1569            Jid {
1570                device: 1,
1571                ..pn.clone()
1572            }
1573        );
1574        assert_ne!(pn, Jid::new("123456789012346", Server::Pn));
1575        assert_ne!(pn, Jid::new("123456789012345", Server::Lid));
1576    }
1577
1578    #[test]
1579    fn display_eq_matches_owned_and_borrowed_jids_without_normalizing() {
1580        let canonical = "123456789.4:17@interop";
1581        let owned = Jid::from_str(canonical).unwrap();
1582        let borrowed = parse_jid_ref(canonical).unwrap();
1583
1584        for value in [canonical, "123456789.4:16@interop", "123456789@interop", ""] {
1585            assert_eq!(owned.display_eq(value), value == canonical);
1586            assert_eq!(borrowed.display_eq(value), value == canonical);
1587        }
1588
1589        let long_user = "a".repeat(128);
1590        let long_value = format!("{long_user}@lid");
1591        let long_jid = Jid::lid(long_user);
1592        assert!(long_jid.display_eq(&long_value));
1593        assert!(!long_jid.display_eq(&format!("{long_value}x")));
1594    }
1595
1596    #[test]
1597    fn display_eq_jid_uses_only_rendered_components() {
1598        let pn = Jid {
1599            user: "12025550111".into(),
1600            server: Server::Pn,
1601            agent: 1,
1602            device: 7,
1603            integrator: 3,
1604        };
1605        let pn_same_display = Jid {
1606            agent: 2,
1607            integrator: 9,
1608            ..pn.clone()
1609        };
1610        assert_eq!(pn.to_string(), pn_same_display.to_string());
1611        assert!(pn.display_eq_jid(&pn_same_display));
1612
1613        let pn_other_device = Jid {
1614            device: 8,
1615            ..pn.clone()
1616        };
1617        assert!(!pn.display_eq_jid(&pn_other_device));
1618
1619        let bot = Jid {
1620            user: "13136555001".into(),
1621            server: Server::Bot,
1622            agent: 1,
1623            device: 0,
1624            integrator: 0,
1625        };
1626        let other_bot_agent = Jid {
1627            agent: 2,
1628            ..bot.clone()
1629        };
1630        assert!(!bot.display_eq_jid(&other_bot_agent));
1631
1632        let server_only = Jid {
1633            user: "".into(),
1634            server: Server::Pn,
1635            agent: 1,
1636            device: 7,
1637            integrator: 3,
1638        };
1639        let same_server_only = Jid {
1640            agent: 2,
1641            device: 9,
1642            integrator: 4,
1643            ..server_only.clone()
1644        };
1645        assert_eq!(server_only.to_string(), same_server_only.to_string());
1646        assert!(server_only.display_eq_jid(&same_server_only));
1647
1648        let borrowed = JidRef {
1649            user: NodeStr::Borrowed("12025550111"),
1650            server: Server::Pn,
1651            agent: 1,
1652            device: 7,
1653            integrator: 0,
1654        };
1655        let borrowed_same_display = JidRef {
1656            agent: 2,
1657            ..borrowed.clone()
1658        };
1659        assert!(borrowed.display_eq_jid(&borrowed_same_display));
1660    }
1661
1662    #[test]
1663    fn owned_and_borrowed_jids_compare_without_conversion() {
1664        let owned = Jid {
1665            user: "12025550111".into(),
1666            server: Server::Pn,
1667            agent: 0,
1668            device: 7,
1669            integrator: 0,
1670        };
1671        let borrowed = JidRef {
1672            user: NodeStr::Borrowed("12025550111"),
1673            server: Server::Pn,
1674            agent: 0,
1675            device: 7,
1676            integrator: 0,
1677        };
1678
1679        assert_eq!(owned, borrowed);
1680        assert_eq!(borrowed, owned);
1681
1682        let other_device = JidRef {
1683            device: 8,
1684            ..borrowed.clone()
1685        };
1686        assert_ne!(owned, other_device);
1687    }
1688
1689    #[cfg(feature = "serde")]
1690    #[test]
1691    fn server_deserializes_borrowed_and_owned_strings() {
1692        let borrowed: Server = serde_json::from_str("\"s.whatsapp.net\"").unwrap();
1693        let owned: Server =
1694            serde_json::from_value(serde_json::Value::String("lid".to_owned())).unwrap();
1695
1696        assert_eq!(borrowed, Server::Pn);
1697        assert_eq!(owned, Server::Lid);
1698    }
1699
1700    /// `observe()` must never leak a raw phone number, must keep pseudonymous /
1701    /// non-personal JIDs intact for correlation, and must preserve device.
1702    #[test]
1703    #[cfg(not(feature = "tracing-pii"))]
1704    fn observe_redacts_phone_but_not_lid_or_group() {
1705        let pn = Jid::from_str("5511999998888:7@s.whatsapp.net").unwrap();
1706        let shown = pn.observe().to_string();
1707        assert!(shown.starts_with("pn#"), "{shown}");
1708        assert!(
1709            !shown.contains("5511999998888"),
1710            "raw number leaked: {shown}"
1711        );
1712        assert!(shown.ends_with(":7@s.whatsapp.net"), "device lost: {shown}");
1713        // Stable within the process so the same peer correlates across spans.
1714        assert_eq!(shown, pn.observe().to_string());
1715
1716        // LID is pseudonymous and modern group ids are non-personal: rendered in full.
1717        let lid = Jid::from_str("123456789@lid").unwrap();
1718        assert_eq!(lid.observe().to_string(), lid.to_string());
1719        let group = Jid::from_str("120363012345678901@g.us").unwrap();
1720        assert_eq!(group.observe().to_string(), group.to_string());
1721
1722        // Legacy group id embeds the creator phone ("<phone>-<ts>"): redact the
1723        // numeric prefix, keep the timestamp.
1724        let legacy = Jid::from_str("123456789-1620000000@g.us").unwrap();
1725        let ls = legacy.observe().to_string();
1726        assert!(
1727            ls.starts_with("pn#") && ls.ends_with("-1620000000@g.us"),
1728            "{ls}"
1729        );
1730        // Exact no-leak invariant: the creator phone must not appear anywhere.
1731        assert!(!ls.contains("123456789"), "creator phone leaked: {ls}");
1732        // The redacted prefix is the fixed-width keyed token, not the raw number.
1733        let mid = &ls["pn#".len()..ls.find('-').unwrap()];
1734        assert_eq!(mid.len(), 16, "token width: {ls}");
1735        assert!(
1736            mid.bytes().all(|b| b.is_ascii_hexdigit()),
1737            "token hex: {ls}"
1738        );
1739    }
1740
1741    /// Helper function to test a full parsing and display round-trip.
1742    fn assert_jid_roundtrip(
1743        input: &str,
1744        expected_user: &str,
1745        expected_server: &str,
1746        expected_device: u16,
1747        expected_agent: u8,
1748    ) {
1749        assert_jid_parse_and_display(
1750            input,
1751            expected_user,
1752            expected_server,
1753            expected_device,
1754            expected_agent,
1755            input,
1756        );
1757    }
1758
1759    /// Helper function to test parsing and display with a custom expected output.
1760    fn assert_jid_parse_and_display(
1761        input: &str,
1762        expected_user: &str,
1763        expected_server: &str,
1764        expected_device: u16,
1765        expected_agent: u8,
1766        expected_output: &str,
1767    ) {
1768        // 1. Test parsing from string (FromStr trait)
1769        let jid = Jid::from_str(input).unwrap_or_else(|_| panic!("Failed to parse JID: {}", input));
1770
1771        assert_eq!(
1772            jid.user, expected_user,
1773            "User part did not match for {}",
1774            input
1775        );
1776        assert_eq!(
1777            jid.server, expected_server,
1778            "Server part did not match for {}",
1779            input
1780        );
1781        assert_eq!(
1782            jid.device, expected_device,
1783            "Device part did not match for {}",
1784            input
1785        );
1786        assert_eq!(
1787            jid.agent, expected_agent,
1788            "Agent part did not match for {}",
1789            input
1790        );
1791
1792        // 2. Test formatting back to string (Display trait)
1793        let formatted = jid.to_string();
1794        assert_eq!(
1795            formatted, expected_output,
1796            "Formatted string did not match expected output for {}",
1797            input
1798        );
1799    }
1800
1801    #[test]
1802    fn borrowed_parser_matches_owned_fast_path_and_bot_classification() {
1803        for raw in [
1804            "5511999998888:7@s.whatsapp.net",
1805            "120363012345678901@g.us",
1806            "123456789012345@lid",
1807            "assistant@bot",
1808            "13135551234@s.whatsapp.net",
1809        ] {
1810            let borrowed = parse_jid_ref(raw).expect("common JID should stay borrowed");
1811            let owned = raw.parse::<Jid>().unwrap();
1812
1813            assert!(matches!(borrowed.user, NodeStr::Borrowed(_)));
1814            assert_eq!(borrowed.to_owned(), owned);
1815            assert_eq!(borrowed.is_bot(), owned.is_bot());
1816        }
1817
1818        assert!(parse_jid_ref("g.us").is_none(), "server-only uses fallback");
1819        assert!(parse_jid_ref("user@unknown").is_none());
1820    }
1821
1822    /// `parse_u16_decimal` stands in for `u16::from_str` inside the scanner, so
1823    /// it has to accept and reject exactly what std does — including the shapes
1824    /// nobody writes on purpose but a malformed stanza can still carry.
1825    #[test]
1826    fn decimal_fast_path_matches_u16_from_str() {
1827        for raw in [
1828            "", "+", "-", "0", "7", "+7", "007", "33", "255", "256", "65535", "65536", "99999",
1829            "-0", "-1", "1x", "x1", " 1", "1 ", "1_0", "+-1", "++1", "1.0", "٣", "𝟛",
1830        ] {
1831            assert_eq!(
1832                parse_u16_decimal(raw),
1833                raw.parse::<u16>().ok(),
1834                "mismatch for {raw:?}"
1835            );
1836        }
1837    }
1838
1839    /// The scan stops at the first `@` and dispatches on the resolved `Server`,
1840    /// so every separator rule it folded together needs a case here: which
1841    /// separator wins per server, and the pathological users where the agent
1842    /// rule reads a different dot than the scan recorded.
1843    ///
1844    /// Asserted against written-out values rather than against `str::parse`,
1845    /// which would prove nothing: `FromStr` tries `parse_jid_ref` first, so for
1846    /// any server this path accepts it would be comparing the scanner with
1847    /// itself. These are the values `main` produces for the same inputs.
1848    #[test]
1849    fn fast_parse_pins_the_separator_rules_per_server() {
1850        // (input, user, agent, device)
1851        let cases = [
1852            // Generic servers read a trailing dotted number as the agent.
1853            ("123456789.4:17@interop", "123456789", 4u8, 17u16),
1854            ("123456789.4@interop", "123456789", 4, 0),
1855            // ...but an agent past u8 leaves the user whole instead.
1856            ("123.999@interop", "123.999", 0, 0),
1857            // On s.whatsapp.net that same trailing number is the legacy device.
1858            ("5511999998888.2@s.whatsapp.net", "5511999998888", 0, 2),
1859            // Dots in a lid user are part of the user.
1860            ("12345.678@lid", "12345.678", 0, 0),
1861            ("12345.678:9@lid", "12345.678", 0, 9),
1862            // `hosted.lid` and `c.us` are NOT in the lid family here — they take
1863            // the generic rule, so a dotted number that fits in u8 becomes the
1864            // agent. Pinned as the pre-existing behaviour, not endorsed as
1865            // correct; changing it needs WA Web as ground truth, not this PR.
1866            ("12345.6@hosted.lid", "12345", 6, 0),
1867            ("12345.678@hosted.lid", "12345.678", 0, 0),
1868            ("12345.6@c.us", "12345", 6, 0),
1869            // A second colon puts the last dot after the first one, which is
1870            // where reusing the scanned dot position would silently diverge.
1871            ("a:1.5:2@bot", "a:1", 5, 2),
1872            // Unparsable device degrades to 0 rather than rejecting the JID.
1873            ("5511999998888:x@s.whatsapp.net", "5511999998888", 0, 0),
1874        ];
1875
1876        for (raw, user, agent, device) in cases {
1877            let fast =
1878                parse_jid_fast(raw).unwrap_or_else(|| panic!("{raw} should take the fast path"));
1879            assert_eq!(fast.user, user, "user for {raw}");
1880            assert_eq!(fast.agent, agent, "agent for {raw}");
1881            assert_eq!(fast.device, device, "device for {raw}");
1882        }
1883    }
1884
1885    #[test]
1886    fn test_jid_parsing_and_display_roundtrip() {
1887        // Standard cases
1888        assert_jid_roundtrip(
1889            "1234567890@s.whatsapp.net",
1890            "1234567890",
1891            "s.whatsapp.net",
1892            0,
1893            0,
1894        );
1895        assert_jid_roundtrip(
1896            "1234567890:15@s.whatsapp.net",
1897            "1234567890",
1898            "s.whatsapp.net",
1899            15,
1900            0,
1901        );
1902        assert_jid_roundtrip("123-456@g.us", "123-456", "g.us", 0, 0);
1903
1904        // Server-only JID: parsing "s.whatsapp.net" should display as "s.whatsapp.net" (no @ prefix)
1905        // This matches WhatsApp Web behavior where server-only JIDs don't have @ prefix
1906        assert_jid_roundtrip("s.whatsapp.net", "", "s.whatsapp.net", 0, 0);
1907
1908        // LID JID cases (critical for the bug)
1909        assert_jid_roundtrip("12345.6789@lid", "12345.6789", "lid", 0, 0);
1910        assert_jid_roundtrip("12345.6789:25@lid", "12345.6789", "lid", 25, 0);
1911
1912        // @call (call-signaling server) must parse and render, not be rejected.
1913        assert_jid_roundtrip("12345@call", "12345", "call", 0, 0);
1914    }
1915
1916    #[test]
1917    fn test_special_from_str_parsing() {
1918        // Test parsing of JIDs with an agent, which should be stored in the struct
1919        let jid = Jid::from_str("1234567890.2:15@hosted").expect("test hosted JID should be valid");
1920        assert_eq!(jid.user, "1234567890");
1921        assert_eq!(jid.server, "hosted");
1922        assert_eq!(jid.device, 15);
1923        assert_eq!(jid.agent, 2);
1924    }
1925
1926    #[test]
1927    fn test_manual_jid_formatting_edge_cases() {
1928        // This test directly validates the fixes for the parity failures.
1929        // We manually construct the Jid struct as the binary decoder would,
1930        // then we assert that its string representation is correct.
1931
1932        // Failure Case 1: An AD-JID for s.whatsapp.net decoded with an agent.
1933        // The Display trait MUST NOT show the agent number.
1934        let jid1 = Jid {
1935            user: "1234567890".into(),
1936            server: Server::Pn,
1937            device: 15,
1938            agent: 2,
1939            integrator: 0,
1940        };
1941        assert_eq!(jid1.to_string(), "1234567890:15@s.whatsapp.net");
1942
1943        let jid2 = Jid {
1944            user: "12345.6789".into(),
1945            server: Server::Lid,
1946            device: 25,
1947            agent: 1,
1948            integrator: 0,
1949        };
1950        assert_eq!(jid2.to_string(), "12345.6789:25@lid");
1951
1952        let jid3 = Jid {
1953            user: "1234567890".into(),
1954            server: Server::Hosted,
1955            device: 15,
1956            agent: 2,
1957            integrator: 0,
1958        };
1959        assert_eq!(jid3.to_string(), "1234567890:15@hosted");
1960
1961        // Agent SHOULD be displayed for non-AD servers (e.g., bot, interop)
1962        let jid4 = Jid {
1963            user: "user".into(),
1964            server: Server::Bot,
1965            device: 10,
1966            agent: 5,
1967            integrator: 0,
1968        };
1969        assert_eq!(jid4.to_string(), "user.5:10@bot");
1970    }
1971
1972    #[test]
1973    fn test_invalid_jids_should_fail_to_parse() {
1974        assert!(Jid::from_str("thisisnotajid").is_err());
1975        assert!(Jid::from_str("").is_err());
1976        // "@s.whatsapp.net" is now valid - it's the protocol format for server-only JIDs
1977        assert!(Jid::from_str("@s.whatsapp.net").is_ok());
1978        // But "@unknown.server" should still fail
1979        assert!(Jid::from_str("@unknown.server").is_err());
1980        // Jid::from_str("2") should not be possible due to type constraints,
1981        // but if it were, it should fail. The string must contain '@'.
1982        assert!(Jid::from_str("2").is_err());
1983    }
1984
1985    /// Tests for HOSTED device detection (`is_hosted()` method).
1986    ///
1987    /// # Context: What are HOSTED devices?
1988    ///
1989    /// HOSTED devices (also known as Cloud API or Meta Business API devices) are
1990    /// WhatsApp Business accounts that use Meta's server-side infrastructure instead
1991    /// of traditional end-to-end encryption with Signal protocol.
1992    ///
1993    /// ## Key characteristics:
1994    /// - Device ID is always 99 (`:99`)
1995    /// - Server is `@hosted` (phone-based) or `@hosted.lid` (LID-based)
1996    /// - They do NOT use Signal protocol prekeys
1997    /// - They should be EXCLUDED from group message fanout
1998    /// - They CAN receive 1:1 messages (but prekey fetch will fail, causing graceful skip)
1999    ///
2000    /// ## Why exclude from groups?
2001    /// WhatsApp Web explicitly filters hosted devices from group SKDM (Sender Key
2002    /// Distribution Message) distribution. From WhatsApp Web JS (`getFanOutList`):
2003    /// ```javascript
2004    /// var isHosted = e.id === 99 || e.isHosted === true;
2005    /// var includeInFanout = !isHosted || isOneToOneChat;
2006    /// ```
2007    ///
2008    /// ## JID formats:
2009    /// - Phone-based: `5511999887766:99@hosted`
2010    /// - LID-based: `100000012345678:99@hosted.lid`
2011    /// - Regular device with ID 99: `5511999887766:99@s.whatsapp.net` (also hosted!)
2012    #[test]
2013    fn test_is_hosted_device_detection() {
2014        // === HOSTED DEVICES (should return true) ===
2015
2016        // Case 1: Device ID 99 on regular server (Cloud API business account)
2017        // This is the most common case - a business using Meta's Cloud API
2018        let cloud_api_device: Jid = "5511999887766:99@s.whatsapp.net"
2019            .parse()
2020            .expect("test JID should be valid");
2021        assert!(
2022            cloud_api_device.is_hosted(),
2023            "Device ID 99 on s.whatsapp.net should be detected as hosted (Cloud API)"
2024        );
2025
2026        // Case 2: Device ID 99 on LID server
2027        let cloud_api_lid: Jid = "100000012345678:99@lid"
2028            .parse()
2029            .expect("test JID should be valid");
2030        assert!(
2031            cloud_api_lid.is_hosted(),
2032            "Device ID 99 on lid server should be detected as hosted"
2033        );
2034
2035        // Case 3: Explicit @hosted server (phone-based hosted JID)
2036        let hosted_server: Jid = "5511999887766:99@hosted"
2037            .parse()
2038            .expect("test JID should be valid");
2039        assert!(
2040            hosted_server.is_hosted(),
2041            "JID with @hosted server should be detected as hosted"
2042        );
2043
2044        // Case 4: Explicit @hosted.lid server (LID-based hosted JID)
2045        let hosted_lid_server: Jid = "100000012345678:99@hosted.lid"
2046            .parse()
2047            .expect("test JID should be valid");
2048        assert!(
2049            hosted_lid_server.is_hosted(),
2050            "JID with @hosted.lid server should be detected as hosted"
2051        );
2052
2053        // Case 5: @hosted server with different device ID (edge case)
2054        // Even with device ID != 99, if server is @hosted, it's a hosted device
2055        let hosted_server_other_device: Jid = "5511999887766:0@hosted"
2056            .parse()
2057            .expect("test JID should be valid");
2058        assert!(
2059            hosted_server_other_device.is_hosted(),
2060            "JID with @hosted server should be hosted regardless of device ID"
2061        );
2062
2063        // === NON-HOSTED DEVICES (should return false) ===
2064
2065        // Case 6: Regular phone device (primary phone, device 0)
2066        let regular_phone: Jid = "5511999887766:0@s.whatsapp.net"
2067            .parse()
2068            .expect("test JID should be valid");
2069        assert!(
2070            !regular_phone.is_hosted(),
2071            "Regular phone device (ID 0) should NOT be hosted"
2072        );
2073
2074        // Case 7: Companion device (WhatsApp Web, device 33+)
2075        let companion_device: Jid = "5511999887766:33@s.whatsapp.net"
2076            .parse()
2077            .expect("test JID should be valid");
2078        assert!(
2079            !companion_device.is_hosted(),
2080            "Companion device (ID 33) should NOT be hosted"
2081        );
2082
2083        // Case 8: Regular LID device
2084        let regular_lid: Jid = "100000012345678:0@lid"
2085            .parse()
2086            .expect("test JID should be valid");
2087        assert!(
2088            !regular_lid.is_hosted(),
2089            "Regular LID device should NOT be hosted"
2090        );
2091
2092        // Case 9: LID companion device
2093        let lid_companion: Jid = "100000012345678:33@lid"
2094            .parse()
2095            .expect("test JID should be valid");
2096        assert!(
2097            !lid_companion.is_hosted(),
2098            "LID companion device (ID 33) should NOT be hosted"
2099        );
2100
2101        // Case 10: Group JID (not a device at all)
2102        let group_jid: Jid = "120363012345678@g.us"
2103            .parse()
2104            .expect("test JID should be valid");
2105        assert!(
2106            !group_jid.is_hosted(),
2107            "Group JID should NOT be detected as hosted"
2108        );
2109
2110        // Case 11: User JID without device
2111        let user_jid: Jid = "5511999887766@s.whatsapp.net"
2112            .parse()
2113            .expect("test JID should be valid");
2114        assert!(
2115            !user_jid.is_hosted(),
2116            "User JID without device should NOT be hosted"
2117        );
2118
2119        // Case 12: Bot device
2120        let bot_jid: Jid = "13136555001:0@s.whatsapp.net"
2121            .parse()
2122            .expect("test JID should be valid");
2123        assert!(
2124            !bot_jid.is_hosted(),
2125            "Bot JID should NOT be detected as hosted (different mechanism)"
2126        );
2127    }
2128
2129    #[test]
2130    fn is_same_chat_as_matches_rendered_chat_identity() {
2131        let base: Jid = "5511999887766@s.whatsapp.net".parse().unwrap();
2132
2133        // Device is ignored.
2134        assert!(base.is_same_chat_as(&base.with_device(33)));
2135        assert!(base.with_device(5).is_same_chat_as(&base.with_device(0)));
2136
2137        // Different user or server -> different chat (server guards the
2138        // is_same_user_as looseness that ignores server).
2139        let other_user: Jid = "5521988776655@s.whatsapp.net".parse().unwrap();
2140        assert!(!base.is_same_chat_as(&other_user));
2141        let as_lid: Jid = "5511999887766@lid".parse().unwrap();
2142        assert!(!base.is_same_chat_as(&as_lid));
2143
2144        // integrator participates in identity.
2145        let other_integrator = Jid {
2146            integrator: 1,
2147            ..base.clone()
2148        };
2149        assert!(!base.is_same_chat_as(&other_integrator));
2150
2151        // Pn suppresses the agent in display, so a stray decoded agent byte must
2152        // not split the chat: same rendered string -> same chat.
2153        let pn_agent1 = Jid {
2154            agent: 1,
2155            ..base.clone()
2156        };
2157        assert_eq!(base.to_string(), pn_agent1.to_string());
2158        assert!(base.is_same_chat_as(&pn_agent1));
2159
2160        // @bot renders the agent, so distinct agents are distinct chats; device
2161        // is still ignored.
2162        let bot_a = Jid {
2163            user: "13136555001".into(),
2164            server: Server::Bot,
2165            agent: 1,
2166            device: 0,
2167            integrator: 0,
2168        };
2169        let bot_b = Jid {
2170            agent: 2,
2171            ..bot_a.clone()
2172        };
2173        assert_ne!(bot_a.to_string(), bot_b.to_string());
2174        assert!(!bot_a.is_same_chat_as(&bot_b));
2175        assert!(bot_a.is_same_chat_as(&bot_a.with_device(7)));
2176    }
2177
2178    /// Tests that document the filtering behavior for group messages.
2179    ///
2180    /// # Why this matters:
2181    /// When sending a group message, we distribute Sender Key Distribution Messages
2182    /// (SKDM) to all participant devices. However, HOSTED devices:
2183    /// 1. Don't use Signal protocol, so they can't process SKDM
2184    /// 2. WhatsApp Web explicitly excludes them from group fanout
2185    /// 3. Including them would cause unnecessary prekey fetch failures
2186    ///
2187    /// This test documents the expected behavior when filtering device lists.
2188    #[test]
2189    fn test_hosted_device_filtering_for_groups() {
2190        // Simulate a group with mixed device types
2191        let devices: Vec<Jid> = vec![
2192            // Regular devices that SHOULD receive SKDM
2193            "5511999887766:0@s.whatsapp.net"
2194                .parse()
2195                .expect("test JID should be valid"), // Phone
2196            "5511999887766:33@s.whatsapp.net"
2197                .parse()
2198                .expect("test JID should be valid"), // WhatsApp Web
2199            "5521988776655:0@s.whatsapp.net"
2200                .parse()
2201                .expect("test JID should be valid"), // Another user's phone
2202            "100000012345678:0@lid"
2203                .parse()
2204                .expect("test JID should be valid"), // LID device
2205            "100000012345678:33@lid"
2206                .parse()
2207                .expect("test JID should be valid"), // LID companion
2208            // HOSTED devices that should be EXCLUDED from group SKDM
2209            "5531977665544:99@s.whatsapp.net"
2210                .parse()
2211                .expect("test JID should be valid"), // Cloud API business
2212            "100000087654321:99@lid"
2213                .parse()
2214                .expect("test JID should be valid"), // Cloud API on LID
2215            "5541966554433:99@hosted"
2216                .parse()
2217                .expect("test JID should be valid"), // Explicit hosted
2218        ];
2219
2220        // Filter out hosted devices (this is what prepare_group_stanza does)
2221        let filtered: Vec<&Jid> = devices.iter().filter(|jid| !jid.is_hosted()).collect();
2222
2223        // Verify correct filtering
2224        assert_eq!(
2225            filtered.len(),
2226            5,
2227            "Should have 5 non-hosted devices after filtering"
2228        );
2229
2230        // All filtered devices should NOT be hosted
2231        for jid in &filtered {
2232            assert!(
2233                !jid.is_hosted(),
2234                "Filtered list should not contain hosted devices: {}",
2235                jid
2236            );
2237        }
2238
2239        // Count how many hosted devices were filtered out
2240        let hosted_count = devices.iter().filter(|jid| jid.is_hosted()).count();
2241        assert_eq!(hosted_count, 3, "Should have filtered out 3 hosted devices");
2242    }
2243
2244    #[test]
2245    fn test_jid_pn_factory() {
2246        let jid = Jid::pn("1234567890");
2247        assert_eq!(jid.user, "1234567890");
2248        assert_eq!(jid.server, DEFAULT_USER_SERVER);
2249        assert_eq!(jid.device, 0);
2250        assert!(jid.is_pn());
2251    }
2252
2253    #[test]
2254    fn test_jid_lid_factory() {
2255        let jid = Jid::lid("100000012345678");
2256        assert_eq!(jid.user, "100000012345678");
2257        assert_eq!(jid.server, HIDDEN_USER_SERVER);
2258        assert_eq!(jid.device, 0);
2259        assert!(jid.is_lid());
2260    }
2261
2262    #[test]
2263    fn test_jid_group_factory() {
2264        let jid = Jid::group("123456789-1234567890");
2265        assert_eq!(jid.user, "123456789-1234567890");
2266        assert_eq!(jid.server, GROUP_SERVER);
2267        assert!(jid.is_group());
2268    }
2269
2270    #[test]
2271    fn test_jid_pn_device_factory() {
2272        let jid = Jid::pn_device("1234567890", 5);
2273        assert_eq!(jid.user, "1234567890");
2274        assert_eq!(jid.server, DEFAULT_USER_SERVER);
2275        assert_eq!(jid.device, 5);
2276        assert!(jid.is_pn());
2277        assert!(jid.is_ad());
2278    }
2279
2280    #[test]
2281    fn test_jid_lid_device_factory() {
2282        let jid = Jid::lid_device("100000012345678", 33);
2283        assert_eq!(jid.user, "100000012345678");
2284        assert_eq!(jid.server, HIDDEN_USER_SERVER);
2285        assert_eq!(jid.device, 33);
2286        assert!(jid.is_lid());
2287        assert!(jid.is_ad());
2288    }
2289
2290    #[test]
2291    fn with_device_hosting_preserves_addressing_family() {
2292        let hosted_pn = Jid::pn("1234567890").with_device_hosting(7, true);
2293        assert_eq!(hosted_pn.server, Server::Hosted);
2294        assert_eq!(hosted_pn.device, 7);
2295
2296        let hosted_lid = Jid::lid("100000012345678").with_device_hosting(8, true);
2297        assert_eq!(hosted_lid.server, Server::HostedLid);
2298        assert_eq!(hosted_lid.device, 8);
2299
2300        let regular = Jid::pn("1234567890").with_device_hosting(9, false);
2301        assert_eq!(regular.server, Server::Pn);
2302
2303        let regular_from_hosted =
2304            Jid::new("1234567890", Server::Hosted).with_device_hosting(9, false);
2305        assert_eq!(regular_from_hosted.server, Server::Pn);
2306
2307        let group = Jid::group("123-456").with_device_hosting(10, true);
2308        assert_eq!(group.server, Server::Group);
2309    }
2310
2311    #[test]
2312    fn test_status_broadcast_jid() {
2313        let jid = Jid::status_broadcast();
2314        assert_eq!(jid.user, STATUS_BROADCAST_USER);
2315        assert_eq!(jid.server, BROADCAST_SERVER);
2316        assert_eq!(jid.device, 0);
2317        assert!(jid.is_status_broadcast());
2318        assert!(!jid.is_group());
2319        assert!(!jid.is_broadcast_list());
2320        assert_eq!(jid.to_string(), "status@broadcast");
2321
2322        // Parsing round-trip
2323        let parsed: Jid = "status@broadcast".parse().expect("should parse");
2324        assert!(parsed.is_status_broadcast());
2325        assert_eq!(parsed.user, "status");
2326        assert_eq!(parsed.server, "broadcast");
2327
2328        // Regular broadcast list should NOT be status broadcast
2329        let broadcast_list = Jid::new("12345", Server::Broadcast);
2330        assert!(broadcast_list.is_broadcast_list());
2331        assert!(!broadcast_list.is_status_broadcast());
2332
2333        // A group is not a status broadcast (the send path gates addressing_mode on this).
2334        let group: Jid = "120363012345678901@g.us".parse().expect("should parse");
2335        assert!(group.is_group());
2336        assert!(!group.is_status_broadcast());
2337    }
2338
2339    #[test]
2340    fn test_jid_to_non_ad_preserves_user_server() {
2341        // Verify to_non_ad strips device but keeps user/server
2342        let device_jid = Jid::pn_device("1234567890", 33);
2343        let non_ad = device_jid.to_non_ad();
2344        assert_eq!(non_ad.user, "1234567890");
2345        assert_eq!(non_ad.server, DEFAULT_USER_SERVER);
2346        assert_eq!(non_ad.device, 0);
2347        assert!(!non_ad.is_ad());
2348
2349        // LID variant
2350        let lid_device = Jid::lid_device("100000012345678", 25);
2351        let lid_non_ad = lid_device.to_non_ad();
2352        assert_eq!(lid_non_ad.user, "100000012345678");
2353        assert_eq!(lid_non_ad.server, HIDDEN_USER_SERVER);
2354        assert_eq!(lid_non_ad.device, 0);
2355
2356        // status@broadcast stays the same
2357        let status = Jid::status_broadcast();
2358        let status_non_ad = status.to_non_ad();
2359        assert_eq!(status_non_ad.to_string(), "status@broadcast");
2360    }
2361
2362    #[test]
2363    fn test_to_non_ad_string_matches_to_non_ad_to_string() {
2364        // to_non_ad_string() must be byte-identical to to_non_ad().to_string()
2365        // across PN/LID/bot/group/status, with and without device + agent.
2366        for s in [
2367            "1234567890:33@s.whatsapp.net",
2368            "1234567890@s.whatsapp.net",
2369            "100000012345678:25@lid",
2370            "100000012345678@lid",
2371            "867051314767696:0@bot",
2372            "867051314767696@bot",
2373            "120363021033254949@g.us",
2374            "status@broadcast",
2375            "12-34@g.us",
2376        ] {
2377            let jid: Jid = s.parse().expect("parse");
2378            assert_eq!(
2379                jid.to_non_ad_string(),
2380                jid.to_non_ad().to_string(),
2381                "mismatch for {s}"
2382            );
2383        }
2384    }
2385
2386    /// The stack-buffered `Arc<str>` form must render exactly what the `String`
2387    /// form does, including for inputs that overflow the stack buffer and fall
2388    /// back to the heap, and for multibyte user parts (the buffer is bounded in
2389    /// bytes, and a split fragment would be invalid UTF-8).
2390    #[test]
2391    fn to_non_ad_arc_str_matches_to_non_ad_string() {
2392        let long_user = "9".repeat(80);
2393        let multibyte_user = "ẞünïcodé-ñ".repeat(3);
2394        let owned = [
2395            format!("{long_user}:12@s.whatsapp.net"),
2396            format!("{multibyte_user}@g.us"),
2397            format!("{multibyte_user}@s.whatsapp.net"),
2398        ];
2399        let cases = [
2400            "1234567890:33@s.whatsapp.net",
2401            "1234567890@s.whatsapp.net",
2402            "100000012345678:25@lid",
2403            "867051314767696:0@bot",
2404            // Nonzero agent on a bot JID: both forms drop the agent (matching
2405            // whatsmeow's ToNonAD), while `is_same_chat_as` still treats it as
2406            // identity-significant. Pinning it here keeps that asymmetry
2407            // deliberate rather than something a later edit can erase quietly.
2408            "867051314767696.5:10@bot",
2409            "120363021033254949@g.us",
2410            "status@broadcast",
2411        ]
2412        .into_iter()
2413        .chain(owned.iter().map(String::as_str));
2414
2415        for s in cases {
2416            let jid: Jid = s.parse().unwrap_or_else(|e| panic!("parse {s}: {e}"));
2417            assert_eq!(
2418                &*jid.to_non_ad_arc_str(),
2419                jid.to_non_ad_string().as_str(),
2420                "mismatch for {s}"
2421            );
2422        }
2423
2424        // A default (empty-user) JID has no wire form to render but must still
2425        // agree with the String path rather than panic in the stack writer.
2426        let empty = Jid::default();
2427        assert_eq!(
2428            &*empty.to_non_ad_arc_str(),
2429            empty.to_non_ad_string().as_str()
2430        );
2431    }
2432
2433    #[test]
2434    fn test_into_non_ad_matches_to_non_ad() {
2435        // into_non_ad (consuming) must produce a JID identical to to_non_ad (cloning).
2436        for s in [
2437            "1234567890.2:33@s.whatsapp.net",
2438            "1234567890@s.whatsapp.net",
2439            "100000012345678:25@lid",
2440            "user.5:10@bot",
2441            "447911123456.3@interop",
2442            "120363021033254949@g.us",
2443            "status@broadcast",
2444        ] {
2445            let jid: Jid = s.parse().expect("parse");
2446            assert_eq!(
2447                jid.clone().into_non_ad(),
2448                jid.to_non_ad(),
2449                "mismatch for {s}"
2450            );
2451        }
2452    }
2453
2454    #[test]
2455    fn test_jid_factories_with_string_types() {
2456        // Test with &str
2457        let jid1 = Jid::pn("123");
2458        assert_eq!(jid1.user, "123");
2459
2460        // Test with String
2461        let jid2 = Jid::lid(String::from("456"));
2462        assert_eq!(jid2.user, "456");
2463
2464        // Test with owned String
2465        let user = "789".to_string();
2466        let jid3 = Jid::group(user);
2467        assert_eq!(jid3.user, "789");
2468    }
2469
2470    /// Verify that all JID formatting paths produce identical output:
2471    /// `Jid::Display`, `JidRef::Display`, `push_jid_to_string`, `push_jid_to_compact`,
2472    /// `Jid::push_to`, and the generic writer. Exercises the agent-elision rules
2473    /// across server variants.
2474    #[test]
2475    fn test_jid_format_parity() {
2476        struct Case {
2477            user: &'static str,
2478            server: Server,
2479            agent: u8,
2480            device: u16,
2481        }
2482
2483        let cases = [
2484            // Empty user (server-only JID)
2485            Case {
2486                user: "",
2487                server: Server::Pn,
2488                agent: 0,
2489                device: 0,
2490            },
2491            // Basic phone, no agent/device
2492            Case {
2493                user: "5511999887766",
2494                server: Server::Pn,
2495                agent: 0,
2496                device: 0,
2497            },
2498            // Phone with device
2499            Case {
2500                user: "5511999887766",
2501                server: Server::Pn,
2502                agent: 0,
2503                device: 2,
2504            },
2505            // Phone with agent (suppressed for Pn)
2506            Case {
2507                user: "5511999887766",
2508                server: Server::Pn,
2509                agent: 3,
2510                device: 15,
2511            },
2512            // LID with agent (suppressed for Lid)
2513            Case {
2514                user: "12345.6789",
2515                server: Server::Lid,
2516                agent: 1,
2517                device: 25,
2518            },
2519            // Hosted with agent (suppressed)
2520            Case {
2521                user: "100000012345678",
2522                server: Server::Hosted,
2523                agent: 2,
2524                device: 99,
2525            },
2526            // HostedLid with agent (suppressed)
2527            Case {
2528                user: "100000012345678",
2529                server: Server::HostedLid,
2530                agent: 1,
2531                device: 99,
2532            },
2533            // Group (no agent, no device)
2534            Case {
2535                user: "120363012345678901",
2536                server: Server::Group,
2537                agent: 0,
2538                device: 0,
2539            },
2540            // Bot with agent (shown)
2541            Case {
2542                user: "user",
2543                server: Server::Bot,
2544                agent: 5,
2545                device: 10,
2546            },
2547            // Interop with agent (shown)
2548            Case {
2549                user: "447911123456",
2550                server: Server::Interop,
2551                agent: 3,
2552                device: 0,
2553            },
2554            // Messenger with device, no agent
2555            Case {
2556                user: "messenger_user",
2557                server: Server::Messenger,
2558                agent: 0,
2559                device: 50,
2560            },
2561            // Broadcast
2562            Case {
2563                user: "status",
2564                server: Server::Broadcast,
2565                agent: 0,
2566                device: 0,
2567            },
2568            // Newsletter
2569            Case {
2570                user: "newsletter_id",
2571                server: Server::Newsletter,
2572                agent: 0,
2573                device: 0,
2574            },
2575            // Max values
2576            Case {
2577                user: "447911123456789",
2578                server: Server::Pn,
2579                agent: 255,
2580                device: 65535,
2581            },
2582            // Short user
2583            Case {
2584                user: "1",
2585                server: Server::Legacy,
2586                agent: 0,
2587                device: 1,
2588            },
2589        ];
2590
2591        for (i, c) in cases.iter().enumerate() {
2592            let jid = Jid {
2593                user: c.user.into(),
2594                server: c.server,
2595                agent: c.agent,
2596                device: c.device,
2597                integrator: 0,
2598            };
2599
2600            // Reference: Display impl (via write_jid! fallible)
2601            let display = jid.to_string();
2602
2603            // JidRef Display
2604            let jid_ref = JidRef {
2605                user: NodeStr::Borrowed(c.user),
2606                server: c.server,
2607                agent: c.agent,
2608                device: c.device,
2609                integrator: 0,
2610            };
2611            let ref_display = jid_ref.to_string();
2612
2613            // push_jid_to_string
2614            let mut string_buf = String::new();
2615            push_jid_to_string(c.user, c.server, c.agent, c.device, &mut string_buf);
2616
2617            // push_jid_to_compact
2618            let mut compact_buf = CompactString::default();
2619            push_jid_to_compact(c.user, c.server, c.agent, c.device, &mut compact_buf);
2620
2621            // Jid::push_to
2622            let mut push_buf = String::new();
2623            jid.push_to(&mut push_buf);
2624
2625            // Generic fmt::Write path.
2626            let mut write_buf = String::new();
2627            jid.write_display_to(&mut write_buf).unwrap();
2628
2629            assert_eq!(display, ref_display, "case {i}: Display vs JidRef::Display");
2630            assert_eq!(
2631                display, string_buf,
2632                "case {i}: Display vs push_jid_to_string"
2633            );
2634            assert_eq!(
2635                display,
2636                compact_buf.as_str(),
2637                "case {i}: Display vs push_jid_to_compact"
2638            );
2639            assert_eq!(display, push_buf, "case {i}: Display vs Jid::push_to");
2640            assert_eq!(display, write_buf, "case {i}: Display vs generic writer");
2641        }
2642    }
2643}