Git · dadoum.zone
Repositories
Help
Report an Issue
apple-crates
Code
Commits
Branches
Tags
Search
Tree:
2ab8bf0
Branches
Tags
main
apple-crates
icloud
src
contacts.rs
Add instructions for agents, and a few minor API adjustments and additions.
Dadoum
commited
2ab8bf0
at 2026-10-27 03:57:30
contacts.rs
Blame
History
Raw
//! The iCloud address book over CardDAV (RFC 6352), as Contacts on a Mac reads //! it: `contacts.icloud.com`, Basic authentication with the DSID and the //! MobileMe token, discovery from the account's principal down to each address //! book, then one `addressbook-query` REPORT per book. //! //! What callers get is the raw vCards; this crate does not interpret them. use reqwest::{Method, Url}; use thiserror::Error; use crate::{AccountSettings, ICloudSession}; /// The dataclass whose `url` is the account's CardDAV server. pub const CONTACTS_DATACLASS: &str = "com.apple.Dataclass.Contacts"; /// Where CardDAV discovery starts when the settings name no server: it answers /// for every account and points at the account's own shard. pub const CONTACTS_FALLBACK: &str = "https://contacts.icloud.com"; const DAV: &str = "DAV:"; const CARDDAV: &str = "urn:ietf:params:xml:ns:carddav"; /// More address books than an account has; a bound on a hostile server. const MAX_BOOKS: usize = 32; #[derive(Debug, Error)] pub enum ContactsError { #[error("the account has no iCloud Contacts server")] NoServer, /// The DSID and token were refused: the session must be renewed. #[error("iCloud Contacts refused the account's session")] Unauthorized, #[error("A new contact must have a valid vCard and UUID.")] InvalidCard, #[error("This contact changed in iCloud. Refresh it before saving or deleting.")] Conflict, #[error("The server did not provide a strong contact version; editing is unavailable.")] MissingVersion, #[error("The server returned an unsafe contact URL.")] UnsafeUrl, #[error("cannot generate device authentication data: {0}")] Anisette(#[from] adi::proxy::ADIError), #[error("cannot reach iCloud Contacts: {0}")] Network(#[from] reqwest::Error), #[error("iCloud Contacts answered {0} to {1}")] Status(u16, &'static str), #[error("iCloud Contacts sent an unreadable answer: {0}")] Xml(String), #[error("iCloud Contacts did not say where {0} is")] Missing(&'static str), } impl AccountSettings { /// `https://pNN-contacts.icloud.com:443`, from the login's service data. pub fn contacts_url(&self) -> Option<String> { self.service(CONTACTS_DATACLASS)? .get("url")? .as_string() .map(str::to_owned) } } /// 401 means the session itself was refused, which the caller can renew. fn status_error(status: u16, what: &'static str) -> ContactsError { match status { 401 => ContactsError::Unauthorized, status => ContactsError::Status(status, what), } } /// The `href`s of `<response>`s in a multistatus whose `<prop>` holds an /// element named `want` (in `ns`), with that element's text. fn responses(xml: &str, ns: &str, want: &str) -> Result<Vec<(String, String)>, ContactsError> { let doc = roxmltree::Document::parse(xml).map_err(|e| ContactsError::Xml(e.to_string()))?; let is = |n: &roxmltree::Node, ns: &str, name: &str| { n.is_element() && n.tag_name().name() == name && n.tag_name().namespace() == Some(ns) }; let mut out = Vec::new(); for response in doc.descendants().filter(|n| is(n, DAV, "response")) { let Some(href) = response .descendants() .find(|n| is(n, DAV, "href")) .and_then(|n| n.text()) else { continue; }; // Only properties the server reports as found (a 200 propstat). for propstat in response.children().filter(|n| is(n, DAV, "propstat")) { let ok = propstat .children() .find(|n| is(n, DAV, "status")) .and_then(|n| n.text()) .is_none_or(|s| s.contains(" 200 ")); if !ok { continue; } if let Some(found) = propstat.descendants().find(|n| is(n, ns, want)) { // The text of the element, or of its first `href` child // (current-user-principal, addressbook-home-set), or the // element's presence (resourcetype/addressbook). let text = found .descendants() .find(|n| is(n, DAV, "href")) .and_then(|n| n.text()) .or_else(|| found.text()) .unwrap_or("") .trim() .to_owned(); out.push((href.trim().to_owned(), text)); } } } Ok(out) } fn join(base: &Url, href: &str) -> Result<Url, ContactsError> { base.join(href) .map_err(|e| ContactsError::Xml(format!("bad href {href:?}: {e}"))) } /// An address book discovered from the account's CardDAV home. #[derive(Clone)] pub struct AddressBook { pub url: String, pub name: String, } /// A raw card and the identity/version required for conditional writes. /// Contact content is deliberately omitted from Debug output. #[derive(Clone)] pub struct ContactResource { pub url: String, pub etag: Option<String>, pub vcard: String, } fn checked_url(value: &str) -> Result<Url, ContactsError> { let url = Url::parse(value).map_err(|_| ContactsError::UnsafeUrl)?; let host = url.host_str().unwrap_or_default(); if url.scheme() != "https" || !(host == "contacts.icloud.com" || host.ends_with("-contacts.icloud.com")) || !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() || !matches!(url.port(), None | Some(443)) { return Err(ContactsError::UnsafeUrl); } Ok(url) } fn strong_etag(value: Option<&str>) -> Result<&str, ContactsError> { value .filter(|v| v.starts_with('"') && v.ends_with('"') && v.len() > 2) .ok_or(ContactsError::MissingVersion) } fn parse_contacts(base: &Url, xml: &str) -> Result<Vec<ContactResource>, ContactsError> { let versions: std::collections::HashMap<_, _> = responses(xml, DAV, "getetag")?.into_iter().collect(); responses(xml, CARDDAV, "address-data")? .into_iter() .filter(|(_, card)| card.contains("BEGIN:VCARD")) .map(|(href, vcard)| { let url = join(base, &href)?; checked_url(url.as_str())?; Ok(ContactResource { url: url.to_string(), etag: versions.get(&href).cloned(), vcard, }) }) .collect() } impl ICloudSession<'_, '_> { async fn dav( &self, method: &'static str, url: &Url, depth: &str, body: &'static str, what: &'static str, ) -> Result<String, ContactsError> { let method = Method::from_bytes(method.as_bytes()).expect("a WebDAV method name"); let response = self .icloud_request_builder(method, url.as_str())? .header("Depth", depth) .header("Content-Type", "application/xml; charset=utf-8") .body(body) .send() .await?; let status = response.status().as_u16(); if status != 207 { return Err(status_error(status, what)); } Ok(response.text().await?) } /// Every vCard in every address book on `server` (an account's /// [`AccountSettings::contacts_url`]). pub async fn address_books(&self, server: &str) -> Result<Vec<AddressBook>, ContactsError> { let server = checked_url(server)?; let found = self .dav( "PROPFIND", &server, "0", r#"<?xml version="1.0" encoding="UTF-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>"#, "the principal", ) .await?; let principal = responses(&found, DAV, "current-user-principal")? .into_iter() .map(|(_, href)| href) .find(|h| !h.is_empty()) .ok_or(ContactsError::Missing("the principal"))?; let principal = checked_url(join(&server, &principal)?.as_str())?; let found = self .dav( "PROPFIND", &principal, "0", r#"<?xml version="1.0" encoding="UTF-8"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:prop><c:addressbook-home-set/></d:prop></d:propfind>"#, "the address book home", ) .await?; let home = responses(&found, CARDDAV, "addressbook-home-set")? .into_iter() .map(|(_, href)| href) .find(|h| !h.is_empty()) .ok_or(ContactsError::Missing("the address book home"))?; let home = checked_url(join(&principal, &home)?.as_str())?; let found = self .dav( "PROPFIND", &home, "1", r#"<?xml version="1.0" encoding="UTF-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:displayname/></d:prop></d:propfind>"#, "the address books", ) .await?; // A collection whose resourcetype contains <c:addressbook/>. let doc = roxmltree::Document::parse(&found).map_err(|e| ContactsError::Xml(e.to_string()))?; let mut books = Vec::new(); for response in doc .descendants() .filter(|n| n.tag_name().name() == "response" && n.tag_name().namespace() == Some(DAV)) { let is_book = response.descendants().any(|n| { n.tag_name().name() == "addressbook" && n.tag_name().namespace() == Some(CARDDAV) }); let href = response .descendants() .find(|n| n.tag_name().name() == "href" && n.tag_name().namespace() == Some(DAV)) .and_then(|n| n.text()); if let (true, Some(href)) = (is_book, href) { let url = join(&home, href.trim())?; checked_url(url.as_str())?; let name = response .descendants() .find(|n| { n.tag_name().name() == "displayname" && n.tag_name().namespace() == Some(DAV) }) .and_then(|n| n.text()) .unwrap_or("iCloud Contacts") .to_owned(); books.push(AddressBook { url: url.to_string(), name, }); } } if books.is_empty() { return Err(ContactsError::Missing("an address book")); } Ok(books.into_iter().take(MAX_BOOKS).collect()) } pub async fn contacts( &self, book: &AddressBook, ) -> Result<Vec<ContactResource>, ContactsError> { let url = checked_url(&book.url)?; let found = self.dav("REPORT", &url, "1", r#"<?xml version="1.0" encoding="UTF-8"?><c:addressbook-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><c:address-data/></d:prop></c:addressbook-query>"#, "the contacts").await?; parse_contacts(&url, &found) } /// Compatibility reader for consumers that only need the raw cards. pub async fn contact_cards(&self, server: &str) -> Result<Vec<String>, ContactsError> { let mut cards = Vec::new(); for book in self.address_books(server).await? { cards.extend( self.contacts(&book) .await? .into_iter() .map(|card| card.vcard), ); } Ok(cards) } /// Create only if this resource does not already exist. pub async fn create_contact( &self, book: &AddressBook, card: &str, ) -> Result<ContactResource, ContactsError> { let mut base = checked_url(&book.url)?; if !base.path().ends_with('/') { base.set_path(&format!("{}/", base.path())); } // The card keeps the same UID across retries, including a timeout // after a successful PUT. If-None-Match then prevents duplicate cards. let parsed = crate::VCard::parse(card).map_err(|_| ContactsError::InvalidCard)?; let id = parsed .uid() .and_then(|id| uuid::Uuid::parse_str(id).ok()) .ok_or(ContactsError::InvalidCard)?; let url = join(&base, &format!("{id}.vcf"))?; self.put_contact(&url, None, card).await } /// Compare-and-swap: a stale card can never overwrite a server edit. pub async fn update_contact( &self, contact: &ContactResource, card: &str, ) -> Result<ContactResource, ContactsError> { let etag = strong_etag(contact.etag.as_deref())?; self.put_contact(&checked_url(&contact.url)?, Some(etag), card) .await } async fn put_contact( &self, url: &Url, etag: Option<&str>, card: &str, ) -> Result<ContactResource, ContactsError> { let request = self .icloud_request_builder(Method::PUT, url.as_str())? .header("Content-Type", "text/vcard; charset=utf-8") .body(card.to_owned()); let request = if let Some(etag) = etag { request.header("If-Match", etag) } else { request.header("If-None-Match", "*") }; let response = request.send().await?; let status = response.status().as_u16(); if status == 412 { return Err(ContactsError::Conflict); } if !matches!(status, 200 | 201 | 204) { return Err(status_error(status, "saving the contact")); } // Do not GET after PUT: a failed read would incorrectly report a failed save. // Without a returned version, refresh before the next mutation. let etag = response .headers() .get("etag") .and_then(|h| h.to_str().ok()) .map(str::to_owned); Ok(ContactResource { url: url.to_string(), etag, vcard: card.to_owned(), }) } pub async fn delete_contact(&self, contact: &ContactResource) -> Result<(), ContactsError> { let url = checked_url(&contact.url)?; let etag = strong_etag(contact.etag.as_deref())?; let response = self .icloud_request_builder(Method::DELETE, url.as_str())? .header("If-Match", etag) .send() .await?; match response.status().as_u16() { 200 | 204 => Ok(()), 412 => Err(ContactsError::Conflict), status => Err(status_error(status, "deleting the contact")), } } } #[cfg(test)] mod tests { use super::*; #[test] fn multistatus_hrefs_and_values_are_read() { let xml = r#"<?xml version="1.0"?> <multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav"> <response><href>/123/principal/</href><propstat><prop> <C:addressbook-home-set><href>https://p48-contacts.icloud.com:443/123/carddavhome/</href></C:addressbook-home-set> </prop><status>HTTP/1.1 200 OK</status></propstat></response> </multistatus>"#; let r = responses(xml, CARDDAV, "addressbook-home-set").unwrap(); assert_eq!( r, [( "/123/principal/".to_owned(), "https://p48-contacts.icloud.com:443/123/carddavhome/".to_owned() )] ); } #[test] fn address_data_and_failed_props_are_told_apart() { let xml = r#"<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"> <d:response><d:href>/123/carddavhome/card/a.vcf</d:href><d:propstat><d:prop> <c:address-data>BEGIN:VCARD EMAIL:a@b.c END:VCARD</c:address-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response> <d:response><d:href>/123/carddavhome/card/b.vcf</d:href><d:propstat><d:prop> <c:address-data/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response> </d:multistatus>"#; let r = responses(xml, CARDDAV, "address-data").unwrap(); assert_eq!(r.len(), 1); assert!(r[0].1.contains("EMAIL:a@b.c")); } #[test] fn relative_hrefs_join_the_server() { let base = Url::parse("https://p48-contacts.icloud.com:443/").unwrap(); assert_eq!( join(&base, "/123/principal/").unwrap().as_str(), "https://p48-contacts.icloud.com/123/principal/" ); } } #[cfg(test)] mod mutation_tests { use super::*; #[test] fn associates_each_card_with_its_own_version_and_rejects_foreign_urls() { let base = Url::parse("https://p48-contacts.icloud.com/book/").unwrap(); let xml = r#"<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:response><d:href>a.vcf</d:href><d:propstat><d:prop><d:getetag>"one"</d:getetag><c:address-data>BEGIN:VCARD VERSION:3.0 FN:Fixture END:VCARD</c:address-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#; let cards = parse_contacts(&base, xml).unwrap(); assert_eq!(cards[0].etag.as_deref(), Some("\"one\"")); assert_eq!(cards[0].url, "https://p48-contacts.icloud.com/book/a.vcf"); assert!(parse_contacts(&base, &xml.replace("a.vcf", "https://example.com/a.vcf")).is_err()); } #[test] fn requires_a_strong_version_and_icloud_https() { for version in [None, Some(""), Some("W/\"one\""), Some("*")] { assert!(strong_etag(version).is_err()); } assert!(strong_etag(Some("\"one\"")).is_ok()); for url in [ "http://contacts.icloud.com/", "https://contacts.icloud.com.evil.test/", "https://evil.test/", "https://u@contacts.icloud.com/", ] { assert!(checked_url(url).is_err()); } } }