KeiSeiKit-1.0/_primitives/_rust/kei-contacts-apple/src/discovery.rs
Parfii-bot 06bcce9981 feat(contacts): glue sync + Google pagination + Apple discovery & folding
Three atomics finish phase 3 of kei-buddy contacts integration:

## kei-buddy: contact-sync glue + slash commands (+5 tests)

New src/contacts_sync.rs (146 LOC):
  * SyncReport { fetched, added, skipped, errors }
  * sync_from_google(access_token, contacts) — builds GooglePeopleClient,
    list_connections, dedups by (name+email) via search_contacts,
    add_contact in loop
  * sync_from_apple(apple_id, app_pw, addressbook_url, contacts) — same
    pattern over ICloudCardDavClient.list_contacts
  * All errors collected into report.errors; never panics, never propagates

New slash commands in commands.rs / command_exec.rs:
  * /sync-google — reads GOOGLE_OAUTH_ACCESS_TOKEN env, calls sync_from_google,
    Russian-formatted summary "Google: загружено N, добавлено M, пропущено K"
  * /sync-apple — reads APPLE_ID + APPLE_APP_PASSWORD + APPLE_CARDDAV_URL,
    calls sync_from_apple
  * Missing env → human-readable "не настроено: …" response
  * /help text updated

Deps added: kei-contacts-google + kei-contacts-apple as path deps.

## kei-contacts-google: pagination via nextPageToken (+1 test)

Refactor: client.rs 182→56 LOC; pagination logic + deserialization moved
to new src/pagination.rs (188 LOC). list_connections unchanged
(back-compat, returns first page only). New list_all_connections loops
via fetch_page(Some(token)) until token=None; hard cap 50 pages with
tracing::warn on cap.

Test list_all_connections_two_pages: wiremock returns page 1 with
nextPageToken="abc" + page 2 without; assert len = sum AND second
request carries pageToken=abc query.

## kei-contacts-apple: vCard line-folding + CardDAV auto-discovery (+2 tests)

vcard.rs +unfold() helper applied in parse_vcard per RFC 6350 §3.2:
continuation lines starting with space/tab strip the prefix and append
to previous line. Test parse_folded_vcard.

New src/discovery.rs (199 LOC): discover_addressbook() walks
.well-known/carddav → current-user-principal → addressbook-home-set →
first addressbook with C:addressbook resourcetype. Three PROPFIND
requests with canned XML bodies. Regex-based extract_first_href_under +
extract_addressbook_href helpers. Test discover_walks_three_propfinds
against 3-step wiremock fixture.

client.rs adds discover_addressbook_url() method calling discovery.

## Verify-before-commit

  * cargo check --workspace: PASS
  * cargo test -p kei-buddy --lib: 46/0 (was 41)
  * cargo test -p kei-contacts-google: 5/0 (was 4, +1 pagination)
  * cargo test -p kei-contacts-apple: 9/0 (was 7, +1 folding +1 discovery)

NOT deployed — user still in live conversation with bot.

Follow-up (deferred, non-blocking):
  * Real iCloud smoke test for discover_addressbook_url — regex parser
    may need adjustment for deeply-nested namespace prefixes
  * Wiremock-backed integration test for sync_from_google glue (HTTP
    layer already covered in kei-contacts-google tests)
2026-05-12 17:04:15 +08:00

