CalDAV Connection Pooling, PROPFIND Discovery & Capabilities #7

Closed
opened 2026-09-22 17:58:54 +00:00 by yuris · 0 comments
Owner

Summary

Implement the foundational CalDAV client adapter using reqwest and rustls, performing authenticated discovery via namespace-aware WebDAV PROPFIND queries, validating collection capabilities (VEVENT and VTODO component sets, supported reports), enforcing strict security boundaries, and implementing the external calendar synchronization engine (POST /v1/sync / life sync) with RFC 6578 sync-collection, fallback enumeration, transactional cursor and projection commits, and observation gap handling (Scenarios R10, R11).


Background & Technical Rationale

The life-service system relies on an upstream Radicale CalDAV server as the authoritative backend for live calendar events and task obligations. Establishing a reliable, secure connection requires robust WebDAV/CalDAV protocol negotiation:

  1. Protocol Negotiation & Multi-Stage Discovery:
    CalDAV (RFC 4791) builds upon WebDAV (RFC 4918) and WebDAV Current Principal (RFC 5397). Clients cannot assume arbitrary hard-coded paths for calendars. Discovery must execute in sequential stages:

    • Bootstrap PROPFIND (Depth: 0): Query the configured base_url for DAV:current-user-principal.
    • Principal PROPFIND (Depth: 0): Query the resolved principal URL for urn:ietf:params:xml:ns:caldav:calendar-home-set.
    • Home-Set PROPFIND (Depth: 1): Enumerate child collections within the calendar home set, querying DAV:resourcetype, DAV:displayname, urn:ietf:params:xml:ns:caldav:supported-calendar-component-set, and DAV:supported-report-set.
  2. Collection Validation & Explicit Logical ID Mapping:
    Never implicitly bind all discovered collections. Service configuration defines logical collection IDs (e.g. personal, work) mapped to expected URLs or paths. The discovery engine must verify that each mapped collection actually exists, has <cal:calendar/> in its resourcetype, and explicitly advertises support for both VEVENT and VTODO components. If a configured collection is missing or lacks required component types, startup must fail fast with configuration_invalid or calendar_unsupported.

  3. Strict Security Boundaries & Defense-in-Depth:

    • No Secret Forwarding / Cross-Origin Redirects: Radicale credentials (read from local secret files) must never leak across origins. The HTTP client must install a custom redirect policy that strictly forbids cross-origin redirects, downgrades from HTTPS to HTTP, or target URLs bearing embedded credentials (http://user:pass@host/).
    • Safe XML Parsing: WebDAV uses XML for requests and responses (multistatus). Untrusted or malformed XML exposes the service to XML Entity Expansion (Billion Laughs / XXE) attacks. Using quick-xml, external DTDs and entity expansion must be disabled, and strict limits on XML body size (e.g. 10 MB) and nesting depth (e.g. 32 levels) must be enforced.
    • Namespace Invariance: WebDAV servers use varying namespace prefixes (e.g., D:, d:, DAV:, C:, cal:). XML parsing must never match on raw tag string prefixes; it must resolve XML namespaces by URI (DAV: and urn:ietf:params:xml:ns:caldav) and match local element names.
  4. External Sync & Observation Semantics (Scenarios R10 & R11):
    External changes made directly to Radicale (or by other CalDAV clients) must be observed and ingested into Life's SQLite store:

    • Use sync-collection REPORT (RFC 6578) when advertised and conformance-tested; otherwise fall back to full href/ETag enumeration with bounded GETs.
    • Atomically commit observation events (life_events with evidence_class: 'observed_external'), projection changes in item_projection, and cursor advancement in sync_state within a single local transaction via the database worker thread.
    • For paged sync, commit a cursor only when it represents all durably processed changes; interrupted pages remain resumable or repeat idempotently.
    • Polling limitation disclosure: External A→B→A transitions between polls do not fabricate intermediate events (Scenario R10).
    • Invalidation of sync cursors triggers a new baseline generation and records an explicit observation gap, never fake deletions (Scenario R11). Only independently established tombstones count as deletions; full enumeration must finish successfully before absence is treated as an observed deletion.

Exact Technical References & Specifications

Normative Quotations from Technical Architecture

From docs/technical/calendar.md (Discovery and conditional writes, lines 5–14):

"Use reqwest with rustls and quick-xml for WebDAV/XML, ical for component parsing, icalendar only for newly generated resources, and chrono/chrono-tz for application time calculations. These choices do not prove lossless round trips or full recurrence support. Bounded server-side CalDAV calendar-data expansion is the primary recurrence engine; no speculative local recurrence engine is proposed.

Configure a trusted HTTPS Radicale base URL, Basic credentials from mounted secret files, an IANA timezone and explicit logical collection IDs mapped to discovered collection URLs. Discover current-user-principal/calendar-home-set and collection capabilities with namespace-aware PROPFIND; inspect supported-calendar-component-set and supported-report-set. Never select every discovered collection implicitly. Resolve relative hrefs only within the configured backend origin/path allowlist; reject credential-bearing URLs and cross-origin redirects. XML parsing disables external entities and applies depth/body limits. Prefix spelling is irrelevant; match namespace URI and local name, and inspect each multistatus property/resource status.

Methods: PROPFIND for discovery and ETags; REPORT for bounded calendar-query/calendar-multiget and advertised sync-collection; GET for full resource; PUT for creation/update; DELETE only for explicit future administrative deletion, not ordinary cancellation."

From docs/technical/operations-security.md (Deployment topology & Configuration contract, lines 11, 20):

"Upstream Radicale/Hindsight/TypeSafe credentials exist only at the service. Agent clients retain only scoped Life tokens. Backend destinations are fixed configuration, never caller-supplied URLs. Restrict egress/redirects to approved origins and paths; validate HTTPS certificates, reject downgrade and credential forwarding across origins. Restrict administrative routes to trusted operator clients/network paths."

Service section Required fields Optional proposed settings
radicale base_url, username_file, password_file, timezone, collections mapping logical ID to approved discovered URL connect_timeout_seconds=5, request_timeout_seconds=20, reconcile_attempts=3

From docs/technical/operations-security.md (Authentication and untrusted inputs, lines 41–42):

"Enforce request/body/time/range limits before expensive parsing or model calls. JSON/XML/ICS parsers apply depth/size limits; no XML external entities."

From docs/technical/verification.md (Deployment scenarios, Scenario D05, line 138):

ID Input / operation Observable acceptance
D05 Cross-origin redirect/href and oversized/deep XML/JSON/ICS Destination/size policy blocks it; no secret forwarding/external entity resolution

Protocol Specifications & XML Envelopes

1. Bootstrap Discovery: Current User Principal PROPFIND (Depth: 0)

Request:

PROPFIND / HTTP/1.1
Host: radicale.example.internal
Depth: 0
Content-Type: application/xml; charset=utf-8
Authorization: Basic <base64(user:pass)>

<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:">
  <D:prop>
    <D:current-user-principal/>
  </D:prop>
</D:propfind>

Expected Response (207 Multi-Status):

<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:">
  <D:response>
    <D:href>/</D:href>
    <D:propstat>
      <D:prop>
        <D:current-user-principal>
          <D:href>/user/</D:href>
        </D:current-user-principal>
      </D:prop>
      <D:status>HTTP/1.1 200 OK</D:status>
    </D:propstat>
  </D:response>
</D:multistatus>

2. Principal Discovery: Calendar Home Set PROPFIND (Depth: 0)

Request:

PROPFIND /user/ HTTP/1.1
Host: radicale.example.internal
Depth: 0
Content-Type: application/xml; charset=utf-8

<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <D:prop>
    <C:calendar-home-set/>
  </D:prop>
</D:propfind>

Expected Response (207 Multi-Status):

<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <D:response>
    <D:href>/user/</D:href>
    <D:propstat>
      <D:prop>
        <C:calendar-home-set>
          <D:href>/user/calendars/</D:href>
        </C:calendar-home-set>
      </D:prop>
      <D:status>HTTP/1.1 200 OK</D:status>
    </D:propstat>
  </D:response>
</D:multistatus>

3. Collection Discovery & Capability Check PROPFIND (Depth: 1)

Request:

PROPFIND /user/calendars/ HTTP/1.1
Host: radicale.example.internal
Depth: 1
Content-Type: application/xml; charset=utf-8

<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <D:prop>
    <D:resourcetype/>
    <D:displayname/>
    <C:supported-calendar-component-set/>
    <D:supported-report-set/>
  </D:prop>
</D:propfind>

Expected Response (207 Multi-Status):

<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <D:response>
    <D:href>/user/calendars/personal/</D:href>
    <D:propstat>
      <D:prop>
        <D:resourcetype>
          <D:collection/>
          <C:calendar/>
        </D:resourcetype>
        <D:displayname>Personal Tasks &amp; Schedule</D:displayname>
        <C:supported-calendar-component-set>
          <C:comp name="VEVENT"/>
          <C:comp name="VTODO"/>
        </C:supported-calendar-component-set>
        <D:supported-report-set>
          <D:supported-report>
            <D:report><C:calendar-query/></D:report>
          </D:supported-report>
          <D:supported-report>
            <D:report><C:calendar-multiget/></D:report>
          </D:supported-report>
        </D:supported-report-set>
      </D:prop>
      <D:status>HTTP/1.1 200 OK</D:status>
    </D:propstat>
  </D:response>
</D:multistatus>

What to Build (Step-by-Step)

1. HTTP Client & Security Boundary (src/calendar/client.rs)

  • Construct a dedicated CalDavClient wrapping reqwest::Client configured with:
    • TLS powered by rustls (native root store).
    • Explicit timeouts: connect_timeout (5s), read_timeout / overall request_timeout (20s) sourced from [radicale] config.
    • Custom reqwest::redirect::Policy:
      • Inspect each redirect attempt.
      • If target.origin() != initial.origin(), return Policy::stop() and flag a cross-origin security violation.
      • If target URL contains userinfo (target.username() != "" or target.password().is_some()), immediately reject.
      • Forbid downgrade from https:// to http://.
  • Authentication injector:
    • Read username and password from configured secret files (username_file, password_file). Strip trailing newlines.
    • Inject the HTTP Authorization: Basic <base64> header on all requests directed to the configured backend origin.
    • Never log credentials or the Authorization header content.

2. Secure WebDAV XML Parser (src/calendar/xml.rs)

  • Implement an XML reader wrapper using quick-xml::Reader:
    • Enforce max response byte limit (10 MB buffer limit before parsing).
    • Implement depth tracking: if element nesting exceeds 32 levels, return XmlError::MaxDepthExceeded.
    • Disable entity resolution to prevent XXE.
    • Implement a namespace resolver: maintain a prefix-to-URI stack to identify elements regardless of prefix (D:, d:, default namespace, etc.).
  • Parse <multistatus> into structured Rust types:
    pub struct MultistatusResponse {
        pub href: String,
        pub propstats: Vec<Propstat>,
    }
    pub struct Propstat {
        pub status_code: u16,
        pub properties: HashMap<QName, PropertyValue>,
    }
    
  • Verify each <propstat>'s HTTP status string matches HTTP/1.1 20x before consuming its properties.

3. CalDAV Discovery & Collection Capability Validator (src/calendar/discovery.rs)

  • Implement CalDavDiscovery:
    • discover_principal(&self) -> Result<String, CalDavError>: Executes Depth: 0 PROPFIND on radicale.base_url.
    • discover_home_set(&self, principal_href: &str) -> Result<String, CalDavError>: Executes Depth: 0 PROPFIND on the principal href.
    • discover_collections(&self, home_set_href: &str) -> Result<Vec<DiscoveredCollection>, CalDavError>: Executes Depth: 1 PROPFIND on the calendar-home-set href.
  • Capability validation:
    • Verify that each collection advertises <C:calendar/> in resourcetype.
    • Inspect supported-calendar-component-set: ensure VEVENT and VTODO are explicitly listed.
    • Inspect supported-report-set: ensure calendar-query and calendar-multiget are present.
  • Resolve relative hrefs against the configured base_url origin, enforcing that the path stays within the approved base path.
  • Map configured logical collection IDs ([radicale.collections] in TOML) to the discovered collections:
    • If a configured collection ID is not found among discovered collections, fail startup with configuration_invalid.
    • If a configured collection lacks VEVENT or VTODO, fail startup with calendar_unsupported.

4. External Calendar Synchronization Engine (src/calendar/sync.rs, src/api/sync.rs)

  • Implement POST /v1/sync (requires admin scope) and life sync --input - [--operation-id KEY] [--json]:
    • Input payload: { "collection_ids": Option<Vec<String>>, "mode": "incremental" | "rebaseline" }.
    • Check whether the collection advertises DAV:sync-collection in supported-report-set.
    • If supported, dispatch sync-collection REPORT with current sync_state.cursor.
    • If unsupported or on cursor invalidation, fall back to full href/ETag enumeration with bounded GETs.
    • In a single local transaction via DatabaseHandle:
      • Upsert/delete rows in item_projection.
      • Append observation events to life_events with evidence_class: 'observed_external' and dedupe key sync:<collection_id>:<generation>:<uid>.
      • Update sync_state with new cursor and generation.
    • On invalid cursor error from Radicale, advance generation, rebaseline current observations, and record an observation gap without fake deletion events (Scenario R11).
    • External A→B→A transitions between polls produce no fabricated intermediate events (Scenario R10).

5. Diagnostics & Error Mapping

  • Map failures to canonical domain errors defined in docs/technical/cli-api.md:
    • Unreachable Radicale or DNS/TLS failure -> dependency_unavailable (HTTP 503, CLI exit 5).
    • Cross-origin redirect or credential in URL -> forbidden / security violation (HTTP 403, CLI exit 1).
    • Missing collection or unsupported components -> configuration_invalid / calendar_unsupported (HTTP 422, CLI exit 2).
    • Malformed or oversized XML -> dependency_unavailable with descriptive diagnostic logging (credentials redacted).

Acceptance Criteria

  • Connection Pooling & TLS: Client connects to Radicale over HTTPS using reqwest + rustls connection pooling; idle connections are reused across sequential requests.
  • Secret File Auth: Credentials are read from local files (username_file, password_file) with trailing newlines stripped; credentials and Authorization headers are excluded from trace logs.
  • Sequential Discovery Flow: Client successfully traverses current-user-principal -> calendar-home-set -> collection enumeration via depth-0 and depth-1 PROPFIND requests.
  • Namespace Invariance: Parser successfully recognizes XML elements with arbitrary prefixes (D:, d:, empty prefix, C:, cal:) by matching namespace URIs (DAV: and urn:ietf:params:xml:ns:caldav).
  • Component Set Validation: Discovery verifies supported-calendar-component-set contains both VEVENT and VTODO; missing either rejects the collection with calendar_unsupported.
  • Report Set Validation: Confirms calendar-query and calendar-multiget are supported reports in the multistatus response.
  • Configured Collection Mapping: Explicit mapping in [radicale.collections] binds logical IDs to verified absolute collection URLs; unmapped collections are ignored, and missing configured collections fail startup.
  • Security Boundary - Redirect Enforcement (Scenario D05): An HTTP 301/302 redirect to a different host, port, or scheme is intercepted and rejected without dispatching credentials to the redirect target.
  • Security Boundary - Credential URL Refusal (Scenario D05): Attempting discovery against a URL containing userinfo (https://user:pass@host/) is rejected with a configuration validation error.
  • Security Boundary - XML Limits (Scenario D05): Supplying a mock XML payload exceeding 10 MB or nested deeper than 32 levels fails with an explicit size/depth limit error without crashing the process.
  • Failure Handling (Scenario C01): When Radicale is completely unreachable or times out, the client returns dependency_unavailable (HTTP 503, exit code 5), clearly distinguishing backend unavailability from an empty calendar.
  • External Sync & Atomicity: POST /v1/sync commits observation events, projection updates, and sync cursor advancement in one atomic local SQLite transaction.
  • Polling Limitation Disclosure (Scenario R10): External A→B→A transitions between polls do not emit fabricated intermediate events, preserving distinct evidence classes and disclosing polling limitations.
  • Cursor Invalidation & Gap Recording (Scenario R11): An invalidated sync cursor advances generation and rebaselines observations without emitting fake deletion events; observation gaps are explicitly recorded.

Blocked by

  • #003: Configuration Loader, Secret File Access & Health Checks
  • #004: SQLite WAL Connection Pool, Worker & Baseline Schema
  • #005: Two-Phase Commit Journaling & Idempotency Fingerprinting
## Summary Implement the foundational CalDAV client adapter using `reqwest` and `rustls`, performing authenticated discovery via namespace-aware WebDAV `PROPFIND` queries, validating collection capabilities (`VEVENT` and `VTODO` component sets, supported reports), enforcing strict security boundaries, and implementing the external calendar synchronization engine (`POST /v1/sync` / `life sync`) with RFC 6578 `sync-collection`, fallback enumeration, transactional cursor and projection commits, and observation gap handling (Scenarios R10, R11). --- ## Background & Technical Rationale The `life-service` system relies on an upstream Radicale CalDAV server as the authoritative backend for live calendar events and task obligations. Establishing a reliable, secure connection requires robust WebDAV/CalDAV protocol negotiation: 1. **Protocol Negotiation & Multi-Stage Discovery:** CalDAV (RFC 4791) builds upon WebDAV (RFC 4918) and WebDAV Current Principal (RFC 5397). Clients cannot assume arbitrary hard-coded paths for calendars. Discovery must execute in sequential stages: - **Bootstrap PROPFIND (Depth: 0):** Query the configured `base_url` for `DAV:current-user-principal`. - **Principal PROPFIND (Depth: 0):** Query the resolved principal URL for `urn:ietf:params:xml:ns:caldav:calendar-home-set`. - **Home-Set PROPFIND (Depth: 1):** Enumerate child collections within the calendar home set, querying `DAV:resourcetype`, `DAV:displayname`, `urn:ietf:params:xml:ns:caldav:supported-calendar-component-set`, and `DAV:supported-report-set`. 2. **Collection Validation & Explicit Logical ID Mapping:** Never implicitly bind all discovered collections. Service configuration defines logical collection IDs (e.g. `personal`, `work`) mapped to expected URLs or paths. The discovery engine must verify that each mapped collection actually exists, has `<cal:calendar/>` in its `resourcetype`, and explicitly advertises support for both `VEVENT` and `VTODO` components. If a configured collection is missing or lacks required component types, startup must fail fast with `configuration_invalid` or `calendar_unsupported`. 3. **Strict Security Boundaries & Defense-in-Depth:** - **No Secret Forwarding / Cross-Origin Redirects:** Radicale credentials (read from local secret files) must never leak across origins. The HTTP client must install a custom redirect policy that strictly forbids cross-origin redirects, downgrades from HTTPS to HTTP, or target URLs bearing embedded credentials (`http://user:pass@host/`). - **Safe XML Parsing:** WebDAV uses XML for requests and responses (`multistatus`). Untrusted or malformed XML exposes the service to XML Entity Expansion (Billion Laughs / XXE) attacks. Using `quick-xml`, external DTDs and entity expansion must be disabled, and strict limits on XML body size (e.g. 10 MB) and nesting depth (e.g. 32 levels) must be enforced. - **Namespace Invariance:** WebDAV servers use varying namespace prefixes (e.g., `D:`, `d:`, `DAV:`, `C:`, `cal:`). XML parsing must never match on raw tag string prefixes; it must resolve XML namespaces by URI (`DAV:` and `urn:ietf:params:xml:ns:caldav`) and match local element names. 4. **External Sync & Observation Semantics (Scenarios R10 & R11):** External changes made directly to Radicale (or by other CalDAV clients) must be observed and ingested into Life's SQLite store: - Use `sync-collection` REPORT (RFC 6578) when advertised and conformance-tested; otherwise fall back to full href/ETag enumeration with bounded GETs. - Atomically commit observation events (`life_events` with `evidence_class: 'observed_external'`), projection changes in `item_projection`, and cursor advancement in `sync_state` within a single local transaction via the database worker thread. - For paged sync, commit a cursor only when it represents all durably processed changes; interrupted pages remain resumable or repeat idempotently. - Polling limitation disclosure: External A→B→A transitions between polls do not fabricate intermediate events (Scenario R10). - Invalidation of sync cursors triggers a new baseline generation and records an explicit observation gap, never fake deletions (Scenario R11). Only independently established tombstones count as deletions; full enumeration must finish successfully before absence is treated as an observed deletion. --- ## Exact Technical References & Specifications ### Normative Quotations from Technical Architecture From `docs/technical/calendar.md` (*Discovery and conditional writes*, lines 5–14): > "Use reqwest with rustls and quick-xml for WebDAV/XML, `ical` for component parsing, `icalendar` only for newly generated resources, and chrono/chrono-tz for application time calculations. These choices do not prove lossless round trips or full recurrence support. Bounded server-side CalDAV calendar-data expansion is the primary recurrence engine; no speculative local recurrence engine is proposed. > > Configure a trusted HTTPS Radicale base URL, Basic credentials from mounted secret files, an IANA timezone and explicit logical collection IDs mapped to discovered collection URLs. Discover current-user-principal/calendar-home-set and collection capabilities with namespace-aware PROPFIND; inspect supported-calendar-component-set and supported-report-set. Never select every discovered collection implicitly. Resolve relative hrefs only within the configured backend origin/path allowlist; reject credential-bearing URLs and cross-origin redirects. XML parsing disables external entities and applies depth/body limits. Prefix spelling is irrelevant; match namespace URI and local name, and inspect each multistatus property/resource status. > > Methods: PROPFIND for discovery and ETags; REPORT for bounded calendar-query/calendar-multiget and advertised sync-collection; GET for full resource; PUT for creation/update; DELETE only for explicit future administrative deletion, not ordinary cancellation." From `docs/technical/operations-security.md` (*Deployment topology* & *Configuration contract*, lines 11, 20): > "Upstream Radicale/Hindsight/TypeSafe credentials exist only at the service. Agent clients retain only scoped Life tokens. Backend destinations are fixed configuration, never caller-supplied URLs. Restrict egress/redirects to approved origins and paths; validate HTTPS certificates, reject downgrade and credential forwarding across origins. Restrict administrative routes to trusted operator clients/network paths." > > | Service section | Required fields | Optional proposed settings | > |---|---|---| > | `radicale` | `base_url`, `username_file`, `password_file`, `timezone`, `collections` mapping logical ID to approved discovered URL | `connect_timeout_seconds=5`, `request_timeout_seconds=20`, `reconcile_attempts=3` | From `docs/technical/operations-security.md` (*Authentication and untrusted inputs*, lines 41–42): > "Enforce request/body/time/range limits before expensive parsing or model calls. JSON/XML/ICS parsers apply depth/size limits; no XML external entities." From `docs/technical/verification.md` (*Deployment scenarios*, Scenario D05, line 138): > | ID | Input / operation | Observable acceptance | > |---|---|---| > | `D05` | Cross-origin redirect/href and oversized/deep XML/JSON/ICS | Destination/size policy blocks it; no secret forwarding/external entity resolution | --- ### Protocol Specifications & XML Envelopes #### 1. Bootstrap Discovery: Current User Principal PROPFIND (Depth: 0) **Request:** ```http PROPFIND / HTTP/1.1 Host: radicale.example.internal Depth: 0 Content-Type: application/xml; charset=utf-8 Authorization: Basic <base64(user:pass)> <?xml version="1.0" encoding="utf-8" ?> <D:propfind xmlns:D="DAV:"> <D:prop> <D:current-user-principal/> </D:prop> </D:propfind> ``` **Expected Response (`207 Multi-Status`):** ```xml <?xml version="1.0" encoding="utf-8" ?> <D:multistatus xmlns:D="DAV:"> <D:response> <D:href>/</D:href> <D:propstat> <D:prop> <D:current-user-principal> <D:href>/user/</D:href> </D:current-user-principal> </D:prop> <D:status>HTTP/1.1 200 OK</D:status> </D:propstat> </D:response> </D:multistatus> ``` #### 2. Principal Discovery: Calendar Home Set PROPFIND (Depth: 0) **Request:** ```http PROPFIND /user/ HTTP/1.1 Host: radicale.example.internal Depth: 0 Content-Type: application/xml; charset=utf-8 <?xml version="1.0" encoding="utf-8" ?> <D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"> <D:prop> <C:calendar-home-set/> </D:prop> </D:propfind> ``` **Expected Response (`207 Multi-Status`):** ```xml <?xml version="1.0" encoding="utf-8" ?> <D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"> <D:response> <D:href>/user/</D:href> <D:propstat> <D:prop> <C:calendar-home-set> <D:href>/user/calendars/</D:href> </C:calendar-home-set> </D:prop> <D:status>HTTP/1.1 200 OK</D:status> </D:propstat> </D:response> </D:multistatus> ``` #### 3. Collection Discovery & Capability Check PROPFIND (Depth: 1) **Request:** ```http PROPFIND /user/calendars/ HTTP/1.1 Host: radicale.example.internal Depth: 1 Content-Type: application/xml; charset=utf-8 <?xml version="1.0" encoding="utf-8" ?> <D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"> <D:prop> <D:resourcetype/> <D:displayname/> <C:supported-calendar-component-set/> <D:supported-report-set/> </D:prop> </D:propfind> ``` **Expected Response (`207 Multi-Status`):** ```xml <?xml version="1.0" encoding="utf-8" ?> <D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"> <D:response> <D:href>/user/calendars/personal/</D:href> <D:propstat> <D:prop> <D:resourcetype> <D:collection/> <C:calendar/> </D:resourcetype> <D:displayname>Personal Tasks &amp; Schedule</D:displayname> <C:supported-calendar-component-set> <C:comp name="VEVENT"/> <C:comp name="VTODO"/> </C:supported-calendar-component-set> <D:supported-report-set> <D:supported-report> <D:report><C:calendar-query/></D:report> </D:supported-report> <D:supported-report> <D:report><C:calendar-multiget/></D:report> </D:supported-report> </D:supported-report-set> </D:prop> <D:status>HTTP/1.1 200 OK</D:status> </D:propstat> </D:response> </D:multistatus> ``` --- ## What to Build (Step-by-Step) ### 1. HTTP Client & Security Boundary (`src/calendar/client.rs`) - Construct a dedicated `CalDavClient` wrapping `reqwest::Client` configured with: - TLS powered by `rustls` (native root store). - Explicit timeouts: `connect_timeout` (5s), `read_timeout` / overall `request_timeout` (20s) sourced from `[radicale]` config. - Custom `reqwest::redirect::Policy`: - Inspect each redirect attempt. - If `target.origin() != initial.origin()`, return `Policy::stop()` and flag a cross-origin security violation. - If target URL contains userinfo (`target.username() != ""` or `target.password().is_some()`), immediately reject. - Forbid downgrade from `https://` to `http://`. - Authentication injector: - Read username and password from configured secret files (`username_file`, `password_file`). Strip trailing newlines. - Inject the HTTP `Authorization: Basic <base64>` header on all requests directed to the configured backend origin. - Never log credentials or the `Authorization` header content. ### 2. Secure WebDAV XML Parser (`src/calendar/xml.rs`) - Implement an XML reader wrapper using `quick-xml::Reader`: - Enforce max response byte limit (10 MB buffer limit before parsing). - Implement depth tracking: if element nesting exceeds 32 levels, return `XmlError::MaxDepthExceeded`. - Disable entity resolution to prevent XXE. - Implement a namespace resolver: maintain a prefix-to-URI stack to identify elements regardless of prefix (`D:`, `d:`, default namespace, etc.). - Parse `<multistatus>` into structured Rust types: ```rust pub struct MultistatusResponse { pub href: String, pub propstats: Vec<Propstat>, } pub struct Propstat { pub status_code: u16, pub properties: HashMap<QName, PropertyValue>, } ``` - Verify each `<propstat>`'s HTTP status string matches `HTTP/1.1 20x` before consuming its properties. ### 3. CalDAV Discovery & Collection Capability Validator (`src/calendar/discovery.rs`) - Implement `CalDavDiscovery`: - `discover_principal(&self) -> Result<String, CalDavError>`: Executes Depth: 0 PROPFIND on `radicale.base_url`. - `discover_home_set(&self, principal_href: &str) -> Result<String, CalDavError>`: Executes Depth: 0 PROPFIND on the principal href. - `discover_collections(&self, home_set_href: &str) -> Result<Vec<DiscoveredCollection>, CalDavError>`: Executes Depth: 1 PROPFIND on the calendar-home-set href. - Capability validation: - Verify that each collection advertises `<C:calendar/>` in `resourcetype`. - Inspect `supported-calendar-component-set`: ensure `VEVENT` and `VTODO` are explicitly listed. - Inspect `supported-report-set`: ensure `calendar-query` and `calendar-multiget` are present. - Resolve relative hrefs against the configured `base_url` origin, enforcing that the path stays within the approved base path. - Map configured logical collection IDs (`[radicale.collections]` in TOML) to the discovered collections: - If a configured collection ID is not found among discovered collections, fail startup with `configuration_invalid`. - If a configured collection lacks `VEVENT` or `VTODO`, fail startup with `calendar_unsupported`. ### 4. External Calendar Synchronization Engine (`src/calendar/sync.rs`, `src/api/sync.rs`) - Implement `POST /v1/sync` (requires `admin` scope) and `life sync --input - [--operation-id KEY] [--json]`: - Input payload: `{ "collection_ids": Option<Vec<String>>, "mode": "incremental" | "rebaseline" }`. - Check whether the collection advertises `DAV:sync-collection` in `supported-report-set`. - If supported, dispatch `sync-collection` REPORT with current `sync_state.cursor`. - If unsupported or on cursor invalidation, fall back to full href/ETag enumeration with bounded GETs. - In a single local transaction via `DatabaseHandle`: - Upsert/delete rows in `item_projection`. - Append observation events to `life_events` with `evidence_class: 'observed_external'` and dedupe key `sync:<collection_id>:<generation>:<uid>`. - Update `sync_state` with new cursor and generation. - On invalid cursor error from Radicale, advance generation, rebaseline current observations, and record an observation gap without fake deletion events (Scenario R11). - External A→B→A transitions between polls produce no fabricated intermediate events (Scenario R10). ### 5. Diagnostics & Error Mapping - Map failures to canonical domain errors defined in `docs/technical/cli-api.md`: - Unreachable Radicale or DNS/TLS failure -> `dependency_unavailable` (HTTP 503, CLI exit 5). - Cross-origin redirect or credential in URL -> `forbidden` / security violation (HTTP 403, CLI exit 1). - Missing collection or unsupported components -> `configuration_invalid` / `calendar_unsupported` (HTTP 422, CLI exit 2). - Malformed or oversized XML -> `dependency_unavailable` with descriptive diagnostic logging (credentials redacted). --- ## Acceptance Criteria - [x] **Connection Pooling & TLS:** Client connects to Radicale over HTTPS using `reqwest` + `rustls` connection pooling; idle connections are reused across sequential requests. - [x] **Secret File Auth:** Credentials are read from local files (`username_file`, `password_file`) with trailing newlines stripped; credentials and Authorization headers are excluded from trace logs. - [x] **Sequential Discovery Flow:** Client successfully traverses `current-user-principal` -> `calendar-home-set` -> collection enumeration via depth-0 and depth-1 `PROPFIND` requests. - [x] **Namespace Invariance:** Parser successfully recognizes XML elements with arbitrary prefixes (`D:`, `d:`, empty prefix, `C:`, `cal:`) by matching namespace URIs (`DAV:` and `urn:ietf:params:xml:ns:caldav`). - [x] **Component Set Validation:** Discovery verifies `supported-calendar-component-set` contains both `VEVENT` and `VTODO`; missing either rejects the collection with `calendar_unsupported`. - [x] **Report Set Validation:** Confirms `calendar-query` and `calendar-multiget` are supported reports in the multistatus response. - [x] **Configured Collection Mapping:** Explicit mapping in `[radicale.collections]` binds logical IDs to verified absolute collection URLs; unmapped collections are ignored, and missing configured collections fail startup. - [x] **Security Boundary - Redirect Enforcement (Scenario D05):** An HTTP 301/302 redirect to a different host, port, or scheme is intercepted and rejected without dispatching credentials to the redirect target. - [x] **Security Boundary - Credential URL Refusal (Scenario D05):** Attempting discovery against a URL containing userinfo (`https://user:pass@host/`) is rejected with a configuration validation error. - [x] **Security Boundary - XML Limits (Scenario D05):** Supplying a mock XML payload exceeding 10 MB or nested deeper than 32 levels fails with an explicit size/depth limit error without crashing the process. - [x] **Failure Handling (Scenario C01):** When Radicale is completely unreachable or times out, the client returns `dependency_unavailable` (HTTP 503, exit code 5), clearly distinguishing backend unavailability from an empty calendar. - [x] **External Sync & Atomicity:** `POST /v1/sync` commits observation events, projection updates, and sync cursor advancement in one atomic local SQLite transaction. - [x] **Polling Limitation Disclosure (Scenario R10):** External A→B→A transitions between polls do not emit fabricated intermediate events, preserving distinct evidence classes and disclosing polling limitations. - [x] **Cursor Invalidation & Gap Recording (Scenario R11):** An invalidated sync cursor advances generation and rebaselines observations without emitting fake deletion events; observation gaps are explicitly recorded. --- ## Blocked by - #003: Configuration Loader, Secret File Access & Health Checks - #004: SQLite WAL Connection Pool, Worker & Baseline Schema - #005: Two-Phase Commit Journaling & Idempotency Fingerprinting
yuris closed this issue 2026-09-22 18:59:02 +00:00
yuris changed title from Issue #007: CalDAV Connection Pooling, PROPFIND Discovery & Capabilities to CalDAV Connection Pooling, PROPFIND Discovery & Capabilities 2026-09-22 20:02:39 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/life-service#7
No description provided.