From 9754aecfa9677ebf4e615a824491d8c2dcd25433 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 22:29:48 +0000 Subject: [PATCH] =?UTF-8?q?perf:=20round=2019=20=E2=80=94=20auth/WOPI/vCar?= =?UTF-8?q?d/PROPFIND=20per-request=20&=20per-row=20alloc=20cuts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benchmark-gated (examples/bench_round19_micro.rs, benches/ROUND19.md): every section ships a BEFORE/AFTER counting-allocator arm with a byte/-value equivalence gate and a GATE-FAIL-rollback exit. All eight pass. No Postgres. - M1 verify_basic_auth cache key: blake3::hash(format!("{u}:{p}")) → incremental Hasher (byte-identical key, 2→0 allocs on every Basic-auth DAV request) - M2 WopiTokenService: prebuild Validation/DecodingKey/EncodingKey in new() instead of per-call (mirrors JwtTokenService; 16→12 allocs/validate) - V1/V2 vCard emit (contact_to_vcard/generate_vcard): FN fallback drops the throwaway to_string, NOTE skips the escape copy for newline-free notes, REV uses new common::fmt::compact_ical_utc stack renderer (11.5× vs chrono strftime, 3→0 allocs); per-contact 9→4 allocs - M4 trash_service::row_to_item_dto: move name/path/blob_hash out of the owned row instead of cloning (3 clones/file row gone) - M5 search cache key: Uuid::hyphenated().encode_lower stack buffer instead of to_string (identical u64 key, 1→0 allocs/request) - M6 streaming PROPFIND: reuse one href buffer across the page instead of a format! per child (native + NC handlers; 192→3 allocs on a 64-child page) - M7 nextcloud extract_url_user: return Cow instead of forcing into_owned (zero-alloc on the common ASCII-username path) common::fmt::compact_ical_utc added with chrono-parity unit tests (CASES + 60-year sweep). cargo fmt + clippy --all-targets clean; 526 lib unit tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ront9bk7YMoffVQkGG47gh --- Cargo.toml | 14 + benches/ROUND19.md | 269 ++++++ examples/bench_round19_micro.rs | 771 ++++++++++++++++++ src/application/adapters/carddav_adapter.rs | 45 +- .../services/app_password_service.rs | 16 +- src/application/services/contact_service.rs | 22 +- src/application/services/search_service.rs | 9 +- src/application/services/trash_service.rs | 11 +- .../services/wopi_token_service.rs | 53 +- src/common/fmt.rs | 52 ++ src/interfaces/api/handlers/webdav_handler.rs | 22 +- src/interfaces/nextcloud/session.rs | 10 +- src/interfaces/nextcloud/webdav_handler.rs | 16 +- 13 files changed, 1240 insertions(+), 70 deletions(-) create mode 100644 benches/ROUND19.md create mode 100644 examples/bench_round19_micro.rs diff --git a/Cargo.toml b/Cargo.toml index 2e9368b8..ffc9bef0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -354,6 +354,20 @@ name = "bench_micro_allocs" path = "examples/bench_micro_allocs.rs" required-features = ["bench"] +# Round-19 battery ──────────────────────────────────────────────────────────── + +# Round-19 CPU/alloc micro-pack — Basic-auth cache-key incremental blake3 (drop +# the per-request `format!("{u}:{p}")` String), WOPI validate/generate prebuilt +# Validation/DecodingKey/EncodingKey (mirrors JwtTokenService), CardDAV vCard +# per-contact emit (FN fallback `to_string` drop, NOTE no-newline borrow, REV via +# new `common::fmt::compact_ical_utc` stack renderer), trash row→DTO move-not-clone, +# search cache-key Uuid stack hyphenated-encode, streaming PROPFIND per-child href +# reused buffer, NC `extract_url_user` Cow (drop `into_owned`). No Postgres. +[[example]] +name = "bench_round19_micro" +path = "examples/bench_round19_micro.rs" +required-features = ["bench"] + # Round-18 battery ──────────────────────────────────────────────────────────── # Round-18 calendar-event edit CPU/alloc micro-pack — `update_ical_property` / diff --git a/benches/ROUND19.md b/benches/ROUND19.md new file mode 100644 index 00000000..84f054e2 --- /dev/null +++ b/benches/ROUND19.md @@ -0,0 +1,269 @@ +# Round 19 — auth/WOPI/vCard/PROPFIND per-request & per-row alloc cuts + +Benchmark-gated, same rule as ROUND2–18: every change ships with a BEFORE/AFTER +benchmark and an equivalence/safety gate; an AFTER that doesn't beat its BEFORE +is rolled back (never applied). The roll-back rule is encoded directly into the +harness — a `GATE FAIL … rollback` non-zero exit if an AFTER arm fails to reduce +allocations (or, for the CPU-only §V2 stamp, fails to beat BEFORE by the required +wall ratio) — so a regression fails CI rather than shipping. + +This round sweeps the **per-request** DAV/WOPI/NextCloud plumbing and two +**per-row** emit loops the earlier rounds' handler passes left untouched. Every +item mirrors an optimization the codebase already proved out elsewhere +(`JwtTokenService`'s prebuilt keys, `common::fmt`'s stack date renderers, the +favorites/recent/folder row mappers' move-not-clone, the CalDAV emitter's reused +per-row buffers) but which never reached these specific paths. + +Reproduce: + +``` +cargo run --release --features bench --example bench_round19_micro +``` + +All arms are **no-Postgres** (release-profile counting-allocator example). + +## Summary + +| # | change | key metric | before → after | +|--:|---|---|---| +| **M1** | `AppPasswordService::verify_basic_auth` built the moka cache key as `blake3::hash(format!("{username}:{password}").as_bytes())` — one throwaway `String` per **Basic-auth request** (runs before the cache lookup, so even hits pay it; DAV sync clients hammer it on every request). Now streamed into an incremental `blake3::Hasher` — byte-identical 32-byte key. | 20-byte creds | **2 → 0 allocs/op · 1.66× wall** (182.0 → 109.4 ns) | +| **M2** | `WopiTokenService::validate_token`/`generate_token` rebuilt a `Validation` (allocates a `required_spec_claims` HashSet + `algorithms` Vec) and a `DecodingKey`/`EncodingKey` (copies the secret into a fresh Vec) on **every WOPI call** — Office/Collabora poll continuously. Now all three are prebuilt struct fields in `new()` (exactly what `JwtTokenService` already does). | HS256 validate | **16 → 12 allocs/op · 1.07× wall** | +| **V1** | `contact_to_vcard`/`generate_vcard`, **per contact** in every CardDAV REPORT/multiget/PROPFIND-with-address-data: FN fallback dropped the throwaway `.to_string()` copy of the trim slice; NOTE `replace('\n', "\\n")` is now guarded (`contains('\n')`) so a newline-free note writes borrowed; REV `.format("%Y%m%dT%H%M%SZ")` → `common::fmt::compact_ical_utc`. | full vCard emit | **9 → 4 allocs/op · 1.97× wall** (548.2 → 277.7 ns) | +| **V2** | The REV/DTSTAMP stamp isolated: chrono `.format("%Y%m%dT%H%M%SZ")` runs the strftime interpreter and (measured) **allocates 3×** per call; the new `common::fmt::compact_ical_utc` renders `YYYYMMDDTHHMMSSZ` into a 16-byte stack buffer via the shared `push2`/`push4` LUT. | one stamp | **3 → 0 allocs/op · 11.77× wall** (216.9 → 18.4 ns) | +| **M4** | `trash_service::row_to_item_dto` `clone()`d `name`/`path`/`blob_hash` out of an **owned** `row` that is dropped at fn end — 2 clones/folder row, 3/file row, up to 200 rows/`/api/trash` page. Now moved (the favorites/recent/folder mappers already move these). | file row | **10 → 7 allocs/op** (3 clones gone) | +| **M5** | `SearchUseCase::search` built the cache-key user segment via `user_id.to_string()` — one heap `String` **per search request** to feed a hasher the fn doc even calls "zero-allocation". Now stack-encoded via `Uuid::hyphenated().encode_lower(&mut [u8; 36])`; byte-identical string ⇒ identical u64 key. | 1 request | **1 → 0 allocs/op · 1.30× wall** | +| **M6** | Streaming WebDAV **PROPFIND** built each child `href` with a fresh `format!` per row — up to 500 rows/page, 4 loops across the native + NextCloud handlers, the single most-travelled DAV path. Now one buffer reused across the page (`clear` + `push_str` + `extend`/`push_str`). | 64-child page | **192 → 3 allocs/op · 2.74× wall** (10.9 → 4.0 µs) | +| **M7** | `nextcloud::session::extract_url_user` forced `.into_owned()` on the `urlencoding::decode` `Cow` on **every path-scoped NC DAV request**, though a plain-ASCII username decodes to `Cow::Borrowed`. Now returns the `Cow` and compares by `.as_ref()`. | ASCII user | **1 → 0 allocs/op · 3.11× wall** (25.5 → 8.2 ns) | + +> Allocs/op is the deterministic primary gate (identical run to run). Wall +> figures are single-shot and noise-bounded; §V2 is the one CPU-only arm (both +> emit the same 0 allocs after the fix is measured against chrono's 3) and is +> gated on a ≥2× wall ratio — it clears it with 11.8×. + +## [M1] Basic-auth cache key — incremental hasher + +`verify_basic_auth` runs on every WebDAV/CalDAV/CardDAV/NextCloud request that +carries Basic auth — and DAV sync clients (DAVx5, Apple, Thunderbird, the +Nextcloud desktop client) send credentials on **every** request, holding 4–8 +parallel connections. The cache key is computed *before* the single-flight cache +lookup, so it runs on hits too: + +```rust +let cache_key: [u8; 32] = + blake3::hash(format!("{}:{}", username, password).as_bytes()).into(); +``` + +The `format!` heap-allocates one `String` per request purely to concatenate the +two parts before handing the bytes to blake3. blake3 is a **streaming** hash — +feeding `username`, then `":"`, then `password` into an incremental `Hasher` +produces the identical digest with no intermediate buffer: + +```rust +let cache_key: [u8; 32] = { + let mut h = blake3::Hasher::new(); + h.update(username.as_bytes()); + h.update(b":"); + h.update(password.as_bytes()); + h.finalize().into() +}; +``` + +The bench's equivalence gate asserts the two 32-byte keys are identical, so +in-flight and cached entries collide exactly as before. **2 → 0 allocs/op, +1.66× wall** — and note the `format!` version's *second* alloc is the +`String`'s grow, both gone. + +## [M2] WOPI token validate/generate — prebuilt keys + +`WopiTokenService` mirrored none of the prebuilt-key discipline +`JwtTokenService` adopted in an earlier round. Every `validate_token` (6 WOPI +handler entry points — CheckFileInfo, GetFile, PutFile, Lock, …, polled +continuously by the Office/Collabora host during an edit session) rebuilt: + +```rust +let validation = Validation::new(Algorithm::HS256); // HashSet + Vec +let token_data = decode::( + token, + &DecodingKey::from_secret(self.secret.as_bytes()), // fresh Vec copy of the secret + &validation, +)… +``` + +`Validation::new` inserts `"exp"` into a fresh `required_spec_claims` HashSet and +allocates an `algorithms` Vec; `DecodingKey::from_secret` copies the secret into +a new Vec. `generate_token` did the same with `EncodingKey::from_secret`. All +three are now built once in `new()` and stored as fields: + +```rust +pub struct WopiTokenService { + encoding_key: EncodingKey, + decoding_key: DecodingKey, + validation: Validation, + token_ttl_secs: i64, +} +``` + +The `secret` field is dropped — nothing else read it. **16 → 12 allocs/op** on +validate (the remaining 12 are the JWT crate's own base64/JSON claim +deserialization, paid by both arms). The four removed are exactly the +`Validation` HashSet + its `"exp"` String + the `algorithms` Vec + the +`DecodingKey` secret-copy. Existing `wopi_token_service` unit tests +(generate→validate round-trip, wrong-secret reject, read-only) pin the behaviour. + +## [V1]/[V2] vCard per-contact emit — FN, NOTE, and the REV stamp renderer + +`contact_to_vcard` (`carddav_adapter.rs`) and its twin `generate_vcard` +(`contact_service.rs`) emit one vCard **per contact** in every CardDAV REPORT, +`addressbook-multiget`, and collection PROPFIND that requests `address-data` +(i.e. every real DAVx5 / Apple Contacts / Thunderbird sync). Three per-contact +allocations: + +1. **FN fallback** (`full_name` absent) built the mandatory `FN` from the + name parts and copied the trimmed slice into a second owned `String`: + ```rust + let fn_name = format!("{} {}", first, last).trim().to_string(); + ``` + The `.to_string()` is redundant — `write!(vcard, "FN:{}\r\n", fn_name.trim())` + writes the borrowed slice straight into the buffer. (The `format!` is kept: + trimming *across* the join is subtle, and this arm is a fallback; dropping the + copy is the unambiguously byte-identical win.) + +2. **NOTE** ran `notes.replace('\n', "\\n")` unconditionally — a full copy of the + note even when it has no newline (the common case), then formatted into the + buffer and dropped. Now guarded: a newline-free note writes its borrowed slice + directly; only a genuine multi-line note pays the escaping copy. + +3. **REV** ran chrono's `updated_at.format("%Y%m%dT%H%M%SZ")` — and §V2 shows + that `DelayedFormat` **allocates 3×** (not the 0 first assumed) while running + the strftime spec interpreter. The new `common::fmt::compact_ical_utc(buf, + secs)` renders the compact iCal/vCard UTC form `YYYYMMDDTHHMMSSZ` into a + 16-byte **stack** buffer via the same `push2`/`push4` LUT the RFC-3339/2822 + renderers use, falling back to chrono for out-of-range seconds. + +Isolated (§V2), the stamp renderer is **11.77× faster and 3 → 0 allocs** +(216.9 → 18.4 ns). Over the whole per-contact emit (§V1, a contact exercising all +three shapes) that is **9 → 4 allocs/op, 1.97× wall** (548.2 → 277.7 ns). Both +`updated_at` fields are `DateTime`, so `compact_ical_utc(ts.timestamp())` is +byte-for-byte the chrono output; `common::fmt`'s existing chrono-parity sweep +(every 6h13m across 60 years) now covers `compact_ical_utc` too. + +## [M4] trash row → DTO — move, don't clone + +`row_to_item_dto` takes an **owned** `TrashResourceRow` (consumed, dropped at fn +end) yet cloned its `String` fields into the DTO — `path` and `name` on a folder +row, plus `blob_hash` and `name` on a file row — up to 200 rows per +`GET /api/trash/resources` page: + +```rust +let path = row.path.clone().unwrap_or_default(); +… +name: row.name.clone(), +… +let content_hash = row.blob_hash.clone().unwrap_or_default(); +``` + +Because `row` is owned, each field can be **moved** (`row.path.unwrap_or_default()`, +`name: row.name`, `row.blob_hash.unwrap_or_default()`). This is precisely what the +sibling `favorites_handler` / `recent_handler` / `folder_handler` row mappers +already do (with explicit "move it instead of cloning" comments); the trash path +was simply missed. **10 → 7 allocs/op** on the file branch (the remaining 7 are +`id.to_string()`, the interned display fields, and the `File::compute_etag` +stand-in — all unavoidable). + +## [M5] search cache key — stack-encode the UUID + +`SearchUseCase::search`'s `create_cache_key` hashes the criteria + a `&str` +user id; the caller fed it `user_id.to_string()`: + +```rust +let user_id_str = user_id.to_string(); // heap, per request +let cache_key = Self::create_cache_key(&criteria, &user_id_str); +``` + +`Uuid::hyphenated().encode_lower(&mut [u8; 36])` writes the identical 36-char +lowercase form into a **stack** buffer, so the hasher sees the same bytes ⇒ the +same `u64` key — the equivalence gate asserts it — with no allocation. The fn's +own doc-comment already claimed "zero-allocation hashing"; this makes it true. +**1 → 0 allocs/op.** + +## [M6] streaming PROPFIND per-child href — one reused buffer + +The streaming folder PROPFIND is the single most-travelled WebDAV path (every +folder listing, every desktop-sync descent). Both the native +(`webdav_handler.rs`) and NextCloud (`nextcloud/webdav_handler.rs`) handlers +built each child's `href` with a fresh `format!` per row — 4 loops, each up to +`PROPFIND_BATCH_SIZE` (500) rows/page: + +```rust +for file in batch.iter() { + let href = format!("{}{}", base_href, utf8_percent_encode(&file.name, …)); + … +} +``` + +One `String` per child. A single buffer hoisted out of the loop and rebuilt in +place (`href.clear(); href.push_str(base); href.extend(encode(name));`) keeps +its capacity across the page — the CalDAV/CardDAV emitters already thread reused +`href`/`etag` buffers exactly this way. On a 64-child page: **192 → 3 allocs/op, +2.74× wall** (10.9 → 4.0 µs); the 3 remaining are the buffer's initial grows to +the widest href. The equivalence gate asserts the emitted href set is +byte-identical. + +## [M7] NextCloud `extract_url_user` — keep the Cow + +Every path-scoped NC DAV request (`/remote.php/dav/{files,uploads,trashbin}/ +{user}/…`) cross-checks the URL `{user}` segment against the session's +`raw_username`. The extractor forced an owned `String`: + +```rust +urlencoding::decode(user_seg).ok().map(|s| s.into_owned()) +``` + +`urlencoding::decode` returns `Cow::Borrowed` for a username with no +percent-escapes (the overwhelming common case), so `.into_owned()` allocates a +`String` on every request for nothing. Returning the `Cow` and comparing +`url_user.as_ref() != session.raw_username.as_str()` is zero-alloc on the common +path; only a percent-encoded username owns. **1 → 0 allocs/op, 3.11× wall.** + +## Not shipped — deferred to a later round + +Surfaced during the Round-19 audit but not landed (each needs Postgres, a +schema/DTO change, or its own decision): + +- **CardDAV vCard etag buffer (`carddav_adapter::write_contact_response`):** the + quoted `getetag` allocates a `String` per contact; the CalDAV emitter threads a + reused `&mut String` etag buffer across the page but CardDAV's + `write_contacts_report_page` never got the equivalent. Wants the buffer threaded + through `write_contact_response` / `write_collection_contact_page` — a + multi-signature change, deferred to keep this round's diff per-item-local. +- **CardDAV whole-book GET buffer (`carddav_handler::handle_get`):** the + `text/vcard` export accumulates into a `String::new()` (repeated grows) and + each `contact_to_vcard` allocates a per-contact throwaway `String` copied into + it. Wants a `write_vcard_into(&mut String, …)` variant so the per-contact + String disappears — an API addition, deferred. +- **BDAY stamp (`%Y-%m-%d` / `%Y%m%d`):** a `NaiveDate` date-only analogue of + `compact_ical_utc`; only fires for contacts-with-birthday, so lower-priority + than REV (every contact). A `compact_date` helper is the natural follow-up. +- **Search `suggest` DTO over-build (`search_service::suggest_with_perms`):** + builds a full `FileDto`/`FolderDto` per candidate (≤20) on every keystroke only + to copy out 5 fields — `size_formatted`/`content_hash`/`etag` are computed and + dropped. Wants the fields pulled off the entity directly; deferred pending a + small helper to avoid duplicating the display classifiers. +- **`grant_handler` shared-with-me deep clone (needs Postgres to bench the full + path):** each shared item does `resource_id.to_string()` to key a map and a + full DTO `.clone().without_hierarchy_info()`; a `remove`-and-move is valid only + if summaries hold unique resource ids — verify before applying. + +## Environment / methodology + +- `cargo run --release --features bench --example bench_round19_micro` — + counting global allocator, no Postgres. Tunable: `BENCH_ITERS` (200000; §M6 + uses a smaller default as each op is a whole 64-child page). +- Each section is BEFORE (verbatim replica of the shipped-before shape) vs AFTER + (the shipped function itself where reachable — `common::fmt::compact_ical_utc`, + `push_upper` — else a verbatim replica of the shipped-after shape), with a + byte/-value equivalence gate; the shipped source now matches each AFTER arm. +- Roll-back rule encoded per section: the harness `std::process::exit(1)`s with + `GATE FAIL … rollback` if an AFTER arm fails to reduce allocations (§M1, M2, V1, + M4, M5, M6, M7) or, for the CPU-only §V2 stamp, fails to beat BEFORE by ≥2× + wall. All eight sections pass. diff --git a/examples/bench_round19_micro.rs b/examples/bench_round19_micro.rs new file mode 100644 index 00000000..27d13850 --- /dev/null +++ b/examples/bench_round19_micro.rs @@ -0,0 +1,771 @@ +//! Round-19 CPU/alloc micro-pack (no Postgres). +//! +//! Same rule as ROUND2–18: each section is BEFORE (verbatim replica of the +//! shipped-before shape) vs AFTER (the shipped function itself where reachable — +//! `common::fmt::compact_ical_utc` — else a verbatim replica of the shipped-after +//! shape), with a byte/-value equivalence gate and a `GATE FAIL … rollback` +//! check that exits non-zero if the AFTER arm fails to beat its BEFORE — the +//! round's roll-back rule encoded into the benchmark. +//! +//! [M1] `AppPasswordService::verify_basic_auth` builds the moka cache key as +//! `blake3::hash(format!("{username}:{password}").as_bytes())` on EVERY +//! Basic-auth DAV/CalDAV/CardDAV/NextCloud request (before the cache +//! lookup, so even cache hits pay it). The `format!` heap-allocates one +//! throw-away `String` per request purely to feed bytes to blake3. The +//! shipped-after form streams the same bytes into an incremental +//! `blake3::Hasher` — byte-identical 32-byte key, zero allocation. +//! +//! [M2] `WopiTokenService::validate_token` / `generate_token` rebuilt a +//! `Validation` (allocates a `required_spec_claims` HashSet + an +//! `algorithms` Vec) and a `DecodingKey`/`EncodingKey` (copies the secret +//! into a fresh Vec) on EVERY WOPI protocol call — Office/Collabora hosts +//! poll these continuously. The shipped-after form prebuilds all three as +//! struct fields in `new()` (exactly what `JwtTokenService` already does). +//! +//! [V1] `contact_to_vcard` / `generate_vcard` emit, per contact in every +//! CardDAV REPORT / multiget / PROPFIND-with-address-data: +//! - FN fallback `format!("{first} {last}").trim().to_string()` dropped +//! the throwaway `.to_string()` copy (writes the borrowed trim slice); +//! - NOTE `notes.replace('\n', "\\n")` allocated a full copy even when +//! the note has no newline — now guarded (`contains('\n')`), the +//! common no-newline note writes the borrowed slice directly; +//! - REV `updated_at.format("%Y%m%dT%H%M%SZ")` ran chrono's strftime +//! interpreter — now `common::fmt::compact_ical_utc` (stack LUT). +//! +//! [V2] REV/DTSTAMP stamp isolated: chrono `.format("%Y%m%dT%H%M%SZ")` vs the +//! new `common::fmt::compact_ical_utc` stack renderer (CPU / wall gate). +//! +//! [M4] `trash_service::row_to_item_dto` `clone()`d `name` / `path` / +//! `blob_hash` out of an OWNED `row` that is dropped at fn end — now +//! moved (the favorites / recent / folder row mappers already move these +//! same fields). +//! +//! [M5] `SearchUseCase::search` built the cache-key user segment via +//! `user_id.to_string()` (heap) to feed `create_cache_key`'s hasher — now +//! stack-encoded via `Uuid::hyphenated().encode_lower(&mut [u8; 36])`, +//! byte-identical string ⇒ identical u64 key, zero allocation. +//! +//! [M6] WebDAV streaming PROPFIND built each child `href` with a fresh +//! `format!` per row (up to 500 rows/page) — now a single buffer reused +//! across the page (`clear` + `push_str` + `extend`). +//! +//! [M7] `nextcloud::session::extract_url_user` forced `.into_owned()` on the +//! `urlencoding::decode` `Cow` on EVERY path-scoped NC DAV request, even +//! though a plain-ASCII username decodes to `Cow::Borrowed` — now returns +//! the `Cow` and compares by `.as_ref()`, zero-alloc on the common path. +//! +//! Run: +//! cargo run --release --features bench --example bench_round19_micro +//! Tunables (env): BENCH_ITERS (200000) + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::collections::hash_map::DefaultHasher; +use std::env; +use std::fmt::Write as _; +use std::hash::{Hash, Hasher}; +use std::hint::black_box; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use chrono::{DateTime, TimeZone, Utc}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +static ALLOC_CALLS: AtomicU64 = AtomicU64::new(0); + +struct CountingAlloc; + +unsafe impl GlobalAlloc for CountingAlloc { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.realloc(ptr, layout, new_size) } + } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc_zeroed(layout) } + } +} + +#[global_allocator] +static GLOBAL: CountingAlloc = CountingAlloc; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +struct Measured { + wall_ns_per_op: f64, + allocs_per_op: f64, +} + +fn measure(iters: usize, mut f: F) -> Measured { + // Warm up (grow any reused buffers, prime caches) so the measured window + // reflects steady state, not first-touch growth. + for _ in 0..(iters / 20).max(1) { + f(); + } + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + for _ in 0..iters { + f(); + } + let wall = t.elapsed().as_nanos() as f64 / iters as f64; + let allocs = (ALLOC_CALLS.load(Ordering::Relaxed) - a0) as f64 / iters as f64; + Measured { + wall_ns_per_op: wall, + allocs_per_op: allocs, + } +} + +fn print_row(label: &str, m: &Measured) { + println!( + "| {:<48} | {:>12.1} | {:>10.2} |", + label, m.wall_ns_per_op, m.allocs_per_op + ); +} + +fn header_footer(name: &str, before: &Measured, after: &Measured) { + println!("| arm | ns/op | allocs/op |"); + print_row(&format!("BEFORE {name}"), before); + print_row(&format!("AFTER {name}"), after); + println!( + "# {:.2}x wall, {:.2} fewer allocs/op", + before.wall_ns_per_op / after.wall_ns_per_op, + before.allocs_per_op - after.allocs_per_op + ); +} + +fn gate_allocs(tag: &str, before: &Measured, after: &Measured) { + if after.allocs_per_op >= before.allocs_per_op { + eprintln!("GATE FAIL [{tag}]: AFTER did not reduce allocations — rollback"); + std::process::exit(1); + } +} + +/// Wall gate for the CPU-only sections (identical alloc count both arms). +/// Requires AFTER to be at least `min_ratio`× faster to guard against noise. +fn gate_wall(tag: &str, before: &Measured, after: &Measured, min_ratio: f64) { + let ratio = before.wall_ns_per_op / after.wall_ns_per_op; + if ratio < min_ratio { + eprintln!( + "GATE FAIL [{tag}]: AFTER wall {:.1}ns not ≥{min_ratio:.2}× faster than BEFORE {:.1}ns (ratio {ratio:.2}) — rollback", + after.wall_ns_per_op, before.wall_ns_per_op + ); + std::process::exit(1); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// [M1] Basic-auth cache key — format! + hash vs incremental hasher +// ──────────────────────────────────────────────────────────────────────────── + +fn m1_before(username: &str, password: &str) -> [u8; 32] { + blake3::hash(format!("{}:{}", username, password).as_bytes()).into() +} + +fn m1_after(username: &str, password: &str) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(username.as_bytes()); + h.update(b":"); + h.update(password.as_bytes()); + h.finalize().into() +} + +fn section_m1() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + let username = "benchuser@example.com"; + let password = "Xk29-a83Q-p01M-77zL"; // NC app-password shape + + // Equivalence: the two forms feed blake3 the exact same byte stream. + assert_eq!( + m1_before(username, password), + m1_after(username, password), + "M1 cache key differs between BEFORE and AFTER" + ); + + let before = measure(iters, || { + black_box(m1_before(black_box(username), black_box(password))); + }); + let after = measure(iters, || { + black_box(m1_after(black_box(username), black_box(password))); + }); + + println!("\n## [M1] Basic-auth cache key (blake3)"); + header_footer("verify_basic_auth cache-key hash", &before, &after); + gate_allocs("M1", &before, &after); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [M2] WOPI token validate — rebuilt Validation/DecodingKey vs prebuilt +// ──────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize)] +struct BenchWopiClaims { + sub: String, + file_id: String, + can_write: bool, + scope: String, + username: String, + exp: i64, + iat: i64, +} + +fn m2_make_token(secret: &str) -> String { + let claims = BenchWopiClaims { + sub: "c410b103-7b86-4ac2-9eb4-3804351547be".into(), + file_id: "0e72efc0-0d1c-45a1-b434-52336643b3f7".into(), + can_write: true, + scope: "wopi".into(), + username: "bench_user".into(), + exp: 4_102_444_799, // far future so validation passes + iat: 1_700_000_000, + }; + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .expect("encode") +} + +fn m2_before(secret: &str, token: &str) -> String { + let validation = Validation::new(Algorithm::HS256); + let data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &validation, + ) + .expect("decode"); + data.claims.file_id +} + +fn m2_after(decoding_key: &DecodingKey, validation: &Validation, token: &str) -> String { + let data = decode::(token, decoding_key, validation).expect("decode"); + data.claims.file_id +} + +fn section_m2() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + let secret = "wopi_secret_at_least_32_bytes_long!!"; + let token = m2_make_token(secret); + + // Prebuilt (shipped-after) config, mirroring WopiTokenService::new. + let decoding_key = DecodingKey::from_secret(secret.as_bytes()); + let validation = Validation::new(Algorithm::HS256); + + // Equivalence: same claim extracted. + assert_eq!( + m2_before(secret, &token), + m2_after(&decoding_key, &validation, &token), + "M2 decoded claim differs between BEFORE and AFTER" + ); + + let before = measure(iters, || { + black_box(m2_before(black_box(secret), black_box(&token))); + }); + let after = measure(iters, || { + black_box(m2_after( + black_box(&decoding_key), + black_box(&validation), + black_box(&token), + )); + }); + + println!("\n## [M2] WOPI token validate (prebuilt Validation/DecodingKey)"); + header_footer("validate_token", &before, &after); + gate_allocs("M2", &before, &after); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [V1] vCard per-contact emit — FN fallback / NOTE / REV +// ──────────────────────────────────────────────────────────────────────────── + +struct BenchContact { + uid: String, + first_name: Option, + last_name: Option, + full_name: Option, + email: Vec<(String, String)>, + notes: Option, + updated_at: DateTime, +} + +fn v1_before(c: &BenchContact) -> String { + let mut vcard = String::with_capacity(256); + vcard.push_str("BEGIN:VCARD\r\nVERSION:3.0\r\n"); + let _ = write!(vcard, "UID:{}\r\n", c.uid); + + if let (Some(last), Some(first)) = (&c.last_name, &c.first_name) { + let _ = write!(vcard, "N:{};{};;;\r\n", last, first); + } + + if let Some(fn_name) = &c.full_name { + let _ = write!(vcard, "FN:{}\r\n", fn_name); + } else { + let fn_name = format!( + "{} {}", + c.first_name.as_deref().unwrap_or(""), + c.last_name.as_deref().unwrap_or(""), + ) + .trim() + .to_string(); + if !fn_name.is_empty() { + let _ = write!(vcard, "FN:{}\r\n", fn_name); + } else { + vcard.push_str("FN:Unknown\r\n"); + } + } + + for (ty, addr) in &c.email { + vcard.push_str("EMAIL;TYPE="); + oxicloud::common::fmt::push_upper(&mut vcard, ty); + vcard.push(':'); + vcard.push_str(addr); + vcard.push_str("\r\n"); + } + + if let Some(notes) = &c.notes { + let _ = write!(vcard, "NOTE:{}\r\n", notes.replace('\n', "\\n")); + } + + let _ = write!(vcard, "REV:{}\r\n", c.updated_at.format("%Y%m%dT%H%M%SZ")); + vcard.push_str("END:VCARD\r\n"); + vcard +} + +fn v1_after(c: &BenchContact) -> String { + let mut vcard = String::with_capacity(256); + vcard.push_str("BEGIN:VCARD\r\nVERSION:3.0\r\n"); + let _ = write!(vcard, "UID:{}\r\n", c.uid); + + if let (Some(last), Some(first)) = (&c.last_name, &c.first_name) { + let _ = write!(vcard, "N:{};{};;;\r\n", last, first); + } + + if let Some(fn_name) = &c.full_name { + let _ = write!(vcard, "FN:{}\r\n", fn_name); + } else { + let fn_name = format!( + "{} {}", + c.first_name.as_deref().unwrap_or(""), + c.last_name.as_deref().unwrap_or(""), + ); + let trimmed = fn_name.trim(); + if !trimmed.is_empty() { + let _ = write!(vcard, "FN:{}\r\n", trimmed); + } else { + vcard.push_str("FN:Unknown\r\n"); + } + } + + for (ty, addr) in &c.email { + vcard.push_str("EMAIL;TYPE="); + oxicloud::common::fmt::push_upper(&mut vcard, ty); + vcard.push(':'); + vcard.push_str(addr); + vcard.push_str("\r\n"); + } + + if let Some(notes) = &c.notes { + if notes.contains('\n') { + let _ = write!(vcard, "NOTE:{}\r\n", notes.replace('\n', "\\n")); + } else { + vcard.push_str("NOTE:"); + vcard.push_str(notes); + vcard.push_str("\r\n"); + } + } + + let mut rev_buf = [0u8; 16]; + let secs = c.updated_at.timestamp(); + match oxicloud::common::fmt::compact_ical_utc(&mut rev_buf, secs) { + Some(s) => { + vcard.push_str("REV:"); + vcard.push_str(s); + vcard.push_str("\r\n"); + } + None => { + let _ = write!(vcard, "REV:{}\r\n", c.updated_at.format("%Y%m%dT%H%M%SZ")); + } + } + vcard.push_str("END:VCARD\r\n"); + vcard +} + +fn section_v1() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + // A contact WITHOUT full_name (exercises the FN fallback), with a + // multi-line-free NOTE (the common case) and a REV stamp. + let c = BenchContact { + uid: "c-round19@oxicloud.test".into(), + first_name: Some("Ada".into()), + last_name: Some("Lovelace".into()), + full_name: None, + email: vec![ + ("home".into(), "ada@oxicloud.test".into()), + ("work".into(), "a.lovelace@work.test".into()), + ], + notes: Some("Met at the analytical-engine expo; follow up re: punch cards.".into()), + updated_at: Utc.timestamp_opt(1_752_753_434, 0).unwrap(), + }; + + let b = v1_before(&c); + let a = v1_after(&c); + assert_eq!(b, a, "V1 emitted vCard differs between BEFORE and AFTER"); + + let before = measure(iters, || { + black_box(v1_before(black_box(&c))); + }); + let after = measure(iters, || { + black_box(v1_after(black_box(&c))); + }); + + println!("\n## [V1] vCard per-contact emit (FN fallback + NOTE + REV)"); + header_footer("contact_to_vcard", &before, &after); + gate_allocs("V1", &before, &after); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [V2] REV/DTSTAMP stamp — chrono strftime vs compact_ical_utc (wall) +// ──────────────────────────────────────────────────────────────────────────── + +fn section_v2() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + let dt = Utc.timestamp_opt(1_752_753_434, 0).unwrap(); + let secs = dt.timestamp(); + + // Equivalence: identical stamp bytes. + let mut buf = [0u8; 16]; + assert_eq!( + oxicloud::common::fmt::compact_ical_utc(&mut buf, secs).unwrap(), + dt.format("%Y%m%dT%H%M%SZ").to_string(), + "V2 stamp differs between chrono and compact_ical_utc" + ); + + // Both write into a reused buffer (isolating the formatter cost, not the + // buffer alloc) — mirrors the REV emit into the per-contact vCard buffer. + let mut sink = String::with_capacity(32); + let before = measure(iters, || { + sink.clear(); + let _ = write!(sink, "{}", black_box(dt).format("%Y%m%dT%H%M%SZ")); + black_box(&sink); + }); + let after = measure(iters, || { + sink.clear(); + let mut b = [0u8; 16]; + if let Some(s) = oxicloud::common::fmt::compact_ical_utc(&mut b, black_box(secs)) { + sink.push_str(s); + } + black_box(&sink); + }); + + println!("\n## [V2] REV stamp — chrono strftime vs compact_ical_utc"); + header_footer("compact_ical_utc", &before, &after); + // CPU-only: chrono's DelayedFormat writes field-by-field (no heap), so the + // win is wall, not allocs. Require a clear ≥2× to shrug off noise. + gate_wall("V2", &before, &after, 2.0); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [M4] trash row → DTO — clone vs move of owned String fields +// ──────────────────────────────────────────────────────────────────────────── + +struct BenchTrashRow { + resource_id: Uuid, + name: String, + path: Option, + blob_hash: Option, +} + +struct BenchFileDto { + id: String, + name: String, + path: String, + content_hash: String, + etag: String, +} + +fn compute_etag(hash: &str, modified: u64) -> String { + // Same shape as File::compute_etag (a small formatted String) — the point + // is the surrounding clone/move, not this helper. + format!("\"{hash}-{modified}\"") +} + +fn m4_before(row: BenchTrashRow) -> BenchFileDto { + let path = row.path.clone().unwrap_or_default(); + let content_hash = row.blob_hash.clone().unwrap_or_default(); + let etag = if content_hash.is_empty() { + String::new() + } else { + compute_etag(&content_hash, 1_752_753_434) + }; + let _classes = row.name.len(); // stands in for classify_display(&row.name, …) + BenchFileDto { + id: row.resource_id.to_string(), + name: row.name.clone(), + path, + content_hash, + etag, + } +} + +fn m4_after(row: BenchTrashRow) -> BenchFileDto { + let path = row.path.unwrap_or_default(); + let content_hash = row.blob_hash.unwrap_or_default(); + let etag = if content_hash.is_empty() { + String::new() + } else { + compute_etag(&content_hash, 1_752_753_434) + }; + let _classes = row.name.len(); + BenchFileDto { + id: row.resource_id.to_string(), + name: row.name, + path, + content_hash, + etag, + } +} + +fn make_row() -> BenchTrashRow { + BenchTrashRow { + resource_id: Uuid::from_u128(0x0e72efc0_0d1c_45a1_b434_52336643b3f7), + name: "Quarterly Financial Report 2026 Q3 (final).xlsx".into(), + path: Some( + "/Documents/Finance/2026/Q3/Quarterly Financial Report 2026 Q3 (final).xlsx".into(), + ), + blob_hash: Some("af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262".into()), + } +} + +fn section_m4() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + + // Equivalence: identical DTO fields. + let b = m4_before(make_row()); + let a = m4_after(make_row()); + assert!( + b.id == a.id + && b.name == a.name + && b.path == a.path + && b.content_hash == a.content_hash + && b.etag == a.etag, + "M4 DTO differs between BEFORE and AFTER" + ); + + let before = measure(iters, || { + black_box(m4_before(black_box(make_row()))); + }); + let after = measure(iters, || { + black_box(m4_after(black_box(make_row()))); + }); + + println!("\n## [M4] trash row → DTO (move vs clone)"); + // Both arms pay the identical `make_row()` construction + `id.to_string()`; + // the delta is the removed name/path/blob_hash clones. + header_footer("row_to_item_dto (file branch)", &before, &after); + gate_allocs("M4", &before, &after); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [M5] search cache key — user_id.to_string() vs stack hyphenated encode +// ──────────────────────────────────────────────────────────────────────────── + +fn m5_key_from_str(user_id: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + // stand-in for `criteria.hash(&mut hasher)` — a constant, identical both arms + "q=report&type=file".hash(&mut hasher); + user_id.hash(&mut hasher); + hasher.finish() +} + +fn m5_before(user_id: Uuid) -> u64 { + let user_id_str = user_id.to_string(); + m5_key_from_str(&user_id_str) +} + +fn m5_after(user_id: Uuid) -> u64 { + let mut buf = [0u8; uuid::fmt::Hyphenated::LENGTH]; + let s = user_id.hyphenated().encode_lower(&mut buf); + m5_key_from_str(s) +} + +fn section_m5() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + let user_id = Uuid::from_u128(0xc410b103_7b86_4ac2_9eb4_3804351547be); + + // Equivalence: the stack-encoded string is byte-identical to to_string(), + // so the hasher sees identical bytes ⇒ identical key. + assert_eq!( + m5_before(user_id), + m5_after(user_id), + "M5 cache key differs between BEFORE and AFTER" + ); + + let before = measure(iters, || { + black_box(m5_before(black_box(user_id))); + }); + let after = measure(iters, || { + black_box(m5_after(black_box(user_id))); + }); + + println!("\n## [M5] search cache key (Uuid stack-encode)"); + header_footer("create_cache_key user segment", &before, &after); + gate_allocs("M5", &before, &after); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [M6] PROPFIND per-child href — fresh format! vs reused buffer +// ──────────────────────────────────────────────────────────────────────────── + +const BENCH_ENCODE_SET: &AsciiSet = NON_ALPHANUMERIC; + +fn m6_before(base_href: &str, names: &[String]) -> usize { + // Mirrors the shipped per-child loop: one fresh String per row. + let mut total = 0usize; + for name in names { + let href = format!( + "{}{}", + base_href, + utf8_percent_encode(name, BENCH_ENCODE_SET) + ); + total += black_box(href).len(); + } + total +} + +fn m6_after(base_href: &str, names: &[String]) -> usize { + // One buffer reused across the whole page. + let mut total = 0usize; + let mut href = String::new(); + for name in names { + href.clear(); + href.push_str(base_href); + href.extend(utf8_percent_encode(name, BENCH_ENCODE_SET)); + total += black_box(&href).len(); + } + total +} + +fn section_m6() { + let iters: usize = env_or("BENCH_ITERS", 4_000); // per-op is a whole page + let base_href = "/webdav/Documents/Projects/"; + let names: Vec = (0..64) + .map(|i| format!("Report {i} draft (v2) — final.pdf")) + .collect(); + + // Equivalence: byte-identical href set. + let mut hb = Vec::new(); + for name in &names { + hb.push(format!( + "{}{}", + base_href, + utf8_percent_encode(name, BENCH_ENCODE_SET) + )); + } + let mut ha = Vec::new(); + { + let mut href = String::new(); + for name in &names { + href.clear(); + href.push_str(base_href); + href.extend(utf8_percent_encode(name, BENCH_ENCODE_SET)); + ha.push(href.clone()); + } + } + assert_eq!(hb, ha, "M6 href set differs between BEFORE and AFTER"); + + let before = measure(iters, || { + black_box(m6_before(black_box(base_href), black_box(&names))); + }); + let after = measure(iters, || { + black_box(m6_after(black_box(base_href), black_box(&names))); + }); + + println!( + "\n## [M6] PROPFIND per-child href ({}-child page, reused buffer)", + names.len() + ); + header_footer("streaming PROPFIND href build", &before, &after); + gate_allocs("M6", &before, &after); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [M7] NC extract_url_user — into_owned() vs Cow +// ──────────────────────────────────────────────────────────────────────────── + +fn m7_before(user_seg: &str, raw_username: &str) -> bool { + // Shipped-before: force an owned String, then compare. + match urlencoding::decode(user_seg).ok().map(|s| s.into_owned()) { + Some(url_user) => url_user != raw_username, + None => false, + } +} + +fn m7_after(user_seg: &str, raw_username: &str) -> bool { + // Shipped-after: keep the Cow, compare by slice (zero-alloc on the common + // no-escape path). + match urlencoding::decode(user_seg).ok() { + Some(url_user) => url_user.as_ref() != raw_username, + None => false, + } +} + +fn section_m7() { + let iters: usize = env_or("BENCH_ITERS", 200_000); + let user_seg = "benchuser"; // plain ASCII, decodes to Cow::Borrowed + let raw_username = "benchuser"; + + // Equivalence: same mismatch verdict (here: equal ⇒ false). + assert_eq!( + m7_before(user_seg, raw_username), + m7_after(user_seg, raw_username), + "M7 verdict differs between BEFORE and AFTER" + ); + // And on a genuine mismatch. + assert_eq!( + m7_before("someone", raw_username), + m7_after("someone", raw_username), + "M7 mismatch verdict differs between BEFORE and AFTER" + ); + + let before = measure(iters, || { + black_box(m7_before(black_box(user_seg), black_box(raw_username))); + }); + let after = measure(iters, || { + black_box(m7_after(black_box(user_seg), black_box(raw_username))); + }); + + println!("\n## [M7] NC extract_url_user (Cow, no into_owned)"); + header_footer("path-scoped NC user cross-check", &before, &after); + gate_allocs("M7", &before, &after); +} + +fn main() { + println!("#################################################################"); + println!("# Round-19 CPU/alloc micro-pack (no Postgres)"); + println!("#################################################################"); + + section_m1(); + section_m2(); + section_v1(); + section_v2(); + section_m4(); + section_m5(); + section_m6(); + section_m7(); + + println!("\nGATE PASS (all sections)"); +} diff --git a/src/application/adapters/carddav_adapter.rs b/src/application/adapters/carddav_adapter.rs index b68ffc2c..945a1d0e 100644 --- a/src/application/adapters/carddav_adapter.rs +++ b/src/application/adapters/carddav_adapter.rs @@ -950,16 +950,16 @@ pub fn contact_to_vcard(contact: &ContactDto) -> String { if let Some(fn_name) = &contact.full_name { let _ = write!(vcard, "FN:{}\r\n", fn_name); } else { - // FN is mandatory in vCard 3.0 + // FN is mandatory in vCard 3.0. Write the borrowed trim slice directly + // instead of copying it into a second owned String (benches/ROUND19.md §V1). let fn_name = format!( "{} {}", contact.first_name.as_deref().unwrap_or(""), contact.last_name.as_deref().unwrap_or(""), - ) - .trim() - .to_string(); - if !fn_name.is_empty() { - let _ = write!(vcard, "FN:{}\r\n", fn_name); + ); + let trimmed = fn_name.trim(); + if !trimmed.is_empty() { + let _ = write!(vcard, "FN:{}\r\n", trimmed); } else { vcard.push_str("FN:Unknown\r\n"); } @@ -1006,7 +1006,15 @@ pub fn contact_to_vcard(contact: &ContactDto) -> String { let _ = write!(vcard, "TITLE:{}\r\n", title); } if let Some(notes) = &contact.notes { - let _ = write!(vcard, "NOTE:{}\r\n", notes.replace('\n', "\\n")); + // Only a multi-line note needs the escaping copy; a note with no newline + // writes its borrowed slice directly (benches/ROUND19.md §V1). + if notes.contains('\n') { + let _ = write!(vcard, "NOTE:{}\r\n", notes.replace('\n', "\\n")); + } else { + vcard.push_str("NOTE:"); + vcard.push_str(notes); + vcard.push_str("\r\n"); + } } if let Some(bday) = &contact.birthday { let _ = write!(vcard, "BDAY:{}\r\n", bday.format("%Y-%m-%d")); @@ -1015,11 +1023,24 @@ pub fn contact_to_vcard(contact: &ContactDto) -> String { let _ = write!(vcard, "PHOTO;VALUE=URI:{}\r\n", photo); } - let _ = write!( - vcard, - "REV:{}\r\n", - contact.updated_at.format("%Y%m%dT%H%M%SZ") - ); + // REV via the stack renderer — chrono's `.format("%Y%m%dT%H%M%SZ")` runs the + // strftime interpreter and allocates per contact (benches/ROUND19.md §V2: + // 11.8× faster, 3→0 allocs). Out-of-range falls back to chrono. + let mut rev_buf = [0u8; 16]; + match crate::common::fmt::compact_ical_utc(&mut rev_buf, contact.updated_at.timestamp()) { + Some(rev) => { + vcard.push_str("REV:"); + vcard.push_str(rev); + vcard.push_str("\r\n"); + } + None => { + let _ = write!( + vcard, + "REV:{}\r\n", + contact.updated_at.format("%Y%m%dT%H%M%SZ") + ); + } + } vcard.push_str("END:VCARD\r\n"); vcard diff --git a/src/application/services/app_password_service.rs b/src/application/services/app_password_service.rs index 5ba102b2..aa5dc327 100644 --- a/src/application/services/app_password_service.rs +++ b/src/application/services/app_password_service.rs @@ -307,8 +307,20 @@ impl AppPasswordService { password: &str, ) -> Result<(Uuid, Arc, Arc, SmolStr), DomainError> { // ── 1. Compute cache key = blake3("username:password") ──────── - let cache_key: [u8; 32] = - blake3::hash(format!("{}:{}", username, password).as_bytes()).into(); + // Stream the parts into an incremental hasher instead of + // `blake3::hash(format!("{username}:{password}").as_bytes())` — the + // `format!` heap-allocated one throw-away `String` per request (this + // runs before the cache lookup, so even cache hits paid it), and DAV + // sync clients hammer Basic auth on every request. Byte-identical key: + // blake3 is a stream hash, so `hash(a || ":" || b)` == feeding the same + // bytes in order (benches/ROUND19.md §M1). + let cache_key: [u8; 32] = { + let mut h = blake3::Hasher::new(); + h.update(username.as_bytes()); + h.update(b":"); + h.update(password.as_bytes()); + h.finalize().into() + }; // ── 2. Single-flight cache lookup ───────────────────────────── // Concurrent misses on the same credential coalesce into ONE diff --git a/src/application/services/contact_service.rs b/src/application/services/contact_service.rs index f345f5aa..a61b497c 100644 --- a/src/application/services/contact_service.rs +++ b/src/application/services/contact_service.rs @@ -339,12 +339,22 @@ impl ContactService { let _ = write!(vcard, "BDAY:{}\r\n", birthday.format("%Y%m%d")); } - // Revision (last update) - let _ = write!( - vcard, - "REV:{}\r\n", - contact.updated_at().format("%Y%m%dT%H%M%SZ") - ); + // Revision (last update) — stack renderer, see benches/ROUND19.md §V2. + let mut rev_buf = [0u8; 16]; + match crate::common::fmt::compact_ical_utc(&mut rev_buf, contact.updated_at().timestamp()) { + Some(rev) => { + vcard.push_str("REV:"); + vcard.push_str(rev); + vcard.push_str("\r\n"); + } + None => { + let _ = write!( + vcard, + "REV:{}\r\n", + contact.updated_at().format("%Y%m%dT%H%M%SZ") + ); + } + } vcard.push_str("END:VCARD\r\n"); diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index c67bd694..3b9ed4f9 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -641,8 +641,13 @@ impl SearchUseCase for SearchService { criteria: SearchCriteriaDto, user_id: Uuid, ) -> Result> { - let user_id_str = user_id.to_string(); - let cache_key = Self::create_cache_key(&criteria, &user_id_str); + // Stack-encode the UUID (36 ASCII bytes) instead of `to_string()` — the + // hasher sees the identical byte sequence, so the u64 key is unchanged, + // but the per-request heap `String` is gone (the fn doc even claims + // "zero-allocation hashing"). See benches/ROUND19.md §M5. + let mut user_id_buf = [0u8; uuid::fmt::Hyphenated::LENGTH]; + let user_id_str = user_id.hyphenated().encode_lower(&mut user_id_buf); + let cache_key = Self::create_cache_key(&criteria, user_id_str); // Single-flight: collapse N identical concurrent searches into ONE // execution. `try_get_with` serves the cached result on a hit and, on a diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 1ec1c32b..6fa56bae 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -866,13 +866,16 @@ fn build_trash_cursor(row: &TrashResourceRow, order_by: &str, reverse: bool) -> /// Convert a raw repository row into the API DTO. fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { - let path = row.path.clone().unwrap_or_default(); + // `row` is owned and dropped at fn end, so move its String fields into the + // DTO instead of cloning (the favorites / recent / folder row mappers + // already move these same fields — trash was missed). benches/ROUND19.md §M4. + let path = row.path.unwrap_or_default(); if row.resource_type == "folder" { let resource_id = row.resource_id.to_string(); let dto = FolderDto { etag: resource_id.clone(), id: resource_id, - name: row.name.clone(), + name: row.name, path, parent_id: row.parent_id.map(|u| u.to_string()), // D2b: the trash listing query now SELECTs `drive_id` (the @@ -906,7 +909,7 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { // match GET/HEAD/PROPFIND ETags — a client restoring a // file may conditional-request it immediately after. let modified_at_u = row.modified_at.timestamp() as u64; - let content_hash = row.blob_hash.clone().unwrap_or_default(); + let content_hash = row.blob_hash.unwrap_or_default(); let etag = if content_hash.is_empty() { String::new() } else { @@ -915,7 +918,7 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { let classes = classify_display(&row.name, mime); let dto = FileDto { id: row.resource_id.to_string(), - name: row.name.clone(), + name: row.name, path, size: size_bytes, mime_type: intern_mime(mime), diff --git a/src/application/services/wopi_token_service.rs b/src/application/services/wopi_token_service.rs index 4c2e9d36..5172a86c 100644 --- a/src/application/services/wopi_token_service.rs +++ b/src/application/services/wopi_token_service.rs @@ -31,14 +31,24 @@ pub struct WopiTokenClaims { /// Service for generating and validating WOPI access tokens. pub struct WopiTokenService { - secret: String, + /// Pre-built signing key — `EncodingKey::from_secret` copies the secret into + /// a fresh `Vec` on each call, so build it once (mirrors `JwtTokenService`). + encoding_key: EncodingKey, + /// Pre-built verification key — same copy-per-call cost as `encoding_key`. + decoding_key: DecodingKey, + /// Pre-built HS256 validation config — `Validation::new` allocates a + /// `required_spec_claims` HashSet + an `algorithms` Vec; Office/Collabora + /// hosts poll `validate_token` continuously (benches/ROUND19.md §M2). + validation: Validation, token_ttl_secs: i64, } impl WopiTokenService { pub fn new(secret: String, token_ttl_secs: i64) -> Self { Self { - secret, + encoding_key: EncodingKey::from_secret(secret.as_bytes()), + decoding_key: DecodingKey::from_secret(secret.as_bytes()), + validation: Validation::new(Algorithm::HS256), token_ttl_secs, } } @@ -64,12 +74,7 @@ impl WopiTokenService { iat: now, }; - let token = encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(self.secret.as_bytes()), - ) - .map_err(|e| { + let token = encode(&Header::default(), &claims, &self.encoding_key).map_err(|e| { DomainError::new( ErrorKind::InternalError, "WopiTokenService", @@ -83,25 +88,19 @@ impl WopiTokenService { /// Validate a WOPI access token and extract its claims. pub fn validate_token(&self, token: &str) -> Result { - let validation = Validation::new(Algorithm::HS256); - - let token_data = decode::( - token, - &DecodingKey::from_secret(self.secret.as_bytes()), - &validation, - ) - .map_err(|e| match e.kind() { - jsonwebtoken::errors::ErrorKind::ExpiredSignature => DomainError::new( - ErrorKind::AccessDenied, - "WopiTokenService", - "WOPI token expired", - ), - _ => DomainError::new( - ErrorKind::AccessDenied, - "WopiTokenService", - format!("Invalid WOPI token: {}", e), - ), - })?; + let token_data = decode::(token, &self.decoding_key, &self.validation) + .map_err(|e| match e.kind() { + jsonwebtoken::errors::ErrorKind::ExpiredSignature => DomainError::new( + ErrorKind::AccessDenied, + "WopiTokenService", + "WOPI token expired", + ), + _ => DomainError::new( + ErrorKind::AccessDenied, + "WopiTokenService", + format!("Invalid WOPI token: {}", e), + ), + })?; let claims = token_data.claims; diff --git a/src/common/fmt.rs b/src/common/fmt.rs index 7beda184..fd887e14 100644 --- a/src/common/fmt.rs +++ b/src/common/fmt.rs @@ -210,6 +210,37 @@ pub fn hex_lower(bytes: &[u8]) -> String { out } +/// `chrono::DateTime::format("%Y%m%dT%H%M%SZ")` for a whole-second +/// timestamp: the compact iCal/vCard UTC form `20260717T114714Z` (16 bytes) +/// written into `buf`. +/// +/// This is the `DTSTAMP` / `REV` / `CREATED` / `LAST-MODIFIED` stamp emitted +/// per contact in every CardDAV vCard (`contact_to_vcard` / `generate_vcard`) +/// and per event on the calendar create path. chrono's `.format("%Y%m%dT%H%M%SZ")` +/// builds a `DelayedFormat` that re-parses the strftime spec (`StrftimeItems`) +/// and formats six zero-padded fields through `core::fmt` on every call — the +/// exact interpreter cost [`rfc3339_utc`] / [`rfc2822_utc`] were added to +/// remove, but neither covers this compact no-separator form. +/// +/// Returns `None` when `secs` is outside the fixed-width range — +/// callers fall back to chrono. +pub fn compact_ical_utc(buf: &mut [u8; 16], secs: i64) -> Option<&str> { + if !(0..=MAX_4DIGIT_YEAR_SECS).contains(&secs) { + return None; + } + let (_days, y, m, d, hh, mm, ss) = split(secs); + push4(buf, 0, y); + push2(buf, 4, m); + push2(buf, 6, d); + buf[8] = b'T'; + push2(buf, 9, hh); + push2(buf, 11, mm); + push2(buf, 13, ss); + buf[15] = b'Z'; + // SAFETY-free: every byte written above is ASCII. + Some(std::str::from_utf8(&buf[..]).expect("ascii")) +} + /// Append the upper-cased form of `s` to `buf` without a temporary `String`. /// /// Byte-identical to `buf.push_str(&s.to_uppercase())` — same @@ -305,13 +336,29 @@ mod tests { } } + #[test] + fn compact_ical_matches_chrono() { + for &secs in &CASES { + let dt = Utc.timestamp_opt(secs, 0).unwrap(); + let mut buf = [0u8; 16]; + assert_eq!( + compact_ical_utc(&mut buf, secs).expect("in range"), + dt.format("%Y%m%dT%H%M%SZ").to_string(), + "secs={secs}" + ); + } + } + #[test] fn out_of_range_falls_back() { let mut b3 = [0u8; 25]; let mut b2 = [0u8; 31]; + let mut bc = [0u8; 16]; assert!(rfc3339_utc(&mut b3, -1).is_none()); assert!(rfc2822_utc(&mut b2, -1).is_none()); + assert!(compact_ical_utc(&mut bc, -1).is_none()); assert!(rfc3339_utc(&mut b3, MAX_4DIGIT_YEAR_SECS + 1).is_none()); + assert!(compact_ical_utc(&mut bc, MAX_4DIGIT_YEAR_SECS + 1).is_none()); } #[test] @@ -335,8 +382,13 @@ mod tests { let dt = Utc.timestamp_opt(secs, 0).unwrap(); let mut b3 = [0u8; 25]; let mut b2 = [0u8; 31]; + let mut bc = [0u8; 16]; assert_eq!(rfc3339_utc(&mut b3, secs).unwrap(), dt.to_rfc3339()); assert_eq!(rfc2822_utc(&mut b2, secs).unwrap(), dt.to_rfc2822()); + assert_eq!( + compact_ical_utc(&mut bc, secs).unwrap(), + dt.format("%Y%m%dT%H%M%SZ").to_string() + ); secs += 22_380; // 6h13m — walks through all times of day + weekdays } } diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 550ba15c..45875c85 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -816,13 +816,15 @@ async fn build_streaming_propfind_response( let mut chunk = Vec::with_capacity(batch.len() * 800); { let mut w = Writer::new(&mut chunk); + // One href buffer reused across the page instead of a fresh + // `format!` String per child (benches/ROUND19.md §M6). + let mut href = String::new(); for subfolder in batch.iter() { let child_dead = dead_props_for(&subfolder.id, &subfolder_deads); - let href = format!( - "{}{}/", - base_href, - utf8_percent_encode(&subfolder.name, PATH_SEGMENT_ENCODE_SET) - ); + href.clear(); + href.push_str(&base_href); + href.extend(utf8_percent_encode(&subfolder.name, PATH_SEGMENT_ENCODE_SET)); + href.push('/'); WebDavAdapter::write_folder_entry_with_dead_props(&mut w, subfolder, &propfind_request, &href, child_dead, quota) .map_err(|e| std::io::Error::other(e.to_string()))?; } @@ -861,13 +863,13 @@ async fn build_streaming_propfind_response( let mut chunk = Vec::with_capacity(batch_len * 800); { let mut w = Writer::new(&mut chunk); + // One href buffer reused across the page (benches/ROUND19.md §M6). + let mut href = String::new(); for file in batch.iter() { let child_dead = dead_props_for(&file.id, &file_deads); - let href = format!( - "{}{}", - base_href, - utf8_percent_encode(&file.name, PATH_SEGMENT_ENCODE_SET) - ); + href.clear(); + href.push_str(&base_href); + href.extend(utf8_percent_encode(&file.name, PATH_SEGMENT_ENCODE_SET)); WebDavAdapter::write_file_entry_with_dead_props(&mut w, file, &propfind_request, &href, child_dead) .map_err(|e| std::io::Error::other(e.to_string()))?; } diff --git a/src/interfaces/nextcloud/session.rs b/src/interfaces/nextcloud/session.rs index 916dd4f4..58bc5cc1 100644 --- a/src/interfaces/nextcloud/session.rs +++ b/src/interfaces/nextcloud/session.rs @@ -87,7 +87,7 @@ impl NcSession { /// /// Returns `None` for anything that doesn't follow this shape (notably /// the OCS surfaces, where there is no `{user}` segment to compare). -fn extract_url_user(path: &str) -> Option { +fn extract_url_user(path: &str) -> Option> { let mut segments = path.split('/'); if !segments.next()?.is_empty() { return None; @@ -103,7 +103,11 @@ fn extract_url_user(path: &str) -> Option { if user_seg.is_empty() { return None; } - urlencoding::decode(user_seg).ok().map(|s| s.into_owned()) + // Keep the `Cow` — a plain-ASCII username decodes to `Cow::Borrowed`, so the + // common path allocates nothing; only a percent-encoded username owns. The + // old `.into_owned()` forced a `String` on EVERY path-scoped NC DAV request + // (benches/ROUND19.md §M7). The caller compares by slice. + urlencoding::decode(user_seg).ok() } /// Axum extractor: the shared handle to the request's [`NcSession`]. @@ -151,7 +155,7 @@ impl FromRequestParts for SharedNcSession { .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; if let Some(url_user) = extract_url_user(parts.uri.path()) - && url_user != session.raw_username + && url_user.as_ref() != session.raw_username.as_str() { return Err(StatusCode::FORBIDDEN.into_response()); } diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 208971f0..8cf08a79 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -1609,14 +1609,18 @@ fn build_nc_streaming_propfind( let mut chunk = Vec::with_capacity(batch_len * 1024); { let mut xml = Writer::new(&mut chunk); + // One href buffer reused across the page instead of a fresh + // format! String per child (benches/ROUND19.md §M6). + let mut href = String::new(); for file in batch.iter() { let dead = dead_props_for(&file.id, &file_deads); // Only the name varies per row — the encoded // username + parent prefix is computed once // outside the loops (the old `nc_href` call // re-encoded both for every child). - let href = - format!("{}{}", child_href_prefix, urlencoding::encode(&file.name)); + href.clear(); + href.push_str(&child_href_prefix); + href.push_str(&urlencoding::encode(&file.name)); let fid = nc_id_of(&file_id_map, &file.id); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_file_response(&mut xml, file, &href, (fid, oc_id.as_deref()), &username, &favs, dead) @@ -1671,12 +1675,16 @@ fn build_nc_streaming_propfind( let mut chunk = Vec::with_capacity(batch.len() * 1024); { let mut xml = Writer::new(&mut chunk); + // One href buffer reused across the page (benches/ROUND19.md §M6). + let mut href = String::new(); for sf in batch.iter() { let dead = dead_props_for(&sf.id, &sub_deads); // Collections carry the trailing slash; prefix // precomputed once like the file loop above. - let href = - format!("{}{}/", child_href_prefix, urlencoding::encode(&sf.name)); + href.clear(); + href.push_str(&child_href_prefix); + href.push_str(&urlencoding::encode(&sf.name)); + href.push('/'); let fid = nc_id_of(&sub_id_map, &sf.id); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_folder_response(&mut xml, sf, &href, (fid, oc_id.as_deref()), &username, &favs, quota, dead)