199 lines
7.9 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//! CardDAV auto-discovery via three successive PROPFIND requests.
//!
//! Implements RFC 6764 §6 well-known URI discovery.
use crate::error::ContactsError;
use regex::Regex;
use reqwest::{Client, Method};
use tracing::debug;
// ── XML bodies ────────────────────────────────────────────────────────────────
fn propfind_principal_xml() -> &'static str {
r#"<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:current-user-principal/></D:prop>
</D:propfind>"#
}
fn propfind_home_set_xml() -> &'static str {
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>"#
}
fn propfind_resourcetype_xml() -> &'static str {
r#"<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:prop><D:resourcetype/></D:prop>
</D:propfind>"#
}
// ── XML helpers ───────────────────────────────────────────────────────────────
/// Extract the first `<D:href>` child of `tag` using regex.
///
/// Matches namespace-prefixed variants of `tag` (e.g. `D:current-user-principal`
/// or `C:addressbook-home-set`).
pub(crate) fn extract_first_href_under(xml: &str, tag: &str) -> Option<String> {
let pattern = format!(
r"(?si)<(?:[a-zA-Z0-9_-]+:)?{tag}[^>]*>\s*<(?:[a-zA-Z0-9_-]+:)?href[^>]*>([^<]+)</",
tag = regex::escape(tag)
);
let re = Regex::new(&pattern).ok()?;
re.captures(xml)
.and_then(|c| c.get(1))
.map(|m| m.as_str().trim().to_string())
}
/// Extract the first `<D:href>` from a multistatus `<D:response>` whose
/// `<D:resourcetype>` contains `addressbook`.
pub(crate) fn extract_addressbook_href(xml: &str) -> Option<String> {
// Split on <D:response> or <response> boundaries (case-insensitive).
let re_split = Regex::new(r"(?si)<(?:[a-zA-Z0-9_-]+:)?response[^>]*>").ok()?;
let boundaries: Vec<_> = re_split.find_iter(xml).map(|m| m.start()).collect();
for (i, &start) in boundaries.iter().enumerate() {
let end = boundaries.get(i + 1).copied().unwrap_or(xml.len());
let chunk = &xml[start..end];
if chunk.to_ascii_lowercase().contains("addressbook") {
// Extract the href from this response chunk.
let re_href =
Regex::new(r"(?si)<(?:[a-zA-Z0-9_-]+:)?href[^>]*>([^<]+)</").ok()?;
if let Some(cap) = re_href.captures(chunk) {
if let Some(m) = cap.get(1) {
return Some(m.as_str().trim().to_string());
}
}
}
}
None
}
// ── PROPFIND helper ───────────────────────────────────────────────────────────
async fn propfind(
client: &Client,
apple_id: &str,
password: &str,
url: &str,
depth: &str,
body: &'static str,
) -> Result<String, ContactsError> {
debug!(%url, %depth, "PROPFIND");
let resp = client
.request(
Method::from_bytes(b"PROPFIND")
.map_err(|e| ContactsError::Http(e.to_string()))?,
url,
)
.basic_auth(apple_id, Some(password))
.header("Content-Type", "application/xml; charset=utf-8")
.header("Depth", depth)
.body(body)
.send()
.await
.map_err(|e| ContactsError::Http(e.to_string()))?;
let status = resp.status();
if status.as_u16() == 401 || status.as_u16() == 403 {
return Err(ContactsError::Auth(format!("iCloud returned {}", status.as_u16())));
}
if !status.is_success() && status.as_u16() != 207 {
return Err(ContactsError::Http(format!("PROPFIND status={}", status)));
}
resp.text()
.await
.map_err(|e| ContactsError::Parse(e.to_string()))
}
// ── public entry point ────────────────────────────────────────────────────────
// ── tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use reqwest::Client;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn discover_walks_three_propfinds() {
let server = MockServer::start().await;
Mock::given(method("PROPFIND"))
.and(path("/.well-known/carddav"))
.respond_with(ResponseTemplate::new(207).set_body_string(
r#"<D:multistatus xmlns:D="DAV:"><D:response><D:propstat>
<D:current-user-principal><D:href>/principals/users/testuser/</D:href></D:current-user-principal>
</D:propstat></D:response></D:multistatus>"#,
))
.mount(&server)
.await;
Mock::given(method("PROPFIND"))
.and(path("/principals/users/testuser/"))
.respond_with(ResponseTemplate::new(207).set_body_string(
r#"<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav"><D:response><D:propstat>
<C:addressbook-home-set><D:href>/addressbooks/testuser/</D:href></C:addressbook-home-set>
</D:propstat></D:response></D:multistatus>"#,
))
.mount(&server)
.await;
Mock::given(method("PROPFIND"))
.and(path("/addressbooks/testuser/"))
.respond_with(ResponseTemplate::new(207).set_body_string(
r#"<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:response>
<D:href>/addressbooks/testuser/card/</D:href>
<D:propstat><D:resourcetype><D:collection/><C:addressbook/></D:resourcetype></D:propstat>
</D:response>
</D:multistatus>"#,
))
.mount(&server)
.await;
let client = Client::new();
let url = discover_addressbook(&client, "user@icloud.com", "app-pass", &server.uri())
.await
.expect("discovery should succeed");
assert_eq!(url, "/addressbooks/testuser/card/");
}
}
/// Perform CardDAV three-step auto-discovery.
///
/// 1. PROPFIND `/.well-known/carddav` → `current-user-principal`
/// 2. PROPFIND `{principal}` → `addressbook-home-set`
/// 3. PROPFIND `{home-set}` (depth=1) → first `addressbook` resource href
pub(crate) async fn discover_addressbook(
client: &Client,
apple_id: &str,
password: &str,
base_url: &str,
) -> Result<String, ContactsError> {
// Step 1: principal
let url1 = format!("{}/.well-known/carddav", base_url);
let xml1 = propfind(client, apple_id, password, &url1, "0", propfind_principal_xml()).await?;
let principal = extract_first_href_under(&xml1, "current-user-principal")
.ok_or_else(|| ContactsError::Parse("discover step 1: no current-user-principal".into()))?;
// Step 2: home set
let url2 = format!("{}{}", base_url, principal);
let xml2 = propfind(client, apple_id, password, &url2, "0", propfind_home_set_xml()).await?;
let home_set = extract_first_href_under(&xml2, "addressbook-home-set")
.ok_or_else(|| ContactsError::Parse("discover step 2: no addressbook-home-set".into()))?;
// Step 3: addressbook resource
let url3 = format!("{}{}", base_url, home_set);
let xml3 =
propfind(client, apple_id, password, &url3, "1", propfind_resourcetype_xml()).await?;
extract_addressbook_href(&xml3)
.ok_or_else(|| ContactsError::Parse("discover step 3: no addressbook resource".into()))
}