opendal/services/icloud/
core.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::collections::BTreeMap;
19use std::fmt::Debug;
20use std::fmt::Formatter;
21use std::sync::Arc;
22
23use bytes::Buf;
24use bytes::Bytes;
25use http::header;
26use http::header::IF_MATCH;
27use http::header::IF_NONE_MATCH;
28use http::Request;
29use http::Response;
30use http::StatusCode;
31use serde::Deserialize;
32use serde::Serialize;
33use serde_json::json;
34use tokio::sync::Mutex;
35
36use crate::raw::*;
37use crate::*;
38
39static ACCOUNT_COUNTRY_HEADER: &str = "X-Apple-ID-Account-Country";
40static OAUTH_STATE_HEADER: &str = "X-Apple-OAuth-State";
41static SESSION_ID_HEADER: &str = "X-Apple-ID-Session-Id";
42static SCNT_HEADER: &str = "scnt";
43static SESSION_TOKEN_HEADER: &str = "X-Apple-Session-Token";
44static APPLE_RESPONSE_HEADER: &str = "X-Apple-I-Rscd";
45
46static AUTH_ENDPOINT: &str = "https://idmsa.apple.com/appleauth/auth";
47static SETUP_ENDPOINT: &str = "https://setup.icloud.com/setup/ws/1";
48
49const AUTH_HEADERS: [(&str, &str); 7] = [
50    (
51        // This code inspire from
52        // https://github.com/picklepete/pyicloud/blob/master/pyicloud/base.py#L392
53        "X-Apple-OAuth-Client-Id",
54        "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d",
55    ),
56    ("X-Apple-OAuth-Client-Type", "firstPartyAuth"),
57    ("X-Apple-OAuth-Redirect-URI", "https://www.icloud.com"),
58    ("X-Apple-OAuth-Require-Grant-Code", "true"),
59    ("X-Apple-OAuth-Response-Mode", "web_message"),
60    ("X-Apple-OAuth-Response-Type", "code"),
61    (
62        "X-Apple-Widget-Key",
63        "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d",
64    ),
65];
66
67#[derive(Clone)]
68pub struct SessionData {
69    oauth_state: String,
70    session_id: Option<String>,
71    session_token: Option<String>,
72
73    scnt: Option<String>,
74    account_country: Option<String>,
75    cookies: BTreeMap<String, String>,
76    drivews_url: String,
77    docws_url: String,
78}
79
80impl Default for SessionData {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl SessionData {
87    pub fn new() -> SessionData {
88        Self {
89            oauth_state: format!("auth-{}", uuid::Uuid::new_v4()).to_string(),
90            session_id: None,
91            session_token: None,
92            scnt: None,
93            account_country: None,
94
95            cookies: BTreeMap::default(),
96            drivews_url: String::new(),
97            docws_url: String::new(),
98        }
99    }
100}
101
102#[derive(Clone)]
103pub struct IcloudSigner {
104    pub info: Arc<AccessorInfo>,
105
106    pub apple_id: String,
107    pub password: String,
108    pub is_china_mainland: bool,
109    pub trust_token: Option<String>,
110    pub ds_web_auth_token: Option<String>,
111
112    pub data: SessionData,
113    pub initiated: bool,
114}
115
116impl Debug for IcloudSigner {
117    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
118        let mut de = f.debug_struct("icloud signer");
119        de.field("is_china_mainland", &self.is_china_mainland);
120        de.finish()
121    }
122}
123
124impl IcloudSigner {
125    /// Get the drivews_url from signer session data.
126    /// Async await init finish.
127    pub async fn drivews_url(&mut self) -> Result<&str> {
128        self.init().await?;
129        Ok(&self.data.drivews_url)
130    }
131
132    /// Get the docws_url from signer session data.
133    /// Async await init finish.
134    pub async fn docws_url(&mut self) -> Result<&str> {
135        self.init().await?;
136        Ok(&self.data.docws_url)
137    }
138
139    /// iCloud will use our oauth state as client id.
140    pub fn client_id(&self) -> &str {
141        &self.data.oauth_state
142    }
143
144    async fn init(&mut self) -> Result<()> {
145        if self.initiated {
146            return Ok(());
147        }
148
149        // Sign the auth endpoint first.
150        let uri = format!("{}/signin?isRememberMeEnable=true", AUTH_ENDPOINT);
151        let body = serde_json::to_vec(&json!({
152            "accountName" : self.apple_id,
153            "password" : self.password,
154            "rememberMe": true,
155            "trustTokens": [self.trust_token.clone().unwrap()],
156        }))
157        .map_err(new_json_serialize_error)?;
158
159        let mut req = Request::post(uri)
160            .header(header::CONTENT_TYPE, "application/json")
161            .body(Buffer::from(Bytes::from(body)))
162            .map_err(new_request_build_error)?;
163        self.sign(&mut req)?;
164
165        let resp = self.info.http_client().send(req).await?;
166        if resp.status() != StatusCode::OK {
167            return Err(parse_error(resp));
168        }
169
170        if let Some(rscd) = resp.headers().get(APPLE_RESPONSE_HEADER) {
171            let status_code = StatusCode::from_bytes(rscd.as_bytes()).unwrap();
172            if status_code != StatusCode::CONFLICT {
173                return Err(parse_error(resp));
174            }
175        }
176
177        // Setup to get the session id.
178        let uri = format!("{}/accountLogin", SETUP_ENDPOINT);
179        let body = serde_json::to_vec(&json!({
180            "accountCountryCode": self.data.account_country.clone().unwrap_or_default(),
181            "dsWebAuthToken":self.ds_web_auth_token.clone().unwrap_or_default(),
182            "extended_login": true,
183            "trustToken": self.trust_token.clone().unwrap_or_default(),}))
184        .map_err(new_json_serialize_error)?;
185
186        let mut req = Request::post(uri)
187            .header(header::CONTENT_TYPE, "application/json")
188            .body(Buffer::from(Bytes::from(body)))
189            .map_err(new_request_build_error)?;
190        self.sign(&mut req)?;
191
192        let resp = self.info.http_client().send(req).await?;
193        if resp.status() != StatusCode::OK {
194            return Err(parse_error(resp));
195        }
196
197        // Update SessionData cookies.We need obtain `X-APPLE-WEBAUTH-USER` cookie to get file.
198        self.update(&resp)?;
199
200        let bs = resp.into_body();
201        let auth_info: IcloudWebservicesResponse =
202            serde_json::from_reader(bs.reader()).map_err(new_json_deserialize_error)?;
203
204        // Check if we have extra challenge to take.
205        if auth_info.hsa_challenge_required && !auth_info.hsa_trusted_browser {
206            return Err(Error::new(ErrorKind::Unexpected, "Apple icloud AuthenticationFailed:Unauthorized request:Needs two-factor authentication"));
207        }
208
209        if let Some(v) = &auth_info.webservices.drivews.url {
210            self.data.drivews_url = v.to_string();
211        }
212        if let Some(v) = &auth_info.webservices.docws.url {
213            self.data.docws_url = v.to_string();
214        }
215
216        self.initiated = true;
217        Ok(())
218    }
219
220    fn sign<T>(&mut self, req: &mut Request<T>) -> Result<()> {
221        let headers = req.headers_mut();
222
223        headers.insert(
224            OAUTH_STATE_HEADER,
225            build_header_value(&self.data.oauth_state)?,
226        );
227
228        if let Some(session_id) = &self.data.session_id {
229            headers.insert(SESSION_ID_HEADER, build_header_value(session_id)?);
230        }
231        if let Some(scnt) = &self.data.scnt {
232            headers.insert(SCNT_HEADER, build_header_value(scnt)?);
233        }
234
235        // You can get more information from [apple.com](https://support.apple.com/en-us/111754)
236        if self.is_china_mainland {
237            headers.insert(
238                header::ORIGIN,
239                build_header_value("https://www.icloud.com.cn")?,
240            );
241            headers.insert(
242                header::REFERER,
243                build_header_value("https://www.icloud.com.cn/")?,
244            );
245        } else {
246            headers.insert(
247                header::ORIGIN,
248                build_header_value("https://www.icloud.com")?,
249            );
250            headers.insert(
251                header::REFERER,
252                build_header_value("https://www.icloud.com/")?,
253            );
254        }
255
256        if !self.data.cookies.is_empty() {
257            let cookies: Vec<String> = self
258                .data
259                .cookies
260                .iter()
261                .map(|(k, v)| format!("{}={}", k, v))
262                .collect();
263            headers.insert(
264                header::COOKIE,
265                build_header_value(&cookies.as_slice().join("; "))?,
266            );
267        }
268
269        for (key, value) in AUTH_HEADERS {
270            headers.insert(key, build_header_value(value)?);
271        }
272
273        Ok(())
274    }
275
276    /// Update signer's data after request sent out.
277    fn update(&mut self, resp: &Response<Buffer>) -> Result<()> {
278        if let Some(account_country) = parse_header_to_str(resp.headers(), ACCOUNT_COUNTRY_HEADER)?
279        {
280            self.data.account_country = Some(account_country.to_string());
281        }
282        if let Some(session_id) = parse_header_to_str(resp.headers(), SESSION_ID_HEADER)? {
283            self.data.session_id = Some(session_id.to_string());
284        }
285        if let Some(session_token) = parse_header_to_str(resp.headers(), SESSION_TOKEN_HEADER)? {
286            self.data.session_token = Some(session_token.to_string());
287        }
288
289        if let Some(scnt) = parse_header_to_str(resp.headers(), SCNT_HEADER)? {
290            self.data.scnt = Some(scnt.to_string());
291        }
292
293        let cookies: Vec<String> = resp
294            .headers()
295            .get_all(header::SET_COOKIE)
296            .iter()
297            .map(|v| v.to_str().unwrap().to_string())
298            .collect();
299
300        for cookie in cookies {
301            if let Some((key, value)) = cookie.split_once('=') {
302                self.data.cookies.insert(key.into(), value.into());
303            }
304        }
305
306        Ok(())
307    }
308
309    /// Send will make sure the following things:
310    ///
311    /// - Init the signer if it's not initiated.
312    /// - Sign the request.
313    /// - Update the session data if needed.
314    pub async fn send(&mut self, mut req: Request<Buffer>) -> Result<Response<Buffer>> {
315        self.sign(&mut req)?;
316        let resp = self.info.http_client().send(req).await?;
317
318        Ok(resp)
319    }
320}
321
322pub struct IcloudCore {
323    pub info: Arc<AccessorInfo>,
324    pub signer: Arc<Mutex<IcloudSigner>>,
325    pub root: String,
326    pub path_cache: PathCacher<IcloudPathQuery>,
327}
328
329impl Debug for IcloudCore {
330    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
331        let mut de = f.debug_struct("IcloudCore");
332        de.field("root", &self.root);
333        de.finish()
334    }
335}
336
337impl IcloudCore {
338    // Retrieves a root within the icloud Drive.
339    // "FOLDER::com.apple.CloudDocs::root"
340    pub async fn get_root(&self, id: &str) -> Result<IcloudRoot> {
341        let mut signer = self.signer.lock().await;
342
343        let uri = format!(
344            "{}/retrieveItemDetailsInFolders",
345            signer.drivews_url().await?
346        );
347
348        let body = serde_json::to_vec(&json!([
349             {
350                 "drivewsid": id,
351                 "partialData": false
352             }
353        ]))
354        .map_err(new_json_serialize_error)?;
355
356        let req = Request::post(uri)
357            .body(Buffer::from(Bytes::from(body)))
358            .map_err(new_request_build_error)?;
359
360        let resp = signer.send(req).await?;
361        if resp.status() != StatusCode::OK {
362            return Err(parse_error(resp));
363        }
364
365        let body = resp.into_body();
366        let drive_node: Vec<IcloudRoot> =
367            serde_json::from_slice(body.chunk()).map_err(new_json_deserialize_error)?;
368        Ok(drive_node[0].clone())
369    }
370
371    pub async fn get_file(
372        &self,
373        id: &str,
374        zone: &str,
375        range: BytesRange,
376        args: OpRead,
377    ) -> Result<Response<HttpBody>> {
378        let mut signer = self.signer.lock().await;
379
380        let uri = format!(
381            "{}/ws/{}/download/by_id?document_id={}",
382            signer.docws_url().await?,
383            zone,
384            id
385        );
386
387        let req = Request::get(uri)
388            .body(Buffer::new())
389            .map_err(new_request_build_error)?;
390
391        let resp = signer.send(req).await?;
392        if resp.status() != StatusCode::OK {
393            return Err(parse_error(resp));
394        }
395
396        let body = resp.into_body();
397        let object: IcloudObject =
398            serde_json::from_slice(body.chunk()).map_err(new_json_deserialize_error)?;
399
400        let url = object.data_token.url.to_string();
401
402        let mut req = Request::get(url);
403
404        if let Some(if_match) = args.if_match() {
405            req = req.header(IF_MATCH, if_match);
406        }
407
408        if range.is_full() {
409            req = req.header(header::RANGE, range.to_header())
410        }
411        if let Some(if_none_match) = args.if_none_match() {
412            req = req.header(IF_NONE_MATCH, if_none_match);
413        }
414
415        let req = req.body(Buffer::new()).map_err(new_request_build_error)?;
416
417        let resp = self.info.http_client().fetch(req).await?;
418
419        Ok(resp)
420    }
421
422    pub async fn read(
423        &self,
424        path: &str,
425        range: BytesRange,
426        args: &OpRead,
427    ) -> Result<Response<HttpBody>> {
428        let path = build_rooted_abs_path(&self.root, path);
429        let base = get_basename(&path);
430
431        let path_id = self.path_cache.get(base).await?.ok_or(Error::new(
432            ErrorKind::NotFound,
433            format!("read path not found: {}", base),
434        ))?;
435
436        if let Some(docwsid) = path_id.strip_prefix("FILE::com.apple.CloudDocs::") {
437            Ok(self
438                .get_file(docwsid, "com.apple.CloudDocs", range, args.clone())
439                .await?)
440        } else {
441            Err(Error::new(
442                ErrorKind::NotFound,
443                "icloud DriveService read error",
444            ))
445        }
446    }
447
448    pub async fn stat(&self, path: &str) -> Result<IcloudItem> {
449        let path = build_rooted_abs_path(&self.root, path);
450
451        let mut base = get_basename(&path);
452        let parent = get_parent(&path);
453
454        if base.ends_with('/') {
455            base = base.trim_end_matches('/');
456        }
457
458        let file_id = self.path_cache.get(base).await?.ok_or(Error::new(
459            ErrorKind::NotFound,
460            format!("stat path not found: {}", base),
461        ))?;
462
463        let folder_id = self.path_cache.get(parent).await?.ok_or(Error::new(
464            ErrorKind::NotFound,
465            format!("stat path not found: {}", parent),
466        ))?;
467
468        let node = self.get_root(&folder_id).await?;
469
470        match node.items.iter().find(|it| it.drivewsid == file_id.clone()) {
471            Some(it) => Ok(it.clone()),
472            None => Err(Error::new(
473                ErrorKind::NotFound,
474                "icloud DriveService stat get parent items error",
475            )),
476        }
477    }
478}
479
480pub struct IcloudPathQuery {
481    pub signer: Arc<Mutex<IcloudSigner>>,
482}
483
484impl IcloudPathQuery {
485    pub fn new(signer: Arc<Mutex<IcloudSigner>>) -> Self {
486        IcloudPathQuery { signer }
487    }
488}
489
490impl PathQuery for IcloudPathQuery {
491    async fn root(&self) -> Result<String> {
492        Ok("FOLDER::com.apple.CloudDocs::root".to_string())
493    }
494
495    /// Retrieves the root directory within the icloud Drive.
496    ///
497    /// FIXME: we are reading the entire dir to find the file, this is not efficient.
498    /// Maybe we should build a new path cache for this kind of services instead.
499    async fn query(&self, parent_id: &str, name: &str) -> Result<Option<String>> {
500        let mut signer = self.signer.lock().await;
501
502        let uri = format!(
503            "{}/retrieveItemDetailsInFolders",
504            signer.drivews_url().await?
505        );
506
507        let body = serde_json::to_vec(&json!([
508             {
509                 "drivewsid": parent_id,
510                 "partialData": false
511             }
512        ]))
513        .map_err(new_json_serialize_error)?;
514
515        let req = Request::post(uri)
516            .body(Buffer::from(Bytes::from(body)))
517            .map_err(new_request_build_error)?;
518
519        let resp = signer.send(req).await?;
520        if resp.status() != StatusCode::OK {
521            return Err(parse_error(resp));
522        }
523
524        let body = resp.into_body();
525        let root: Vec<IcloudRoot> =
526            serde_json::from_slice(body.chunk()).map_err(new_json_deserialize_error)?;
527
528        let node = &root[0];
529
530        Ok(node
531            .items
532            .iter()
533            .find(|it| it.name == name)
534            .map(|it| it.drivewsid.clone()))
535    }
536
537    async fn create_dir(&self, parent_id: &str, name: &str) -> Result<String> {
538        let mut signer = self.signer.lock().await;
539
540        let client_id = signer.client_id().to_string();
541
542        let uri = format!("{}/createFolders", signer.drivews_url().await?);
543        let body = serde_json::to_vec(&json!(
544             {
545                 "destinationDrivewsId": parent_id,
546                 "folders": [
547                 {
548                    "clientId": client_id,
549                    "name": name,
550                }
551                ],
552             }
553        ))
554        .map_err(new_json_serialize_error)?;
555
556        let req = Request::post(uri)
557            .body(Buffer::from(Bytes::from(body)))
558            .map_err(new_request_build_error)?;
559
560        let resp = signer.send(req).await?;
561        if resp.status() != StatusCode::OK {
562            return Err(parse_error(resp));
563        }
564
565        let body = resp.into_body();
566        let create_folder: IcloudCreateFolder =
567            serde_json::from_slice(body.chunk()).map_err(new_json_deserialize_error)?;
568        Ok(create_folder.destination_drivews_id)
569    }
570}
571
572pub(super) fn parse_error(resp: Response<Buffer>) -> Error {
573    let (parts, body) = resp.into_parts();
574    let bs = body.to_bytes();
575
576    let mut kind = match parts.status.as_u16() {
577        421 | 450 | 500 => ErrorKind::NotFound,
578        401 => ErrorKind::Unexpected,
579        _ => ErrorKind::Unexpected,
580    };
581
582    let (message, icloud_err) = serde_json::from_reader::<_, IcloudError>(bs.clone().reader())
583        .map(|icloud_err| (format!("{icloud_err:?}"), Some(icloud_err)))
584        .unwrap_or_else(|_| (String::from_utf8_lossy(&bs).into_owned(), None));
585
586    if let Some(icloud_err) = &icloud_err {
587        kind = match icloud_err.status_code.as_str() {
588            "NOT_FOUND" => ErrorKind::NotFound,
589            "PERMISSION_DENIED" => ErrorKind::PermissionDenied,
590            _ => ErrorKind::Unexpected,
591        }
592    }
593
594    let mut err = Error::new(kind, message);
595
596    err = with_error_response_context(err, parts);
597
598    err
599}
600
601#[derive(Default, Debug, Deserialize)]
602#[allow(dead_code)]
603struct IcloudError {
604    status_code: String,
605    message: String,
606}
607
608#[derive(Default, Deserialize, Clone)]
609#[serde(rename_all = "camelCase")]
610pub struct IcloudWebservicesResponse {
611    #[serde(default)]
612    pub hsa_challenge_required: bool,
613    #[serde(default)]
614    pub hsa_trusted_browser: bool,
615    pub webservices: Webservices,
616}
617
618#[derive(Deserialize, Default, Clone, Debug)]
619pub struct Webservices {
620    pub drivews: Drivews,
621    pub docws: Docws,
622}
623
624#[derive(Deserialize, Default, Clone, Debug)]
625#[serde(rename_all = "camelCase")]
626pub struct Drivews {
627    pub url: Option<String>,
628}
629
630#[derive(Deserialize, Default, Clone, Debug)]
631#[serde(rename_all = "camelCase")]
632pub struct Docws {
633    pub url: Option<String>,
634}
635
636#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
637#[serde(rename_all = "camelCase")]
638pub struct IcloudRoot {
639    #[serde(default)]
640    pub asset_quota: i64,
641    #[serde(default)]
642    pub date_created: String,
643    #[serde(default)]
644    pub direct_children_count: i64,
645    pub docwsid: String,
646    pub drivewsid: String,
647    pub etag: String,
648    #[serde(default)]
649    pub file_count: i64,
650    pub items: Vec<IcloudItem>,
651    pub name: String,
652    pub number_of_items: i64,
653    pub status: String,
654    #[serde(rename = "type")]
655    pub type_field: String,
656    pub zone: String,
657}
658
659#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
660#[serde(rename_all = "camelCase")]
661pub struct IcloudItem {
662    #[serde(default)]
663    pub asset_quota: Option<i64>,
664    #[serde(default)]
665    pub date_created: String,
666    #[serde(default)]
667    pub date_modified: String,
668    #[serde(default)]
669    pub direct_children_count: Option<i64>,
670    pub docwsid: String,
671    pub drivewsid: String,
672    pub etag: String,
673    #[serde(default)]
674    pub file_count: Option<i64>,
675    pub item_id: Option<String>,
676    pub name: String,
677    pub parent_id: String,
678    #[serde(default)]
679    pub size: u64,
680    #[serde(rename = "type")]
681    pub type_field: String,
682    pub zone: String,
683    #[serde(default)]
684    pub max_depth: Option<String>,
685    #[serde(default)]
686    pub is_chained_to_parent: Option<bool>,
687}
688
689#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
690pub struct IcloudObject {
691    pub document_id: String,
692    pub item_id: String,
693    pub owner_dsid: i64,
694    pub data_token: DataToken,
695    pub double_etag: String,
696}
697
698#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
699pub struct DataToken {
700    pub url: String,
701    pub token: String,
702    pub signature: String,
703    pub wrapping_key: String,
704    pub reference_signature: String,
705}
706
707#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
708pub struct IcloudCreateFolder {
709    pub destination_drivews_id: String,
710    pub folders: Vec<IcloudItem>,
711}
712
713#[cfg(test)]
714mod tests {
715    use pretty_assertions::assert_eq;
716
717    use super::IcloudRoot;
718    use super::IcloudWebservicesResponse;
719
720    #[test]
721    fn test_parse_icloud_drive_root_json() {
722        let data = r#"{
723          "assetQuota": 19603579,
724          "dateCreated": "2019-06-10T14:17:49Z",
725          "directChildrenCount": 3,
726          "docwsid": "root",
727          "drivewsid": "FOLDER::com.apple.CloudDocs::root",
728          "etag": "w7",
729          "fileCount": 22,
730          "items": [
731            {
732              "assetQuota": 19603579,
733              "dateCreated": "2021-02-05T08:30:58Z",
734              "directChildrenCount": 22,
735              "docwsid": "1E013608-C669-43DB-AC14-3D7A4E0A0500",
736              "drivewsid": "FOLDER::com.apple.CloudDocs::1E013608-C669-43DB-AC14-3D7A4E0A0500",
737              "etag": "sn",
738              "fileCount": 22,
739              "item_id": "CJWdk48eEAAiEB4BNgjGaUPbrBQ9ek4KBQAoAQ",
740              "name": "Downloads",
741              "parentId": "FOLDER::com.apple.CloudDocs::root",
742              "shareAliasCount": 0,
743              "shareCount": 0,
744              "type": "FOLDER",
745              "zone": "com.apple.CloudDocs"
746            },
747            {
748              "dateCreated": "2019-06-10T14:17:54Z",
749              "docwsid": "documents",
750              "drivewsid": "FOLDER::com.apple.Keynote::documents",
751              "etag": "1v",
752              "maxDepth": "ANY",
753              "name": "Keynote",
754              "parentId": "FOLDER::com.apple.CloudDocs::root",
755              "type": "APP_LIBRARY",
756              "zone": "com.apple.Keynote"
757            },
758            {
759              "assetQuota": 0,
760              "dateCreated": "2024-01-06T02:35:08Z",
761              "directChildrenCount": 0,
762              "docwsid": "21E4A15E-DA77-472A-BAC8-B0C35A91F237",
763              "drivewsid": "FOLDER::com.apple.CloudDocs::21E4A15E-DA77-472A-BAC8-B0C35A91F237",
764              "etag": "w8",
765              "fileCount": 0,
766              "isChainedToParent": true,
767              "item_id": "CJWdk48eEAAiECHkoV7ad0cqusiww1qR8jcoAQ",
768              "name": "opendal",
769              "parentId": "FOLDER::com.apple.CloudDocs::root",
770              "shareAliasCount": 0,
771              "shareCount": 0,
772              "type": "FOLDER",
773              "zone": "com.apple.CloudDocs"
774            }
775          ],
776          "name": "",
777          "numberOfItems": 16,
778          "shareAliasCount": 0,
779          "shareCount": 0,
780          "status": "OK",
781          "type": "FOLDER",
782          "zone": "com.apple.CloudDocs"
783        }"#;
784
785        let response: IcloudRoot = serde_json::from_str(data).unwrap();
786        assert_eq!(response.name, "");
787        assert_eq!(response.type_field, "FOLDER");
788        assert_eq!(response.zone, "com.apple.CloudDocs");
789        assert_eq!(response.docwsid, "root");
790        assert_eq!(response.drivewsid, "FOLDER::com.apple.CloudDocs::root");
791        assert_eq!(response.etag, "w7");
792        assert_eq!(response.file_count, 22);
793    }
794
795    #[test]
796    fn test_parse_icloud_drive_folder_file() {
797        let data = r#"{
798          "assetQuota": 19603579,
799          "dateCreated": "2021-02-05T08:34:21Z",
800          "directChildrenCount": 22,
801          "docwsid": "1E013608-C669-43DB-AC14-3D7A4E0A0500",
802          "drivewsid": "FOLDER::com.apple.CloudDocs::1E013608-C669-43DB-AC14-3D7A4E0A0500",
803          "etag": "w9",
804          "fileCount": 22,
805          "items": [
806            {
807              "dateChanged": "2021-02-18T14:10:46Z",
808              "dateCreated": "2021-02-10T07:01:34Z",
809              "dateModified": "2021-02-10T07:01:34Z",
810              "docwsid": "9605331E-7BF3-41A0-A128-A68FFA377C50",
811              "drivewsid": "FILE::com.apple.CloudDocs::9605331E-7BF3-41A0-A128-A68FFA377C50",
812              "etag": "5b::5a",
813              "extension": "pdf",
814              "item_id": "CJWdk48eEAAiEJYFMx5780GgoSimj_o3fFA",
815              "lastOpenTime": "2021-02-10T10:28:42Z",
816              "name": "1-11-ARP-notes",
817              "parentId": "FOLDER::com.apple.CloudDocs::1E013608-C669-43DB-AC14-3D7A4E0A0500",
818              "size": 639483,
819              "type": "FILE",
820              "zone": "com.apple.CloudDocs"
821            }
822            ],
823          "name": "Downloads",
824          "numberOfItems": 22,
825          "parentId": "FOLDER::com.apple.CloudDocs::root",
826          "shareAliasCount": 0,
827          "shareCount": 0,
828          "status": "OK",
829          "type": "FOLDER",
830          "zone": "com.apple.CloudDocs"
831        }"#;
832
833        let response = serde_json::from_str::<IcloudRoot>(data).unwrap();
834
835        assert_eq!(response.name, "Downloads");
836        assert_eq!(response.type_field, "FOLDER");
837        assert_eq!(response.zone, "com.apple.CloudDocs");
838        assert_eq!(response.docwsid, "1E013608-C669-43DB-AC14-3D7A4E0A0500");
839        assert_eq!(
840            response.drivewsid,
841            "FOLDER::com.apple.CloudDocs::1E013608-C669-43DB-AC14-3D7A4E0A0500"
842        );
843        assert_eq!(response.etag, "w9");
844        assert_eq!(response.file_count, 22);
845    }
846
847    #[test]
848    fn test_parse_icloud_webservices() {
849        let data = r#"
850        {
851          "hsaChallengeRequired": false,
852          "hsaTrustedBrowser": true,
853          "webservices": {
854            "docws": {
855              "pcsRequired": true,
856              "status": "active",
857              "url": "https://p219-docws.icloud.com.cn:443"
858            },
859            "drivews": {
860              "pcsRequired": true,
861              "status": "active",
862              "url": "https://p219-drivews.icloud.com.cn:443"
863            }
864          }
865        }
866        "#;
867        let response = serde_json::from_str::<IcloudWebservicesResponse>(data).unwrap();
868        assert!(!response.hsa_challenge_required);
869        assert!(response.hsa_trusted_browser);
870        assert_eq!(
871            response.webservices.docws.url,
872            Some("https://p219-docws.icloud.com.cn:443".to_string())
873        );
874        assert_eq!(
875            response.webservices.drivews.url,
876            Some("https://p219-drivews.icloud.com.cn:443".to_string())
877        );
878    }
879}