Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions src/pair_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ use wacore::pair_code::{PairCodeState, PairCodeUtils, resolve_companion_platform
use wacore_binary::Jid;
use wacore_binary::{NodeContent, NodeContentRef, NodeRef};

pub use wacore::companion_reg::CompanionWebClientType;
pub use wacore::companion_reg::{CompanionOs, CompanionWebClientType};
pub use wacore::pair_code::{PairCodeError, PairCodeOptions};

/// Errors raised by the high-level pair-code flow.
Expand All @@ -69,6 +69,16 @@ pub enum PairError {
#[error(transparent)]
PairCode(#[from] PairCodeError),

/// The pair-code IQ was rejected by the server.
///
/// Note the server returns `bad-request` (400) **both** for genuinely invalid
/// content and for **rate-limiting** — it throttles pair-code requests per
/// phone number and reuses the same error. So a 400 here is not necessarily a
/// permanent/invalid-input failure: back off and retry rather than treating
/// every 400 as fatal. (The lib canonicalizes the `companion_platform_display`
/// OS, so a display-shaped rejection is already ruled out — see
/// [`wacore::companion_reg::CompanionOs`].) Any server `backoff` hint is
/// preserved on the wrapped [`IqError`].
#[error("pair-code IQ request failed")]
RequestFailed(#[from] IqError),
}
Expand All @@ -89,7 +99,9 @@ impl Client {
/// # Returns
///
/// * `Ok(String)` - The 8-character pairing code to display
/// * `Err` - If validation fails, not connected, or server error
/// * `Err` - If validation fails, not connected, or server error. A
/// [`PairError::RequestFailed`] carrying `bad-request` may be **rate-limiting**
/// (throttled per phone number), not invalid input — back off and retry.
///
/// # Example
///
Expand Down Expand Up @@ -187,6 +199,32 @@ impl Client {
resolve_companion_platform(&options, &device_snapshot.device_props);
let platform_id_str = platform_id.to_string();

// The pair-code server rejects a non-OS `companion_platform_display` with
// bad-request (QR pairing never sends this field, so it tolerates arbitrary
// branding). If `DeviceProps::os` was a branding string it is coerced to
// "Linux"; warn so a consumer sees why their branding didn't ride through.
// Skipped when `display_os` overrides the OS (then props.os isn't coerced).
// Gated to fire once per process: a rate-limited caller may retry
// pair_with_code repeatedly (see PairError::RequestFailed) with the same
// unchanged os, and an identical warning per retry is just noise.
static OS_COERCE_WARNED: std::sync::Once = std::sync::Once::new();
let os_overridden = options
.display_os
.as_deref()
.is_some_and(|o| !o.trim().is_empty());
if !os_overridden
&& let Some(os) = device_snapshot.device_props.os.as_deref()
&& !os.trim().is_empty()
&& CompanionOs::classify(os).is_none()
{
OS_COERCE_WARNED.call_once(|| {
warn!(
target: "Client/PairCode",
"companion_platform_display OS {os:?} is not a recognized OS; coerced to \"Linux\" for pair-code (the server would reject a non-OS display with bad-request)"
);
});
}

let req_id = self.generate_request_id();
let iq_content = PairCodeUtils::build_companion_hello_iq(
&phone_number,
Expand Down
196 changes: 186 additions & 10 deletions wacore/src/companion_reg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,98 @@ pub fn companion_web_client_type_for_props(props: &wa::DeviceProps) -> Companion
.unwrap_or(CompanionWebClientType::OtherWebClient)
}

/// `companion_platform_display` body. Server validates only length
/// 1..=100; there is no browser whitelist. Web variants emit
/// `<Browser> (<OS>)`, mirroring `WAWebAltDeviceLinkingIq`; Android
/// variants emit `Android (<OS>)`, matching the official Android client.
/// Empty OS substitutes `Linux`.
pub fn companion_platform_display(ct: CompanionWebClientType, os: &str) -> String {
/// Canonical OS label for the pair-code `companion_platform_display`.
///
/// WA Web only ever emits a real OS name from its UA parser
/// (`WAWebBrowserInfo().os` → `ua-parser-js` `getOS().name`). Unlike QR pairing —
/// which never sends this field and therefore tolerates an arbitrary branding
/// string in `DeviceProps::os` — the pair-code `companion_hello` server
/// **rejects a non-OS display with `bad-request`**. So the OS component must come
/// from a closed set of real OS names, never the free-form branding string.
///
/// The set is deliberately small and conservative: the server is lenient toward
/// real OS names today (it also accepts `Ubuntu`, `Fedora`, `Mac`, …), but
/// collapsing everything to a guaranteed-accepted canonical value is robust
/// against that leniency changing. Anything unrecognized coerces to
/// [`Self::Linux`] (see [`Self::from_hint`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompanionOs {
Windows,
MacOs,
Linux,
Android,
Ios,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

impl CompanionOs {
/// The exact wire string, matching `ua-parser-js` `getOS().name` (what WA Web
/// sends) — note the `"Mac OS"` and `"iOS"` spellings.
pub const fn wire_str(self) -> &'static str {
match self {
Self::Windows => "Windows",
Self::MacOs => "Mac OS",
Self::Linux => "Linux",
Self::Android => "Android",
Self::Ios => "iOS",
}
}

/// Classify a free-form OS hint into a known canonical OS, or `None` when it
/// is not recognizably an OS (empty, or a branding label such as `"Veloz"`).
/// Case-insensitive. iPad folds to [`Self::Ios`]: older UA parsers report iPad
/// as `"iOS"`, so `"iPadOS"` is not a confirmed server-accepted value. Chrome
/// OS and Linux distros fold to [`Self::Linux`].
pub fn classify(os: &str) -> Option<Self> {
let os = os.trim().to_ascii_lowercase();
if os.is_empty() {
None
} else if os.contains("windows") {
Some(Self::Windows)
} else if os.contains("mac") || os.contains("osx") || os.contains("darwin") {
Some(Self::MacOs)
} else if os.contains("ipad")
|| os.contains("iphone")
// Whole-word "ios" only, so "KaiOS" etc. don't false-match the substring.
|| os.split(|c: char| !c.is_ascii_alphanumeric())
.any(|tok| tok == "ios")
{
Some(Self::Ios)
} else if os.contains("android") {
Some(Self::Android)
} else if os.contains("linux")
|| os.contains("ubuntu")
|| os.contains("debian")
|| os.contains("fedora")
|| os.contains("chrome os")
|| os.contains("chromeos")
|| os.contains("chromium")
// Whole-word for the short ambiguous ones so branding like "March"/
// "Search"/"across" doesn't false-match (bare "Arch"/"CrOS" still do;
// "Arch Linux"/"archlinux" are caught by the "linux" substring above).
|| os.split(|c: char| !c.is_ascii_alphanumeric())
.any(|tok| tok == "arch" || tok == "cros")
{
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Some(Self::Linux)
} else {
None
}
}

/// Coerce a free-form `DeviceProps::os` into a server-safe canonical OS,
/// defaulting an unrecognized/branding value to [`Self::Linux`].
pub fn from_hint(os: &str) -> Self {
Self::classify(os).unwrap_or(Self::Linux)
}
}

/// Formats `<Browser> (<os>)` (Android client types → `Android (<os>)`),
/// mirroring `WAWebAltDeviceLinkingIq`, with `os` used **verbatim** — no
/// canonicalization. This is the escape hatch for an advanced caller that
/// overrides the display OS (e.g. to keep a real distro name like `"Ubuntu"`
/// the server accepts); the server validates the OS, so a non-OS string here is
/// rejected with `bad-request`. Most callers want [`companion_platform_display`].
pub fn companion_platform_display_raw(ct: CompanionWebClientType, os: &str) -> String {
use CompanionWebClientType as C;
let os = os.trim();
let os = if os.is_empty() { "Linux" } else { os };
match ct {
C::AndroidPhone | C::AndroidTablet | C::AndroidAmbiguous => {
format!("Android ({os})")
Expand All @@ -139,6 +222,15 @@ pub fn companion_platform_display(ct: CompanionWebClientType, os: &str) -> Strin
}
}

/// `companion_platform_display` body: `<Browser> (<OS>)` (Android client types
/// emit `Android (<OS>)`), mirroring `WAWebAltDeviceLinkingIq`. The OS is
/// canonicalized through [`CompanionOs`] because the pair-code server rejects a
/// non-OS string here with `bad-request`; an unrecognized/branding `os` (or an
/// empty one) becomes `Linux`.
pub fn companion_platform_display(ct: CompanionWebClientType, os: &str) -> String {
companion_platform_display_raw(ct, CompanionOs::from_hint(os).wire_str())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -324,9 +416,10 @@ mod tests {
companion_platform_display(CompanionWebClientType::Chrome, "Linux"),
"Chrome (Linux)"
);
// "Mac" canonicalizes to the ua-parser name WA Web actually sends.
assert_eq!(
companion_platform_display(CompanionWebClientType::Firefox, "Mac"),
"Firefox (Mac)"
"Firefox (Mac OS)"
);
}

Expand All @@ -350,7 +443,90 @@ mod tests {
);
assert_eq!(
companion_platform_display(CompanionWebClientType::Electron, "Mac"),
"Chrome (Mac)"
"Chrome (Mac OS)"
);
}

#[test]
fn companion_os_wire_str_matches_ua_parser_names() {
assert_eq!(CompanionOs::Windows.wire_str(), "Windows");
assert_eq!(CompanionOs::MacOs.wire_str(), "Mac OS");
assert_eq!(CompanionOs::Linux.wire_str(), "Linux");
assert_eq!(CompanionOs::Android.wire_str(), "Android");
assert_eq!(CompanionOs::Ios.wire_str(), "iOS");
}

#[test]
fn companion_os_classify_known_aliases() {
use CompanionOs as O;
for (hint, want) in [
("Windows", O::Windows),
("windows 11", O::Windows),
("Mac", O::MacOs),
("macOS", O::MacOs),
("Mac OS", O::MacOs),
("Mac OS X", O::MacOs),
("darwin", O::MacOs),
("Linux", O::Linux),
("Ubuntu", O::Linux),
("Fedora", O::Linux),
("Arch Linux", O::Linux),
("Arch", O::Linux),
("archlinux", O::Linux),
("CrOS", O::Linux),
("Chrome OS", O::Linux),
("ChromeOS", O::Linux),
("Chromium OS", O::Linux),
("Android", O::Android),
("android 14", O::Android),
("iOS", O::Ios),
("iOS 17", O::Ios),
("iPhone", O::Ios),
("iPad", O::Ios),
("iPadOS", O::Ios),
] {
assert_eq!(CompanionOs::classify(hint), Some(want), "{hint:?}");
}
}

#[test]
fn companion_os_branding_and_empty_are_unclassified_and_default_linux() {
// "KaiOS" must NOT substring-match "ios", and branding containing "arch"/
// "cros" as a fragment ("March", "Search", "across") must NOT match Linux
// -> all unrecognized -> Linux via fallback (not via classify).
for hint in [
"Veloz",
"Foobar123",
"KaiOS",
"March",
"Search",
"across",
"",
" ",
] {
assert_eq!(CompanionOs::classify(hint), None, "{hint:?}");
assert_eq!(CompanionOs::from_hint(hint), CompanionOs::Linux, "{hint:?}");
}
}

/// The regression this whole enum exists for: a branding `os` must never ride
/// through to the wire (the pair-code server rejects it with `bad-request`).
#[test]
fn platform_display_coerces_branding_os_to_linux() {
assert_eq!(
companion_platform_display(CompanionWebClientType::Chrome, "Veloz"),
"Chrome (Linux)"
);
}

#[test]
fn platform_display_canonicalizes_mac_aliases() {
for os in ["Mac", "macOS", "Mac OS", "Mac OS X", "darwin"] {
assert_eq!(
companion_platform_display(CompanionWebClientType::Chrome, os),
"Chrome (Mac OS)",
"{os:?}"
);
}
}
}
Loading
Loading