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
| ... | ... |
@@ -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" }
|
| ... | ... |
@@ -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(¬ification).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(¬ification).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,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 |
+} |