Add instructions for agents, and a few minor API adjustments and additions.
Dadoum

Dadoum commited on 2026-10-27 03:57:30
Showing 16 changed files, with 1350 additions and 64 deletions.

... ...
@@ -0,0 +1,5 @@
1
+# Code style
2
+
3
+Follow `CODE_STYLE.md` for new code. It summarises the handwritten `adi`, `grandslam` and `xcode`
4
+crates, which remain the reference when the two disagree. Run `cargo fmt -p <package>` on the
5
+packages you change only.
... ...
@@ -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.
... ...
@@ -7,6 +7,7 @@ edition = "2024"
7 7
 adi.workspace = true
8 8
 plist.workspace = true
9 9
 plist-macros.workspace = true
10
+serde_json = "1"
10 11
 
11 12
 reqwest.workspace = true
12 13
 thiserror.workspace = true
... ...
@@ -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
+}
... ...
@@ -13,3 +13,4 @@ thiserror.workspace = true
13 13
 serde = { version = "1", features = ["derive"] }
14 14
 serde_json = "1"
15 15
 uuid = { version = "1.19", features = ["v4"] }
16
+roxmltree = "0.20"
... ...
@@ -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&#13;
421
+EMAIL:a@b.c&#13;
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
+}
... ...
@@ -2,6 +2,11 @@
2 2
 
3 3
 mod account;
4 4
 mod cloudkit;
5
+mod contacts;
5 6
 
6 7
 pub use account::*;
7 8
 pub use cloudkit::*;
9
+pub use contacts::*;
10
+
11
+mod vcard;
12
+pub use vcard::*;
... ...
@@ -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