Skip to content

Commit 0dcb7fe

Browse files
wiggdevinclaude
andauthored
feat(desktop): bounded google calendar event contract and API walk (T12, slice one) (#21)
* feat(desktop): fallible compare-and-set mutation on the secret store `mutate_blob` runs a read-modify-write under the interprocess advisory lock with a fresh read inside it, but its mutation cannot refuse. A caller that needs "write this only if the stored state still says X" therefore had to check and then write, which loses the race the check exists for. Add `mutate_checked`, the same operation with a fallible mutation: an `Err` leaves the durable blob and the cache exactly as they were and is returned to the caller, so a predicate can be evaluated on the freshly-read durable state inside the lock. `mutate_blob` now delegates to it, so there is one implementation of the locking, reading and copy-on-write rules. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> * feat(desktop): google calendar authorization contract (T12, slice one) The credential half of T12, implementing the accepted memo `docs/plans/2026-09-04-calendar-authorization.md`. Nothing here is wired to a command, the webview or the sidebar, and no token reaches either: the module is the contract, the views and the render cache are slice two. * `redact` — the wrapper every credential is held in. It renders as a fixed marker and has no serializer, so a struct holding one cannot be serialized into a UI payload at all; persistence goes through an explicit wire form that is private to `binding`. * `oauth` — PKCE S256, a CSPRNG `state` checked with a constant-time compare, a CSPRNG `nonce` checked inside the ID token, the bounded callback parser, and the exchange conditions (refresh token, ID token, every requested scope) short of which no binding is written. Signature verification is a trait with no implementation here, so no path can produce validated claims without one being supplied (decisions 1 and 2). * `loopback` — the callback listener, bounded on request bytes, connections accepted, per-read time and total wait (decision 1). * `binding` — the stored envelope and five transition-specific compare-and-set predicates, each evaluated on the freshly-read durable state inside the store's lock. Disconnect clears the binding and opens the journal entry in one commit, so no prefix of it leaves a grant unreachable and unrevoked (decisions 2, 5 and 6). The envelope key sits outside the `mcp:` namespace the launcher resolves, which is decision 9's denial seam. * `revocation` — the journal: only HTTP 200 confirms, an entry clears only when the purge and the revocation both confirm, failures back off, and the seven-day ceiling converges on the terminal `revocation_unconfirmed` state rather than retrying forever (decision 5). * `failure` — the three error matrices and their four states, each with an explicit default that fails closed (decision 8). * `interval` — what a batch proves, half-open and never inferred (T12a decision 13). Every predicate has a test that fails when the predicate is deleted, and the concurrent disconnect is a barrier-held race over the shipped transition code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> * feat(desktop): bounded google calendar event contract and API walk The data half of T12 slice one, implementing `docs/plans/2026-09-04-calendar-view-design.md` where it decides the model and the wire behaviour. Still nothing wired to a command or the webview. * `dto` — the only way API-sourced text enters the fork. Every string is capped here, at the boundary, with one `truncated` flag per field because a truncated field is read-only; the caps also hold a row inside T11 decision 6's 256 KiB bound. An all-day value stays a date in the calendar's zone, never an instant. Editability is `accessRole` narrowed by event type and organizer, and an unrecognized role or type is read-only (decisions 1 and 2). * `client` — the bounded `events.list` walk. Pages, bytes read off the socket, events accumulated and wall-clock time are each capped, a page cut at the byte budget is discarded whole, and the batch carries the interval it proved plus the classified failure that stopped it. The three mutations fence with `If-Match`, carry a client-generated id so a lost create is replayable, and treat a 404 as ambiguous until the calendar is probed (decisions 5, 11, 13). * The shipped transport pins HTTPS, refuses redirects — a followed redirect would replay the bearer token at another host — and never reads proxy settings from the process environment, which a managed agent at operator trust can write. * `mock_server` — a keep-alive mock Google Calendar with two principals on one shared calendar, an ACL-loss switch, paging, `If-Match`, a duplicate-id create and a redirect probe, so the transport, the walk, the caps, the classification and the parser under test are all the shipped ones. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> * fix(desktop): bind the calendar containments with removal tests One consolidated fix round on T12 slice one. Every guard the review found undertested now has a test that fails when the guard is deleted, and two guards that bounded the wrong thing were corrected. Transport containment (client.rs). `TransportConfig::base_url` is private and the origin is pinned by parsing, not prefix-matching: a shipped configuration reaches `https://www.googleapis.com/calendar/v3/` and nothing else, so `www.googleapis.com.evil.test`, a non-default port, another API path, an embedded credential and a query on the base are all refused. The `no_proxy()` containment now has a differential test: with every proxy variable pointing at a sentinel listener, a test-only configuration that reads the environment routes there, and the shipped one reaches the loopback mock directly with the sentinel untouched. Deleting `no_proxy()` fails it. Loopback listener (loopback.rs). The wait's deadline is carried into `read_callback`, so each read waits for the shorter of the idle bound and the wait's remainder. Before, the only bound inside a connection was the per-read timeout, which resets on every byte: one local process could hold the flow for hours without ever sending CRLFCRLF. `ListenerError::TimedOut` now has two tests, one with no connection and one with a stalled one; without the fix the second runs 30 s against a 250 ms budget. Compare-and-set seam (secret_store.rs, binding.rs). The candidate builder inside `mutate_checked` is extracted as `checked_candidate` and tested without a keychain, so turning `f(&mut next)?` into a discarded result fails a test on every platform. `KeychainEnvelopes` commits through a `CheckedBlob` seam, and every envelope test now drives that shipped commit over an in-memory blob rather than a reimplementation: swapping the `Refused` and `Store` arms fails seven tests. A backend failure and an unreadable stored envelope each have their own test. Bounds that bounded the wrong quantity. The walk applies the event cap to a page before adding it, so it can no longer return `max_events + page - 1` events, and the proven interval ends at the last kept start rather than the page's. An over-long `recurringEventId` is dropped rather than truncated into a different id. Also bound: the `events.list` query Google is asked for (`singleEvents`, `orderBy=startTime`, `timeMin`, `timeMax`, `maxResults`), the id, etag, zone and page-token caps, the id-token byte cap, and T11 decision 9 driven through the shipped `McpSecretLookup::resolve` with a working control. The mock server no longer `dup`s its socket: a failing `try_clone` near the descriptor limit closed a connection silently, which a client sees as a reset mid-request. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> * test(desktop): record the calendar events URLs in the egress inventory The events-URL tripwire in `egress_guard_tests.rs` scans every `.rs` file under `desktop/src-tauri/src` for `/events` URL construction and fails until each site is paired with an inventory row. The Google Calendar slice adds five such sites — `client.rs:606` (`events_path`), `mock_server.rs:287` and three fixtures in `client_tests.rs` — so `cargo test --lib` (the lane behind `just desktop-tauri-test` and CI's Tauri Rust job) failed `events_url_inventory_is_fully_guarded`. Answer the question the tripwire asks rather than silence it. The Calendar REST collection is not a relay egress boundary: the request goes to `www.googleapis.com` with a Google bearer token and carries no Nostr event, so `assert_no_key_backup` — whose stated scope in `egress_guard.rs` is relay-bound egress of NIP-49 backup material — has nothing to check there. Record that as three rows with zero expected guard calls and the reasoning written beside them, in the file's existing style. The rows still fence the files: a second `/events` site in `client.rs`, or a fourth in `client_tests.rs`, trips the scan and asks again. The existing mutation tests (`inventory_scan_catches_new_site_in_allowlisted_file`, `inventory_scan_catches_removed_guard_call`, `inventory_scan_catches_new_unlisted_file`) already prove the pairing fails when a row or a guard call is removed, so no new mutation test is added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> --------- Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent c7f0397 commit 0dcb7fe

18 files changed

Lines changed: 6375 additions & 8 deletions

desktop/src-tauri/src/egress_guard_tests.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,16 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[
291291
// Stub-relay route in the tombstone-flush gate tests; production flush
292292
// publishes through the guarded boundary-1 funnel.
293293
("src/commands/teams/pending/tests/gate.rs", 1, 0),
294+
// Google Calendar API events collection — NOT a relay egress boundary and
295+
// deliberately unguarded. The request goes to `www.googleapis.com` with a
296+
// Google bearer token over the Calendar REST API; it carries no Nostr
297+
// event and reaches no relay, so `assert_no_key_backup` (scope: relay-bound
298+
// egress, see `egress_guard.rs`) has nothing to check here. The rows still
299+
// fence the files: a fourth site in `client_tests.rs`, or a second one in
300+
// `client.rs`, fails this scan and asks the question again.
301+
("src/google_calendar/client.rs", 1, 0), // `events_path`
302+
("src/google_calendar/client_tests.rs", 3, 0), // list/walk/percent-encoding fixtures
303+
("src/google_calendar/mock_server.rs", 1, 0), // mock Calendar route
294304
];
295305

296306
// Needles are assembled at runtime so this scan file itself contains no

0 commit comments

Comments
 (0)