perf: round 19 — auth/WOPI/vCard/PROPFIND per-request & per-row alloc cuts
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ront9bk7YMoffVQkGG47gh
This commit is contained in:
+14
@@ -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` /
|
||||
|
||||
@@ -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::<WopiTokenClaims>(
|
||||
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<Utc>`, 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.
|
||||
@@ -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<T: std::str::FromStr>(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<F: FnMut()>(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::<BenchWopiClaims>(
|
||||
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::<BenchWopiClaims>(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<String>,
|
||||
last_name: Option<String>,
|
||||
full_name: Option<String>,
|
||||
email: Vec<(String, String)>,
|
||||
notes: Option<String>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
blob_hash: Option<String>,
|
||||
}
|
||||
|
||||
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<String> = (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)");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -307,8 +307,20 @@ impl AppPasswordService {
|
||||
password: &str,
|
||||
) -> Result<(Uuid, Arc<str>, Arc<str>, 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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -641,8 +641,13 @@ impl SearchUseCase for SearchService {
|
||||
criteria: SearchCriteriaDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<Arc<SearchResultsDto>> {
|
||||
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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<WopiTokenClaims, DomainError> {
|
||||
let validation = Validation::new(Algorithm::HS256);
|
||||
|
||||
let token_data = decode::<WopiTokenClaims>(
|
||||
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::<WopiTokenClaims>(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;
|
||||
|
||||
|
||||
@@ -210,6 +210,37 @@ pub fn hex_lower(bytes: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// `chrono::DateTime<Utc>::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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))?;
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
fn extract_url_user(path: &str) -> Option<std::borrow::Cow<'_, str>> {
|
||||
let mut segments = path.split('/');
|
||||
if !segments.next()?.is_empty() {
|
||||
return None;
|
||||
@@ -103,7 +103,11 @@ fn extract_url_user(path: &str) -> Option<String> {
|
||||
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<S: Send + Sync> FromRequestParts<S> 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());
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user