Implement an icloud crate
Dadoum

Dadoum commited on 2026-10-02 15:14:52
Showing 26 changed files, with 1359 additions and 75 deletions.


Add Xcode downloads
Update APS
Refine grandslam API
... ...
@@ -1 +1 @@
1
-/target
1
+target/
... ...
@@ -3475,6 +3475,21 @@ dependencies = [
3475 3475
  "cc",
3476 3476
 ]
3477 3477
 
3478
+[[package]]
3479
+name = "icloud"
3480
+version = "0.1.0"
3481
+dependencies = [
3482
+ "adi",
3483
+ "grandslam",
3484
+ "plist",
3485
+ "plist-macros",
3486
+ "reqwest",
3487
+ "serde",
3488
+ "serde_json",
3489
+ "thiserror 2.0.18",
3490
+ "uuid",
3491
+]
3492
+
3478 3493
 [[package]]
3479 3494
 name = "icu_collections"
3480 3495
 version = "2.2.0"
... ...
@@ -6111,6 +6126,27 @@ dependencies = [
6111 6126
  "zeroize",
6112 6127
 ]
6113 6128
 
6129
+[[package]]
6130
+name = "rust-adi"
6131
+version = "0.1.0"
6132
+dependencies = [
6133
+ "adi",
6134
+ "aes 0.9.1",
6135
+ "base64",
6136
+ "cbc 0.2.1",
6137
+ "getrandom 0.4.3",
6138
+ "grandslam",
6139
+ "hex",
6140
+ "md-5",
6141
+ "plist",
6142
+ "reqwest",
6143
+ "serde_json",
6144
+ "sha1 0.11.0",
6145
+ "sha2 0.10.9",
6146
+ "tempfile",
6147
+ "tokio",
6148
+]
6149
+
6114 6150
 [[package]]
6115 6151
 name = "rust-embed"
6116 6152
 version = "8.11.0"
