Dadoum commited on 2026-10-27 03:57:30
Showing 16 changed files, with 1350 additions and 64 deletions.
| ... | ... |
@@ -0,0 +1,72 @@ |
| 1 |
+# Code style |
|
| 2 |
+ |
|
| 3 |
+The style of the hand-written `adi`, `grandslam` and `xcode` crates. It applies to new code in |
|
| 4 |
+apple-crates and Antarctic. When this file and those crates disagree, the crates win. |
|
| 5 |
+ |
|
| 6 |
+## Naming |
|
| 7 |
+ |
|
| 8 |
+- Acronyms stay upper-case in types, traits and variants: `ADIError`, `HTTPSession`, `URLBag`, |
|
| 9 |
+ `TeamID`. Apple names with a lower-case prefix only capitalise the first letter: `IOS`, `TvOS`, |
|
| 10 |
+ `ICloud`. In snake_case, each acronym is one lower-case word: `ds_id`, `set_idms_routing`. |
|
| 11 |
+- Locals and fields are spelled out in full. Keep Apple's wire names (`spim`, `cpim`, `ptm`, `tk`, |
|
| 12 |
+ `mid`, `otp`). Raw bytes before encoding take a `_b` suffix. |
|
| 13 |
+- Functions start with a verb. A short entry point delegates to `<base>_with_<thing>` variants |
|
| 14 |
+ (`http_session` → `http_session_with_idms_env`). Request builders are `<layer>_request_builder`. |
|
| 15 |
+ Wire types are named after their endpoint. |
|
| 16 |
+- Lifetimes are `'lt` (and `'adi`), or `'_` when no name is needed. Never mix them with `'a` in a file. |
|
| 17 |
+ |
|
| 18 |
+## Types and API shape |
|
| 19 |
+ |
|
| 20 |
+- Plain data has `pub` fields and is built with struct literals: no builders, setters or getters. |
|
| 21 |
+- Tokens and identifiers are tuple newtypes with a private field, `From<String>` and `AsRef<str>`. |
|
| 22 |
+- Session layers wrap the layer below (`pub http_session: Inner` plus one credential, `new(inner, |
|
| 23 |
+ credential)`) and forward its request builders by hand. No `Deref`, no generic service traits. |
|
| 24 |
+- Small traits, with associated consts or types. Generics only for compile-time typing; `&dyn Trait` |
|
| 25 |
+ for runtime providers. Bounds inline, no `where` clauses. Implement `From`, never `Into`. |
|
| 26 |
+- Protocol states that aren't failures are variants of an outcome enum returned in `Ok`. |
|
| 27 |
+- Plain `pub async fn` with sequential awaits. Libraries spawn nothing, pick no runtime, and keep |
|
| 28 |
+ ADI and crypto calls synchronous. |
|
| 29 |
+- Derive only what's needed, in this order: `Debug, Clone, Copy, Default, PartialEq, Eq, Hash, |
|
| 30 |
+ Serialize, Deserialize`. Types holding credentials don't derive `Debug`. |
|
| 31 |
+- Wire structs use `#[serde(rename_all = "camelCase")]`. String-backed enums map to |
|
| 32 |
+ `&'static str` in one `match`. |
|
| 33 |
+- Native resources are linear guards: `#[must_use]` and a `Drop` that panics unless |
|
| 34 |
+ `std::thread::panicking()`. |
|
| 35 |
+ |
|
| 36 |
+## Errors |
|
| 37 |
+ |
|
| 38 |
+- `thiserror` everywhere, one enum per operation or module, living next to the code returning it. |
|
| 39 |
+- Variants are named after the layer that failed (`Network`, `Anisette`, `Parsing`, `Apple`) and |
|
| 40 |
+ carry the source error or the offending `Dictionary`. Server failures are struct variants. |
|
| 41 |
+ Mark the rest with `// vvv Internal errors vvv`. |
|
| 42 |
+- Messages are sentences: what failed, then `: {0}`, no trailing period.
|
|
| 43 |
+- `map_err` takes a function path (`map_err(AuthError::Parsing)`). A crate result alias is welcome. |
|
| 44 |
+ |
|
| 45 |
+## Control flow |
|
| 46 |
+ |
|
| 47 |
+- No `let ... else`, and few early returns. Prefer `if`/`match` as tail expressions, tuple matches |
|
| 48 |
+ and or-patterns (`None | Some(0) => Ok(())`). |
|
| 49 |
+- Shadow names through a pipeline, and annotate `let`s instead of using a turbofish. |
|
| 50 |
+- Combinators take function paths when one fits (`.and_then(Value::as_string)`); |
|
| 51 |
+ use `.map(..).transpose()?`. |
|
| 52 |
+- One fluent chain per HTTP request, ending with `.error_for_status()?` and `.bytes().await?`, then |
|
| 53 |
+ parsed into an annotated binding. Build plists with `dict!`/`array!` and send them with |
|
| 54 |
+ `plist_to_body`/`dict_to_body`. |
|
| 55 |
+- No `.unwrap()` in library code. `expect` only for invariants, with a sentence message, and a |
|
| 56 |
+ `// UNWRAP safety:` comment when it isn't obvious. |
|
| 57 |
+- Edition 2024 `unsafe` blocks wrap just the unsafe expression. Out-parameters use `MaybeUninit`. |
|
| 58 |
+ |
|
| 59 |
+## Layout and comments |
|
| 60 |
+ |
|
| 61 |
+- `rustfmt`, run per package. Long `#[error]` attributes and comments stay on one line. |
|
| 62 |
+- Inline format captures (`"{name}"`); `.to_string()`/`String::from`, not `.to_owned()`.
|
|
| 63 |
+- Match arms say `TypeName::Variant`, not `Self::Variant`. |
|
| 64 |
+- One `use` block in rustfmt order, without nested braces; items used once go by full path. |
|
| 65 |
+- Flat crates: `foo.rs` plus `foo/`, never `mod.rs`; private modules re-exported with |
|
| 66 |
+ `pub use foo::*`; files of roughly 300–500 lines. |
|
| 67 |
+- Short `///` docs on public entry points only, no `//!` essays or rustdoc templates. |
|
| 68 |
+- Inline comments are first person and informal, explaining Apple quirks and uncertainty, never |
|
| 69 |
+ restating the code. Tags: `// TODO:`, `// HACK`, `// UNWRAP safety:`. |
|
| 70 |
+- Tests: inline `#[cfg(test)] mod tests { use super::*; }` with tiny mocks. Network crates get
|
|
| 71 |
+ runnable examples reading credentials from environment variables. |
|
| 72 |
+- Libraries rarely log (`warn!` for surprises), and never log credentials or decrypted dictionaries. |
| ... | ... |
@@ -3199,6 +3199,7 @@ dependencies = [ |
| 3199 | 3199 |
"rand 0.9.4", |
| 3200 | 3200 |
"reqwest", |
| 3201 | 3201 |
"serde", |
| 3202 |
+ "serde_json", |
|
| 3202 | 3203 |
"sha2 0.11.0", |
| 3203 | 3204 |
"srp", |
| 3204 | 3205 |
"sys-locale", |
| ... | ... |
@@ -3531,6 +3532,7 @@ dependencies = [ |
| 3531 | 3532 |
"plist", |
| 3532 | 3533 |
"plist-macros", |
| 3533 | 3534 |
"reqwest", |
| 3535 |
+ "roxmltree", |
|
| 3534 | 3536 |
"serde", |
| 3535 | 3537 |
"serde_json", |
| 3536 | 3538 |
"thiserror 2.0.18", |
| ... | ... |
@@ -1,92 +1,135 @@ |
| 1 | 1 |
# apple crate collection |
| 2 | 2 |
|
| 3 | 3 |
At some point I started a sideloader rewrite in Rust in |
| 4 |
-parallel of my D rewrite, but now that there are a few |
|
| 5 |
-alternatives, I am not sure of whether I will finish it. |
|
| 4 |
+parallel of my D rewrite. |
|
| 6 | 5 |
|
| 7 |
-Anyway, for that I wrote a few shiny crates that could help |
|
| 8 |
-rewrite iTunes or anything apple related. |
|
| 6 |
+To this end, I wanted to have clean crates to interact with Apple servers. |
|
| 7 |
+After trying the other solutions, I always find the API clunky and kind of pile of hacks. |
|
| 9 | 8 |
|
| 10 |
-Here is a breakdown of every crate here: |
|
| 9 |
+So I rewrote everything taking in account only Apple implementations. |
|
| 11 | 10 |
|
| 12 |
-### adi |
|
| 11 |
+But when you start rewriting following Apple behaviours, it shapes your code in a way that |
|
| 12 |
+encourages you to reimplement more and more. I ended-up rewriting my sideloader, but along |
|
| 13 |
+with it, I started reimplementing a whole Apple daemon userland xD. It requires so many APIs. |
|
| 13 | 14 |
|
| 14 |
-Abstracts an ADI connection, allows communication with ADI code. |
|
| 15 |
+Eventually, I wrote enough code for the LLMs to be able to understand how I write code, and |
|
| 16 |
+I let those write more code, following my guidance on how I want things to be done. |
|
| 15 | 17 |
|
| 16 |
-### adid-proxy |
|
| 18 |
+The number of crates here skyrocketed in consequence. Here is a breakdown of every crate here: |
|
| 19 |
+ |
|
| 20 |
+But just before, don't hesitate to reach me out if you have any questions, in the discussion tab here or on Discord! |
|
| 21 |
+I do my best to be reactive and useful. |
|
| 22 |
+ |
|
| 23 |
+Also, if you enjoy my work, please consider giving me a small tip <3 |
|
| 24 |
+ |
|
| 25 |
+## Apple server interaction |
|
| 26 |
+ |
|
| 27 |
+### ADI implementations |
|
| 28 |
+ |
|
| 29 |
+#### adi |
|
| 30 |
+ |
|
| 31 |
+Abstracts an ADI connection, allows communication with ADI code. You can use any ADI backend, and there are wrappers |
|
| 32 |
+for every official Apple implementation in other crates, cool for your tests. |
|
| 33 |
+ |
|
| 34 |
+#### adid-proxy |
|
| 35 |
+ |
|
| 36 |
+The ADI wrapper for `adid`, Apple's implementation of ADI on iOS/tvOS/iPadOS and macOS High Sierra and later. |
|
| 37 |
+ |
|
| 38 |
+On macOS Golden Gate (27.0), this daemon now needs an entitlement to be contacted. |
|
| 17 | 39 |
|
| 18 | 40 |
Create an `adi::ADIProxy` on a Darwin system that can connect to the `adid` daemon. |
| 19 | 41 |
|
| 20 |
-### android-coreadi |
|
| 42 |
+#### android-coreadi |
|
| 21 | 43 |
|
| 22 |
-Create an `adi::ADIProxy` on any Unix system (TODO: add Windows support) from |
|
| 23 |
-any `libCoreADI.so` distribution supporting your architecture. |
|
| 44 |
+The ADI wrapper for Android `libCoreADI.so` (not other `libCoreADI.so`, because I know there are others :p, |
|
| 45 |
+but the impl inside isn't much different xD). The main advantage is that it is available on most architecture and is |
|
| 46 |
+customizable, but now Apple blocks it from using iTunes/iCloud APIs ('cause this kind of ADI is not used with iCloud).
|
|
| 24 | 47 |
|
| 25 |
-### library-coreadi |
|
| 48 |
+#### library-coreadi |
|
| 26 | 49 |
|
| 27 | 50 |
Create an `adi::ADIProxy` given a distribution of CoreADI for your system. |
| 28 | 51 |
|
| 29 | 52 |
This includes Intel Macs (using an old iTunes' binary), Windows |
| 30 | 53 |
(with iCloud, iTunes, or any other MS Store Apple application), and Android |
| 31 |
-(with Apple Music for Android's binaries, or Apple TV for Android's) [non-exhaustive list]. |
|
| 54 |
+(with Apple Music for Android's binaries, or Apple TV for Android (NOT Android TV, and no other esoteric impl)). |
|
| 32 | 55 |
|
| 33 |
-### grandslam |
|
| 56 |
+### APIs |
|
| 57 |
+ |
|
| 58 |
+#### grandslam |
|
| 34 | 59 |
|
| 35 | 60 |
A fully-featured, type-safe, well-designed Apple server API wrapper. |
| 36 | 61 |
|
| 37 | 62 |
You can log into an Apple account via GSA APIs with any ADIProxy, fetch the tokens, |
| 38 | 63 |
while handling every error correctly, including Apple's server errors. |
| 39 | 64 |
|
| 40 |
-(currently, the Grandslam part lacks the code to do 2FA without a browser) |
|
| 65 |
+You might ask: How do I handle 2FA? There's no API? |
|
| 41 | 66 |
|
| 42 |
-### apple-codesign(-quick) (unfinished yet) |
|
| 67 |
+Yeah, `grandslam` does not handle that, but in the URLBag, there is the second step URL. |
|
| 68 |
+You need to request it with an IdentityHTTPSession (along with all the subsequent requests), and translate the mustache |
|
| 69 |
+templates in it to show it right. |
|
| 43 | 70 |
|
| 44 |
-A port of Sideloader's barebone very fast signer in Rust. |
|
| 71 |
+And why would I use your crate that leaves me with that gigantic work? Because it handles everything!! Continuation |
|
| 72 |
+tokens, PET, tokens in general, ADSID, Reprovision, Sync, URL Switching, custom bundles, with good typed APIs. |
|
| 73 |
+You can really build solid software on top of it. |
|
| 45 | 74 |
|
| 46 |
-Sideloader's signer is less hacky than zsign and is way faster than apple-codesign-rs |
|
| 47 |
-(probably partly because it takes a few shortcuts too in addition of being multithreaded |
|
| 48 |
-and designed with performance in mind). |
|
| 75 |
+#### aps |
|
| 49 | 76 |
|
| 50 |
-### elf-loader |
|
| 77 |
+Connect to Apple Push Services and APNS as a Mac. It used to contain a Windows implementation too, but it seems that |
|
| 78 |
+Apple does no longer accept Windows client so everyone know need to look like a Mac! |
|
| 51 | 79 |
|
| 52 |
-A better minimal cross-platform elf-loader crate. |
|
| 80 |
+The usage is narrow but still cool to have it for what it's worth. |
|
| 53 | 81 |
|
| 54 |
-I will add feature as needed for the other packages |
|
| 82 |
+#### icloud (unfinished) |
|
| 55 | 83 |
|
| 56 |
-### fairplay (unfinished yet) |
|
| 84 |
+Retrieve iCloud Contacts. For now, it's only that, probably more in the future. |
|
| 57 | 85 |
|
| 58 |
-Abstracting CoreFP for use on any system, a bit like `adi`, will be used in |
|
| 59 |
-`itunes` part. |
|
| 86 |
+#### itunes (basically a stub) |
|
| 60 | 87 |
|
| 61 |
-### plist-macros |
|
| 88 |
+Implement iTunes APIs here, store APIs… |
|
| 62 | 89 |
|
| 63 |
-Allows declarative declaration of plist. |
|
| 90 |
+#### macos-catalog |
|
| 64 | 91 |
|
| 65 |
-### purple-builder (unfinished yet) |
|
| 92 |
+Get a list of available macOS updates. Useful to know all current Mac devices along with their identifiers. |
|
| 66 | 93 |
|
| 67 |
-A reimplementation of Xcode maybe some day? (but Rust GUI situation is not great, so I postponed that D:) |
|
| 94 |
+#### sharing |
|
| 68 | 95 |
|
| 69 |
-### super-sideloader |
|
| 96 |
+Fetch a certificate to be able to identify to other Apple devices on AWDL (AirDrop). |
|
| 70 | 97 |
|
| 71 |
-That Sideloader rewrite in Rust I postponed. Work-in-progress. |
|
| 98 |
+If you are authenticated, this gets linked to your Apple Account so your other devices trust it. |
|
| 72 | 99 |
|
| 73 |
-The icons have been taken from the Lucide icon set. See the license in [LICENSE-lucide.md]. |
|
| 100 |
+#### xcode |
|
| 74 | 101 |
|
| 75 |
-Should mostly work. The frontend is heavily AI generated though, so there are probably a lot of rough edges. |
|
| 102 |
+Interact with Xcode legacy and more recent API, and get the Xcode download catalogs, both for personal and |
|
| 103 |
+paid developer accounts. |
|
| 76 | 104 |
|
| 77 |
-I need to change that bundle-id too, before packaging it for macOS and Linux. And I should try to make it work on Windows. |
|
| 105 |
+## Other good stuff |
|
| 78 | 106 |
|
| 79 |
-### there should be a separate xcode api crate |
|
| 107 |
+#### airdrop |
|
| 80 | 108 |
|
| 81 |
-but I did not start to work on it :p |
|
| 109 |
+Basically a rip-off of part of omdrop to re-use my `sharing` crate. |
|
| 82 | 110 |
|
| 83 |
-## Okay but why have you rewritten everything? |
|
| 111 |
+#### apple-codesign-quick |
|
| 84 | 112 |
|
| 85 |
-I wasn't satisfied with what was already available :p. |
|
| 113 |
+A port of Sideloader's barebone very fast signer in Rust. |
|
| 86 | 114 |
|
| 87 |
-## Addendum |
|
| 115 |
+Sideloader's signer is less hacky than zsign and is way faster than apple-codesign-rs |
|
| 116 |
+(probably partly because it takes a few shortcuts too in addition of being multithreaded |
|
| 117 |
+and designed with performance in mind). |
|
| 88 | 118 |
|
| 89 |
-Don't hesitate to reach me out if you have any questions, in the discussion tab here or on Discord! |
|
| 90 |
-I do my best to be reactive and useful. |
|
| 119 |
+#### elf-loader |
|
| 91 | 120 |
|
| 92 |
-Also, if you enjoy my work, please consider giving me a small tip <3 |
|
| 121 |
+A better minimal cross-platform elf-loader crate. |
|
| 122 |
+ |
|
| 123 |
+I will add feature as needed for the other packages |
|
| 124 |
+ |
|
| 125 |
+#### fairplay |
|
| 126 |
+ |
|
| 127 |
+You can decrypt Apple executables personalized for a specific device if you have their keys. |
|
| 128 |
+ |
|
| 129 |
+A simplification of part of the code of [fairplay-decrypt](https://github.com/EthanArbuckle/fairplay-decrypt)/ |
|
| 130 |
+ |
|
| 131 |
+#### plist-macros |
|
| 132 |
+ |
|
| 133 |
+Allows declarative syntax for Apple property lists (plist). |
|
| 134 |
+ |
|
| 135 |
+Should be included in the official plist crate if you ask me. |
| ... | ... |
@@ -81,7 +81,7 @@ pub async fn http_session_with_idms_env( |
| 81 | 81 |
client_bundle_info: BundleInformation<'_>, |
| 82 | 82 |
idms_env: usize, |
| 83 | 83 |
) -> Result<HTTPSession<'_>, HTTPSessionCreationError> {
|
| 84 |
- http_session_with_custom_bag_url(device, client_bundle_info, bag_url(idms_env)).await |
|
| 84 |
+ http_session_with_bag_url(device, client_bundle_info, bag_url(idms_env)).await |
|
| 85 | 85 |
} |
| 86 | 86 |
|
| 87 | 87 |
pub(crate) fn parse_url_bag_response(url_bag_response: &[u8]) -> Result<URLBag, URLBagError> {
|
| ... | ... |
@@ -94,10 +94,10 @@ pub(crate) fn parse_url_bag_response(url_bag_response: &[u8]) -> Result<URLBag, |
| 94 | 94 |
.ok_or(URLBagError::InvalidUrlBag) |
| 95 | 95 |
} |
| 96 | 96 |
|
| 97 |
-pub async fn http_session_with_custom_bag_url<'lt>( |
|
| 97 |
+pub async fn http_session_with_bag_url<'lt>( |
|
| 98 | 98 |
device: Device, |
| 99 | 99 |
client_bundle_info: BundleInformation<'lt>, |
| 100 |
- url: &str, |
|
| 100 |
+ bag_url: &str, |
|
| 101 | 101 |
) -> Result<HTTPSession<'lt>, HTTPSessionCreationError> {
|
| 102 | 102 |
let http_session = BasicHTTPSession::new( |
| 103 | 103 |
device, |
| ... | ... |
@@ -106,7 +106,7 @@ pub async fn http_session_with_custom_bag_url<'lt>( |
| 106 | 106 |
)?; |
| 107 | 107 |
|
| 108 | 108 |
let url_bag_response = http_session |
| 109 |
- .request_builder(Method::GET, url) |
|
| 109 |
+ .request_builder(Method::GET, bag_url) |
|
| 110 | 110 |
.send() |
| 111 | 111 |
.await? |
| 112 | 112 |
.error_for_status()? |
| ... | ... |
@@ -232,10 +232,29 @@ pub struct ContinuationToken(String); |
| 232 | 232 |
|
| 233 | 233 |
impl ContinuationToken {
|
| 234 | 234 |
/// Read the continuation token independently of other response headers. |
| 235 |
+ /// |
|
| 236 |
+ /// Apple's `akd` hands `X-Apple-I-CK` to `idmsTokenWithBase64String:`: the |
|
| 237 |
+ /// header is `base64(token)` or `base64(token:number)`, and the token is the |
|
| 238 |
+ /// decoded text. A continuation token from the first login reply (`ck`) is |
|
| 239 |
+ /// already plain text and needs no decoding. |
|
| 235 | 240 |
pub fn from_response_headers( |
| 236 | 241 |
headers: &reqwest::header::HeaderMap, |
| 237 | 242 |
) -> Result<Option<ContinuationToken>, ResponseTokenError> {
|
| 238 |
- Ok(account::header_text(headers, "x-apple-i-ck")?.map(ContinuationToken)) |
|
| 243 |
+ const NAME: &str = "x-apple-i-ck"; |
|
| 244 |
+ let Some(text) = account::header_text(headers, NAME)? else {
|
|
| 245 |
+ return Ok(None); |
|
| 246 |
+ }; |
|
| 247 |
+ let decoded = BASE64_STANDARD |
|
| 248 |
+ .decode(text.as_bytes()) |
|
| 249 |
+ .map_err(|_| ResponseTokenError::InvalidHeader(NAME))?; |
|
| 250 |
+ let decoded = |
|
| 251 |
+ String::from_utf8(decoded).map_err(|_| ResponseTokenError::InvalidHeader(NAME))?; |
|
| 252 |
+ let token = decoded |
|
| 253 |
+ .split(':')
|
|
| 254 |
+ .next() |
|
| 255 |
+ .filter(|token| !token.is_empty()) |
|
| 256 |
+ .ok_or(ResponseTokenError::InvalidHeader(NAME))?; |
|
| 257 |
+ Ok(Some(ContinuationToken(token.to_string()))) |
|
| 239 | 258 |
} |
| 240 | 259 |
} |
| 241 | 260 |
|
| ... | ... |
@@ -260,11 +279,22 @@ pub async fn login_with_continuation_token( |
| 260 | 279 |
http_session: &AnisetteHTTPSession<'_, '_>, |
| 261 | 280 |
user: &str, |
| 262 | 281 |
continuation_token: &ContinuationToken, |
| 282 |
+) -> AuthResult {
|
|
| 283 |
+ login_with_continuation_token_created(http_session, user, continuation_token, None).await |
|
| 284 |
+} |
|
| 285 |
+ |
|
| 286 |
+/// Like `login_with_continuation_token`, also sending when the token was received |
|
| 287 |
+/// (`ckcts`, epoch milliseconds), as macOS does. |
|
| 288 |
+pub async fn login_with_continuation_token_created( |
|
| 289 |
+ http_session: &AnisetteHTTPSession<'_, '_>, |
|
| 290 |
+ user: &str, |
|
| 291 |
+ continuation_token: &ContinuationToken, |
|
| 292 |
+ created_millis: Option<u64>, |
|
| 263 | 293 |
) -> AuthResult {
|
| 264 | 294 |
login_with_credential( |
| 265 | 295 |
http_session, |
| 266 | 296 |
user, |
| 267 |
- LoginCredential::ContinuationToken(continuation_token), |
|
| 297 |
+ LoginCredential::ContinuationToken(continuation_token, created_millis), |
|
| 268 | 298 |
) |
| 269 | 299 |
.await |
| 270 | 300 |
} |
| ... | ... |
@@ -272,7 +302,7 @@ pub async fn login_with_continuation_token( |
| 272 | 302 |
#[derive(Clone, Copy)] |
| 273 | 303 |
enum LoginCredential<'lt> {
|
| 274 | 304 |
Password(&'lt str), |
| 275 |
- ContinuationToken(&'lt ContinuationToken), |
|
| 305 |
+ ContinuationToken(&'lt ContinuationToken, Option<u64>), |
|
| 276 | 306 |
} |
| 277 | 307 |
|
| 278 | 308 |
async fn login_with_credential( |
| ... | ... |
@@ -290,8 +320,11 @@ async fn login_with_credential( |
| 290 | 320 |
let mut cpd = build_client_provided_data(http_session)?; |
| 291 | 321 |
|
| 292 | 322 |
match credential {
|
| 293 |
- LoginCredential::ContinuationToken(_) => {
|
|
| 323 |
+ LoginCredential::ContinuationToken(_, created_millis) => {
|
|
| 294 | 324 |
cpd.insert("ckauth".into(), true.into());
|
| 325 |
+ if let Some(created_millis) = created_millis {
|
|
| 326 |
+ cpd.insert("ckcts".into(), created_millis.into());
|
|
| 327 |
+ } |
|
| 295 | 328 |
} |
| 296 | 329 |
LoginCredential::Password(_) => {
|
| 297 | 330 |
cpd.insert("bootstrap".into(), true.into());
|
| ... | ... |
@@ -379,7 +412,7 @@ async fn login_with_credential( |
| 379 | 412 |
|
| 380 | 413 |
let secret = match credential {
|
| 381 | 414 |
LoginCredential::Password(password) => password, |
| 382 |
- LoginCredential::ContinuationToken(ck) => ck.0.as_str(), |
|
| 415 |
+ LoginCredential::ContinuationToken(ck, _) => ck.0.as_str(), |
|
| 383 | 416 |
} |
| 384 | 417 |
.as_bytes(); |
| 385 | 418 |
|
| ... | ... |
@@ -551,3 +584,48 @@ fn decrypt_server_provided_data( |
| 551 | 584 |
|
| 552 | 585 |
Ok(ServerProvidedData(server_provided_data)) |
| 553 | 586 |
} |
| 587 |
+ |
|
| 588 |
+#[cfg(test)] |
|
| 589 |
+mod continuation_token_tests {
|
|
| 590 |
+ use super::*; |
|
| 591 |
+ use reqwest::header::{HeaderMap, HeaderValue};
|
|
| 592 |
+ |
|
| 593 |
+ fn headers(value: &str) -> HeaderMap {
|
|
| 594 |
+ let mut headers = HeaderMap::new(); |
|
| 595 |
+ headers.insert("x-apple-i-ck", HeaderValue::from_str(value).unwrap());
|
|
| 596 |
+ headers |
|
| 597 |
+ } |
|
| 598 |
+ |
|
| 599 |
+ fn token(headers: &HeaderMap) -> Option<String> {
|
|
| 600 |
+ ContinuationToken::from_response_headers(headers) |
|
| 601 |
+ .unwrap() |
|
| 602 |
+ .map(|token| token.0) |
|
| 603 |
+ } |
|
| 604 |
+ |
|
| 605 |
+ #[test] |
|
| 606 |
+ fn the_header_is_the_base64_of_the_token() {
|
|
| 607 |
+ let encoded = BASE64_STANDARD.encode("abc123def456");
|
|
| 608 |
+ assert_eq!(token(&headers(&encoded)).as_deref(), Some("abc123def456"));
|
|
| 609 |
+ } |
|
| 610 |
+ |
|
| 611 |
+ #[test] |
|
| 612 |
+ fn a_trailing_number_is_not_part_of_the_token() {
|
|
| 613 |
+ let encoded = BASE64_STANDARD.encode("abc123:2592000");
|
|
| 614 |
+ assert_eq!(token(&headers(&encoded)).as_deref(), Some("abc123"));
|
|
| 615 |
+ } |
|
| 616 |
+ |
|
| 617 |
+ #[test] |
|
| 618 |
+ fn a_missing_header_is_no_token_and_a_malformed_one_is_an_error() {
|
|
| 619 |
+ assert_eq!(token(&HeaderMap::new()), None); |
|
| 620 |
+ for bad in ["not base64!", ""] {
|
|
| 621 |
+ let mut headers = HeaderMap::new(); |
|
| 622 |
+ headers.insert("x-apple-i-ck", HeaderValue::from_str(bad).unwrap());
|
|
| 623 |
+ assert!(ContinuationToken::from_response_headers(&headers).is_err()); |
|
| 624 |
+ } |
|
| 625 |
+ // Decodes, but to nothing. |
|
| 626 |
+ assert!( |
|
| 627 |
+ ContinuationToken::from_response_headers(&headers(&BASE64_STANDARD.encode(":5")))
|
|
| 628 |
+ .is_err() |
|
| 629 |
+ ); |
|
| 630 |
+ } |
|
| 631 |
+} |
| ... | ... |
@@ -402,6 +402,43 @@ pub(super) fn header_token( |
| 402 | 402 |
} |
| 403 | 403 |
} |
| 404 | 404 |
|
| 405 |
+/// Parse a `base64(service:token:duration)` header. The password-equivalent token |
|
| 406 |
+/// carries no issue time: it counts from its receipt. |
|
| 407 |
+pub(super) fn pe_header_token( |
|
| 408 |
+ value: &HeaderValue, |
|
| 409 |
+ name: &'static str, |
|
| 410 |
+) -> Result<(String, RawToken), ResponseTokenError> {
|
|
| 411 |
+ let value = BASE64_STANDARD |
|
| 412 |
+ .decode(value.as_bytes()) |
|
| 413 |
+ .map_err(|_| ResponseTokenError::InvalidHeader(name))?; |
|
| 414 |
+ let value = String::from_utf8(value).map_err(|_| ResponseTokenError::InvalidHeader(name))?; |
|
| 415 |
+ let fields: Vec<&str> = value.split(':').collect();
|
|
| 416 |
+ match fields.as_slice() {
|
|
| 417 |
+ [service, token, duration] if !service.is_empty() && !token.is_empty() => {
|
|
| 418 |
+ let duration: u64 = duration |
|
| 419 |
+ .parse() |
|
| 420 |
+ .map_err(|_| ResponseTokenError::InvalidHeader(name))?; |
|
| 421 |
+ let received = std::time::SystemTime::now() |
|
| 422 |
+ .duration_since(std::time::UNIX_EPOCH) |
|
| 423 |
+ .map_err(|_| ResponseTokenError::InvalidHeader(name))? |
|
| 424 |
+ .as_millis() as u64; |
|
| 425 |
+ let expiry_epoch_millis = duration |
|
| 426 |
+ .checked_mul(1000) |
|
| 427 |
+ .and_then(|duration| received.checked_add(duration)) |
|
| 428 |
+ .ok_or(ResponseTokenError::InvalidHeader(name))?; |
|
| 429 |
+ Ok(( |
|
| 430 |
+ service.to_string(), |
|
| 431 |
+ RawToken {
|
|
| 432 |
+ token: token.to_string(), |
|
| 433 |
+ duration, |
|
| 434 |
+ expiry_epoch_millis, |
|
| 435 |
+ }, |
|
| 436 |
+ )) |
|
| 437 |
+ } |
|
| 438 |
+ _ => Err(ResponseTokenError::InvalidHeader(name)), |
|
| 439 |
+ } |
|
| 440 |
+} |
|
| 441 |
+ |
|
| 405 | 442 |
#[derive(Debug, Error)] |
| 406 | 443 |
pub enum ResponseTokenError {
|
| 407 | 444 |
#[error("Invalid authentication response header: {0}")]
|
| ... | ... |
@@ -412,6 +449,28 @@ pub enum ResponseTokenError {
|
| 412 | 449 |
|
| 413 | 450 |
#[cfg(test)] |
| 414 | 451 |
mod tests {
|
| 452 |
+ #[test] |
|
| 453 |
+ fn password_equivalent_token_header_counts_from_receipt() {
|
|
| 454 |
+ let value = |
|
| 455 |
+ HeaderValue::from_str(&BASE64_STANDARD.encode("com.apple.gs.idms.pet:secret:300"))
|
|
| 456 |
+ .unwrap(); |
|
| 457 |
+ let before = std::time::SystemTime::now() |
|
| 458 |
+ .duration_since(std::time::UNIX_EPOCH) |
|
| 459 |
+ .unwrap() |
|
| 460 |
+ .as_millis() as u64; |
|
| 461 |
+ let (service, token) = pe_header_token(&value, "x-apple-pe-token").unwrap(); |
|
| 462 |
+ assert_eq!(service, "com.apple.gs.idms.pet"); |
|
| 463 |
+ assert_eq!((token.token.as_str(), token.duration), ("secret", 300));
|
|
| 464 |
+ assert!(token.expiry_epoch_millis >= before + 300_000); |
|
| 465 |
+ for bad in ["a:b", "a:b:c", "a::300", ":b:300", "a:b:300:1"] {
|
|
| 466 |
+ let value = HeaderValue::from_str(&BASE64_STANDARD.encode(bad)).unwrap(); |
|
| 467 |
+ assert!( |
|
| 468 |
+ pe_header_token(&value, "x-apple-pe-token").is_err(), |
|
| 469 |
+ "{bad}"
|
|
| 470 |
+ ); |
|
| 471 |
+ } |
|
| 472 |
+ } |
|
| 473 |
+ |
|
| 415 | 474 |
use super::*; |
| 416 | 475 |
|
| 417 | 476 |
fn encoded(value: impl AsRef<[u8]>) -> HeaderValue {
|
| ... | ... |
@@ -1,4 +1,4 @@ |
| 1 |
-use super::account::{AccountHTTPSession, ResponseTokenError, header_token};
|
|
| 1 |
+use super::account::{AccountHTTPSession, ResponseTokenError, header_token, pe_header_token};
|
|
| 2 | 2 |
use adi::proxy::ADIResult; |
| 3 | 3 |
use plist::Dictionary; |
| 4 | 4 |
use plist_macros::dict; |
| ... | ... |
@@ -43,9 +43,21 @@ pub trait AppToken: From<String> + AsRef<str> {
|
| 43 | 43 |
pub struct TokenBag(pub(super) Dictionary); |
| 44 | 44 |
|
| 45 | 45 |
impl TokenBag {
|
| 46 |
- /// Extract app and heartbeat tokens. Missing headers produce an empty bag. |
|
| 46 |
+ /// Extract app, heartbeat and password-equivalent tokens. Missing headers produce |
|
| 47 |
+ /// an empty bag. |
|
| 47 | 48 |
pub fn from_response_headers(headers: &HeaderMap) -> Result<Self, ResponseTokenError> {
|
| 48 | 49 |
let mut tokens = Dictionary::new(); |
| 50 |
+ for value in headers.get_all("x-apple-pe-token") {
|
|
| 51 |
+ let (service, token) = pe_header_token(value, "x-apple-pe-token")?; |
|
| 52 |
+ let value = dict! {
|
|
| 53 |
+ "token": token.token, |
|
| 54 |
+ "duration": token.duration, |
|
| 55 |
+ "expiry": token.expiry_epoch_millis, |
|
| 56 |
+ }; |
|
| 57 |
+ if tokens.insert(service, value.into()).is_some() {
|
|
| 58 |
+ return Err(ResponseTokenError::DuplicateService); |
|
| 59 |
+ } |
|
| 60 |
+ } |
|
| 49 | 61 |
for name in ["x-apple-gs-token", "x-apple-hb-token"] {
|
| 50 | 62 |
for value in headers.get_all(name) {
|
| 51 | 63 |
let (service, token) = header_token(value, name)?; |
| ... | ... |
@@ -44,6 +44,8 @@ pub enum AuthenticatedRequestError {
|
| 44 | 44 |
Network(#[from] reqwest::Error), |
| 45 | 45 |
#[error("Invalid server response: {0}")]
|
| 46 | 46 |
InvalidResponse(plist::Error), |
| 47 |
+ #[error("Invalid device list: {0}")]
|
|
| 48 |
+ InvalidDeviceList(serde_json::Error), |
|
| 47 | 49 |
} |
| 48 | 50 |
|
| 49 | 51 |
pub type AuthenticatedRequestResult<T> = Result<T, AuthenticatedRequestError>; |
| ... | ... |
@@ -100,6 +102,26 @@ impl Serialize for CheckInEvent {
|
| 100 | 102 |
} |
| 101 | 103 |
} |
| 102 | 104 |
|
| 105 |
+#[derive(Deserialize)] |
|
| 106 |
+struct TrustedDeviceList {
|
|
| 107 |
+ #[serde(default)] |
|
| 108 |
+ devices: Vec<TrustedDevice>, |
|
| 109 |
+} |
|
| 110 |
+ |
|
| 111 |
+/// A device in the Apple Account's list. The wire names are `AATrustedDevice`'s. |
|
| 112 |
+#[derive(Debug, Clone, Default, Deserialize)] |
|
| 113 |
+#[serde(rename_all = "camelCase")] |
|
| 114 |
+pub struct TrustedDevice {
|
|
| 115 |
+ pub id: Option<String>, |
|
| 116 |
+ pub name: Option<String>, |
|
| 117 |
+ pub model_name: Option<String>, |
|
| 118 |
+ pub os_version: Option<String>, |
|
| 119 |
+ pub current_device: Option<bool>, |
|
| 120 |
+ pub list_image_location: Option<String>, |
|
| 121 |
+ pub list_image_location2x: Option<String>, |
|
| 122 |
+ pub list_image_location3x: Option<String>, |
|
| 123 |
+} |
|
| 124 |
+ |
|
| 103 | 125 |
#[derive(Debug, Default, Serialize)] |
| 104 | 126 |
pub struct DeviceData {
|
| 105 | 127 |
#[serde(rename = "circleStatus")] |
| ... | ... |
@@ -200,6 +222,41 @@ impl<'lt, 'adi> HeartbeatHTTPSession<'lt, 'adi> {
|
| 200 | 222 |
Ok(dict) |
| 201 | 223 |
} |
| 202 | 224 |
|
| 225 |
+ /// The devices the Apple Account trusts, as AppleAccount's `AADeviceList` requests them. |
|
| 226 |
+ pub async fn fetch_trusted_devices(&self) -> AuthenticatedRequestResult<Vec<TrustedDevice>> {
|
|
| 227 |
+ let url_bag = self.http_session.http_session.url_bag(); |
|
| 228 |
+ let url = ["trustedDevices", "deviceListUrl"] |
|
| 229 |
+ .into_iter() |
|
| 230 |
+ .find_map(|key| url_bag.get(key).and_then(Value::as_string)) |
|
| 231 |
+ .ok_or(AuthenticatedRequestError::InvalidURLBag); |
|
| 232 |
+ if url.is_err() {
|
|
| 233 |
+ log::debug!( |
|
| 234 |
+ "No device list URL in the URL bag; keys: {}",
|
|
| 235 |
+ url_bag.keys().cloned().collect::<Vec<_>>().join(", ")
|
|
| 236 |
+ ); |
|
| 237 |
+ } |
|
| 238 |
+ let url = url?; |
|
| 239 |
+ |
|
| 240 |
+ let response = self |
|
| 241 |
+ .heartbeat_request_builder(Method::GET, url)? |
|
| 242 |
+ .header("Accept", "application/json")
|
|
| 243 |
+ .send() |
|
| 244 |
+ .await? |
|
| 245 |
+ .error_for_status()? |
|
| 246 |
+ .bytes() |
|
| 247 |
+ .await?; |
|
| 248 |
+ |
|
| 249 |
+ // The request asks for JSON; the other GrandSlam services answer in property lists. |
|
| 250 |
+ let list: TrustedDeviceList = if response.trim_ascii_start().starts_with(b"{") {
|
|
| 251 |
+ serde_json::from_slice(&response) |
|
| 252 |
+ .map_err(AuthenticatedRequestError::InvalidDeviceList)? |
|
| 253 |
+ } else {
|
|
| 254 |
+ plist::from_bytes(&response).map_err(AuthenticatedRequestError::InvalidResponse)? |
|
| 255 |
+ }; |
|
| 256 |
+ |
|
| 257 |
+ Ok(list.devices) |
|
| 258 |
+ } |
|
| 259 |
+ |
|
| 203 | 260 |
/// Check-in succeeds on HTTP 200; AuthKitWin does not interpret its response body. |
| 204 | 261 |
pub async fn post_data(&self, device_data: DeviceData) -> Result<(), PostDataError> {
|
| 205 | 262 |
let post_data = plist::to_value(&device_data).map_err(PostDataError::Serialization)?; |
| ... | ... |
@@ -225,6 +282,13 @@ impl<'lt, 'adi> HeartbeatHTTPSession<'lt, 'adi> {
|
| 225 | 282 |
|
| 226 | 283 |
let status = response.status(); |
| 227 | 284 |
if status == StatusCode::OK {
|
| 285 |
+ // macOS reads `livenessNonce`, `attestationNonce`, `sendAttestationOSVersion` |
|
| 286 |
+ // and `actionNeeded` here. A development diagnostic: the reply, verbatim. |
|
| 287 |
+ if let Ok(body) = response.bytes().await |
|
| 288 |
+ && let Ok(reply) = plist::from_bytes::<Dictionary>(&body) |
|
| 289 |
+ {
|
|
| 290 |
+ log::debug!("Check-in response: {reply:?}");
|
|
| 291 |
+ } |
|
| 228 | 292 |
return Ok(()); |
| 229 | 293 |
} |
| 230 | 294 |
// A rejected check-in may explain itself in a GrandSlam status dictionary. |
| ... | ... |
@@ -244,3 +308,23 @@ impl<'lt, 'adi> HeartbeatHTTPSession<'lt, 'adi> {
|
| 244 | 308 |
Err(PostDataError::HttpStatus(status)) |
| 245 | 309 |
} |
| 246 | 310 |
} |
| 311 |
+ |
|
| 312 |
+#[cfg(test)] |
|
| 313 |
+mod trusted_device_tests {
|
|
| 314 |
+ use super::*; |
|
| 315 |
+ |
|
| 316 |
+ #[test] |
|
| 317 |
+ fn reads_the_displayed_device_fields_and_ignores_the_rest() {
|
|
| 318 |
+ let list: TrustedDeviceList = serde_json::from_str( |
|
| 319 |
+ r#"{"status":0,"manageDevices":true,"devices":[
|
|
| 320 |
+ {"id":"A1","name":"Ada's Mac","modelName":"MacBook Pro 14\"","osVersion":"macOS 15",
|
|
| 321 |
+ "currentDevice":true,"pushToken":"secret"}, |
|
| 322 |
+ {"name":"Phone"}]}"#,
|
|
| 323 |
+ ) |
|
| 324 |
+ .unwrap(); |
|
| 325 |
+ assert_eq!(list.devices.len(), 2); |
|
| 326 |
+ assert_eq!(list.devices[0].name.as_deref(), Some("Ada's Mac"));
|
|
| 327 |
+ assert_eq!(list.devices[0].current_device, Some(true)); |
|
| 328 |
+ assert!(list.devices[1].id.is_none()); |
|
| 329 |
+ } |
|
| 330 |
+} |
| ... | ... |
@@ -5,6 +5,7 @@ use plist::{Dictionary, Value};
|
| 5 | 5 |
use plist_macros::dict; |
| 6 | 6 |
use reqwest::{Method, RequestBuilder};
|
| 7 | 7 |
use serde::{Deserialize, Serialize};
|
| 8 |
+use std::collections::BTreeMap; |
|
| 8 | 9 |
use thiserror::Error; |
| 9 | 10 |
|
| 10 | 11 |
pub use grandslam::tokens::PasswordEquivalentToken; |
| ... | ... |
@@ -66,7 +67,13 @@ pub struct AccountSettings(Dictionary); |
| 66 | 67 |
|
| 67 | 68 |
impl AccountSettings {
|
| 68 | 69 |
pub fn ds_id(&self) -> Option<DSID> {
|
| 69 |
- match self.account_info()?.get("dsPrsID")? {
|
|
| 70 |
+ // The setup-assistant login carries it beside the delegates, not in |
|
| 71 |
+ // the account info; `parse_login_response` keeps it as `dsid`. |
|
| 72 |
+ let found = match self.account_info().and_then(|info| info.get("dsPrsID")) {
|
|
| 73 |
+ Some(found) => found, |
|
| 74 |
+ None => self.0.get("dsid")?,
|
|
| 75 |
+ }; |
|
| 76 |
+ match found {
|
|
| 70 | 77 |
Value::String(ds_id) |
| 71 | 78 |
if !ds_id.is_empty() && ds_id.bytes().all(|byte| byte.is_ascii_digit()) => |
| 72 | 79 |
{
|
| ... | ... |
@@ -77,6 +84,32 @@ impl AccountSettings {
|
| 77 | 84 |
} |
| 78 | 85 |
} |
| 79 | 86 |
|
| 87 |
+ /// The keys and value types, never the values: what to log when an |
|
| 88 |
+ /// expected entry is missing (the values include tokens). |
|
| 89 |
+ pub fn shape(&self) -> String {
|
|
| 90 |
+ fn walk(d: &Dictionary, depth: usize, out: &mut String) {
|
|
| 91 |
+ for (k, v) in d {
|
|
| 92 |
+ out.push_str(&format!("{}{k}: ", " ".repeat(depth)));
|
|
| 93 |
+ match v {
|
|
| 94 |
+ Value::Dictionary(inner) if depth < 4 => {
|
|
| 95 |
+ out.push_str("dict\n");
|
|
| 96 |
+ walk(inner, depth + 1, out); |
|
| 97 |
+ } |
|
| 98 |
+ Value::Dictionary(_) => out.push_str("dict ...\n"),
|
|
| 99 |
+ Value::Array(a) => out.push_str(&format!("array[{}]\n", a.len())),
|
|
| 100 |
+ Value::String(_) => out.push_str("string\n"),
|
|
| 101 |
+ Value::Integer(_) => out.push_str("integer\n"),
|
|
| 102 |
+ Value::Boolean(_) => out.push_str("bool\n"),
|
|
| 103 |
+ Value::Data(_) => out.push_str("data\n"),
|
|
| 104 |
+ _ => out.push_str("other\n"),
|
|
| 105 |
+ } |
|
| 106 |
+ } |
|
| 107 |
+ } |
|
| 108 |
+ let mut out = String::new(); |
|
| 109 |
+ walk(&self.0, 0, &mut out); |
|
| 110 |
+ out |
|
| 111 |
+ } |
|
| 112 |
+ |
|
| 80 | 113 |
pub fn account_info(&self) -> Option<&Dictionary> {
|
| 81 | 114 |
self.0.get("appleAccountInfo")?.as_dictionary()
|
| 82 | 115 |
} |
| ... | ... |
@@ -89,14 +122,52 @@ impl AccountSettings {
|
| 89 | 122 |
self.token("cloudKitToken").map(CloudKitToken::from)
|
| 90 | 123 |
} |
| 91 | 124 |
|
| 125 |
+ /// Every token the login returned, by name (`mmeAuthToken`, `cloudKitToken`…). |
|
| 126 |
+ pub fn tokens(&self) -> BTreeMap<String, String> {
|
|
| 127 |
+ self.0 |
|
| 128 |
+ .get("tokens")
|
|
| 129 |
+ .and_then(Value::as_dictionary) |
|
| 130 |
+ .into_iter() |
|
| 131 |
+ .flatten() |
|
| 132 |
+ .filter_map(|(name, value)| Some((name.clone(), value.as_string()?.to_owned()))) |
|
| 133 |
+ .filter(|(_, value)| !value.is_empty()) |
|
| 134 |
+ .collect() |
|
| 135 |
+ } |
|
| 136 |
+ |
|
| 137 |
+ /// Each provisioned `com.apple.Dataclass.*` with its string properties, such as `url`. |
|
| 138 |
+ pub fn dataclasses(&self) -> BTreeMap<String, BTreeMap<String, String>> {
|
|
| 139 |
+ let mut dataclasses = BTreeMap::new(); |
|
| 140 |
+ let bags = ["mobilemeAccountInfo", "com.apple.mobileme"] |
|
| 141 |
+ .iter() |
|
| 142 |
+ .filter_map(|bag| self.0.get(*bag)?.as_dictionary()) |
|
| 143 |
+ .chain(std::iter::once(&self.0)); |
|
| 144 |
+ for bag in bags {
|
|
| 145 |
+ for (name, properties) in bag {
|
|
| 146 |
+ let Some(properties) = properties.as_dictionary() else {
|
|
| 147 |
+ continue; |
|
| 148 |
+ }; |
|
| 149 |
+ if !name.starts_with("com.apple.Dataclass.") || dataclasses.contains_key(name) {
|
|
| 150 |
+ continue; |
|
| 151 |
+ } |
|
| 152 |
+ let properties = properties |
|
| 153 |
+ .iter() |
|
| 154 |
+ .filter_map(|(key, value)| Some((key.clone(), value.as_string()?.to_owned()))) |
|
| 155 |
+ .collect(); |
|
| 156 |
+ dataclasses.insert(name.clone(), properties); |
|
| 157 |
+ } |
|
| 158 |
+ } |
|
| 159 |
+ dataclasses |
|
| 160 |
+ } |
|
| 161 |
+ |
|
| 92 | 162 |
pub fn service(&self, dataclass: &str) -> Option<&Dictionary> {
|
| 93 | 163 |
// Delegate login returns dataclasses directly; get_account_settings |
| 94 |
- // groups them under mobilemeAccountInfo (the AOSKit account bag). |
|
| 95 |
- let services = match self.0.get("mobilemeAccountInfo") {
|
|
| 96 |
- Some(value) => value.as_dictionary()?, |
|
| 97 |
- None => &self.0, |
|
| 98 |
- }; |
|
| 99 |
- services.get(dataclass)?.as_dictionary() |
|
| 164 |
+ // groups them under mobilemeAccountInfo (the AOSKit account bag), or |
|
| 165 |
+ // (measured 26 Oct 2026) under com.apple.mobileme. |
|
| 166 |
+ ["mobilemeAccountInfo", "com.apple.mobileme"] |
|
| 167 |
+ .iter() |
|
| 168 |
+ .filter_map(|bag| self.0.get(*bag)?.as_dictionary()) |
|
| 169 |
+ .chain(std::iter::once(&self.0)) |
|
| 170 |
+ .find_map(|services| services.get(dataclass)?.as_dictionary()) |
|
| 100 | 171 |
} |
| 101 | 172 |
|
| 102 | 173 |
fn token(&self, name: &str) -> Option<String> {
|
| ... | ... |
@@ -162,6 +233,34 @@ pub fn login_request_builder( |
| 162 | 233 |
.body(dict_to_body(request))) |
| 163 | 234 |
} |
| 164 | 235 |
|
| 236 |
+/// The delegate login an iPhone's setup flow (and iCloudHelper) sends: |
|
| 237 |
+/// `setup/iosbuddy/loginDelegates`, with the Apple ID and the PET in the body as |
|
| 238 |
+/// well as in Basic authentication. Answered like `login_request_builder`'s |
|
| 239 |
+/// request, so `login` finishes it. |
|
| 240 |
+pub fn login_delegates_request_builder( |
|
| 241 |
+ session: &AccountHTTPSession<'_, '_>, |
|
| 242 |
+ apple_id: &str, |
|
| 243 |
+ token: &PasswordEquivalentToken, |
|
| 244 |
+) -> ADIResult<RequestBuilder> {
|
|
| 245 |
+ let request = dict! {
|
|
| 246 |
+ "apple-id": apple_id.trim(), |
|
| 247 |
+ "client-id": session.http_session.device().device_uuid.as_str(), |
|
| 248 |
+ "delegates": dict! { "com.apple.mobileme": Dictionary::new() },
|
|
| 249 |
+ "password": token.as_ref(), |
|
| 250 |
+ }; |
|
| 251 |
+ |
|
| 252 |
+ Ok(session |
|
| 253 |
+ .anisette_request_builder( |
|
| 254 |
+ Method::POST, |
|
| 255 |
+ "https://setup.icloud.com/setup/iosbuddy/loginDelegates", |
|
| 256 |
+ )? |
|
| 257 |
+ .header("X-Apple-ADSID", session.alt_dsid.as_ref())
|
|
| 258 |
+ .header("Content-Type", "text/plist")
|
|
| 259 |
+ .header("Accept", "application/x-plist")
|
|
| 260 |
+ .basic_auth(apple_id.trim(), Some(token.as_ref())) |
|
| 261 |
+ .body(dict_to_body(request))) |
|
| 262 |
+} |
|
| 263 |
+ |
|
| 165 | 264 |
/// Finish a request made by `login_request_builder`, checking both status levels. |
| 166 | 265 |
pub async fn login(request: RequestBuilder) -> Result<AccountSettings, LoginError> {
|
| 167 | 266 |
let response = request.send().await?.error_for_status()?.bytes().await?; |
| ... | ... |
@@ -181,6 +280,7 @@ pub fn parse_login_response(response: &[u8]) -> Result<AccountSettings, LoginErr |
| 181 | 280 |
(None, None) => Err(LoginError::InvalidResponse("status")),
|
| 182 | 281 |
}?; |
| 183 | 282 |
|
| 283 |
+ let top_dsid = response.remove("dsid");
|
|
| 184 | 284 |
let mut delegate = response |
| 185 | 285 |
.remove("delegates")
|
| 186 | 286 |
.and_then(Value::into_dictionary) |
| ... | ... |
@@ -195,11 +295,16 @@ pub fn parse_login_response(response: &[u8]) -> Result<AccountSettings, LoginErr |
| 195 | 295 |
None => Err(LoginError::InvalidResponse("MobileMe delegate status")),
|
| 196 | 296 |
}?; |
| 197 | 297 |
|
| 198 |
- delegate |
|
| 298 |
+ let mut data = delegate |
|
| 199 | 299 |
.remove("service-data")
|
| 200 | 300 |
.and_then(Value::into_dictionary) |
| 201 |
- .map(AccountSettings) |
|
| 202 |
- .ok_or(LoginError::InvalidResponse("MobileMe service data"))
|
|
| 301 |
+ .ok_or(LoginError::InvalidResponse("MobileMe service data"))?;
|
|
| 302 |
+ if let Some(dsid) = top_dsid |
|
| 303 |
+ && !data.contains_key("dsid")
|
|
| 304 |
+ {
|
|
| 305 |
+ data.insert("dsid".into(), dsid);
|
|
| 306 |
+ } |
|
| 307 |
+ Ok(AccountSettings(data)) |
|
| 203 | 308 |
} |
| 204 | 309 |
|
| 205 | 310 |
#[derive(Debug, Error)] |
| ... | ... |
@@ -280,6 +385,30 @@ impl<'lt, 'adi> ICloudSession<'lt, 'adi> {
|
| 280 | 385 |
mod tests {
|
| 281 | 386 |
use super::*; |
| 282 | 387 |
|
| 388 |
+ #[test] |
|
| 389 |
+ fn dataclasses_come_from_every_settings_bag() {
|
|
| 390 |
+ let settings = AccountSettings(dict! {
|
|
| 391 |
+ "com.apple.Dataclass.Contacts": dict! { "url": "https://p1-contacts.icloud.com:443", "port": 1 },
|
|
| 392 |
+ "mobilemeAccountInfo": dict! {
|
|
| 393 |
+ "com.apple.Dataclass.Calendars": dict! { "url": "https://p1-caldav.icloud.com:443" },
|
|
| 394 |
+ "com.apple.Dataclass.Contacts": dict! { "url": "https://p2-contacts.icloud.com:443" },
|
|
| 395 |
+ }, |
|
| 396 |
+ "appleAccountInfo": dict! { "dsPrsID": 1234 },
|
|
| 397 |
+ }); |
|
| 398 |
+ let dataclasses = settings.dataclasses(); |
|
| 399 |
+ assert_eq!(dataclasses.len(), 2); |
|
| 400 |
+ // The AOSKit bag wins, as in `service`; non-string properties are left out. |
|
| 401 |
+ assert_eq!( |
|
| 402 |
+ dataclasses["com.apple.Dataclass.Contacts"] |
|
| 403 |
+ .iter() |
|
| 404 |
+ .collect::<Vec<_>>(), |
|
| 405 |
+ [( |
|
| 406 |
+ &"url".to_string(), |
|
| 407 |
+ &"https://p2-contacts.icloud.com:443".to_string() |
|
| 408 |
+ )] |
|
| 409 |
+ ); |
|
| 410 |
+ } |
|
| 411 |
+ |
|
| 283 | 412 |
#[test] |
| 284 | 413 |
fn login_response_yields_the_service_data() {
|
| 285 | 414 |
let response = |status: i64, delegate_status: i64| {
|
| ... | ... |
@@ -302,6 +431,11 @@ mod tests {
|
| 302 | 431 |
|
| 303 | 432 |
let settings = parse_login_response(&response(0, 0)).unwrap(); |
| 304 | 433 |
assert_eq!(settings.ds_id(), Some(DSID("1234".to_string())));
|
| 434 |
+ // Empty tokens are not tokens. |
|
| 435 |
+ assert_eq!( |
|
| 436 |
+ settings.tokens().into_iter().collect::<Vec<_>>(), |
|
| 437 |
+ [("mmeAuthToken".to_string(), "token".to_string())]
|
|
| 438 |
+ ); |
|
| 305 | 439 |
assert_eq!(settings.mme_token().unwrap().as_ref(), "token"); |
| 306 | 440 |
assert!(settings.cloudkit_token().is_none()); |
| 307 | 441 |
|
| ... | ... |
@@ -0,0 +1,471 @@ |
| 1 |
+//! The iCloud address book over CardDAV (RFC 6352), as Contacts on a Mac reads |
|
| 2 |
+//! it: `contacts.icloud.com`, Basic authentication with the DSID and the |
|
| 3 |
+//! MobileMe token, discovery from the account's principal down to each address |
|
| 4 |
+//! book, then one `addressbook-query` REPORT per book. |
|
| 5 |
+//! |
|
| 6 |
+//! What callers get is the raw vCards; this crate does not interpret them. |
|
| 7 |
+ |
|
| 8 |
+use reqwest::{Method, Url};
|
|
| 9 |
+use thiserror::Error; |
|
| 10 |
+ |
|
| 11 |
+use crate::{AccountSettings, ICloudSession};
|
|
| 12 |
+ |
|
| 13 |
+/// The dataclass whose `url` is the account's CardDAV server. |
|
| 14 |
+pub const CONTACTS_DATACLASS: &str = "com.apple.Dataclass.Contacts"; |
|
| 15 |
+/// Where CardDAV discovery starts when the settings name no server: it answers |
|
| 16 |
+/// for every account and points at the account's own shard. |
|
| 17 |
+pub const CONTACTS_FALLBACK: &str = "https://contacts.icloud.com"; |
|
| 18 |
+ |
|
| 19 |
+const DAV: &str = "DAV:"; |
|
| 20 |
+const CARDDAV: &str = "urn:ietf:params:xml:ns:carddav"; |
|
| 21 |
+/// More address books than an account has; a bound on a hostile server. |
|
| 22 |
+const MAX_BOOKS: usize = 32; |
|
| 23 |
+ |
|
| 24 |
+#[derive(Debug, Error)] |
|
| 25 |
+pub enum ContactsError {
|
|
| 26 |
+ #[error("the account has no iCloud Contacts server")]
|
|
| 27 |
+ NoServer, |
|
| 28 |
+ /// The DSID and token were refused: the session must be renewed. |
|
| 29 |
+ #[error("iCloud Contacts refused the account's session")]
|
|
| 30 |
+ Unauthorized, |
|
| 31 |
+ #[error("A new contact must have a valid vCard and UUID.")]
|
|
| 32 |
+ InvalidCard, |
|
| 33 |
+ #[error("This contact changed in iCloud. Refresh it before saving or deleting.")]
|
|
| 34 |
+ Conflict, |
|
| 35 |
+ #[error("The server did not provide a strong contact version; editing is unavailable.")]
|
|
| 36 |
+ MissingVersion, |
|
| 37 |
+ #[error("The server returned an unsafe contact URL.")]
|
|
| 38 |
+ UnsafeUrl, |
|
| 39 |
+ #[error("cannot generate device authentication data: {0}")]
|
|
| 40 |
+ Anisette(#[from] adi::proxy::ADIError), |
|
| 41 |
+ #[error("cannot reach iCloud Contacts: {0}")]
|
|
| 42 |
+ Network(#[from] reqwest::Error), |
|
| 43 |
+ #[error("iCloud Contacts answered {0} to {1}")]
|
|
| 44 |
+ Status(u16, &'static str), |
|
| 45 |
+ #[error("iCloud Contacts sent an unreadable answer: {0}")]
|
|
| 46 |
+ Xml(String), |
|
| 47 |
+ #[error("iCloud Contacts did not say where {0} is")]
|
|
| 48 |
+ Missing(&'static str), |
|
| 49 |
+} |
|
| 50 |
+ |
|
| 51 |
+impl AccountSettings {
|
|
| 52 |
+ /// `https://pNN-contacts.icloud.com:443`, from the login's service data. |
|
| 53 |
+ pub fn contacts_url(&self) -> Option<String> {
|
|
| 54 |
+ self.service(CONTACTS_DATACLASS)? |
|
| 55 |
+ .get("url")?
|
|
| 56 |
+ .as_string() |
|
| 57 |
+ .map(str::to_owned) |
|
| 58 |
+ } |
|
| 59 |
+} |
|
| 60 |
+ |
|
| 61 |
+/// 401 means the session itself was refused, which the caller can renew. |
|
| 62 |
+fn status_error(status: u16, what: &'static str) -> ContactsError {
|
|
| 63 |
+ match status {
|
|
| 64 |
+ 401 => ContactsError::Unauthorized, |
|
| 65 |
+ status => ContactsError::Status(status, what), |
|
| 66 |
+ } |
|
| 67 |
+} |
|
| 68 |
+ |
|
| 69 |
+/// The `href`s of `<response>`s in a multistatus whose `<prop>` holds an |
|
| 70 |
+/// element named `want` (in `ns`), with that element's text. |
|
| 71 |
+fn responses(xml: &str, ns: &str, want: &str) -> Result<Vec<(String, String)>, ContactsError> {
|
|
| 72 |
+ let doc = roxmltree::Document::parse(xml).map_err(|e| ContactsError::Xml(e.to_string()))?; |
|
| 73 |
+ let is = |n: &roxmltree::Node, ns: &str, name: &str| {
|
|
| 74 |
+ n.is_element() && n.tag_name().name() == name && n.tag_name().namespace() == Some(ns) |
|
| 75 |
+ }; |
|
| 76 |
+ let mut out = Vec::new(); |
|
| 77 |
+ for response in doc.descendants().filter(|n| is(n, DAV, "response")) {
|
|
| 78 |
+ let Some(href) = response |
|
| 79 |
+ .descendants() |
|
| 80 |
+ .find(|n| is(n, DAV, "href")) |
|
| 81 |
+ .and_then(|n| n.text()) |
|
| 82 |
+ else {
|
|
| 83 |
+ continue; |
|
| 84 |
+ }; |
|
| 85 |
+ // Only properties the server reports as found (a 200 propstat). |
|
| 86 |
+ for propstat in response.children().filter(|n| is(n, DAV, "propstat")) {
|
|
| 87 |
+ let ok = propstat |
|
| 88 |
+ .children() |
|
| 89 |
+ .find(|n| is(n, DAV, "status")) |
|
| 90 |
+ .and_then(|n| n.text()) |
|
| 91 |
+ .is_none_or(|s| s.contains(" 200 "));
|
|
| 92 |
+ if !ok {
|
|
| 93 |
+ continue; |
|
| 94 |
+ } |
|
| 95 |
+ if let Some(found) = propstat.descendants().find(|n| is(n, ns, want)) {
|
|
| 96 |
+ // The text of the element, or of its first `href` child |
|
| 97 |
+ // (current-user-principal, addressbook-home-set), or the |
|
| 98 |
+ // element's presence (resourcetype/addressbook). |
|
| 99 |
+ let text = found |
|
| 100 |
+ .descendants() |
|
| 101 |
+ .find(|n| is(n, DAV, "href")) |
|
| 102 |
+ .and_then(|n| n.text()) |
|
| 103 |
+ .or_else(|| found.text()) |
|
| 104 |
+ .unwrap_or("")
|
|
| 105 |
+ .trim() |
|
| 106 |
+ .to_owned(); |
|
| 107 |
+ out.push((href.trim().to_owned(), text)); |
|
| 108 |
+ } |
|
| 109 |
+ } |
|
| 110 |
+ } |
|
| 111 |
+ Ok(out) |
|
| 112 |
+} |
|
| 113 |
+ |
|
| 114 |
+fn join(base: &Url, href: &str) -> Result<Url, ContactsError> {
|
|
| 115 |
+ base.join(href) |
|
| 116 |
+ .map_err(|e| ContactsError::Xml(format!("bad href {href:?}: {e}")))
|
|
| 117 |
+} |
|
| 118 |
+ |
|
| 119 |
+/// An address book discovered from the account's CardDAV home. |
|
| 120 |
+#[derive(Clone)] |
|
| 121 |
+pub struct AddressBook {
|
|
| 122 |
+ pub url: String, |
|
| 123 |
+ pub name: String, |
|
| 124 |
+} |
|
| 125 |
+ |
|
| 126 |
+/// A raw card and the identity/version required for conditional writes. |
|
| 127 |
+/// Contact content is deliberately omitted from Debug output. |
|
| 128 |
+#[derive(Clone)] |
|
| 129 |
+pub struct ContactResource {
|
|
| 130 |
+ pub url: String, |
|
| 131 |
+ pub etag: Option<String>, |
|
| 132 |
+ pub vcard: String, |
|
| 133 |
+} |
|
| 134 |
+ |
|
| 135 |
+fn checked_url(value: &str) -> Result<Url, ContactsError> {
|
|
| 136 |
+ let url = Url::parse(value).map_err(|_| ContactsError::UnsafeUrl)?; |
|
| 137 |
+ let host = url.host_str().unwrap_or_default(); |
|
| 138 |
+ if url.scheme() != "https" |
|
| 139 |
+ || !(host == "contacts.icloud.com" || host.ends_with("-contacts.icloud.com"))
|
|
| 140 |
+ || !url.username().is_empty() |
|
| 141 |
+ || url.password().is_some() |
|
| 142 |
+ || url.fragment().is_some() |
|
| 143 |
+ || !matches!(url.port(), None | Some(443)) |
|
| 144 |
+ {
|
|
| 145 |
+ return Err(ContactsError::UnsafeUrl); |
|
| 146 |
+ } |
|
| 147 |
+ Ok(url) |
|
| 148 |
+} |
|
| 149 |
+ |
|
| 150 |
+fn strong_etag(value: Option<&str>) -> Result<&str, ContactsError> {
|
|
| 151 |
+ value |
|
| 152 |
+ .filter(|v| v.starts_with('"') && v.ends_with('"') && v.len() > 2)
|
|
| 153 |
+ .ok_or(ContactsError::MissingVersion) |
|
| 154 |
+} |
|
| 155 |
+ |
|
| 156 |
+fn parse_contacts(base: &Url, xml: &str) -> Result<Vec<ContactResource>, ContactsError> {
|
|
| 157 |
+ let versions: std::collections::HashMap<_, _> = |
|
| 158 |
+ responses(xml, DAV, "getetag")?.into_iter().collect(); |
|
| 159 |
+ responses(xml, CARDDAV, "address-data")? |
|
| 160 |
+ .into_iter() |
|
| 161 |
+ .filter(|(_, card)| card.contains("BEGIN:VCARD"))
|
|
| 162 |
+ .map(|(href, vcard)| {
|
|
| 163 |
+ let url = join(base, &href)?; |
|
| 164 |
+ checked_url(url.as_str())?; |
|
| 165 |
+ Ok(ContactResource {
|
|
| 166 |
+ url: url.to_string(), |
|
| 167 |
+ etag: versions.get(&href).cloned(), |
|
| 168 |
+ vcard, |
|
| 169 |
+ }) |
|
| 170 |
+ }) |
|
| 171 |
+ .collect() |
|
| 172 |
+} |
|
| 173 |
+ |
|
| 174 |
+impl ICloudSession<'_, '_> {
|
|
| 175 |
+ async fn dav( |
|
| 176 |
+ &self, |
|
| 177 |
+ method: &'static str, |
|
| 178 |
+ url: &Url, |
|
| 179 |
+ depth: &str, |
|
| 180 |
+ body: &'static str, |
|
| 181 |
+ what: &'static str, |
|
| 182 |
+ ) -> Result<String, ContactsError> {
|
|
| 183 |
+ let method = Method::from_bytes(method.as_bytes()).expect("a WebDAV method name");
|
|
| 184 |
+ let response = self |
|
| 185 |
+ .icloud_request_builder(method, url.as_str())? |
|
| 186 |
+ .header("Depth", depth)
|
|
| 187 |
+ .header("Content-Type", "application/xml; charset=utf-8")
|
|
| 188 |
+ .body(body) |
|
| 189 |
+ .send() |
|
| 190 |
+ .await?; |
|
| 191 |
+ let status = response.status().as_u16(); |
|
| 192 |
+ if status != 207 {
|
|
| 193 |
+ return Err(status_error(status, what)); |
|
| 194 |
+ } |
|
| 195 |
+ Ok(response.text().await?) |
|
| 196 |
+ } |
|
| 197 |
+ |
|
| 198 |
+ /// Every vCard in every address book on `server` (an account's |
|
| 199 |
+ /// [`AccountSettings::contacts_url`]). |
|
| 200 |
+ pub async fn address_books(&self, server: &str) -> Result<Vec<AddressBook>, ContactsError> {
|
|
| 201 |
+ let server = checked_url(server)?; |
|
| 202 |
+ |
|
| 203 |
+ let found = self |
|
| 204 |
+ .dav( |
|
| 205 |
+ "PROPFIND", |
|
| 206 |
+ &server, |
|
| 207 |
+ "0", |
|
| 208 |
+ r#"<?xml version="1.0" encoding="UTF-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>"#, |
|
| 209 |
+ "the principal", |
|
| 210 |
+ ) |
|
| 211 |
+ .await?; |
|
| 212 |
+ let principal = responses(&found, DAV, "current-user-principal")? |
|
| 213 |
+ .into_iter() |
|
| 214 |
+ .map(|(_, href)| href) |
|
| 215 |
+ .find(|h| !h.is_empty()) |
|
| 216 |
+ .ok_or(ContactsError::Missing("the principal"))?;
|
|
| 217 |
+ let principal = checked_url(join(&server, &principal)?.as_str())?; |
|
| 218 |
+ |
|
| 219 |
+ let found = self |
|
| 220 |
+ .dav( |
|
| 221 |
+ "PROPFIND", |
|
| 222 |
+ &principal, |
|
| 223 |
+ "0", |
|
| 224 |
+ r#"<?xml version="1.0" encoding="UTF-8"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:prop><c:addressbook-home-set/></d:prop></d:propfind>"#, |
|
| 225 |
+ "the address book home", |
|
| 226 |
+ ) |
|
| 227 |
+ .await?; |
|
| 228 |
+ let home = responses(&found, CARDDAV, "addressbook-home-set")? |
|
| 229 |
+ .into_iter() |
|
| 230 |
+ .map(|(_, href)| href) |
|
| 231 |
+ .find(|h| !h.is_empty()) |
|
| 232 |
+ .ok_or(ContactsError::Missing("the address book home"))?;
|
|
| 233 |
+ let home = checked_url(join(&principal, &home)?.as_str())?; |
|
| 234 |
+ |
|
| 235 |
+ let found = self |
|
| 236 |
+ .dav( |
|
| 237 |
+ "PROPFIND", |
|
| 238 |
+ &home, |
|
| 239 |
+ "1", |
|
| 240 |
+ r#"<?xml version="1.0" encoding="UTF-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:displayname/></d:prop></d:propfind>"#, |
|
| 241 |
+ "the address books", |
|
| 242 |
+ ) |
|
| 243 |
+ .await?; |
|
| 244 |
+ // A collection whose resourcetype contains <c:addressbook/>. |
|
| 245 |
+ let doc = |
|
| 246 |
+ roxmltree::Document::parse(&found).map_err(|e| ContactsError::Xml(e.to_string()))?; |
|
| 247 |
+ let mut books = Vec::new(); |
|
| 248 |
+ for response in doc |
|
| 249 |
+ .descendants() |
|
| 250 |
+ .filter(|n| n.tag_name().name() == "response" && n.tag_name().namespace() == Some(DAV)) |
|
| 251 |
+ {
|
|
| 252 |
+ let is_book = response.descendants().any(|n| {
|
|
| 253 |
+ n.tag_name().name() == "addressbook" && n.tag_name().namespace() == Some(CARDDAV) |
|
| 254 |
+ }); |
|
| 255 |
+ let href = response |
|
| 256 |
+ .descendants() |
|
| 257 |
+ .find(|n| n.tag_name().name() == "href" && n.tag_name().namespace() == Some(DAV)) |
|
| 258 |
+ .and_then(|n| n.text()); |
|
| 259 |
+ if let (true, Some(href)) = (is_book, href) {
|
|
| 260 |
+ let url = join(&home, href.trim())?; |
|
| 261 |
+ checked_url(url.as_str())?; |
|
| 262 |
+ let name = response |
|
| 263 |
+ .descendants() |
|
| 264 |
+ .find(|n| {
|
|
| 265 |
+ n.tag_name().name() == "displayname" |
|
| 266 |
+ && n.tag_name().namespace() == Some(DAV) |
|
| 267 |
+ }) |
|
| 268 |
+ .and_then(|n| n.text()) |
|
| 269 |
+ .unwrap_or("iCloud Contacts")
|
|
| 270 |
+ .to_owned(); |
|
| 271 |
+ books.push(AddressBook {
|
|
| 272 |
+ url: url.to_string(), |
|
| 273 |
+ name, |
|
| 274 |
+ }); |
|
| 275 |
+ } |
|
| 276 |
+ } |
|
| 277 |
+ if books.is_empty() {
|
|
| 278 |
+ return Err(ContactsError::Missing("an address book"));
|
|
| 279 |
+ } |
|
| 280 |
+ |
|
| 281 |
+ Ok(books.into_iter().take(MAX_BOOKS).collect()) |
|
| 282 |
+ } |
|
| 283 |
+ |
|
| 284 |
+ pub async fn contacts( |
|
| 285 |
+ &self, |
|
| 286 |
+ book: &AddressBook, |
|
| 287 |
+ ) -> Result<Vec<ContactResource>, ContactsError> {
|
|
| 288 |
+ let url = checked_url(&book.url)?; |
|
| 289 |
+ let found = self.dav("REPORT", &url, "1",
|
|
| 290 |
+ r#"<?xml version="1.0" encoding="UTF-8"?><c:addressbook-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><c:address-data/></d:prop></c:addressbook-query>"#, |
|
| 291 |
+ "the contacts").await?; |
|
| 292 |
+ parse_contacts(&url, &found) |
|
| 293 |
+ } |
|
| 294 |
+ |
|
| 295 |
+ /// Compatibility reader for consumers that only need the raw cards. |
|
| 296 |
+ pub async fn contact_cards(&self, server: &str) -> Result<Vec<String>, ContactsError> {
|
|
| 297 |
+ let mut cards = Vec::new(); |
|
| 298 |
+ for book in self.address_books(server).await? {
|
|
| 299 |
+ cards.extend( |
|
| 300 |
+ self.contacts(&book) |
|
| 301 |
+ .await? |
|
| 302 |
+ .into_iter() |
|
| 303 |
+ .map(|card| card.vcard), |
|
| 304 |
+ ); |
|
| 305 |
+ } |
|
| 306 |
+ Ok(cards) |
|
| 307 |
+ } |
|
| 308 |
+ |
|
| 309 |
+ /// Create only if this resource does not already exist. |
|
| 310 |
+ pub async fn create_contact( |
|
| 311 |
+ &self, |
|
| 312 |
+ book: &AddressBook, |
|
| 313 |
+ card: &str, |
|
| 314 |
+ ) -> Result<ContactResource, ContactsError> {
|
|
| 315 |
+ let mut base = checked_url(&book.url)?; |
|
| 316 |
+ if !base.path().ends_with('/') {
|
|
| 317 |
+ base.set_path(&format!("{}/", base.path()));
|
|
| 318 |
+ } |
|
| 319 |
+ // The card keeps the same UID across retries, including a timeout |
|
| 320 |
+ // after a successful PUT. If-None-Match then prevents duplicate cards. |
|
| 321 |
+ let parsed = crate::VCard::parse(card).map_err(|_| ContactsError::InvalidCard)?; |
|
| 322 |
+ let id = parsed |
|
| 323 |
+ .uid() |
|
| 324 |
+ .and_then(|id| uuid::Uuid::parse_str(id).ok()) |
|
| 325 |
+ .ok_or(ContactsError::InvalidCard)?; |
|
| 326 |
+ let url = join(&base, &format!("{id}.vcf"))?;
|
|
| 327 |
+ self.put_contact(&url, None, card).await |
|
| 328 |
+ } |
|
| 329 |
+ |
|
| 330 |
+ /// Compare-and-swap: a stale card can never overwrite a server edit. |
|
| 331 |
+ pub async fn update_contact( |
|
| 332 |
+ &self, |
|
| 333 |
+ contact: &ContactResource, |
|
| 334 |
+ card: &str, |
|
| 335 |
+ ) -> Result<ContactResource, ContactsError> {
|
|
| 336 |
+ let etag = strong_etag(contact.etag.as_deref())?; |
|
| 337 |
+ self.put_contact(&checked_url(&contact.url)?, Some(etag), card) |
|
| 338 |
+ .await |
|
| 339 |
+ } |
|
| 340 |
+ |
|
| 341 |
+ async fn put_contact( |
|
| 342 |
+ &self, |
|
| 343 |
+ url: &Url, |
|
| 344 |
+ etag: Option<&str>, |
|
| 345 |
+ card: &str, |
|
| 346 |
+ ) -> Result<ContactResource, ContactsError> {
|
|
| 347 |
+ let request = self |
|
| 348 |
+ .icloud_request_builder(Method::PUT, url.as_str())? |
|
| 349 |
+ .header("Content-Type", "text/vcard; charset=utf-8")
|
|
| 350 |
+ .body(card.to_owned()); |
|
| 351 |
+ let request = if let Some(etag) = etag {
|
|
| 352 |
+ request.header("If-Match", etag)
|
|
| 353 |
+ } else {
|
|
| 354 |
+ request.header("If-None-Match", "*")
|
|
| 355 |
+ }; |
|
| 356 |
+ let response = request.send().await?; |
|
| 357 |
+ let status = response.status().as_u16(); |
|
| 358 |
+ if status == 412 {
|
|
| 359 |
+ return Err(ContactsError::Conflict); |
|
| 360 |
+ } |
|
| 361 |
+ if !matches!(status, 200 | 201 | 204) {
|
|
| 362 |
+ return Err(status_error(status, "saving the contact")); |
|
| 363 |
+ } |
|
| 364 |
+ // Do not GET after PUT: a failed read would incorrectly report a failed save. |
|
| 365 |
+ // Without a returned version, refresh before the next mutation. |
|
| 366 |
+ let etag = response |
|
| 367 |
+ .headers() |
|
| 368 |
+ .get("etag")
|
|
| 369 |
+ .and_then(|h| h.to_str().ok()) |
|
| 370 |
+ .map(str::to_owned); |
|
| 371 |
+ Ok(ContactResource {
|
|
| 372 |
+ url: url.to_string(), |
|
| 373 |
+ etag, |
|
| 374 |
+ vcard: card.to_owned(), |
|
| 375 |
+ }) |
|
| 376 |
+ } |
|
| 377 |
+ |
|
| 378 |
+ pub async fn delete_contact(&self, contact: &ContactResource) -> Result<(), ContactsError> {
|
|
| 379 |
+ let url = checked_url(&contact.url)?; |
|
| 380 |
+ let etag = strong_etag(contact.etag.as_deref())?; |
|
| 381 |
+ let response = self |
|
| 382 |
+ .icloud_request_builder(Method::DELETE, url.as_str())? |
|
| 383 |
+ .header("If-Match", etag)
|
|
| 384 |
+ .send() |
|
| 385 |
+ .await?; |
|
| 386 |
+ match response.status().as_u16() {
|
|
| 387 |
+ 200 | 204 => Ok(()), |
|
| 388 |
+ 412 => Err(ContactsError::Conflict), |
|
| 389 |
+ status => Err(status_error(status, "deleting the contact")), |
|
| 390 |
+ } |
|
| 391 |
+ } |
|
| 392 |
+} |
|
| 393 |
+ |
|
| 394 |
+#[cfg(test)] |
|
| 395 |
+mod tests {
|
|
| 396 |
+ use super::*; |
|
| 397 |
+ |
|
| 398 |
+ #[test] |
|
| 399 |
+ fn multistatus_hrefs_and_values_are_read() {
|
|
| 400 |
+ let xml = r#"<?xml version="1.0"?> |
|
| 401 |
+<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav"> |
|
| 402 |
+ <response><href>/123/principal/</href><propstat><prop> |
|
| 403 |
+ <C:addressbook-home-set><href>https://p48-contacts.icloud.com:443/123/carddavhome/</href></C:addressbook-home-set> |
|
| 404 |
+ </prop><status>HTTP/1.1 200 OK</status></propstat></response> |
|
| 405 |
+</multistatus>"#; |
|
| 406 |
+ let r = responses(xml, CARDDAV, "addressbook-home-set").unwrap(); |
|
| 407 |
+ assert_eq!( |
|
| 408 |
+ r, |
|
| 409 |
+ [( |
|
| 410 |
+ "/123/principal/".to_owned(), |
|
| 411 |
+ "https://p48-contacts.icloud.com:443/123/carddavhome/".to_owned() |
|
| 412 |
+ )] |
|
| 413 |
+ ); |
|
| 414 |
+ } |
|
| 415 |
+ |
|
| 416 |
+ #[test] |
|
| 417 |
+ fn address_data_and_failed_props_are_told_apart() {
|
|
| 418 |
+ let xml = r#"<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"> |
|
| 419 |
+ <d:response><d:href>/123/carddavhome/card/a.vcf</d:href><d:propstat><d:prop> |
|
| 420 |
+ <c:address-data>BEGIN:VCARD |
|
| 421 |
+EMAIL:a@b.c |
|
| 422 |
+END:VCARD</c:address-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response> |
|
| 423 |
+ <d:response><d:href>/123/carddavhome/card/b.vcf</d:href><d:propstat><d:prop> |
|
| 424 |
+ <c:address-data/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response> |
|
| 425 |
+</d:multistatus>"#; |
|
| 426 |
+ let r = responses(xml, CARDDAV, "address-data").unwrap(); |
|
| 427 |
+ assert_eq!(r.len(), 1); |
|
| 428 |
+ assert!(r[0].1.contains("EMAIL:a@b.c"));
|
|
| 429 |
+ } |
|
| 430 |
+ |
|
| 431 |
+ #[test] |
|
| 432 |
+ fn relative_hrefs_join_the_server() {
|
|
| 433 |
+ let base = Url::parse("https://p48-contacts.icloud.com:443/").unwrap();
|
|
| 434 |
+ assert_eq!( |
|
| 435 |
+ join(&base, "/123/principal/").unwrap().as_str(), |
|
| 436 |
+ "https://p48-contacts.icloud.com/123/principal/" |
|
| 437 |
+ ); |
|
| 438 |
+ } |
|
| 439 |
+} |
|
| 440 |
+ |
|
| 441 |
+#[cfg(test)] |
|
| 442 |
+mod mutation_tests {
|
|
| 443 |
+ use super::*; |
|
| 444 |
+ #[test] |
|
| 445 |
+ fn associates_each_card_with_its_own_version_and_rejects_foreign_urls() {
|
|
| 446 |
+ let base = Url::parse("https://p48-contacts.icloud.com/book/").unwrap();
|
|
| 447 |
+ let xml = r#"<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:response><d:href>a.vcf</d:href><d:propstat><d:prop><d:getetag>"one"</d:getetag><c:address-data>BEGIN:VCARD |
|
| 448 |
+VERSION:3.0 |
|
| 449 |
+FN:Fixture |
|
| 450 |
+END:VCARD</c:address-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#; |
|
| 451 |
+ let cards = parse_contacts(&base, xml).unwrap(); |
|
| 452 |
+ assert_eq!(cards[0].etag.as_deref(), Some("\"one\""));
|
|
| 453 |
+ assert_eq!(cards[0].url, "https://p48-contacts.icloud.com/book/a.vcf"); |
|
| 454 |
+ assert!(parse_contacts(&base, &xml.replace("a.vcf", "https://example.com/a.vcf")).is_err());
|
|
| 455 |
+ } |
|
| 456 |
+ #[test] |
|
| 457 |
+ fn requires_a_strong_version_and_icloud_https() {
|
|
| 458 |
+ for version in [None, Some(""), Some("W/\"one\""), Some("*")] {
|
|
| 459 |
+ assert!(strong_etag(version).is_err()); |
|
| 460 |
+ } |
|
| 461 |
+ assert!(strong_etag(Some("\"one\"")).is_ok());
|
|
| 462 |
+ for url in [ |
|
| 463 |
+ "http://contacts.icloud.com/", |
|
| 464 |
+ "https://contacts.icloud.com.evil.test/", |
|
| 465 |
+ "https://evil.test/", |
|
| 466 |
+ "https://u@contacts.icloud.com/", |
|
| 467 |
+ ] {
|
|
| 468 |
+ assert!(checked_url(url).is_err()); |
|
| 469 |
+ } |
|
| 470 |
+ } |
|
| 471 |
+} |
| ... | ... |
@@ -0,0 +1,319 @@ |
| 1 |
+//! A lossless vCard editor: untouched properties (including photos and Apple |
|
| 2 |
+//! extensions) survive edits. Values are decoded only for the fields we expose. |
|
| 3 |
+use thiserror::Error; |
|
| 4 |
+ |
|
| 5 |
+#[derive(Debug, Error)] |
|
| 6 |
+#[error("This contact is not a supported vCard 3.0 or 4.0.")]
|
|
| 7 |
+pub struct VCardError; |
|
| 8 |
+ |
|
| 9 |
+#[derive(Clone, PartialEq, Eq)] |
|
| 10 |
+pub struct VCard {
|
|
| 11 |
+ lines: Vec<String>, |
|
| 12 |
+} |
|
| 13 |
+ |
|
| 14 |
+#[derive(Clone)] |
|
| 15 |
+pub struct CardField {
|
|
| 16 |
+ pub index: usize, |
|
| 17 |
+ pub name: String, |
|
| 18 |
+ pub label: String, |
|
| 19 |
+ pub values: Vec<String>, |
|
| 20 |
+} |
|
| 21 |
+ |
|
| 22 |
+fn property(line: &str) -> Option<(&str, &str, &str)> {
|
|
| 23 |
+ // A colon inside a quoted parameter is not the value delimiter. |
|
| 24 |
+ let mut quoted = false; |
|
| 25 |
+ let colon = line.char_indices().find_map(|(i, c)| {
|
|
| 26 |
+ if c == '"' {
|
|
| 27 |
+ quoted = !quoted; |
|
| 28 |
+ } |
|
| 29 |
+ (c == ':' && !quoted).then_some(i) |
|
| 30 |
+ })?; |
|
| 31 |
+ let head = &line[..colon]; |
|
| 32 |
+ let name = head.split(';').next()?.rsplit('.').next()?;
|
|
| 33 |
+ Some((head, name, &line[colon + 1..])) |
|
| 34 |
+} |
|
| 35 |
+ |
|
| 36 |
+fn decode(value: &str, structured: bool) -> Vec<String> {
|
|
| 37 |
+ let mut values = vec![String::new()]; |
|
| 38 |
+ let mut chars = value.chars(); |
|
| 39 |
+ while let Some(c) = chars.next() {
|
|
| 40 |
+ match c {
|
|
| 41 |
+ '\\' => {
|
|
| 42 |
+ if let Some(c) = chars.next() {
|
|
| 43 |
+ values |
|
| 44 |
+ .last_mut() |
|
| 45 |
+ .unwrap() |
|
| 46 |
+ .push(if matches!(c, 'n' | 'N') { '\n' } else { c });
|
|
| 47 |
+ } |
|
| 48 |
+ } |
|
| 49 |
+ ';' if structured => values.push(String::new()), |
|
| 50 |
+ _ => values.last_mut().unwrap().push(c), |
|
| 51 |
+ } |
|
| 52 |
+ } |
|
| 53 |
+ values |
|
| 54 |
+} |
|
| 55 |
+ |
|
| 56 |
+fn encode(value: &str) -> String {
|
|
| 57 |
+ value |
|
| 58 |
+ .replace('\\', "\\\\")
|
|
| 59 |
+ .replace("\r\n", "\n")
|
|
| 60 |
+ .replace('\r', "\n")
|
|
| 61 |
+ .replace('\n', "\\n")
|
|
| 62 |
+ .replace(';', "\\;")
|
|
| 63 |
+ .replace(',', "\\,")
|
|
| 64 |
+} |
|
| 65 |
+ |
|
| 66 |
+impl VCard {
|
|
| 67 |
+ pub fn parse(text: &str) -> Result<Self, VCardError> {
|
|
| 68 |
+ let mut lines: Vec<String> = Vec::new(); |
|
| 69 |
+ for line in text.lines() {
|
|
| 70 |
+ if (line.starts_with(' ') || line.starts_with('\t')) && !lines.is_empty() {
|
|
| 71 |
+ lines.last_mut().unwrap().push_str(&line[1..]); |
|
| 72 |
+ } else if !line.is_empty() {
|
|
| 73 |
+ lines.push(line.to_owned()); |
|
| 74 |
+ } |
|
| 75 |
+ } |
|
| 76 |
+ if lines |
|
| 77 |
+ .first() |
|
| 78 |
+ .is_none_or(|s| !s.eq_ignore_ascii_case("BEGIN:VCARD"))
|
|
| 79 |
+ || lines |
|
| 80 |
+ .last() |
|
| 81 |
+ .is_none_or(|s| !s.eq_ignore_ascii_case("END:VCARD"))
|
|
| 82 |
+ || !lines |
|
| 83 |
+ .iter() |
|
| 84 |
+ .any(|s| s == "VERSION:3.0" || s == "VERSION:4.0") |
|
| 85 |
+ || lines |
|
| 86 |
+ .iter() |
|
| 87 |
+ .filter(|s| s.eq_ignore_ascii_case("BEGIN:VCARD"))
|
|
| 88 |
+ .count() |
|
| 89 |
+ != 1 |
|
| 90 |
+ || lines.iter().any(|line| property(line).is_none()) |
|
| 91 |
+ {
|
|
| 92 |
+ return Err(VCardError); |
|
| 93 |
+ } |
|
| 94 |
+ Ok(Self { lines })
|
|
| 95 |
+ } |
|
| 96 |
+ |
|
| 97 |
+ pub fn new_contact() -> Self {
|
|
| 98 |
+ Self {
|
|
| 99 |
+ lines: vec![ |
|
| 100 |
+ "BEGIN:VCARD".into(), |
|
| 101 |
+ "VERSION:3.0".into(), |
|
| 102 |
+ format!("UID:{}", uuid::Uuid::new_v4()),
|
|
| 103 |
+ "FN:".into(), |
|
| 104 |
+ "N:;;;;".into(), |
|
| 105 |
+ "END:VCARD".into(), |
|
| 106 |
+ ], |
|
| 107 |
+ } |
|
| 108 |
+ } |
|
| 109 |
+ |
|
| 110 |
+ pub fn uid(&self) -> Option<&str> {
|
|
| 111 |
+ self.lines.iter().find_map(|line| {
|
|
| 112 |
+ let (_, name, value) = property(line)?; |
|
| 113 |
+ name.eq_ignore_ascii_case("UID").then_some(value)
|
|
| 114 |
+ }) |
|
| 115 |
+ } |
|
| 116 |
+ |
|
| 117 |
+ pub fn display_name(&self) -> String {
|
|
| 118 |
+ let fields = self.fields(); |
|
| 119 |
+ if let Some(field) = fields |
|
| 120 |
+ .iter() |
|
| 121 |
+ .find(|f| f.name == "FN" && !f.values[0].trim().is_empty()) |
|
| 122 |
+ {
|
|
| 123 |
+ return field.values[0].clone(); |
|
| 124 |
+ } |
|
| 125 |
+ if let Some(field) = fields.iter().find(|f| f.name == "N") {
|
|
| 126 |
+ let name = [3, 1, 2, 0, 4] |
|
| 127 |
+ .into_iter() |
|
| 128 |
+ .filter_map(|i| field.values.get(i)) |
|
| 129 |
+ .filter(|s| !s.trim().is_empty()) |
|
| 130 |
+ .cloned() |
|
| 131 |
+ .collect::<Vec<_>>() |
|
| 132 |
+ .join(" ");
|
|
| 133 |
+ if !name.is_empty() {
|
|
| 134 |
+ return name; |
|
| 135 |
+ } |
|
| 136 |
+ } |
|
| 137 |
+ fields |
|
| 138 |
+ .iter() |
|
| 139 |
+ .find(|f| matches!(f.name.as_str(), "ORG" | "EMAIL" | "TEL") && !f.values[0].is_empty()) |
|
| 140 |
+ .map(|f| f.values.join(" "))
|
|
| 141 |
+ .unwrap_or_else(|| "Unnamed contact".into()) |
|
| 142 |
+ } |
|
| 143 |
+ |
|
| 144 |
+ pub fn fields(&self) -> Vec<CardField> {
|
|
| 145 |
+ self.lines |
|
| 146 |
+ .iter() |
|
| 147 |
+ .enumerate() |
|
| 148 |
+ .filter_map(|(index, line)| {
|
|
| 149 |
+ let (head, name, value) = property(line)?; |
|
| 150 |
+ let name = name.to_ascii_uppercase(); |
|
| 151 |
+ let title = match name.as_str() {
|
|
| 152 |
+ "FN" => "Display name", |
|
| 153 |
+ "N" => "Name", |
|
| 154 |
+ "ORG" => "Company", |
|
| 155 |
+ "TITLE" => "Job title", |
|
| 156 |
+ "TEL" => "Phone", |
|
| 157 |
+ "EMAIL" => "Email", |
|
| 158 |
+ "ADR" => "Address", |
|
| 159 |
+ "BDAY" => "Birthday", |
|
| 160 |
+ "NOTE" => "Notes", |
|
| 161 |
+ _ => return None, |
|
| 162 |
+ }; |
|
| 163 |
+ // Non-text encodings require another editor; preserve them verbatim. |
|
| 164 |
+ if head.to_ascii_uppercase().contains("ENCODING=") {
|
|
| 165 |
+ return None; |
|
| 166 |
+ } |
|
| 167 |
+ let group = head |
|
| 168 |
+ .split(';')
|
|
| 169 |
+ .next() |
|
| 170 |
+ .unwrap_or("")
|
|
| 171 |
+ .rsplit_once('.')
|
|
| 172 |
+ .map(|(g, _)| g); |
|
| 173 |
+ let custom_label = group.and_then(|group| {
|
|
| 174 |
+ self.lines.iter().find_map(|l| {
|
|
| 175 |
+ let (h, n, v) = property(l)?; |
|
| 176 |
+ (n.eq_ignore_ascii_case("X-ABLabel") && h.starts_with(&format!("{group}.")))
|
|
| 177 |
+ .then(|| {
|
|
| 178 |
+ decode(v, false)[0] |
|
| 179 |
+ .trim_start_matches("_$!<")
|
|
| 180 |
+ .trim_end_matches(">!$_")
|
|
| 181 |
+ .to_owned() |
|
| 182 |
+ }) |
|
| 183 |
+ }) |
|
| 184 |
+ }); |
|
| 185 |
+ let kind = custom_label.or_else(|| {
|
|
| 186 |
+ head.split(';').skip(1).find_map(|p| {
|
|
| 187 |
+ let (key, value) = p.split_once('=')?;
|
|
| 188 |
+ key.eq_ignore_ascii_case("TYPE")
|
|
| 189 |
+ .then(|| value.trim_matches('"').replace(',', ", ").to_lowercase())
|
|
| 190 |
+ }) |
|
| 191 |
+ }); |
|
| 192 |
+ Some(CardField {
|
|
| 193 |
+ index, |
|
| 194 |
+ label: kind |
|
| 195 |
+ .map(|k| format!("{title} · {k}"))
|
|
| 196 |
+ .unwrap_or_else(|| title.into()), |
|
| 197 |
+ values: decode(value, matches!(name.as_str(), "N" | "ADR" | "ORG")), |
|
| 198 |
+ name, |
|
| 199 |
+ }) |
|
| 200 |
+ }) |
|
| 201 |
+ .collect() |
|
| 202 |
+ } |
|
| 203 |
+ |
|
| 204 |
+ pub fn set_field(&mut self, index: usize, values: &[String]) {
|
|
| 205 |
+ let Some(line) = self.lines.get(index) else {
|
|
| 206 |
+ return; |
|
| 207 |
+ }; |
|
| 208 |
+ let Some((head, _, _)) = property(line) else {
|
|
| 209 |
+ return; |
|
| 210 |
+ }; |
|
| 211 |
+ self.lines[index] = format!( |
|
| 212 |
+ "{head}:{}",
|
|
| 213 |
+ values |
|
| 214 |
+ .iter() |
|
| 215 |
+ .map(|v| encode(v)) |
|
| 216 |
+ .collect::<Vec<_>>() |
|
| 217 |
+ .join(";")
|
|
| 218 |
+ ); |
|
| 219 |
+ } |
|
| 220 |
+ |
|
| 221 |
+ /// Add a property from this editor's finite field set, never arbitrary input. |
|
| 222 |
+ pub fn add_field(&mut self, name: &str, label: &str) {
|
|
| 223 |
+ if !matches!( |
|
| 224 |
+ name, |
|
| 225 |
+ "FN" | "N" | "ORG" | "TITLE" | "TEL" | "EMAIL" | "ADR" | "BDAY" | "NOTE" |
|
| 226 |
+ ) {
|
|
| 227 |
+ return; |
|
| 228 |
+ } |
|
| 229 |
+ let count = match name {
|
|
| 230 |
+ "N" => 5, |
|
| 231 |
+ "ADR" => 7, |
|
| 232 |
+ _ => 1, |
|
| 233 |
+ }; |
|
| 234 |
+ let head = if matches!(name, "TEL" | "EMAIL" | "ADR") |
|
| 235 |
+ && matches!(label, "HOME" | "WORK" | "CELL") |
|
| 236 |
+ {
|
|
| 237 |
+ format!("{name};TYPE={label}")
|
|
| 238 |
+ } else {
|
|
| 239 |
+ name.into() |
|
| 240 |
+ }; |
|
| 241 |
+ self.lines.insert( |
|
| 242 |
+ self.lines.len() - 1, |
|
| 243 |
+ format!("{head}:{}", ";".repeat(count - 1)),
|
|
| 244 |
+ ); |
|
| 245 |
+ } |
|
| 246 |
+ |
|
| 247 |
+ pub fn remove_field(&mut self, index: usize) {
|
|
| 248 |
+ if index > 1 && index + 1 < self.lines.len() {
|
|
| 249 |
+ self.lines.remove(index); |
|
| 250 |
+ } |
|
| 251 |
+ } |
|
| 252 |
+ |
|
| 253 |
+ pub fn search_text(&self) -> String {
|
|
| 254 |
+ self.fields() |
|
| 255 |
+ .iter() |
|
| 256 |
+ .flat_map(|f| f.values.iter()) |
|
| 257 |
+ .cloned() |
|
| 258 |
+ .collect::<Vec<_>>() |
|
| 259 |
+ .join(" ")
|
|
| 260 |
+ .to_lowercase() |
|
| 261 |
+ } |
|
| 262 |
+ |
|
| 263 |
+ pub fn serialize(&self) -> String {
|
|
| 264 |
+ let mut out = String::new(); |
|
| 265 |
+ for line in &self.lines {
|
|
| 266 |
+ let mut used = 0; |
|
| 267 |
+ for c in line.chars() {
|
|
| 268 |
+ if used + c.len_utf8() > 75 {
|
|
| 269 |
+ out.push_str("\r\n ");
|
|
| 270 |
+ used = 1; |
|
| 271 |
+ } |
|
| 272 |
+ out.push(c); |
|
| 273 |
+ used += c.len_utf8(); |
|
| 274 |
+ } |
|
| 275 |
+ out.push_str("\r\n");
|
|
| 276 |
+ } |
|
| 277 |
+ out |
|
| 278 |
+ } |
|
| 279 |
+} |
|
| 280 |
+ |
|
| 281 |
+#[cfg(test)] |
|
| 282 |
+mod tests {
|
|
| 283 |
+ use super::*; |
|
| 284 |
+ #[test] |
|
| 285 |
+ fn editing_preserves_extensions_photos_parameters_and_structured_values() {
|
|
| 286 |
+ let text = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Zoë Test\r\nN:Test;Zoë;;;\r\nitem1.EMAIL;TYPE=HOME:z@example.invalid\r\nitem1.X-ABLabel:_$!<Home>!$_\r\nPHOTO;ENCODING=b:YWJj\r\n ZGVm\r\nX-UNKNOWN:keep\\,this\r\nEND:VCARD\r\n"; |
|
| 287 |
+ let mut card = VCard::parse(text).unwrap(); |
|
| 288 |
+ assert_eq!(card.display_name(), "Zoë Test"); |
|
| 289 |
+ assert_eq!(card.fields()[2].label, "Email · Home"); |
|
| 290 |
+ card.set_field(4, &["new@example.invalid".into()]); |
|
| 291 |
+ let out = card.serialize(); |
|
| 292 |
+ assert!(out.contains("item1.EMAIL;TYPE=HOME:new@example.invalid"));
|
|
| 293 |
+ assert!(out.contains("PHOTO;ENCODING=b:YWJjZGVm"));
|
|
| 294 |
+ assert!(out.contains("X-UNKNOWN:keep\\,this"));
|
|
| 295 |
+ assert!(out.contains("N:Test;Zoë;;;"));
|
|
| 296 |
+ } |
|
| 297 |
+ #[test] |
|
| 298 |
+ fn text_and_unicode_folding_round_trip_without_property_injection() {
|
|
| 299 |
+ let mut card = VCard::new_contact(); |
|
| 300 |
+ card.add_field("NOTE", "");
|
|
| 301 |
+ let index = card.fields().last().unwrap().index; |
|
| 302 |
+ let note = format!("{},;\\\nEMAIL:injected", "é".repeat(100));
|
|
| 303 |
+ card.set_field(index, &[note.clone()]); |
|
| 304 |
+ let text = card.serialize(); |
|
| 305 |
+ assert!(text.lines().all(|l| l.len() <= 75)); |
|
| 306 |
+ let decoded = VCard::parse(&text).unwrap(); |
|
| 307 |
+ assert_eq!(decoded.fields().last().unwrap().values, [note]); |
|
| 308 |
+ assert!(!decoded.fields().iter().any(|f| f.name == "EMAIL")); |
|
| 309 |
+ } |
|
| 310 |
+ #[test] |
|
| 311 |
+ fn quoted_parameter_colons_and_escaped_component_separators() {
|
|
| 312 |
+ let card = VCard::parse( |
|
| 313 |
+ "BEGIN:VCARD\nVERSION:4.0\nFN:A\nADR;LABEL=\"a:b\":;;Road\\; Lane;City;;;\nEND:VCARD", |
|
| 314 |
+ ) |
|
| 315 |
+ .unwrap(); |
|
| 316 |
+ assert_eq!(card.fields()[1].values[2], "Road; Lane"); |
|
| 317 |
+ assert!(VCard::parse("BEGIN:VCARD\nVERSION:2.1\nEND:VCARD").is_err());
|
|
| 318 |
+ } |
|
| 319 |
+} |
|
| 0 | 320 |