... ...
@@ -7113,7 +7149,6 @@ dependencies = [
7113 7149
  "crc32fast",
7114 7150
  "der",
7115 7151
  "directories",
7116
- "dlopen2",
7117 7152
  "env_logger",
7118 7153
  "flate2",
7119 7154
  "futures",
... ...
@@ -7128,12 +7163,12 @@ dependencies = [
7128 7163
  "libc",
7129 7164
  "library-coreadi",
7130 7165
  "log",
7131
- "ouroboros",
7132 7166
  "pem-rfc7468",
7133 7167
  "plist",
7134 7168
  "rand 0.10.2",
7135 7169
  "reqwest",
7136 7170
  "rsa",
7171
+ "rust-adi",
7137 7172
  "serde",
7138 7173
  "sha1 0.11.0",
7139 7174
  "sha2 0.10.9",
... ...
@@ -1,6 +1,6 @@
1 1
 [workspace]
2 2
 resolver = "2"
3
-members = ["adi", "adid-proxy", "android-coreadi", "aps", "grandslam", "apple-codesign-quick", "elf-loader", "fairplay", "itunes", "library-coreadi", "plist-macros", "purple-builder", "super-sideloader", "xcode"]
3
+members = ["adi", "adid-proxy", "android-coreadi", "aps", "grandslam", "apple-codesign-quick", "elf-loader", "fairplay", "icloud", "itunes", "library-coreadi", "plist-macros", "purple-builder", "super-sideloader", "xcode"]
4 4
 
5 5
 [workspace.dependencies]
6 6
 adi = { path = "adi" }
... ...
@@ -9,6 +9,7 @@ android-coreadi = { path = "android-coreadi" }
9 9
 aps = { path = "aps" }
10 10
 elf-loader = { path = "elf-loader" }
11 11
 grandslam = { path = "grandslam" }
12
+icloud = { path = "icloud" }
12 13
 itunes = { path = "itunes" }
13 14
 library-coreadi = { path = "library-coreadi" }
14 15
 plist-macros = { path = "plist-macros" }
... ...
@@ -11,7 +11,7 @@ Here is a breakdown of every crate here:
11 11
 
12 12
 ### adi
13 13
 
14
-Abstracts an ADI connection, allows direct communication with ADI code.
14
+Abstracts an ADI connection, allows communication with ADI code.
15 15
 
16 16
 ### adid-proxy
17 17
 
... ...
@@ -401,7 +401,7 @@ impl<T: CoreADIADIProxy> ADIProxy for T {
401 401
     fn get_all_provisioned_accounts(&self) -> ADIResult<Vec<ADIAccount>> {
402 402
         let coreadi_accounts = <Self as CoreADIADIProxy>::get_all_provisioned_accounts(self)?;
403 403
         let accounts: Vec<ADIAccount> = unsafe {
404
-            (&*coreadi_accounts).iter().map(
404
+            coreadi_accounts.iter().map(
405 405
                 |&CoreADIAccount {
406 406
                      ds_id,
407 407
                      mid_len,
... ...
@@ -465,7 +465,7 @@ impl<T: CoreADIADIProxy> ADIProxy for T {
465 465
         let (mid_buf, otp_buf) = <Self as CoreADIADIProxy>::request_otp(self, ds_id)?;
466 466
         let (mid, otp) = (mid_buf.to_vec(), otp_buf.to_vec());
467 467
         self.dispose(mid_buf).and_then(|()| self.dispose(otp_buf))?;
468
-        Ok((mid.into(), otp.into()))
468
+        Ok((mid, otp))
469 469
     }
470 470
 
471 471
     fn set_provisioning_path(&self, path: &CStr) -> ADIResult<()> {
... ...
@@ -31,7 +31,7 @@ impl Serialize for EncryptionType {
31 31
 }
32 32
 
33 33
 #[repr(i32)]
34
-#[derive(Debug)]
34
+#[derive(Debug, PartialEq, Eq)]
35 35
 pub enum ADIError {
36 36
     // -45001 - Invalid parameters, have all the library fields been properly initialized?
37 37
     InvalidParameters = -45001,
... ...
@@ -19,10 +19,16 @@ let device_data = grandslam::DeviceData {
19 19
 
20 20
 connection.set_topics(&["com.apple.idmsauth"]).await?;
21 21
 loop {
22
-    let notification = connection.receive().await?;
22
+    match connection.receive().await? {
23
+        aps::Event::Notification(notification) => {
23 24
             // Accept or store notification.payload according to its topic.
24 25
             connection.acknowledge(&notification).await?;
25 26
         }
27
+        aps::Event::Acknowledgement { id, status } => {
28
+            // Match id to an outgoing send; status is Accepted or Rejected(code).
29
+        }
30
+    }
31
+}
26 32
 ```
27 33
 
28 34
 `ActivationDevice` contains the actual device fields sent in the activation
... ...
@@ -33,7 +39,7 @@ and CSR. A returned `PushIdentity` always contains the matching device key and
33 39
 certificate, and a returned `PushConnection` always has a token.
34 40
 
35 41
 Errors follow those boundaries: `IdentityError`, `ActivationError<S::Error>`,
36
-`ConnectError`, and `CourierError`. Signer errors keep their concrete type.
42
+`ConnectError`, `SendError`, and `CourierError`. Signer errors keep their concrete type.
37 43
 Connecting moves the identity into the connection; reconnecting reuses it
38 44
 without cloning the private key.
39 45
 
... ...
@@ -43,8 +49,9 @@ signer uses embedded constants and Rust cryptography.
43 49
 
44 50
 ## Connection lifetime and persistence
45 51
 
46
-`receive` returns notifications and handles ping/pong traffic. Call `acknowledge`
47
-after accepting or storing each notification; it borrows the notification, so
52
+`receive` returns notifications or outgoing-send acknowledgements and handles
53
+ping/pong traffic. Call `acknowledge` after accepting or storing each notification;
54
+it borrows the notification, so
48 55
 an acknowledgement error leaves it available to the caller. Keep calling
49 56
 `receive` while expecting pushes. There are no hidden background tasks or retry
50 57
 loops. Call `reconnect` after transport failure; it reuses the identity/token
... ...
@@ -53,6 +60,28 @@ token's existence does not mean a connection is currently reachable.
53 60
 If cancelling `acknowledge` during its write, or `receive` during a keepalive
54 61
 write, reconnect before continuing.
55 62
 
63
+`send(id, topic, payload)` writes unchanged payload bytes. Only one send can await
64
+acknowledgement at a time; attempting another returns
65
+`SendError::PendingAcknowledgement`. Use a new ID for each send. Continue calling
66
+`receive`, processing notifications as usual, until `Event::Acknowledgement`
67
+reports that ID. The courier can omit the ID on the wire, in which case it is
68
+associated with the one outstanding send, as in macOS. An explicit nonmatching
69
+ID is ignored. `SendStatus::Accepted` means courier acceptance, not delivery to a
70
+recipient. Nonzero response codes are preserved as `SendStatus::Rejected(code)`.
71
+
72
+`max_payload_size()` exposes the courier's advertised limit; larger payloads are
73
+rejected before writing or occupying the pending slot. If the courier omits the
74
+limit, the two-byte TLV length is the local ceiling, not a promise of acceptance.
75
+
76
+`receive` reports `CourierError::AcknowledgementTimeout(id)` after 30 seconds
77
+without a matching acknowledgement. This is the crate's existing operation
78
+timeout policy; callers can impose shorter deadlines. The caller owns retry
79
+policy. After a timeout, transport failure, or cancelled send, reconnect before
80
+sending again. Reconnect
81
+clears the outstanding send without replaying it: its outcome is unknown, and
82
+retrying could duplicate delivery. Payload encoding and IDS service registration
83
+remain the caller's responsibility.
84
+
56 85
 Persist the device certificate, private key, and latest token together.
57 86
 `PushIdentity::certificate` returns DER bytes; `private_key` exposes the RSA key
58 87
 for PKCS#8 export or a future adapter. `PushIdentity::new` validates imported
... ...
@@ -83,6 +112,9 @@ once. No DLL or extraction step is needed. Signing errors use `rsa::Error`.
83 112
 cargo run -p aps --example push_token -- /path/to/push-state.plist
84 113
 ```
85 114
 
115
+Add `--check-connection` to drive receive/keepalive for 95 seconds, reconnect,
116
+and report whether the token was preserved. It does not print tokens or payloads.
117
+
86 118
 The example activates on its first run, subscribes to `com.apple.idmsauth`,
87 119
 saves the identity/token, and closes the connection. Subsequent runs restore
88 120
 that state. The state file contains a private key; the example requests mode
... ...
@@ -94,7 +126,10 @@ wrapper is at `0x180005250`. The APS protocol was cross-checked against Apple's
94 126
 Windows client and rustpush's public protocol implementation. This crate does
95 127
 not depend on rustpush or its account APIs.
96 128
 
97
-See [the signing format notes](docs/windows-signing.md) for the decoded wrapping
129
+Outgoing messages and acknowledgement handling were also checked against
130
+macOS 27's `apsd`; see [the comparison notes](../research/aps/docs/macos-protocol.md).
131
+
132
+See [the signing format notes](../research/aps/docs/windows-signing.md) for the decoded wrapping
98 133
 format and its provenance.
99 134
 
100 135
 All ten embedded identities passed independent RSA signature verification.
... ...
@@ -1,4 +1,4 @@
1
-//! cargo run -p aps --example push_token -- STATE.plist
1
+//! cargo run -p aps --example push_token -- STATE.plist [--check-connection]
2 2
 use aps::{ActivationDevice, PushConnection, PushIdentity, PushToken, WindowsActivationSigner};
3 3
 use plist::Data;
4 4
 use rsa::{
... ...
@@ -11,7 +11,7 @@ use thiserror::Error;
11 11
 
12 12
 #[derive(Debug, Error)]
13 13
 enum ExampleError {
14
-    #[error("usage: push_token STATE.plist")]
14
+    #[error("usage: push_token STATE.plist [--check-connection]")]
15 15
     Usage,
16 16
     #[error(transparent)]
17 17
     Io(#[from] io::Error),
... ...
@@ -41,7 +41,7 @@ struct SavedState {
41 41
 #[tokio::main]
42 42
 async fn main() -> Result<(), ExampleError> {
43 43
     let args: Vec<_> = std::env::args_os().skip(1).collect();
44
-    if args.len() != 1 {
44
+    if args.is_empty() || args.len() > 2 || (args.len() == 2 && args[1] != "--check-connection") {
45 45
         return Err(ExampleError::Usage);
46 46
     }
47 47
     let state_path = PathBuf::from(&args[0]);
... ...
@@ -109,6 +109,38 @@ async fn main() -> Result<(), ExampleError> {
109 109
         connection.push_token().as_bytes().len(),
110 110
         state_path.display()
111 111
     );
112
+    println!(
113
+        "Courier payload limit: {} bytes",
114
+        connection.max_payload_size()
115
+    );
116
+    if args.len() == 2 {
117
+        // Drive the first keepalive (60s) and its response deadline (30s).
118
+        // Do not print received payloads or credential values.
119
+        let until = tokio::time::Instant::now() + Duration::from_secs(95);
120
+        loop {
121
+            match tokio::time::timeout_at(until, connection.receive()).await {
122
+                Ok(Ok(aps::Event::Notification(notification))) => {
123
+                    connection.acknowledge(&notification).await?;
124
+                }
125
+                Ok(Ok(aps::Event::Acknowledgement { .. })) => {}
126
+                Ok(Err(error)) => return Err(error.into()),
127
+                Err(_) => break,
128
+            }
129
+        }
130
+        println!("95-second receive/keepalive check passed");
131
+        let token = connection.push_token().clone();
132
+        connection.reconnect(&client).await?;
133
+        println!(
134
+            "Reconnected; token unchanged: {}",
135
+            connection.push_token() == &token
136
+        );
137
+        // Persist again because reconnect may replace the token.
138
+        let state = SavedState {
139
+            token: connection.push_token().clone(),
140
+            ..state
141
+        };
142
+        plist::to_writer_xml(options.open(&state_path)?, &state)?;
143
+    }
112 144
     connection.close().await?;
113 145
     Ok(())
114 146
 }
... ...
@@ -92,6 +92,28 @@ pub struct Notification {
92 92
     pub payload: Bytes,
93 93
 }
94 94
 
95
+/// The courier's response to an outgoing message, not a recipient delivery receipt.
96
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97
+pub enum SendStatus {
98
+    Accepted,
99
+    Rejected(u8),
100
+}
101
+
102
+#[derive(Debug, Error)]
103
+pub enum SendError {
104
+    #[error("APS message {0} is still awaiting acknowledgement")]
105
+    PendingAcknowledgement(u32),
106
+    #[error("APS payload is {size} bytes, exceeding the courier limit of {maximum}")]
107
+    PayloadTooLarge { size: usize, maximum: u16 },
108
+    #[error("Sending the APS message failed: {0}")]
109
+    Courier(#[from] CourierError),
110
+}
111
+
112
+pub enum Event {
113
+    Notification(Notification),
114
+    Acknowledgement { id: u32, status: SendStatus },
115
+}
116
+
95 117
 /// A completed APS handshake and its live courier transport.
96 118
 /// Call `receive` to drive notifications and keepalive. No tasks are spawned.
97 119
 pub struct PushConnection {
... ...
@@ -102,6 +124,8 @@ pub struct PushConnection {
102 124
     topics: Vec<[u8; 20]>,
103 125
     next_ping: Instant,
104 126
     pong_deadline: Option<Instant>,
127
+    pending_send: Option<(u32, Instant)>,
128
+    max_payload_size: u16,
105 129
 }
106 130
 
107 131
 impl PushConnection {
... ...
@@ -125,7 +149,8 @@ impl PushConnection {
125 149
         identity: PushIdentity,
126 150
         previous: Option<&PushToken>,
127 151
     ) -> Result<Self, ConnectError> {
128
-        let (stream, reader, token) = timeout(TIMEOUT, handshake(client, &identity, previous))
152
+        let (stream, reader, token, max_payload_size) =
153
+            timeout(TIMEOUT, handshake(client, &identity, previous))
129 154
                 .await
130 155
                 .map_err(|_| ConnectError::Timeout)??;
131 156
         Ok(Self {
... ...
@@ -136,6 +161,8 @@ impl PushConnection {
136 161
             topics: Vec::new(),
137 162
             next_ping: Instant::now() + KEEPALIVE,
138 163
             pong_deadline: None,
164
+            pending_send: None,
165
+            max_payload_size,
139 166
         })
140 167
     }
141 168
 
... ...
@@ -147,10 +174,16 @@ impl PushConnection {
147 174
         &self.identity
148 175
     }
149 176
 
177
+    /// The courier's advertised payload limit, or the TLV limit if omitted.
178
+    pub fn max_payload_size(&self) -> u16 {
179
+        self.max_payload_size
180
+    }
181
+
150 182
     /// Reuses the current identity/token and restores topics. Failed attempts
151 183
     /// leave the existing identity and token available for another attempt.
184
+    /// An unacknowledged send has an unknown outcome and is not retried.
152 185
     pub async fn reconnect(&mut self, client: &Client) -> Result<(), ConnectError> {
153
-        let (mut stream, reader, token) = timeout(
186
+        let (mut stream, reader, token, max_payload_size) = timeout(
154 187
             TIMEOUT,
155 188
             handshake(client, &self.identity, Some(&self.token)),
156 189
         )
... ...
@@ -167,6 +200,8 @@ impl PushConnection {
167 200
         self.token = token;
168 201
         self.next_ping = Instant::now() + KEEPALIVE;
169 202
         self.pong_deadline = None;
203
+        self.pending_send = None;
204
+        self.max_payload_size = max_payload_size;
170 205
         Ok(())
171 206
     }
172 207
 
... ...
@@ -186,15 +221,60 @@ impl PushConnection {
186 221
         Ok(())
187 222
     }
188 223
 
189
-    /// Receives the next notification, driving keepalive while waiting.
190
-    /// Call `acknowledge` once the notification is accepted or stored.
224
+    /// Writes an outgoing message without waiting for its acknowledgement.
225
+    /// Only one send may await acknowledgement at a time: the courier can omit
226
+    /// its message ID. `receive` returns the supplied `id` in the acknowledgement.
227
+    /// Use a new ID for each send. Payload bytes are sent unchanged.
228
+    /// A successful write does not imply courier acceptance or recipient delivery.
229
+    /// Reconnect after I/O errors, timeouts, or cancellation during the write.
230
+    pub async fn send(&mut self, id: u32, topic: &str, payload: &[u8]) -> Result<(), SendError> {
231
+        if let Some((id, _)) = self.pending_send {
232
+            return Err(SendError::PendingAcknowledgement(id));
233
+        }
234
+        // Validate before marking the send in flight. The remaining fields
235
+        // have fixed sizes and fit within the frame limit.
236
+        if payload.len() > usize::from(self.max_payload_size) {
237
+            return Err(SendError::PayloadTooLarge {
238
+                size: payload.len(),
239
+                maximum: self.max_payload_size,
240
+            });
241
+        }
242
+        // Set before the write so cancellation cannot permit a second send.
243
+        self.pending_send = Some((id, Instant::now() + TIMEOUT));
244
+        Message::Send {
245
+            token: &self.token,
246
+            topic: &Sha1::digest(topic.as_bytes()).into(),
247
+            id,
248
+            payload,
249
+        }
250
+        .write(&mut self.stream)
251
+        .await?;
252
+        Ok(())
253
+    }
254
+
255
+    /// Receives the next notification or send acknowledgement, driving keepalive.
256
+    /// Call `acknowledge` once a notification is accepted or stored.
257
+    /// Send acknowledgements do not themselves require acknowledgement.
258
+    /// An outgoing send times out after 30 seconds. Reconnect afterward; its
259
+    /// outcome is unknown and it is not replayed.
191 260
     /// Reconnect after errors or cancellation during a keepalive write.
192
-    pub async fn receive(&mut self) -> Result<Notification, CourierError> {
261
+    pub async fn receive(&mut self) -> Result<Event, CourierError> {
193 262
         loop {
194
-            let deadline = self.pong_deadline.unwrap_or(self.next_ping);
263
+            let mut deadline = self.pong_deadline.unwrap_or(self.next_ping);
264
+            if let Some((id, expires)) = self.pending_send {
265
+                // Check before reading so incoming traffic cannot starve the timeout.
266
+                if Instant::now() >= expires {
267
+                    return Err(CourierError::AcknowledgementTimeout(id));
268
+                }
269
+                deadline = deadline.min(expires);
270
+            }
195 271
             let frame = tokio::select! {
196 272
                 frame = self.reader.read(&mut self.stream) => frame?,
197 273
                 _ = sleep_until(deadline) => {
274
+                    if let Some((id, expires)) = self.pending_send
275
+                        && Instant::now() >= expires {
276
+                        return Err(CourierError::AcknowledgementTimeout(id));
277
+                    }
198 278
                     if self.pong_deadline.is_some() { return Err(CourierError::Timeout); }
199 279
                     Message::Ping.write(&mut self.stream).await?;
200 280
                     self.pong_deadline = Some(Instant::now() + TIMEOUT);
... ...
@@ -209,18 +289,48 @@ impl PushConnection {
209 289
                             .try_into()
210 290
                             .map_err(|_| CourierError::InvalidField(4))?,
211 291
                     );
292
+                    let token = frame.get(1).unwrap_or(self.token.as_bytes());
293
+                    if token != self.token.as_bytes() {
294
+                        return Err(CourierError::TokenMismatch);
295
+                    }
212 296
                     let topic = frame
213 297
                         .required(2)?
214 298
                         .try_into()
215 299
                         .map_err(|_| CourierError::InvalidField(2))?;
300
+                    return Ok(Event::Notification(Notification {
301
+                        id,
302
+                        topic,
303
+                        payload: frame.into_field(3)?,
304
+                    }));
305
+                }
306
+                Command::Ack => {
307
+                    let Some((id, _)) = self.pending_send else {
308
+                        continue;
309
+                    };
216 310
                     let token = frame.get(1).unwrap_or(self.token.as_bytes());
217 311
                     if token != self.token.as_bytes() {
218 312
                         return Err(CourierError::TokenMismatch);
219 313
                     }
220
-                    return Ok(Notification {
314
+                    if let Some(received_id) = frame.get(4) {
315
+                        let received_id = u32::from_be_bytes(
316
+                            received_id
317
+                                .try_into()
318
+                                .map_err(|_| CourierError::InvalidField(4))?,
319
+                        );
320
+                        if received_id != id {
321
+                            continue;
322
+                        }
323
+                    }
324
+                    let [status] = frame.required(8)? else {
325
+                        return Err(CourierError::InvalidField(8));
326
+                    };
327
+                    self.pending_send = None;
328
+                    return Ok(Event::Acknowledgement {
221 329
                         id,
222
-                        topic,
223
-                        payload: frame.into_field(3)?,
330
+                        status: match status {
331
+                            0 => SendStatus::Accepted,
332
+                            status => SendStatus::Rejected(*status),
333
+                        },
224 334
                     });
225 335
                 }
226 336
                 Command::Ping => Message::Pong.write(&mut self.stream).await?,
... ...
@@ -255,7 +365,7 @@ async fn handshake(
255 365
     client: &Client,
256 366
     identity: &PushIdentity,
257 367
     previous: Option<&PushToken>,
258
-) -> Result<(TlsStream<TcpStream>, FrameReader, PushToken), ConnectError> {
368
+) -> Result<(TlsStream<TcpStream>, FrameReader, PushToken, u16), ConnectError> {
259 369
     let body = client
260 370
         .get("https://init.push.apple.com/bag")
261 371
         .send()
... ...
@@ -332,6 +442,14 @@ async fn handshake(
332 442
         ),
333 443
         None => previous.cloned().ok_or(CourierError::MissingField(3))?,
334 444
     };
445
+    let max_payload_size = match reply.get(4) {
446
+        Some(bytes) => u16::from_be_bytes(
447
+            bytes
448
+                .try_into()
449
+                .map_err(|_| CourierError::InvalidField(4))?,
450
+        ),
451
+        None => u16::MAX,
452
+    };
335 453
     Message::SetActive.write(&mut stream).await?;
336
-    Ok((stream, reader, token))
454
+    Ok((stream, reader, token, max_payload_size))
337 455
 }
... ...
@@ -12,7 +12,9 @@ pub use activation::{
12 12
     ActivationDevice, ActivationError, ActivationSignature, ActivationSigner, IdentityError,
13 13
     PushIdentity, activate,
14 14
 };
15
-pub use connection::{ConnectError, Notification, PushConnection, PushToken};
15
+pub use connection::{
16
+    ConnectError, Event, Notification, PushConnection, PushToken, SendError, SendStatus,
17
+};
16 18
 pub use protocol::CourierError;
17 19
 pub use windows_signer::WindowsActivationSigner;
18 20
 
... ...
@@ -49,6 +49,12 @@ pub(crate) enum Message<'a> {
49 49
         token: &'a PushToken,
50 50
         topics: &'a [[u8; 20]],
51 51
     },
52
+    Send {
53
+        token: &'a PushToken,
54
+        topic: &'a [u8; 20],
55
+        id: u32,
56
+        payload: &'a [u8],
57
+    },
52 58
     Ack {
53 59
         token: &'a PushToken,
54 60
         id: u32,
... ...
@@ -78,8 +84,8 @@ impl Message<'_> {
78 84
                 frame.push(12, certificate)?;
79 85
                 frame.push(13, nonce)?;
80 86
                 frame.push(14, signature)?;
81
-                // Version-like field used by rustpush; this Windows build sends 5.
82
-                // Its precise semantics are not established.
87
+                // Protocol version used by rustpush. Windows sends 5; macOS 27
88
+                // sends 12 with additional capabilities we do not implement.
83 89
                 frame.push(16, 9u16.to_be_bytes())?;
84 90
                 frame
85 91
             }
... ...
@@ -91,6 +97,20 @@ impl Message<'_> {
91 97
                 }
92 98
                 frame
93 99
             }
100
+            Self::Send {
101
+                token,
102
+                topic,
103
+                id,
104
+                payload,
105
+            } => {
106
+                let mut frame = Frame::new(Command::Notification);
107
+                frame.push(4, id.to_be_bytes())?;
108
+                // Outgoing messages reverse the incoming token/topic fields.
109
+                frame.push(1, topic)?;
110
+                frame.push(2, token.as_bytes())?;
111
+                frame.push(3, payload)?;
112
+                frame
113
+            }
94 114
             Self::Ack { token, id } => {
95 115
                 let mut frame = Frame::new(Command::Ack);
96 116
                 frame.push(1, token.as_bytes())?;
... ...
@@ -128,10 +148,12 @@ pub enum CourierError {
128 148
     InvalidField(u8),
129 149
     #[error("Unexpected APS command {0}")]
130 150
     UnexpectedCommand(u8),
131
-    #[error("The notification belongs to another push token")]
151
+    #[error("The message belongs to another push token")]
132 152
     TokenMismatch,
133 153
     #[error("Courier operation timed out")]
134 154
     Timeout,
155
+    #[error("APS message {0} was not acknowledged before the deadline; reconnect before retrying")]
156
+    AcknowledgementTimeout(u32),
135 157
 }
136 158
 
137 159
 // command:u8, length:u32, then (id:u8, length:u16, value), all big endian.
... ...
@@ -51,14 +51,20 @@ const ROOT_CA: &[u8] = include_bytes!("grandslam/root-ca.pem");
51 51
 ///
52 52
 /// # Examples
53 53
 ///
54
-/// ```
54
+/// ```no_run
55
+/// use grandslam::bundle_information::AUTH_KIT_BUNDLE_INFORMATION;
56
+/// use grandslam::device::Device;
57
+///
58
+/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
55 59
 /// let device = Device {
56 60
 ///     device_model: "MacBookPro13,2".to_string(),
57 61
 ///     operating_system_information: "macOS;15.6.1;24G90".to_string(),
58 62
 ///     device_uuid: "A8B31C86-359B-4D95-8950-BA5DD8FFC46F".to_string(),
59 63
 /// };
60 64
 ///
61
-/// let grandslam_http_session = grandslam::http_session(device, XCODE_BUNDLE_INFORMATION).await?;
65
+/// let grandslam_http_session = grandslam::http_session(device, AUTH_KIT_BUNDLE_INFORMATION).await?;
66
+/// # Ok(())
67
+/// # }
62 68
 /// ```
63 69
 pub async fn http_session(
64 70
     device: Device,
... ...
@@ -410,8 +416,7 @@ async fn login_with_credential(
410 416
         buf
411 417
     };
412 418
 
413
-    let verifier =
414
-        srp_client.process_reply(&a, user.as_bytes(), &processed_password, salt, b)?;
419
+    let verifier = srp_client.process_reply(&a, user.as_bytes(), &processed_password, salt, b)?;
415 420
 
416 421
     let request_plist = dict! {
417 422
         "Header": dict!{
... ...
@@ -1,4 +1,4 @@
1
-use super::app::{AppToken, Token, TokenBag};
1
+use super::app::{AppToken, RawToken, Token, TokenBag};
2 2
 use crate::grandslam::{ContinuationToken, build_client_provided_data};
3 3
 use crate::http_session::{AnisetteHTTPSession, AppleError, parse_status};
4 4
 use adi::proxy::{ADIError, ADIResult};
... ...
@@ -64,6 +64,63 @@ pub struct SessionKey(Vec<u8>);
64 64
 #[serde(transparent)]
65 65
 pub struct AuthCookie(Vec<u8>);
66 66
 
67
+/// Credentials used together by GrandSlam's `apptokens` operation.
68
+/// Kept separate from service tokens and the continuation token; never logged.
69
+#[derive(Clone, Serialize, Deserialize)]
70
+pub struct MasterToken {
71
+    idms_token: IdmsToken,
72
+    session_key: SessionKey,
73
+    cookie: AuthCookie,
74
+}
75
+
76
+impl MasterToken {
77
+    pub fn new(idms_token: IdmsToken, session_key: SessionKey, cookie: AuthCookie) -> Self {
78
+        Self {
79
+            idms_token,
80
+            session_key,
81
+            cookie,
82
+        }
83
+    }
84
+
85
+    /// Web authentication replaces all three credentials together.
86
+    pub fn from_response_headers(
87
+        headers: &reqwest::header::HeaderMap,
88
+    ) -> Result<Self, ResponseTokenError> {
89
+        use base64::{Engine, prelude::BASE64_STANDARD};
90
+        let name = "x-apple-identity-token";
91
+        let mut values = headers.get_all(name).iter();
92
+        let value = values
93
+            .next()
94
+            .ok_or(ResponseTokenError::InvalidHeader(name))?;
95
+        if values.next().is_some() {
96
+            return Err(ResponseTokenError::InvalidHeader(name));
97
+        }
98
+        let (_, token) = header_token(value, name)?;
99
+        let decode = |name| {
100
+            let value =
101
+                header_text(headers, name)?.ok_or(ResponseTokenError::InvalidHeader(name))?;
102
+            BASE64_STANDARD
103
+                .decode(value)
104
+                .map_err(|_| ResponseTokenError::InvalidHeader(name))
105
+        };
106
+        let session_key = decode("x-apple-session-key")?;
107
+        let cookie = decode("x-apple-encrypted-session-key")?;
108
+        if session_key.len() != 32 {
109
+            return Err(ResponseTokenError::InvalidHeader("x-apple-session-key"));
110
+        }
111
+        if cookie.is_empty() {
112
+            return Err(ResponseTokenError::InvalidHeader(
113
+                "x-apple-encrypted-session-key",
114
+            ));
115
+        }
116
+        Ok(Self {
117
+            idms_token: IdmsToken(token.token),
118
+            session_key: SessionKey(session_key),
119
+            cookie: AuthCookie(cookie),
120
+        })
121
+    }
122
+}
123
+
67 124
 #[derive(Debug, Clone, Deserialize)]
68 125
 #[serde(transparent)]
69 126
 pub struct ServerProvidedData(pub(super) Dictionary);
... ...
@@ -87,6 +144,21 @@ impl ServerProvidedData {
87 144
         Some(AuthCookie(self.0.get("c")?.as_data()?.to_vec()))
88 145
     }
89 146
 
147
+    pub fn master_token(&self) -> Option<MasterToken> {
148
+        let token = MasterToken {
149
+            idms_token: self.idms_token()?,
150
+            session_key: self.session_key()?,
151
+            cookie: self.cookie()?,
152
+        };
153
+        if token.idms_token.0.is_empty()
154
+            || token.session_key.0.len() != 32
155
+            || token.cookie.0.is_empty()
156
+        {
157
+            return None;
158
+        }
159
+        Some(token)
160
+    }
161
+
90 162
     pub fn continuation_token(&self) -> Option<ContinuationToken> {
91 163
         Some(ContinuationToken(self.0.get("ck")?.as_string()?.to_owned()))
92 164
     }
... ...
@@ -148,28 +220,25 @@ impl<'lt, 'adi> AccountHTTPSession<'lt, 'adi> {
148 220
 
149 221
     pub async fn get_app_token<T: AppToken>(
150 222
         &self,
151
-        idms_token: &IdmsToken,
152
-        session_key: &SessionKey,
153
-        cookie: &AuthCookie,
223
+        master: &MasterToken,
154 224
     ) -> Result<Token<T>, AppTokenRequestError> {
155
-        let token = self
156
-            .get_app_token_for_service(T::APP_TOKEN_IDENTIFIER, idms_token, session_key, cookie)
157
-            .await?;
158
-        Ok(Token {
159
-            duration: token.duration,
160
-            expiry_epoch_millis: token.expiry_epoch_millis,
161
-            token: T::from(token.token),
162
-        })
225
+        Ok(self
226
+            .get_app_token_for_service(T::APP_TOKEN_IDENTIFIER, master)
227
+            .await?
228
+            .into())
163 229
     }
164 230
 
165 231
     /// Request a token for a service identifier supplied at runtime.
166 232
     pub async fn get_app_token_for_service(
167 233
         &self,
168 234
         app_token_identifier: &str,
169
-        idms_token: &IdmsToken,
170
-        session_key: &SessionKey,
171
-        cookie: &AuthCookie,
172
-    ) -> Result<Token, AppTokenRequestError> {
235
+        master: &MasterToken,
236
+    ) -> Result<RawToken, AppTokenRequestError> {
237
+        let MasterToken {
238
+            idms_token,
239
+            session_key,
240
+            cookie,
241
+        } = master;
173 242
         let alt_dsid = self.alt_dsid.0.as_str();
174 243
 
175 244
         let gs_service_url = self
... ...
@@ -308,7 +377,7 @@ pub(super) fn header_text(
308 377
 pub(super) fn header_token(
309 378
     value: &reqwest::header::HeaderValue,
310 379
     name: &'static str,
311
-) -> Result<(String, Token), ResponseTokenError> {
380
+) -> Result<(String, RawToken), ResponseTokenError> {
312 381
     use base64::{Engine, prelude::BASE64_STANDARD};
313 382
     let bytes = BASE64_STANDARD
314 383
         .decode(value.as_bytes())
... ...
@@ -330,7 +399,7 @@ pub(super) fn header_token(
330 399
         .ok_or(ResponseTokenError::InvalidHeader(name))?;
331 400
     Ok((
332 401
         fields[0].to_owned(),
333
-        Token {
402
+        RawToken {
334 403
             token: fields[1].to_owned(),
335 404
             duration,
336 405
             expiry_epoch_millis,
... ...
@@ -1,5 +1,5 @@
1 1
 use crate::http_session::{AnisetteHTTPSession, AppleError, parse_status};
2
-use adi::proxy::ADIError;
2
+use adi::proxy::{ADIError, ADIProvisioningSession, ADIProxy, ADIResult};
3 3
 use base64::Engine;
4 4
 use base64::prelude::BASE64_STANDARD;
5 5
 use plist::{Dictionary, Value};
... ...
@@ -306,6 +306,7 @@ pub async fn sync_machine(
306 306
         .ok_or_else(|| SyncError::ResponseStructure(sync_machine_response_plist.clone()))?;
307 307
     if code != 0 {
308 308
         return Err(SyncError::Apple(AppleError {
309
+            subcode: status.get("sec").and_then(Value::as_signed_integer),
309 310
             code,
310 311
             message: status
311 312
                 .get("em")
... ...
@@ -5,7 +5,7 @@ use reqwest::{Method, RequestBuilder};
5 5
 use serde::{Deserialize, Serialize};
6 6
 
7 7
 #[derive(Debug, Deserialize, Serialize, Clone)]
8
-pub struct Token<T = String> {
8
+pub struct Token<T: AppToken> {
9 9
     pub duration: u64,
10 10
     // #[serde(rename = "cts")]
11 11
     // pub start_epoch_millis: u64,
... ...
@@ -14,6 +14,25 @@ pub struct Token<T = String> {
14 14
     pub token: T,
15 15
 }
16 16
 
17
+/// An app token for a service identifier supplied at runtime.
18
+#[derive(Debug, Deserialize, Serialize, Clone)]
19
+pub struct RawToken {
20
+    pub duration: u64,
21
+    #[serde(rename = "expiry")]
22
+    pub expiry_epoch_millis: u64,
23
+    pub token: String,
24
+}
25
+
26
+impl<T: AppToken> From<RawToken> for Token<T> {
27
+    fn from(token: RawToken) -> Self {
28
+        Self {
29
+            duration: token.duration,
30
+            expiry_epoch_millis: token.expiry_epoch_millis,
31
+            token: T::from(token.token),
32
+        }
33
+    }
34
+}
35
+
17 36
 pub trait AppToken: From<String> + AsRef<str> {
18 37
     const APP_TOKEN_IDENTIFIER: &'static str;
19 38
 }
... ...
@@ -43,7 +62,7 @@ impl TokenBag {
43 62
         Ok(Self(tokens))
44 63
     }
45 64
 
46
-    pub fn entries(&self) -> Result<std::collections::BTreeMap<String, Token>, plist::Error> {
65
+    pub fn entries(&self) -> Result<std::collections::BTreeMap<String, RawToken>, plist::Error> {
47 66
         self.0
48 67
             .iter()
49 68
             .map(|(service, value)| Ok((service.clone(), plist::from_value(value)?)))
... ...
@@ -51,12 +70,8 @@ impl TokenBag {
51 70
     }
52 71
 
53 72
     pub fn get<T: AppToken>(&self) -> Option<Token<T>> {
54
-        let token: Token = plist::from_value(self.0.get(T::APP_TOKEN_IDENTIFIER)?).ok()?;
55
-        Some(Token {
56
-            duration: token.duration,
57
-            expiry_epoch_millis: token.expiry_epoch_millis,
58
-            token: T::from(token.token),
59
-        })
73
+        let token: RawToken = plist::from_value(self.0.get(T::APP_TOKEN_IDENTIFIER)?).ok()?;
74
+        Some(token.into())
60 75
     }
61 76
 }
62 77
 
... ...
@@ -97,6 +97,7 @@ impl<'lt, 'adi> IdentityHTTPSession<'lt, 'adi> {
97 97
             0 => Ok(()),
98 98
             -21669 => Err(ValidateCodeError::InvalidCode),
99 99
             code => Err(AppleError {
100
+                subcode: response_plist.get("sec").and_then(Value::as_signed_integer),
100 101
                 code,
101 102
                 message: response_plist
102 103
                     .get("em")
... ...
@@ -18,6 +18,7 @@ const SIMULATED_AUTHENTICATION_FRAMEWORK_INFORMATION: BundleInformation =
18 18
 pub struct AppleError {
19 19
     // description: String,
20 20
     pub code: i64,
21
+    pub subcode: Option<i64>,
21 22
     pub message: String,
22 23
 }
23 24
 
... ...
@@ -64,6 +65,9 @@ pub fn parse_status(status_dict: &Dictionary) -> Result<(), AppleError> {
64 65
             Err(AppleError {
65 66
                 // description: ed.to_string(),
66 67
                 code: ec,
68
+                subcode: status_dict
69
+                    .get("sec")
70
+                    .and_then(plist::Value::as_signed_integer),
67 71
                 message: em.to_string(),
68 72
             })
69 73
         }
... ...
@@ -0,0 +1,15 @@
1
+[package]
2
+name = "icloud"
3
+version = "0.1.0"
4
+edition = "2024"
5
+
6
+[dependencies]
7
+adi.workspace = true
8
+grandslam.workspace = true
9
+plist.workspace = true
10
+plist-macros.workspace = true
11
+reqwest.workspace = true
12
+thiserror.workspace = true
13
+serde = { version = "1", features = ["derive"] }
14
+serde_json = "1"
15
+uuid = { version = "1.19", features = ["v4"] }
... ...
@@ -0,0 +1,44 @@
1
+# Native iCloud
2
+
3
+Experimental native authentication and CloudKit container setup, built on
4
+`grandslam`. This crate does **not yet list or download iCloud Drive files**.
5
+It does not use the iCloud website's session cookies or Drive JSON endpoints.
6
+
7
+The implemented authentication steps are:
8
+
9
+1. Obtain `PasswordEquivalentToken` from a GrandSlam SRP authentication result.
10
+   Antarctic may return a still-valid cached PET or renew one using CK; it does
11
+   not submit this identifier to the master-token `apptokens` exchange.
12
+2. Build a MobileMe delegate request with `account::login_request_builder` and
13
+   submit it with `account::login`. The result wraps the returned service data.
14
+3. Extract the numeric DSID, `MmeAuthToken`, and `CloudKitToken` as needed.
15
+4. Call `cloudkit::CLOUD_DOCS.initialize` with the DSID and MobileMe token to
16
+   resolve the container-scoped user ID.
17
+5. Construct `CloudKitSession` with that ID and the CloudKit token. Its request
18
+   builder is a low-level header builder; protobuf operations are not implemented.
19
+
20
+`ICloudSession::account_settings` implements the native account-settings refresh.
21
+`AccountSettings::service` exposes the server's dataclass configuration, including
22
+`com.apple.Dataclass.CKDatabaseService` and its `url`. Future operations should use
23
+these URLs instead of assuming an account's server shard.
24
+
25
+PETs are used for login; MobileMe tokens authenticate setup/account requests;
26
+CloudKit tokens authenticate database requests. None are implicitly forwarded to
27
+asset download servers. Secret-bearing types deliberately omit `Debug`.
28
+
29
+## Validation status
30
+
31
+Compiled locally; synthetic parser checks covered success, rejected top-level
32
+and delegate statuses, missing fields, malformed plists and both settings layouts.
33
+Native MobileMe login and container initialization have not yet been validated
34
+with an account. PET acquisition has been: password-based SRP returned a fresh
35
+PET, while requesting the PET identifier through the same fresh master-token
36
+exchange returned `-22413`; an Xcode token request before and after it succeeded.
37
+
38
+The login builder permits a caller to add `X-Mme-Nas-Qualify` if validation data is
39
+available. Acceptance without that header is **unverified**. The Windows and
40
+macOS login paths must not be assumed interchangeable. No automatic terms
41
+acceptance, IDS enrollment, or account-key recovery is performed.
42
+
43
+See [native protocol notes](../research/icloud-windows/docs/native-drive.md) for the verified binary anchors
44
+and the remaining work needed for actual file access.
... ...
@@ -0,0 +1,279 @@
1
+use adi::proxy::{ADIError, ADIResult};
2
+use grandslam::plist_request::dict_to_body;
3
+use grandslam::{AccountHTTPSession, AppToken};
4
+use plist::{Dictionary, Value};
5
+use plist_macros::dict;
6
+use reqwest::{Method, RequestBuilder};
7
+use serde::{Deserialize, Serialize};
8
+use thiserror::Error;
9
+
10
+/// GrandSlam's password-equivalent token. Only used to establish iCloud credentials.
11
+#[derive(Clone, Serialize, Deserialize)]
12
+#[serde(transparent)]
13
+pub struct PasswordEquivalentToken(String);
14
+
15
+impl From<String> for PasswordEquivalentToken {
16
+    fn from(value: String) -> Self {
17
+        Self(value)
18
+    }
19
+}
20
+
21
+impl AsRef<str> for PasswordEquivalentToken {
22
+    fn as_ref(&self) -> &str {
23
+        &self.0
24
+    }
25
+}
26
+
27
+impl AppToken for PasswordEquivalentToken {
28
+    const APP_TOKEN_IDENTIFIER: &'static str = "com.apple.gs.idms.pet";
29
+}
30
+
31
+/// Numeric account ID returned by iCloud, distinct from GrandSlam's alternate ID.
32
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33
+#[serde(transparent)]
34
+pub struct Dsid(String);
35
+
36
+impl AsRef<str> for Dsid {
37
+    fn as_ref(&self) -> &str {
38
+        &self.0
39
+    }
40
+}
41
+
42
+#[derive(Clone, Serialize, Deserialize)]
43
+#[serde(transparent)]
44
+pub struct MmeAuthToken(String);
45
+
46
+#[derive(Clone, Serialize, Deserialize)]
47
+#[serde(transparent)]
48
+pub struct CloudKitToken(String);
49
+
50
+impl AsRef<str> for MmeAuthToken {
51
+    fn as_ref(&self) -> &str {
52
+        &self.0
53
+    }
54
+}
55
+
56
+impl AsRef<str> for CloudKitToken {
57
+    fn as_ref(&self) -> &str {
58
+        &self.0
59
+    }
60
+}
61
+
62
+/// iCloud's service data. Extract only the credentials needed by each service.
63
+/// Deliberately has no Debug implementation: the dictionary contains credentials.
64
+#[derive(Clone, Deserialize, Serialize)]
65
+#[serde(transparent)]
66
+pub struct AccountSettings(Dictionary);
67
+
68
+impl AccountSettings {
69
+    pub fn dsid(&self) -> Option<Dsid> {
70
+        let value = self.account_info()?.get("dsPrsID")?;
71
+        let dsid = match value {
72
+            Value::String(value)
73
+                if !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) =>
74
+            {
75
+                value.clone()
76
+            }
77
+            Value::Integer(value) => value.as_unsigned()?.to_string(),
78
+            _ => return None,
79
+        };
80
+        Some(Dsid(dsid))
81
+    }
82
+
83
+    pub fn account_info(&self) -> Option<&Dictionary> {
84
+        self.0.get("appleAccountInfo")?.as_dictionary()
85
+    }
86
+
87
+    pub fn mme_token(&self) -> Option<MmeAuthToken> {
88
+        Some(MmeAuthToken(self.token("mmeAuthToken")?.to_owned()))
89
+    }
90
+
91
+    pub fn cloudkit_token(&self) -> Option<CloudKitToken> {
92
+        Some(CloudKitToken(self.token("cloudKitToken")?.to_owned()))
93
+    }
94
+
95
+    pub fn service(&self, dataclass: &str) -> Option<&Dictionary> {
96
+        // Delegate login returns dataclasses directly; get_account_settings
97
+        // groups them under mobilemeAccountInfo (the AOSKit account bag).
98
+        let services = match self.0.get("mobilemeAccountInfo") {
99
+            Some(value) => value.as_dictionary()?,
100
+            None => &self.0,
101
+        };
102
+        services.get(dataclass)?.as_dictionary()
103
+    }
104
+
105
+    fn token(&self, name: &str) -> Option<&str> {
106
+        self.0
107
+            .get("tokens")?
108
+            .as_dictionary()?
109
+            .get(name)?
110
+            .as_string()
111
+            .filter(|value| !value.is_empty())
112
+    }
113
+}
114
+
115
+#[derive(Debug, Error)]
116
+pub enum LoginError {
117
+    #[error("Cannot generate device authentication data: {0}")]
118
+    Anisette(#[from] ADIError),
119
+    #[error("iCloud request failed: {0}")]
120
+    Network(#[from] reqwest::Error),
121
+    #[error("Cannot parse the iCloud response: {0}")]
122
+    Parsing(#[from] plist::Error),
123
+    #[error("iCloud rejected authentication (status {0})")]
124
+    Status(i64),
125
+    #[error("iCloud rejected authentication: {0}")]
126
+    Rejected(String),
127
+    #[error("iCloud rejected the MobileMe delegate (status {0})")]
128
+    DelegateStatus(i64),
129
+    #[error("iCloud returned an invalid or missing {0}")]
130
+    InvalidResponse(&'static str),
131
+}
132
+
133
+/// Build the native MobileMe delegate request, without starting another password login.
134
+///
135
+/// The caller can add `X-Mme-Nas-Qualify` here when supplying validation data.
136
+/// Whether a particular client/account is accepted without it must be checked live.
137
+/// This request neither registers IDS nor accepts new terms of service.
138
+pub fn login_request_builder(
139
+    session: &AccountHTTPSession<'_, '_>,
140
+    apple_id: &str,
141
+    token: &PasswordEquivalentToken,
142
+    language: &str,
143
+    timezone: &str,
144
+) -> ADIResult<RequestBuilder> {
145
+    let request = dict! {
146
+        "delegates": dict! { "com.apple.mobileme": Dictionary::new() },
147
+        "protocolVersion": "1.0",
148
+        "userInfo": dict! {
149
+            "client-id": session.http_session.device().device_uuid.as_str(),
150
+            "language": language,
151
+            "timezone": timezone,
152
+        },
153
+    };
154
+    Ok(session
155
+        .anisette_request_builder(
156
+            Method::POST,
157
+            "https://setup.icloud.com/setup/signin/v2/login",
158
+        )?
159
+        .header("X-Apple-ADSID", session.alt_dsid.as_ref())
160
+        .header("Content-Type", "text/plist")
161
+        .header("Accept", "application/x-plist")
162
+        .basic_auth(apple_id.trim(), Some(token.as_ref()))
163
+        .body(dict_to_body(request)))
164
+}
165
+
166
+/// Finish a request made by `login_request_builder`, checking both status levels.
167
+pub async fn login(request: RequestBuilder) -> Result<AccountSettings, LoginError> {
168
+    let response = request.send().await?.error_for_status()?.bytes().await?;
169
+    parse_login_response(&response)
170
+}
171
+
172
+pub fn parse_login_response(response: &[u8]) -> Result<AccountSettings, LoginError> {
173
+    let mut response: Dictionary = plist::from_bytes(response)?;
174
+    if let Some(error) = response.get("localizedError").and_then(Value::as_string) {
175
+        return Err(LoginError::Rejected(error.to_owned()));
176
+    }
177
+    let status = response
178
+        .get("status")
179
+        .and_then(Value::as_signed_integer)
180
+        .ok_or(LoginError::InvalidResponse("status"))?;
181
+    if status != 0 {
182
+        return Err(LoginError::Status(status));
183
+    }
184
+    let mut delegates = response
185
+        .remove("delegates")
186
+        .and_then(Value::into_dictionary)
187
+        .ok_or(LoginError::InvalidResponse("delegates"))?;
188
+    let mut delegate = delegates
189
+        .remove("com.apple.mobileme")
190
+        .and_then(Value::into_dictionary)
191
+        .ok_or(LoginError::InvalidResponse("MobileMe delegate"))?;
192
+    let status = delegate
193
+        .get("status")
194
+        .and_then(Value::as_signed_integer)
195
+        .ok_or(LoginError::InvalidResponse("MobileMe delegate status"))?;
196
+    if status != 0 {
197
+        return Err(LoginError::DelegateStatus(status));
198
+    }
199
+    let settings = delegate
200
+        .remove("service-data")
201
+        .and_then(Value::into_dictionary)
202
+        .ok_or(LoginError::InvalidResponse("MobileMe service data"))?;
203
+    Ok(AccountSettings(settings))
204
+}
205
+
206
+#[derive(Debug, Error)]
207
+pub enum AccountSettingsError {
208
+    #[error("Cannot generate device authentication data: {0}")]
209
+    Anisette(#[from] ADIError),
210
+    #[error("Cannot retrieve iCloud account settings: {0}")]
211
+    Network(#[from] reqwest::Error),
212
+    #[error("Cannot parse iCloud account settings: {0}")]
213
+    Parsing(#[from] plist::Error),
214
+    #[error("iCloud account settings contain no numeric account ID")]
215
+    MissingAccountId,
216
+    #[error("iCloud returned settings for a different account")]
217
+    AccountMismatch,
218
+}
219
+
220
+pub struct ICloudSession<'lt, 'adi> {
221
+    pub http_session: AccountHTTPSession<'lt, 'adi>,
222
+    pub dsid: Dsid,
223
+    token: MmeAuthToken,
224
+}
225
+
226
+impl<'lt, 'adi> ICloudSession<'lt, 'adi> {
227
+    pub fn new(
228
+        http_session: AccountHTTPSession<'lt, 'adi>,
229
+        dsid: Dsid,
230
+        token: MmeAuthToken,
231
+    ) -> Self {
232
+        Self {
233
+            http_session,
234
+            dsid,
235
+            token,
236
+        }
237
+    }
238
+
239
+    pub fn simple_request_builder(&self, method: Method, url: &str) -> RequestBuilder {
240
+        self.http_session.simple_request_builder(method, url)
241
+    }
242
+
243
+    pub fn anisette_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
244
+        self.http_session.anisette_request_builder(method, url)
245
+    }
246
+
247
+    pub fn account_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
248
+        self.http_session.account_request_builder(method, url)
249
+    }
250
+
251
+    pub fn icloud_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
252
+        Ok(self
253
+            .anisette_request_builder(method, url)?
254
+            .header("X-Apple-ADSID", self.http_session.alt_dsid.as_ref())
255
+            .basic_auth(self.dsid.as_ref(), Some(self.token.as_ref())))
256
+    }
257
+
258
+    pub async fn account_settings(&self) -> Result<AccountSettings, AccountSettingsError> {
259
+        let response = self
260
+            .icloud_request_builder(
261
+                Method::POST,
262
+                "https://setup.icloud.com/setup/get_account_settings",
263
+            )?
264
+            .header("X-Aos-Accept-Tos", "false")
265
+            .send()
266
+            .await?
267
+            .error_for_status()?
268
+            .bytes()
269
+            .await?;
270
+        let settings: AccountSettings = plist::from_bytes(&response)?;
271
+        let dsid = settings
272
+            .dsid()
273
+            .ok_or(AccountSettingsError::MissingAccountId)?;
274
+        if dsid != self.dsid {
275
+            return Err(AccountSettingsError::AccountMismatch);
276
+        }
277
+        Ok(settings)
278
+    }
279
+}
... ...
@@ -0,0 +1,161 @@
1
+use crate::account::{CloudKitToken, Dsid, MmeAuthToken};
2
+use adi::proxy::{ADIError, ADIResult};
3
+use grandslam::AccountHTTPSession;
4
+use reqwest::{Method, RequestBuilder};
5
+use serde::Deserialize;
6
+use thiserror::Error;
7
+
8
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9
+pub enum Database {
10
+    Private,
11
+    Shared,
12
+    Public,
13
+}
14
+
15
+impl Database {
16
+    pub fn as_str(self) -> &'static str {
17
+        match self {
18
+            Self::Private => "private",
19
+            Self::Shared => "shared",
20
+            Self::Public => "public",
21
+        }
22
+    }
23
+}
24
+
25
+#[derive(Debug, Clone, Copy)]
26
+pub struct Container<'a> {
27
+    pub identifier: &'a str,
28
+    pub bundle_id: &'a str,
29
+    pub database: Database,
30
+}
31
+
32
+/// The native Drive container; its records still require PCS decryption.
33
+pub const CLOUD_DOCS: Container<'static> = Container {
34
+    identifier: "com.apple.CloudDocs",
35
+    bundle_id: "com.apple.clouddocs",
36
+    database: Database::Private,
37
+};
38
+
39
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
40
+#[serde(transparent)]
41
+pub struct UserId(String);
42
+
43
+impl AsRef<str> for UserId {
44
+    fn as_ref(&self) -> &str {
45
+        &self.0
46
+    }
47
+}
48
+
49
+#[derive(Debug, Error)]
50
+pub enum ContainerInitError {
51
+    #[error("Cannot generate device authentication data: {0}")]
52
+    Anisette(#[from] ADIError),
53
+    #[error("CloudKit initialization failed: {0}")]
54
+    Network(#[from] reqwest::Error),
55
+    #[error("Cannot parse CloudKit initialization: {0}")]
56
+    Parsing(#[from] serde_json::Error),
57
+    #[error("CloudKit returned an empty user identifier")]
58
+    EmptyUserId,
59
+}
60
+
61
+impl Container<'_> {
62
+    /// Resolve the container-scoped user ID using the iCloud setup credential.
63
+    /// This does not create zones, recover keys, or register for push notifications.
64
+    pub async fn initialize(
65
+        &self,
66
+        session: &AccountHTTPSession<'_, '_>,
67
+        dsid: &Dsid,
68
+        token: &MmeAuthToken,
69
+    ) -> Result<UserId, ContainerInitError> {
70
+        #[derive(Deserialize)]
71
+        struct Response {
72
+            #[serde(rename = "cloudKitUserId")]
73
+            user_id: UserId,
74
+        }
75
+
76
+        let request = session.anisette_request_builder(
77
+            Method::POST,
78
+            "https://setup.icloud.com/setup/ck/v1/ckAppInit",
79
+        )?;
80
+        let response = self
81
+            .request_headers(request)
82
+            .header("Accept", "application/json")
83
+            .basic_auth(dsid.as_ref(), Some(token.as_ref()))
84
+            .query(&[("container", self.identifier)])
85
+            .send()
86
+            .await?
87
+            .error_for_status()?
88
+            .bytes()
89
+            .await?;
90
+        let response: Response = serde_json::from_slice(&response)?;
91
+        if response.user_id.0.is_empty() {
92
+            return Err(ContainerInitError::EmptyUserId);
93
+        }
94
+        Ok(response.user_id)
95
+    }
96
+
97
+    fn request_headers(&self, request: RequestBuilder) -> RequestBuilder {
98
+        request
99
+            .header("X-CloudKit-BundleId", self.bundle_id)
100
+            .header("X-CloudKit-ContainerId", self.identifier)
101
+            .header("X-CloudKit-DatabaseScope", self.database.as_str())
102
+            .header("X-CloudKit-Environment", "production")
103
+            .header(
104
+                "X-Apple-Request-UUID",
105
+                uuid::Uuid::new_v4().to_string().to_uppercase(),
106
+            )
107
+    }
108
+}
109
+
110
+/// A native CloudKit request context. MMe/PET credentials are not retained here.
111
+///
112
+/// Operation bodies use CloudKit's delimited protobuf protocol; this is not the
113
+/// developer-facing CloudKit JSON API. Drive records and transfers are not yet
114
+/// implemented by this crate.
115
+pub struct CloudKitSession<'lt, 'adi, 'container> {
116
+    pub http_session: AccountHTTPSession<'lt, 'adi>,
117
+    pub container: Container<'container>,
118
+    pub user_id: UserId,
119
+    token: CloudKitToken,
120
+}
121
+
122
+impl<'lt, 'adi, 'container> CloudKitSession<'lt, 'adi, 'container> {
123
+    pub fn new(
124
+        http_session: AccountHTTPSession<'lt, 'adi>,
125
+        container: Container<'container>,
126
+        user_id: UserId,
127
+        token: CloudKitToken,
128
+    ) -> Self {
129
+        Self {
130
+            http_session,
131
+            container,
132
+            user_id,
133
+            token,
134
+        }
135
+    }
136
+
137
+    pub fn simple_request_builder(&self, method: Method, url: &str) -> RequestBuilder {
138
+        self.http_session.simple_request_builder(method, url)
139
+    }
140
+
141
+    pub fn anisette_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
142
+        self.http_session.anisette_request_builder(method, url)
143
+    }
144
+
145
+    pub fn account_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
146
+        self.http_session.account_request_builder(method, url)
147
+    }
148
+
149
+    pub fn cloudkit_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
150
+        Ok(self
151
+            .container
152
+            .request_headers(self.anisette_request_builder(method, url)?)
153
+            .header("X-CloudKit-UserId", self.user_id.as_ref())
154
+            .header("X-CloudKit-AuthToken", self.token.as_ref())
155
+            .header("Accept", "application/x-protobuf")
156
+            .header(
157
+                "Content-Type",
158
+                "application/x-protobuf; messageType=RequestOperation; delimited=true",
159
+            ))
160
+    }
161
+}
... ...
@@ -0,0 +1,6 @@
1
+//! Native iCloud authentication and CloudKit container setup.
2
+//!
3
+//! Drive records, PCS key recovery and MMCS downloads are not implemented yet.
4
+
5
+pub mod account;
6
+pub mod cloudkit;
... ...
@@ -0,0 +1,26 @@
1
+use xcode::downloads::{ComponentKind, DownloadCatalog, DownloadSource};
2
+
3
+#[tokio::main]
4
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
5
+    let catalog = match std::env::args_os().nth(1) {
6
+        Some(path) => DownloadCatalog::from_bytes(&std::fs::read(path)?)?,
7
+        None => DownloadCatalog::fetch(&reqwest::Client::new()).await?,
8
+    };
9
+    println!(
10
+        "{} simulator runtimes, {} components; refresh after {} seconds",
11
+        catalog.simulators.len(),
12
+        catalog.components.len(),
13
+        catalog.refresh_interval,
14
+    );
15
+    for component in &catalog.components {
16
+        if component.kind != ComponentKind::DeviceSupport {
17
+            continue;
18
+        }
19
+        println!("{} (build {})", component.download.name, component.build);
20
+        match &component.download.source {
21
+            DownloadSource::Direct(url) => println!("  {url}"),
22
+            DownloadSource::MobileAsset => println!("  Requires MobileAsset resolution"),
23
+        }
24
+    }
25
+    Ok(())
26
+}
... ...
@@ -65,21 +65,12 @@ async fn grandslam_test(
65 65
     match &auth_outcome {
66 66
         AuthOutcome::Success(server_provided_data)
67 67
         | AuthOutcome::SecondaryActionRequired(Some(server_provided_data), _) => {
68
-            if let (
69
-                Some(alt_dsid),
70
-                Some(idms_token),
71
-                Some(session_key),
72
-                Some(cookie),
73
-            ) = (
68
+            if let (Some(alt_dsid), Some(master)) = (
74 69
                 server_provided_data.alt_dsid(),
75
-                server_provided_data.idms_token(),
76
-                server_provided_data.session_key(),
77
-                server_provided_data.cookie(),
70
+                server_provided_data.master_token(),
78 71
             ) {
79
-                let http_session =
80
-                    AccountHTTPSession::new(http_session, alt_dsid);
81
-
82
-                let xcode_token = http_session.get_app_token(&idms_token, &session_key, &cookie).await?;
72
+                let http_session = AccountHTTPSession::new(http_session, alt_dsid);
73
+                let xcode_token = http_session.get_app_token(&master).await?;
83 74
                 let xcode_session = XcodeSession::new(http_session, xcode_token);
84 75
 
85 76
                 let developer =
... ...
@@ -0,0 +1,420 @@
1
+//! Apple's public Xcode download catalog. MobileAsset entries need separate
2
+//! resolution before downloading; this module only fetches and parses the index.
3
+
4
+use plist::{Dictionary, Value};
5
+use reqwest::{Client, Url};
6
+use serde::{Deserialize, de::DeserializeOwned};
7
+use thiserror::Error;
8
+
9
+pub const DOWNLOAD_CATALOG_URL: &str =
10
+    "https://devimages-cdn.apple.com/downloads/xcode/simulators/index2.dvtdownloadableindex";
11
+
12
+#[derive(Debug, Clone)]
13
+pub enum DownloadSource {
14
+    Direct(Url),
15
+    /// Resolve using the download identifier and its runtime/component metadata.
16
+    MobileAsset,
17
+}
18
+
19
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
20
+#[serde(rename_all = "camelCase")]
21
+pub enum ContentType {
22
+    Package,
23
+    DiskImage,
24
+    CryptexDiskImage,
25
+    Directory,
26
+}
27
+
28
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
29
+pub enum Platform {
30
+    #[serde(rename = "com.apple.platform.iphoneos")]
31
+    IOS,
32
+    #[serde(rename = "com.apple.platform.appletvos")]
33
+    TvOS,
34
+    #[serde(rename = "com.apple.platform.watchos")]
35
+    WatchOS,
36
+    #[serde(rename = "com.apple.platform.macosx")]
37
+    MacOS,
38
+    #[serde(rename = "com.apple.platform.xros")]
39
+    VisionOS,
40
+}
41
+
42
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
43
+pub enum ComponentKind {
44
+    #[serde(rename = "xcodeDeviceSupport")]
45
+    DeviceSupport,
46
+    #[serde(rename = "metalToolchain")]
47
+    MetalToolchain,
48
+    #[serde(rename = "developerDocumentation")]
49
+    DeveloperDocumentation,
50
+}
51
+
52
+#[derive(Debug, Clone)]
53
+pub struct Download {
54
+    pub identifier: String,
55
+    pub name: String,
56
+    /// Advertised file size in bytes.
57
+    pub size: u64,
58
+    pub content_type: ContentType,
59
+    pub source: DownloadSource,
60
+    /// Apple's value, such as `virtual` or `none`. Absence does not imply
61
+    /// anonymous access, and a direct URL may still require authentication.
62
+    pub authentication: Option<String>,
63
+    pub dictionary_version: u64,
64
+}
65
+
66
+#[derive(Debug, Clone, Default, Deserialize)]
67
+#[serde(rename_all = "camelCase")]
68
+pub struct HostRequirements {
69
+    pub min_host_version: Option<String>,
70
+    pub max_host_version: Option<String>,
71
+    pub min_xcode_version: Option<String>,
72
+    pub max_xcode_version: Option<String>,
73
+    #[serde(default)]
74
+    pub excluded_host_architectures: Vec<String>,
75
+}
76
+
77
+#[derive(Debug, Clone)]
78
+pub struct SimulatorRuntime {
79
+    pub download: Download,
80
+    pub platform: Platform,
81
+    pub version: String,
82
+    pub build: String,
83
+    /// The catalog's package version, distinct from the simulated OS version.
84
+    pub download_version: String,
85
+    /// Architectures explicitly advertised by Apple, when supplied.
86
+    pub architectures: Option<Vec<String>>,
87
+    pub host_requirements: HostRequirements,
88
+    pub is_internal_content: Option<bool>,
89
+    pub is_user_initiated: Option<bool>,
90
+}
91
+
92
+#[derive(Debug, Clone)]
93
+pub struct Component {
94
+    pub download: Download,
95
+    pub kind: ComponentKind,
96
+    pub build: String,
97
+    pub description: String,
98
+}
99
+
100
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
101
+#[serde(rename_all = "camelCase")]
102
+pub enum Affinity {
103
+    Available,
104
+    Preferred,
105
+    Unavailable,
106
+}
107
+
108
+#[derive(Debug, Clone, Deserialize)]
109
+#[serde(rename_all = "camelCase")]
110
+pub struct SdkSeedMapping {
111
+    pub build_update: String,
112
+    pub platform: Platform,
113
+    pub seed_number: u64,
114
+    pub identifier: Option<String>,
115
+}
116
+
117
+#[derive(Debug, Clone)]
118
+pub struct SdkSimulatorMapping {
119
+    pub sdk_build_update: String,
120
+    pub sdk_identifier: String,
121
+    pub simulator_build_update: String,
122
+    /// Normalizes the singular and plural catalog fields. Empty means the
123
+    /// mapping names the simulator by build rather than download identifier.
124
+    pub downloadable_identifiers: Vec<String>,
125
+    pub affinity: Option<Affinity>,
126
+    pub group_tag: Option<String>,
127
+}
128
+
129
+#[derive(Debug, Clone, PartialEq, Eq)]
130
+pub enum XcodeRelease {
131
+    Build(String),
132
+    Version(String),
133
+}
134
+
135
+#[derive(Debug, Clone)]
136
+pub struct XcodeComponentMapping {
137
+    pub xcode: XcodeRelease,
138
+    pub kind: ComponentKind,
139
+    pub build: String,
140
+    /// Some mappings identify the component by kind and build alone.
141
+    pub downloadable_identifier: Option<String>,
142
+    pub affinity: Affinity,
143
+}
144
+
145
+#[derive(Debug, Clone)]
146
+pub struct DownloadCatalog {
147
+    pub version: String,
148
+    /// Apple's suggested refresh interval in seconds.
149
+    pub refresh_interval: u64,
150
+    pub simulators: Vec<SimulatorRuntime>,
151
+    pub components: Vec<Component>,
152
+    pub sdk_to_seed_mappings: Vec<SdkSeedMapping>,
153
+    pub sdk_to_simulator_mappings: Vec<SdkSimulatorMapping>,
154
+    pub xcode_to_component_mappings: Vec<XcodeComponentMapping>,
155
+}
156
+
157
+#[derive(Debug, Error)]
158
+pub enum CatalogFetchError {
159
+    #[error("Failed to fetch the download catalog: {0}")]
160
+    Reqwest(#[source] reqwest::Error),
161
+    #[error("Failed to parse the download catalog: {0}")]
162
+    Parsing(#[source] CatalogParseError),
163
+}
164
+
165
+#[derive(Debug, Error)]
166
+pub enum CatalogParseError {
167
+    #[error("Invalid catalog plist: {0}")]
168
+    Plist(#[source] plist::Error),
169
+    #[error("Missing catalog field: {0}")]
170
+    MissingField(String),
171
+    #[error("Invalid catalog field {path}: {source}")]
172
+    InvalidField {
173
+        path: String,
174
+        #[source]
175
+        source: plist::Error,
176
+    },
177
+    #[error("Invalid catalog structure at {path}: {reason}")]
178
+    Structure { path: String, reason: &'static str },
179
+}
180
+
181
+impl DownloadCatalog {
182
+    /// Fetch the public index with the caller's HTTP client. No Xcode session
183
+    /// or Apple account credentials are required.
184
+    pub async fn fetch(client: &Client) -> Result<Self, CatalogFetchError> {
185
+        let bytes = client
186
+            .get(DOWNLOAD_CATALOG_URL)
187
+            .send()
188
+            .await
189
+            .map_err(CatalogFetchError::Reqwest)?
190
+            .error_for_status()
191
+            .map_err(CatalogFetchError::Reqwest)?
192
+            .bytes()
193
+            .await
194
+            .map_err(CatalogFetchError::Reqwest)?;
195
+        Self::from_bytes(&bytes).map_err(CatalogFetchError::Parsing)
196
+    }
197
+
198
+    /// Parse an XML or binary plist. Unknown dictionary keys are ignored;
199
+    /// unsupported enum values and malformed entries are reported as errors.
200
+    pub fn from_bytes(bytes: &[u8]) -> Result<Self, CatalogParseError> {
201
+        let value =
202
+            Value::from_reader(std::io::Cursor::new(bytes)).map_err(CatalogParseError::Plist)?;
203
+        let dict = dictionary(&value, "catalog")?;
204
+        Ok(Self {
205
+            version: field(dict, "catalog", "version")?,
206
+            refresh_interval: field(dict, "catalog", "refreshInterval")?,
207
+            simulators: entries(dict, "downloadables", SimulatorRuntime::parse)?,
208
+            components: entries(dict, "otherDownloadables", Component::parse)?,
209
+            sdk_to_seed_mappings: entries(dict, "sdkToSeedMappings", |value, path| {
210
+                plist::from_value(value).map_err(|source| CatalogParseError::InvalidField {
211
+                    path: path.to_owned(),
212
+                    source,
213
+                })
214
+            })?,
215
+            sdk_to_simulator_mappings: entries(
216
+                dict,
217
+                "sdkToSimulatorMappings",
218
+                SdkSimulatorMapping::parse,
219
+            )?,
220
+            xcode_to_component_mappings: entries(
221
+                dict,
222
+                "xcodeToOtherDownloadablesMappings",
223
+                XcodeComponentMapping::parse,
224
+            )?,
225
+        })
226
+    }
227
+}
228
+
229
+impl Download {
230
+    fn parse(
231
+        dict: &Dictionary,
232
+        path: &str,
233
+        size_key: &'static str,
234
+        url_key: &'static str,
235
+    ) -> Result<Self, CatalogParseError> {
236
+        let method: Option<String> = optional_field(dict, path, "downloadMethod")?;
237
+        let url: Option<String> = optional_field(dict, path, url_key)?;
238
+        let source = match (method.as_deref(), url) {
239
+            (None, Some(url)) => {
240
+                let url = Url::parse(&url).map_err(|_| CatalogParseError::Structure {
241
+                    path: format!("{path}.{url_key}"),
242
+                    reason: "expected an absolute HTTP or HTTPS URL",
243
+                })?;
244
+                if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
245
+                    return Err(CatalogParseError::Structure {
246
+                        path: format!("{path}.{url_key}"),
247
+                        reason: "expected an absolute HTTP or HTTPS URL",
248
+                    });
249
+                }
250
+                DownloadSource::Direct(url)
251
+            }
252
+            (Some("mobileAsset"), None) => DownloadSource::MobileAsset,
253
+            _ => {
254
+                return Err(CatalogParseError::Structure {
255
+                    path: format!("{path}.downloadMethod"),
256
+                    reason: "expected a direct source URL or downloadMethod = mobileAsset, exclusively",
257
+                });
258
+            }
259
+        };
260
+        Ok(Self {
261
+            identifier: field(dict, path, "identifier")?,
262
+            name: field(dict, path, "name")?,
263
+            size: field(dict, path, size_key)?,
264
+            content_type: field(dict, path, "contentType")?,
265
+            source,
266
+            authentication: optional_field(dict, path, "authentication")?,
267
+            dictionary_version: field(dict, path, "dictionaryVersion")?,
268
+        })
269
+    }
270
+}
271
+
272
+impl SimulatorRuntime {
273
+    fn parse(value: &Value, path: &str) -> Result<Self, CatalogParseError> {
274
+        let dict = dictionary(value, path)?;
275
+        if field::<String>(dict, path, "category")? != "simulator" {
276
+            return Err(CatalogParseError::Structure {
277
+                path: format!("{path}.category"),
278
+                reason: "expected simulator",
279
+            });
280
+        }
281
+        let version_path = format!("{path}.simulatorVersion");
282
+        let version = dictionary(
283
+            required_value(dict, path, "simulatorVersion")?,
284
+            &version_path,
285
+        )?;
286
+        Ok(Self {
287
+            download: Download::parse(dict, path, "fileSize", "source")?,
288
+            platform: field(dict, path, "platform")?,
289
+            version: field(version, &version_path, "version")?,
290
+            build: field(version, &version_path, "buildUpdate")?,
291
+            download_version: field(dict, path, "version")?,
292
+            architectures: optional_field(dict, path, "architectures")?,
293
+            host_requirements: optional_field(dict, path, "hostRequirements")?.unwrap_or_default(),
294
+            is_internal_content: optional_field(dict, path, "isInternalContent")?,
295
+            is_user_initiated: optional_field(dict, path, "isUserInitiated")?,
296
+        })
297
+    }
298
+}
299
+
300
+impl Component {
301
+    fn parse(value: &Value, path: &str) -> Result<Self, CatalogParseError> {
302
+        let dict = dictionary(value, path)?;
303
+        Ok(Self {
304
+            download: Download::parse(dict, path, "fileSizeBytes", "sourceURL")?,
305
+            kind: field(dict, path, "assetType")?,
306
+            build: field(dict, path, "assetBuildUpdate")?,
307
+            description: field(dict, path, "description")?,
308
+        })
309
+    }
310
+}
311
+
312
+impl SdkSimulatorMapping {
313
+    fn parse(value: &Value, path: &str) -> Result<Self, CatalogParseError> {
314
+        let dict = dictionary(value, path)?;
315
+        let identifier: Option<String> = optional_field(dict, path, "downloadableIdentifier")?;
316
+        let identifiers: Option<Vec<String>> =
317
+            optional_field(dict, path, "downloadableIdentifiers")?;
318
+        let downloadable_identifiers = match (identifier, identifiers) {
319
+            (Some(identifier), None) => vec![identifier],
320
+            (None, Some(identifiers)) => identifiers,
321
+            (None, None) => Vec::new(),
322
+            (Some(_), Some(_)) => {
323
+                return Err(CatalogParseError::Structure {
324
+                    path: format!("{path}.downloadableIdentifiers"),
325
+                    reason: "both singular and plural download identifiers are present",
326
+                });
327
+            }
328
+        };
329
+        Ok(Self {
330
+            sdk_build_update: field(dict, path, "sdkBuildUpdate")?,
331
+            sdk_identifier: field(dict, path, "sdkIdentifier")?,
332
+            simulator_build_update: field(dict, path, "simulatorBuildUpdate")?,
333
+            downloadable_identifiers,
334
+            affinity: optional_field(dict, path, "affinity")?,
335
+            group_tag: optional_field(dict, path, "groupTag")?,
336
+        })
337
+    }
338
+}
339
+
340
+impl XcodeComponentMapping {
341
+    fn parse(value: &Value, path: &str) -> Result<Self, CatalogParseError> {
342
+        let dict = dictionary(value, path)?;
343
+        let build: Option<String> = optional_field(dict, path, "xcodeBuildUpdate")?;
344
+        let version: Option<String> = optional_field(dict, path, "xcodeVersion")?;
345
+        let xcode = match (build, version) {
346
+            (Some(build), None) => XcodeRelease::Build(build),
347
+            (None, Some(version)) => XcodeRelease::Version(version),
348
+            _ => {
349
+                return Err(CatalogParseError::Structure {
350
+                    path: path.to_owned(),
351
+                    reason: "expected exactly one of xcodeBuildUpdate and xcodeVersion",
352
+                });
353
+            }
354
+        };
355
+        Ok(Self {
356
+            xcode,
357
+            kind: field(dict, path, "assetType")?,
358
+            build: field(dict, path, "assetBuildUpdate")?,
359
+            downloadable_identifier: optional_field(dict, path, "downloadableIdentifier")?,
360
+            affinity: field(dict, path, "affinity")?,
361
+        })
362
+    }
363
+}
364
+
365
+fn dictionary<'a>(value: &'a Value, path: &str) -> Result<&'a Dictionary, CatalogParseError> {
366
+    value
367
+        .as_dictionary()
368
+        .ok_or_else(|| CatalogParseError::Structure {
369
+            path: path.to_owned(),
370
+            reason: "expected a dictionary",
371
+        })
372
+}
373
+
374
+fn required_value<'a>(
375
+    dict: &'a Dictionary,
376
+    path: &str,
377
+    name: &str,
378
+) -> Result<&'a Value, CatalogParseError> {
379
+    dict.get(name)
380
+        .ok_or_else(|| CatalogParseError::MissingField(format!("{path}.{name}")))
381
+}
382
+
383
+fn field<T: DeserializeOwned>(
384
+    dict: &Dictionary,
385
+    path: &str,
386
+    name: &str,
387
+) -> Result<T, CatalogParseError> {
388
+    plist::from_value(required_value(dict, path, name)?).map_err(|source| {
389
+        CatalogParseError::InvalidField {
390
+            path: format!("{path}.{name}"),
391
+            source,
392
+        }
393
+    })
394
+}
395
+
396
+fn optional_field<T: DeserializeOwned>(
397
+    dict: &Dictionary,
398
+    path: &str,
399
+    name: &str,
400
+) -> Result<Option<T>, CatalogParseError> {
401
+    dict.get(name).map(|_| field(dict, path, name)).transpose()
402
+}
403
+
404
+fn entries<T>(
405
+    dict: &Dictionary,
406
+    name: &str,
407
+    parse: impl Fn(&Value, &str) -> Result<T, CatalogParseError>,
408
+) -> Result<Vec<T>, CatalogParseError> {
409
+    let values = required_value(dict, "catalog", name)?
410
+        .as_array()
411
+        .ok_or_else(|| CatalogParseError::Structure {
412
+            path: name.to_owned(),
413
+            reason: "expected an array",
414
+        })?;
415
+    values
416
+        .iter()
417
+        .enumerate()
418
+        .map(|(index, value)| parse(value, &format!("{name}[{index}]")))
419
+        .collect()
420
+}
... ...
@@ -10,6 +10,8 @@ use std::borrow::Cow;
10 10
 use std::fmt::Display;
11 11
 use thiserror::Error;
12 12
 
13
+pub mod downloads;
14
+
13 15
 /// From Xcode 16.4
14 16
 pub const XCODE_BUNDLE_INFORMATION: BundleInformation = BundleInformation {
15 17
     bundle_name: "Xcode",
16 18