perf: round 22 — hot-GET HeaderMap borrow, native-WebDAV/CalDAV etag borrowed quotes, FileDto content_hash move, CalendarEvent stamp, ShareItemType case-fold
Benchmark-gated, same rule as ROUND2-21: every change ships with a
BEFORE/AFTER counting-allocator benchmark and a byte/-value equivalence
gate; an AFTER that fails to reduce allocations exits non-zero (rollback).
See benches/ROUND22.md and examples/bench_round22_micro.rs. All arms
no-Postgres.
- H1: the hot GET handlers (get_thumbnail, download_file, list_files_query,
list_photos, NextCloud preview, public-share download/access) take
`req: Request` last and read `req.headers()` by borrow instead of axum's
HeaderMap extractor, whose FromRequestParts impl clones the whole request
header table just to read 1-3 headers (the ROUND14 §A4 middleware pattern,
finally propagated to the handlers). 2 -> 0 allocs/req · 9.95x wall.
- W1: native WebDAV write_etag_quoted — the etag emitter for every /webdav/
PROPFIND row (per file AND per folder, up to 500/page) — emits the quotes
as borrowed pre-escaped " text events instead of escaping a "{etag}"
String (the ROUND20 §C1 / ROUND21 §R4 pattern). 3 -> 0 allocs/row.
- C1: CalDAV getetag routed through a shared write_quoted_etag helper across
all 5 sites (3 per-event + 2 per-calendar); the now-dead etag: &mut String
buffer threaded through write_event_response/standard/requested props + the
two per-page buffers removed. 2 -> 0 allocs/row.
- D1: FileDto::from reuses the moved parts.blob_hash instead of cloning it
via the content_hash() getter (the ROUND19/20 move-not-clone sweep missed
it — hash/etag are read before into_parts()). Per file row of every
listing. 1 -> 0 allocs/row.
- E1: CalendarEvent::update_time_range/update_all_day stamp timed
DTSTART/DTEND via fmt::compact_ical_utc stack render (chrono fallback out
of range) instead of the %Y%m%dT%H%M%SZ strftime interpreter. 4 -> 0.
- S1: ShareItemType::try_from uses eq_ignore_ascii_case instead of a
throwaway to_lowercase() String. 1 -> 0 allocs/parse.
Verified: cargo clippy --features bench --all-targets -D warnings clean,
cargo fmt --all --check clean, cargo test --lib --features bench = 529
passed / 0 failed (incl. the OpenAPI-spec-validity test guarding the H1
utoipa-handler signature change).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKyQ4AnYtgp1JtjzweyMeo
This commit is contained in:
+16
@@ -354,6 +354,22 @@ name = "bench_micro_allocs"
|
||||
path = "examples/bench_micro_allocs.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Round-22 battery ────────────────────────────────────────────────────────────
|
||||
|
||||
# Round-22 CPU/alloc micro-pack — the deferred hot-GET-handler `HeaderMap`
|
||||
# extractor clone (thumbnail/list/download/photos/preview/share read 1–3 headers
|
||||
# out of a full `parts.headers.clone()`), routed through `Request` + borrow (H1);
|
||||
# native-WebDAV `write_etag_quoted` borrowed pre-escaped quotes, the sweep's last
|
||||
# per-row DAV site (W1); CalDAV `getetag` shared `write_quoted_etag` helper across
|
||||
# 5 sites + dead etag-buffer removal (C1); `FileDto::from` reuse of the moved
|
||||
# `blob_hash` instead of the getter clone the move-not-clone sweep missed (D1);
|
||||
# `CalendarEvent` timed DTSTART/DTEND via `fmt::compact_ical_utc` (E1);
|
||||
# `ShareItemType::try_from` `eq_ignore_ascii_case` (S1). No Postgres.
|
||||
[[example]]
|
||||
name = "bench_round22_micro"
|
||||
path = "examples/bench_round22_micro.rs"
|
||||
required-features = ["bench"]
|
||||
|
||||
# Round-21 battery ────────────────────────────────────────────────────────────
|
||||
|
||||
# Round-21 CPU/alloc micro-pack — CalDAV/CardDAV row-mapper container pre-size
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# Round 22 — hot-GET HeaderMap borrow, native-WebDAV & CalDAV etag borrowed quotes, FileDto content_hash move, CalendarEvent stamp, ShareItemType case-fold
|
||||
|
||||
Benchmark-gated, same rule as ROUND2–21: every change ships with a BEFORE/AFTER
|
||||
benchmark and a byte/-value equivalence gate; an AFTER that doesn't beat its
|
||||
BEFORE is rolled back (never applied). The roll-back rule is encoded directly in
|
||||
the harness — a `GATE FAIL … rollback` non-zero exit if an AFTER arm fails to
|
||||
reduce allocations — so a regression fails CI rather than shipping.
|
||||
|
||||
This round drains the two biggest items the ROUND21 audit explicitly deferred —
|
||||
the hot-GET-handler `HeaderMap` extractor clone and the two DAV etag emitters the
|
||||
borrowed-pre-escaped-quote sweep never reached (native WebDAV + CalDAV) — plus
|
||||
the `FileDto::from` `content_hash` clone the ROUND19/20 move-not-clone sweep
|
||||
missed (it is computed *before* `into_parts()`), and two low-heat strftime /
|
||||
case-fold cuts.
|
||||
|
||||
Reproduce:
|
||||
|
||||
```
|
||||
cargo run --release --features bench --example bench_round22_micro
|
||||
```
|
||||
|
||||
All arms are **no-Postgres** (release-profile counting-allocator example).
|
||||
|
||||
## Summary
|
||||
|
||||
| # | change | key metric | before → after |
|
||||
|--:|---|---|---|
|
||||
| **H1** | The hot GET handlers (`get_thumbnail`, `download_file`, `list_files_query`, `list_photos`, NextCloud `preview`, public-share `download`/`access`) took axum's `HeaderMap` extractor, whose `FromRequestParts` impl does `parts.headers.clone()` — an owned clone of the **whole** request header table — purely to read 1–3 headers (`If-None-Match` / `Accept` / `Range` / unlock cookie). Now they take `req: Request` last and read `req.headers()` by borrow (the ROUND14 §A4 middleware pattern, finally propagated to the handlers). The 3 wrapper/`_impl` file handlers pass `req.headers()` into an `_impl` that now takes `&HeaderMap` (`+ use<>` on the return so the 2024-edition `impl Trait` capture doesn't tie the owned `Response` to the borrow). | realistic 13-header req | **2 → 0 allocs/op · 9.95× wall** |
|
||||
| **W1** | `webdav_adapter::write_etag_quoted` — the etag emitter for **every** native `/webdav/` PROPFIND row (per file AND per folder, up to `PROPFIND_BATCH_SIZE`=500/page — the most-travelled DAV path) — built a sized `"{etag}"` `String` then wrote it auto-escaped; `quick_xml` escapes the `"` → `"`, re-allocating an owned `Cow`. Now emits the two quotes as borrowed pre-escaped `"` text events around the escaped body (the ROUND20 §C1 / ROUND21 §R4 pattern the native adapter never got). Byte-identical for any etag. | per PROPFIND row | **3 → 0 allocs/op · 1.59× wall** |
|
||||
| **C1** | The CalDAV `getetag` emit — per event of every calendar-query/multiget/sync REPORT + depth-1 collection PROPFIND (the DAVx5/Apple/Thunderbird sync path), and per calendar of the home-set PROPFIND — still escaped a `"…"` value: the event sites paid the escape `Cow` over the ROUND14 reused buffer (1 alloc/event); the two calendar sites `format!`-ed as well (2 allocs). All **five** sites now route through a new `write_quoted_etag` helper (the CardDAV twin), and the now-dead `etag: &mut String` buffer threaded through `write_event_response`/`write_event_standard_props`/`write_event_requested_props` + the two page buffers are dropped. | per event row | **2 → 0 allocs/op · 1.63× wall** |
|
||||
| **D1** | `FileDto::from` computed `content_hash = file.content_hash().to_string()` (a clone of `blob_hash`) and then `into_parts()` **moved** that same `blob_hash` into `parts.blob_hash`, which was dropped unused in the `Self { … }` ctor. The ROUND19/20 move-not-clone sweep fixed id/name/path/folder_id but missed this one because the etag/hash are read *before* `into_parts()`. Now `content_hash: parts.blob_hash` reuses the moved `String`; `etag` still computes first from the live entity. Runs **per file row on every listing** (folder browse, streaming PROPFIND, search/favorites/recent hydration). | per file row | **1 → 0 allocs/op · 2.91× wall** |
|
||||
| **E1** | `CalendarEvent::update_time_range` / `update_all_day` stamped **timed** DTSTART/DTEND via `format!("{}", t.format("%Y%m%dT%H%M%SZ"))` — chrono's strftime `DelayedFormat` interpreter. Now stack-renders via the shipped `fmt::compact_ical_utc` and passes the `&str` straight to `update_ical_property`, with the chrono `format!` kept as the out-of-range fallback and the all-day `%Y%m%d` form untouched. Per event-edit PUT. | per timed stamp | **4 → 0 allocs/op · 14.49× wall** |
|
||||
| **S1** | `ShareItemType::try_from` matched `s.to_lowercase().as_str()` — a throwaway Unicode-lowercased `String` — against the two ASCII literals `"file"`/`"folder"`. Now `s.eq_ignore_ascii_case("file")` / `("folder")`: byte-identical acceptance for the ASCII targets, no allocation. | per parse | **1 → 0 allocs/op · 7.81× wall** |
|
||||
|
||||
> Allocs/op is the deterministic primary gate (identical run to run). Wall
|
||||
> figures are single-shot and noise-bounded. Every section carries a
|
||||
> byte/-value equivalence gate; the shipped source now matches each AFTER arm.
|
||||
|
||||
## [H1] Hot GET handler `HeaderMap` extractor → `Request` + borrow
|
||||
|
||||
axum 0.8's `impl FromRequestParts for HeaderMap` is literally
|
||||
`Ok(parts.headers.clone())` — cloning the whole request header table (its
|
||||
`entries` + `indices` backing vectors; the counting allocator measures exactly
|
||||
2 allocs on a realistic 13-header browser request). The handlers below read only
|
||||
1–3 headers out of it, so the clone is pure waste — the exact cost
|
||||
`middleware/auth.rs` removed in ROUND14 §A4 (`request.headers().get(…)` by
|
||||
borrow) but which was never propagated to the handlers.
|
||||
|
||||
The fix takes `req: Request` as the **last** extractor (all the others —
|
||||
`State`, `AuthUser`, `Path`, `Query` — are `FromRequestParts`, so they coexist
|
||||
with a single trailing `FromRequest`), and reads `req.headers()` by borrow:
|
||||
|
||||
- **Standalone handlers** (`list_photos`, NC `preview`, share `download`/`access`):
|
||||
swap `headers: HeaderMap` for `req: Request` and read `req.headers().get(…)`
|
||||
at the (single) use site.
|
||||
- **Wrapper/`_impl` handlers** (`get_thumbnail`, `download_file`,
|
||||
`list_files_query`): the wrapper takes `req: Request` and passes
|
||||
`req.headers()` into an `_impl` whose param becomes `headers: &HeaderMap`. The
|
||||
`_impl` return type gets `+ use<>` so the 2024-edition `impl Trait` lifetime
|
||||
capture doesn't tie the (owned) `Response` output to the header borrow — the
|
||||
future still borrows the headers during its inline `.await`, but the response
|
||||
it yields captures nothing.
|
||||
|
||||
Byte-identical: every call site reads the same header by `.get()`. The
|
||||
`openapi_spec_is_valid_and_has_expected_structure` test confirms the
|
||||
utoipa-annotated handlers still emit a valid spec after the signature change.
|
||||
|
||||
NextCloud `avatar` (dual caller `handle_dav_avatar` → `handle_avatar` + dual
|
||||
route) and the share-management handlers (`create`/`update`/… take a `Json`
|
||||
body, so no second `Request` extractor is possible) were left for a dedicated
|
||||
pass — see *Not shipped*.
|
||||
|
||||
## [W1] Native WebDAV `getetag` — borrowed pre-escaped quotes
|
||||
|
||||
`write_etag_quoted` is the single helper behind all four native PROPFIND etag
|
||||
sites (`webdav_adapter.rs:857/935/1004/1089` — file + folder, allprop + named).
|
||||
It built a `String::with_capacity(etag.len()+2)` `"{etag}"` and wrote it via
|
||||
`BytesText::new`, which escapes the `"` → `"` and re-allocates an owned
|
||||
`Cow`. Now (the ROUND20 §C1 / ROUND21 §R4 shape):
|
||||
|
||||
```rust
|
||||
xml_writer.write_event(Event::Text(BytesText::from_escaped(""")))?; // borrowed
|
||||
xml_writer.write_event(Event::Text(BytesText::new(etag)))?; // escaped body
|
||||
xml_writer.write_event(Event::Text(BytesText::from_escaped(""")))?;
|
||||
```
|
||||
|
||||
`escape` maps `"`→`"` per char, so `"{escape(etag)}"` is
|
||||
byte-identical to escaping `"{etag}"` for **any** etag (the equivalence gate
|
||||
asserts it, including an etag carrying `&`/`<`/`"`). One helper body fixes all
|
||||
four call sites — 0 allocs/row on the hottest native-WebDAV path.
|
||||
|
||||
## [C1] CalDAV `getetag` — shared `write_quoted_etag` helper (5 sites)
|
||||
|
||||
The CalDAV adapter was the last DAV emitter still escaping a quoted etag value.
|
||||
A new file-local `write_quoted_etag` (identical to the shipped CardDAV twin)
|
||||
replaces the manual quote-and-escape at all five sites:
|
||||
|
||||
- `write_event_standard_props` / `write_event_requested_props` /
|
||||
`write_collection_event_page` — **per event bundle** (the reused ROUND14
|
||||
buffer was already amortized, so the remaining cost was the escape `Cow`;
|
||||
1 → 0 alloc/event).
|
||||
- `write_calendar_standard_props` / `write_calendar_requested_props` — **per
|
||||
calendar**, which additionally `format!`-ed the value (2 → 0).
|
||||
|
||||
The etag bodies are bare `Uuid`s (`anchor.id` / `calendar.id`), so
|
||||
`BytesText::new(id)` is itself a borrow (0 allocs). With the emit no longer
|
||||
needing a scratch `String`, the `etag: &mut String` buffer threaded through
|
||||
`write_event_response` → `write_event_standard_props` /
|
||||
`write_event_requested_props` and the two per-page `String::new()` buffers were
|
||||
removed. The 34 caldav-adapter unit tests (PROPFIND/REPORT output) pass
|
||||
unchanged.
|
||||
|
||||
## [D1] `FileDto::from` — reuse the moved `blob_hash`, don't clone it
|
||||
|
||||
The per-row DTO builder computed the ETag and the content hash from the live
|
||||
entity, then consumed it:
|
||||
|
||||
```rust
|
||||
let etag = file.etag();
|
||||
let content_hash = file.content_hash().to_string(); // clone of self.blob_hash
|
||||
let parts = file.into_parts(); // MOVES self.blob_hash → parts.blob_hash
|
||||
// … Self { …, content_hash, etag, … } // parts.blob_hash dropped unused
|
||||
```
|
||||
|
||||
`etag` genuinely must run against the live entity (it borrows `blob_hash` +
|
||||
`modified_at`), but `content_hash` is just the raw hash — and `into_parts()`
|
||||
already hands it over by ownership. Now `content_hash: parts.blob_hash` reuses
|
||||
that `String`; the getter clone (one 64-byte hex `String` per row) is gone. This
|
||||
is the file-side twin of the fields `FolderDto::from` already moves, on the
|
||||
single most-travelled API path. Byte-identical: `parts.blob_hash` **is** the
|
||||
`String` the getter cloned.
|
||||
|
||||
## [E1] `CalendarEvent` timed DTSTART/DTEND — `compact_ical_utc` stack render
|
||||
|
||||
The timed branches of `update_time_range` / `update_all_day` stamped
|
||||
`format!("{}", t.format("%Y%m%dT%H%M%SZ"))`, running chrono's strftime
|
||||
interpreter (4 allocs measured). `fmt::compact_ical_utc` already renders exactly
|
||||
`YYYYMMDDTHHMMSSZ` on the stack (the ROUND19 §V2 helper), and the property
|
||||
setter takes a `&str`, so the render is passed straight through with no owned
|
||||
`String`:
|
||||
|
||||
```rust
|
||||
let start_str: &str = if self.all_day {
|
||||
start_owned = format!("{}T000000Z", start_time.format("%Y%m%d")); &start_owned
|
||||
} else if let Some(s) = fmt::compact_ical_utc(&mut sbuf, start_time.timestamp()) {
|
||||
s // 0 allocs, the common case
|
||||
} else {
|
||||
start_owned = format!("{}", start_time.format("%Y%m%dT%H%M%SZ")); &start_owned // fallback
|
||||
};
|
||||
```
|
||||
|
||||
The all-day `%Y%m%d` + literal-suffix form is unchanged (no existing
|
||||
no-separator helper covers it — see *Not shipped*). The 20 calendar_event unit
|
||||
tests (iCal round-trip, exception handling) pass unchanged.
|
||||
|
||||
## [S1] `ShareItemType::try_from` — `eq_ignore_ascii_case`
|
||||
|
||||
`match s.to_lowercase().as_str()` allocated a Unicode-lowercased `String` per
|
||||
call only to compare against `"file"`/`"folder"`. `eq_ignore_ascii_case` folds
|
||||
only ASCII A–Z — but the targets are pure ASCII, and any input whose
|
||||
`to_lowercase()` equals `"file"`/`"folder"` is by definition an ASCII case
|
||||
variant of it, so acceptance is byte-identical (the gate checks mixed-case +
|
||||
invalid inputs). 0 allocs.
|
||||
|
||||
## Not shipped — deferred to a later round
|
||||
|
||||
Surfaced by the Round-22 audit (three parallel sub-audits across the HTTP, DAV
|
||||
and application/parse layers), verified against current source, but held back —
|
||||
each needs a signature/API decision or a gate the deterministic alloc-counter
|
||||
can't provide:
|
||||
|
||||
- **`list_files_query` `Query<HashMap<String,String>>` → typed `Query<…>`**: the
|
||||
listing reads only `folder_id`, so a `struct ListFilesQuery { folder_id:
|
||||
Option<String> }` drops the `HashMap` table + the `"folder_id"` key `String`
|
||||
(~3 → 1 allocs). Byte-identical for the frontend's actual usage, but a
|
||||
**malformed** `?folder_id=a&folder_id=b` diverges (HashMap last-wins vs serde
|
||||
field-decode), so it wants its own byte-identity proof before shipping — the
|
||||
H1 half of this handler is unimpeachable and shipped alone.
|
||||
- **NextCloud `avatar` HeaderMap clone**: `handle_avatar` has two callers
|
||||
(`handle_dav_avatar` + a direct route), so the `Request` conversion is a
|
||||
dual-signature change, not the clean leaf swap the other H1 handlers were.
|
||||
Low frequency (avatars revalidate hourly).
|
||||
- **Share-management HeaderMap clones** (`create`/`update`/`verify`/… at
|
||||
`share_handler.rs:583+`): these take a `Json` body (a `FromRequest` body
|
||||
extractor), so a second `Request` extractor is impossible — they need a
|
||||
different borrow strategy. Lower frequency than the public download/access
|
||||
path shipped here.
|
||||
- **`update_all_day` / `update_time_range` all-day `%Y%m%d` stamp**: no
|
||||
no-separator date helper exists (`compact_ical_utc` is date+time,
|
||||
`compact_date` is `YYYY-MM-DD`); a `compact_date_basic` (`YYYYMMDD`) would
|
||||
close the remaining 2 all-day sites. Low heat.
|
||||
- **`ContactService::generate_vcard` BDAY** (`contact_service.rs:342`) still uses
|
||||
`birthday.format("%Y%m%d")` on the contact write path — same missing
|
||||
`%Y%m%d` helper as above; the per-contact *read* twin was already fixed
|
||||
(ROUND21 §R5). Low heat.
|
||||
- **`extract_webdav_path(req.uri())`** (`webdav_handler.rs:507`): a per-PROPFIND
|
||||
percent-decode + `String`, but byte-identity is **unproven** — the code
|
||||
comment states the `path` parameter carries a home-folder prefix that is
|
||||
wrong for WebDAV hrefs, directly contradicting ROUND21's "stale comment"
|
||||
note. Needs a dedicated href-equivalence proof, not a perf banner.
|
||||
|
||||
## Environment / methodology
|
||||
|
||||
- `cargo run --release --features bench --example bench_round22_micro` —
|
||||
counting global allocator, no Postgres. Tunable (env): `BENCH_ITERS` (200000).
|
||||
- Each section is BEFORE (verbatim replica of the shipped-before shape) vs AFTER
|
||||
(verbatim replica of the shipped-after shape, which the source is then made to
|
||||
match), 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.
|
||||
- Verified beyond the bench: `cargo clippy --features bench --all-targets -D
|
||||
warnings` clean, `cargo fmt --all --check` clean, and `cargo test --lib
|
||||
--features bench` = **529 passed / 0 failed** (incl. the OpenAPI-spec-validity
|
||||
test that guards the H1 utoipa-handler signature change).
|
||||
```
|
||||
@@ -0,0 +1,521 @@
|
||||
//! Round-22 CPU/alloc micro-pack (no Postgres).
|
||||
//!
|
||||
//! Same rule as ROUND2–21: each section is BEFORE (verbatim replica of the
|
||||
//! shipped-before shape) vs AFTER (verbatim replica of the shipped-after shape,
|
||||
//! which the source is then made to match), with a byte/-value equivalence gate
|
||||
//! and a `GATE FAIL … rollback` check that `std::process::exit(1)`s if the AFTER
|
||||
//! arm fails to beat its BEFORE — the round's roll-back rule encoded into the
|
||||
//! benchmark. An AFTER that doesn't win is never applied to the source.
|
||||
//!
|
||||
//! [H1] Hot GET handlers (`get_thumbnail`, `download_file`, `list_files`,
|
||||
//! `list_photos`, NC `preview`, public-share download/access) took the
|
||||
//! axum `HeaderMap` extractor, which does `parts.headers.clone()` — an
|
||||
//! owned clone of the whole request header table (~2 allocs) — just to
|
||||
//! read 1–3 headers. AFTER takes `req: Request` and reads `req.headers()`
|
||||
//! by borrow (the ROUND14 §A4 middleware pattern applied to the handlers).
|
||||
//!
|
||||
//! [W1] The native WebDAV `write_etag_quoted` (per file AND per folder of
|
||||
//! every `/webdav/` PROPFIND row, up to 500/page — the most-travelled
|
||||
//! DAV path) built a `"{etag}"` String then wrote it auto-escaped;
|
||||
//! `quick_xml` escapes the `"` → `"`, re-allocating an owned `Cow`,
|
||||
//! so 2 allocs/row (buffer + escape). AFTER emits the quotes as borrowed
|
||||
//! pre-escaped `"` events (the ROUND20 §C1 / ROUND21 §R4 pattern).
|
||||
//!
|
||||
//! [C1] The CalDAV `getetag` emit (per event of every calendar REPORT/multiget,
|
||||
//! per calendar of the home-set PROPFIND) still escaped a `"…"` value
|
||||
//! (reused buffer → 1 alloc/event escape; `format!` calendar sites → 2).
|
||||
//! AFTER routes all five sites through a `write_quoted_etag` helper
|
||||
//! (borrowed pre-escaped quotes) — 0 allocs.
|
||||
//!
|
||||
//! [D1] `FileDto::from` cloned `content_hash` via `file.content_hash()
|
||||
//! .to_string()` and then `into_parts()` MOVED the same `blob_hash` into
|
||||
//! `parts.blob_hash`, which was dropped unused — 1 wasted alloc on every
|
||||
//! listing row. AFTER reuses `parts.blob_hash` (0).
|
||||
//!
|
||||
//! [E1] `CalendarEvent::update_time_range` / `update_all_day` stamped timed
|
||||
//! DTSTART/DTEND via `format!("{}", t.format("%Y%m%dT%H%M%SZ"))` — chrono's
|
||||
//! strftime interpreter (~3 allocs). AFTER stack-renders via the shipped
|
||||
//! `fmt::compact_ical_utc` and passes the `&str` straight to
|
||||
//! `update_ical_property` (0 allocs), chrono fallback out of range.
|
||||
//!
|
||||
//! [S1] `ShareItemType::try_from` matched `s.to_lowercase().as_str()` — a
|
||||
//! throwaway Unicode-lowercased String — against two ASCII literals.
|
||||
//! AFTER uses `eq_ignore_ascii_case` (byte-identical acceptance, 0 allocs).
|
||||
//!
|
||||
//! Run:
|
||||
//! cargo run --release --features bench --example bench_round22_micro
|
||||
//! Tunables (env): BENCH_ITERS (200000)
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::env;
|
||||
use std::hint::black_box;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, header};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use quick_xml::Writer;
|
||||
use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
|
||||
|
||||
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!(
|
||||
"| {:<52} | {:>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);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// [H1] Hot GET handler HeaderMap extractor — axum `HeaderMap` (clones the whole
|
||||
// request header table via `parts.headers.clone()`) vs `Request` + borrow.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A realistic browser GET request header set (what a thumbnail / photo-list /
|
||||
/// download request actually carries). The `HeaderMap` extractor clones ALL of
|
||||
/// it just so the handler can read 1–3 headers (IF_NONE_MATCH / ACCEPT / RANGE).
|
||||
fn realistic_request_headers() -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(header::HOST, HeaderValue::from_static("cloud.example.com"));
|
||||
h.insert(
|
||||
header::USER_AGENT,
|
||||
HeaderValue::from_static(
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) \
|
||||
Chrome/126.0 Safari/537.36",
|
||||
),
|
||||
);
|
||||
h.insert(
|
||||
header::ACCEPT,
|
||||
HeaderValue::from_static("image/avif,image/webp,image/apng,image/*,*/*;q=0.8"),
|
||||
);
|
||||
h.insert(
|
||||
header::ACCEPT_ENCODING,
|
||||
HeaderValue::from_static("gzip, deflate, br, zstd"),
|
||||
);
|
||||
h.insert(
|
||||
header::ACCEPT_LANGUAGE,
|
||||
HeaderValue::from_static("en-US,en;q=0.9,es;q=0.8"),
|
||||
);
|
||||
h.insert(
|
||||
header::REFERER,
|
||||
HeaderValue::from_static("https://cloud.example.com/photos"),
|
||||
);
|
||||
h.insert(header::CONNECTION, HeaderValue::from_static("keep-alive"));
|
||||
h.insert(
|
||||
HeaderName::from_static("sec-fetch-dest"),
|
||||
HeaderValue::from_static("image"),
|
||||
);
|
||||
h.insert(
|
||||
HeaderName::from_static("sec-fetch-mode"),
|
||||
HeaderValue::from_static("no-cors"),
|
||||
);
|
||||
h.insert(
|
||||
HeaderName::from_static("sec-fetch-site"),
|
||||
HeaderValue::from_static("same-origin"),
|
||||
);
|
||||
h.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_static(
|
||||
"oxicloud_session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature; csrf=abc123",
|
||||
),
|
||||
);
|
||||
h.insert(
|
||||
header::IF_NONE_MATCH,
|
||||
HeaderValue::from_static("\"thumb-6b1e9f00-preview-webp\""),
|
||||
);
|
||||
h
|
||||
}
|
||||
|
||||
fn section_h1() {
|
||||
let iters: usize = env_or("BENCH_ITERS", 200_000);
|
||||
let headers = realistic_request_headers();
|
||||
|
||||
// Equivalence: both arms read the identical IF_NONE_MATCH value.
|
||||
let cloned = headers.clone();
|
||||
let borrowed = headers
|
||||
.get(header::IF_NONE_MATCH)
|
||||
.and_then(|v| v.to_str().ok());
|
||||
assert_eq!(
|
||||
cloned
|
||||
.get(header::IF_NONE_MATCH)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
borrowed,
|
||||
"H1 read value differs"
|
||||
);
|
||||
|
||||
// BEFORE: axum's `HeaderMap` extractor materializes an owned clone of the
|
||||
// whole request header table (`parts.headers.clone()`), then the handler
|
||||
// reads one header out of it.
|
||||
let before = measure(iters, || {
|
||||
let owned = black_box(&headers).clone();
|
||||
black_box(
|
||||
owned
|
||||
.get(header::IF_NONE_MATCH)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
);
|
||||
});
|
||||
|
||||
// AFTER: take `req: Request` and read `req.headers()` by borrow — the header
|
||||
// table is never cloned; the same value is read straight from the borrow.
|
||||
let after = measure(iters, || {
|
||||
let borrow: &HeaderMap = black_box(&headers);
|
||||
black_box(
|
||||
borrow
|
||||
.get(header::IF_NONE_MATCH)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
);
|
||||
});
|
||||
|
||||
println!(
|
||||
"\n## [H1] Hot GET handler HeaderMap clone (per thumbnail/photo/download/preview/share req)"
|
||||
);
|
||||
header_footer("HeaderMap::clone() vs &HeaderMap borrow", &before, &after);
|
||||
gate_allocs("H1", &before, &after);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// [W1] Native WebDAV getetag — sized String + escape vs borrowed pre-escaped.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// BEFORE (verbatim `webdav_adapter::write_etag_quoted`): sized `"{etag}"`
|
||||
/// String then auto-escaped write — `quick_xml` escapes the `"` → `"`,
|
||||
/// re-allocating an owned `Cow`. 2 allocs/row (buffer + escape).
|
||||
fn w1_before(buf: &mut Vec<u8>, etag: &str) {
|
||||
let mut w = Writer::new(&mut *buf);
|
||||
w.write_event(Event::Start(BytesStart::new("D:getetag")))
|
||||
.unwrap();
|
||||
let mut quoted = String::with_capacity(etag.len() + 2);
|
||||
quoted.push('"');
|
||||
quoted.push_str(etag);
|
||||
quoted.push('"');
|
||||
w.write_event(Event::Text(BytesText::new("ed))).unwrap();
|
||||
w.write_event(Event::End(BytesEnd::new("D:getetag")))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// AFTER: borrowed pre-escaped `"` quotes around the escaped body.
|
||||
fn w1_after(buf: &mut Vec<u8>, etag: &str) {
|
||||
let mut w = Writer::new(&mut *buf);
|
||||
w.write_event(Event::Start(BytesStart::new("D:getetag")))
|
||||
.unwrap();
|
||||
w.write_event(Event::Text(BytesText::from_escaped(""")))
|
||||
.unwrap();
|
||||
w.write_event(Event::Text(BytesText::new(etag))).unwrap();
|
||||
w.write_event(Event::Text(BytesText::from_escaped(""")))
|
||||
.unwrap();
|
||||
w.write_event(Event::End(BytesEnd::new("D:getetag")))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn section_w1() {
|
||||
let iters: usize = env_or("BENCH_ITERS", 200_000);
|
||||
let etag = "d41d8cd98f00b204e9800998ecf8427e-1719792000"; // realistic file etag
|
||||
|
||||
// Equivalence: byte-identical output, incl. an etag with XML-special chars.
|
||||
let (mut b1, mut b2) = (Vec::new(), Vec::new());
|
||||
w1_before(&mut b1, etag);
|
||||
w1_after(&mut b2, etag);
|
||||
assert_eq!(b1, b2, "W1 emitted bytes differ (hex etag)");
|
||||
let (mut s1, mut s2) = (Vec::new(), Vec::new());
|
||||
w1_before(&mut s1, "a&b<c\"d-42");
|
||||
w1_after(&mut s2, "a&b<c\"d-42");
|
||||
assert_eq!(s1, s2, "W1 emitted bytes differ (special chars)");
|
||||
|
||||
let mut buf = Vec::with_capacity(96);
|
||||
let before = measure(iters, || {
|
||||
buf.clear();
|
||||
w1_before(black_box(&mut buf), black_box(etag));
|
||||
});
|
||||
let after = measure(iters, || {
|
||||
buf.clear();
|
||||
w1_after(black_box(&mut buf), black_box(etag));
|
||||
});
|
||||
|
||||
println!("\n## [W1] Native WebDAV getetag (per file+folder PROPFIND row, up to 500/page)");
|
||||
header_footer("sized String + escape vs borrowed events", &before, &after);
|
||||
gate_allocs("W1", &before, &after);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// [C1] CalDAV getetag — reused-buffer escape vs borrowed pre-escaped.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// BEFORE (verbatim `caldav_adapter` event sites): reused buffer holds `"{id}"`,
|
||||
/// written auto-escaped — the buffer is amortized, so the only per-event alloc
|
||||
/// is the escape of the two `"` → owned `Cow`. 1 alloc/event.
|
||||
fn c1_before(buf: &mut Vec<u8>, etag_buf: &mut String, id: &str) {
|
||||
let mut w = Writer::new(&mut *buf);
|
||||
w.write_event(Event::Start(BytesStart::new("D:getetag")))
|
||||
.unwrap();
|
||||
etag_buf.clear();
|
||||
etag_buf.push('"');
|
||||
etag_buf.push_str(id);
|
||||
etag_buf.push('"');
|
||||
w.write_event(Event::Text(BytesText::new(etag_buf.as_str())))
|
||||
.unwrap();
|
||||
w.write_event(Event::End(BytesEnd::new("D:getetag")))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// AFTER (`write_quoted_etag`): borrowed pre-escaped quotes; the UUID body is a
|
||||
/// borrow (no XML-special chars). 0 allocs/event.
|
||||
fn c1_after(buf: &mut Vec<u8>, id: &str) {
|
||||
let mut w = Writer::new(&mut *buf);
|
||||
w.write_event(Event::Start(BytesStart::new("D:getetag")))
|
||||
.unwrap();
|
||||
w.write_event(Event::Text(BytesText::from_escaped(""")))
|
||||
.unwrap();
|
||||
w.write_event(Event::Text(BytesText::new(id))).unwrap();
|
||||
w.write_event(Event::Text(BytesText::from_escaped(""")))
|
||||
.unwrap();
|
||||
w.write_event(Event::End(BytesEnd::new("D:getetag")))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn section_c1() {
|
||||
let iters: usize = env_or("BENCH_ITERS", 200_000);
|
||||
let id = "6b1e9f00-4c2a-4f1e-9b7a-2d5e8c1f0a3b"; // calendar / event UUID
|
||||
|
||||
// Equivalence: byte-identical output for the UUID body.
|
||||
let (mut b1, mut eb, mut b2) = (Vec::new(), String::new(), Vec::new());
|
||||
c1_before(&mut b1, &mut eb, id);
|
||||
c1_after(&mut b2, id);
|
||||
assert_eq!(b1, b2, "C1 emitted bytes differ");
|
||||
|
||||
let mut buf = Vec::with_capacity(96);
|
||||
let mut etag_buf = String::with_capacity(40);
|
||||
let before = measure(iters, || {
|
||||
buf.clear();
|
||||
c1_before(black_box(&mut buf), black_box(&mut etag_buf), black_box(id));
|
||||
});
|
||||
let after = measure(iters, || {
|
||||
buf.clear();
|
||||
c1_after(black_box(&mut buf), black_box(id));
|
||||
});
|
||||
|
||||
println!("\n## [C1] CalDAV getetag (per event of every REPORT/multiget, per calendar)");
|
||||
header_footer("reused-buffer escape vs borrowed events", &before, &after);
|
||||
gate_allocs("C1", &before, &after);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// [D1] FileDto::from content_hash — getter clone (moved parts.blob_hash dropped)
|
||||
// vs reuse the moved String.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The 64-char BLAKE3-hex String a `File` owns in `blob_hash` (allocated in both
|
||||
/// arms — the baseline; the delta is exactly the `content_hash` clone).
|
||||
fn make_hash() -> String {
|
||||
"d41d8cd98f00b204e9800998ecf8427ed41d8cd98f00b204e9800998ecf8427e".to_string()
|
||||
}
|
||||
|
||||
/// BEFORE: `content_hash = file.content_hash().to_string()` clones the hash,
|
||||
/// then `into_parts()` moves the *same* `blob_hash` into `parts.blob_hash`,
|
||||
/// which is dropped unused in the `Self { … }` ctor.
|
||||
fn d1_before(owned_hash: String) -> String {
|
||||
let content_hash = owned_hash.clone(); // File::content_hash().to_string()
|
||||
let parts_blob_hash = owned_hash; // into_parts() moves blob_hash
|
||||
let _ = parts_blob_hash; // dropped unused in the Self{} ctor
|
||||
content_hash
|
||||
}
|
||||
|
||||
/// AFTER: `content_hash: parts.blob_hash` — reuse the moved String, no clone.
|
||||
fn d1_after(owned_hash: String) -> String {
|
||||
owned_hash
|
||||
}
|
||||
|
||||
fn section_d1() {
|
||||
let iters: usize = env_or("BENCH_ITERS", 200_000);
|
||||
|
||||
// Equivalence: identical content_hash string.
|
||||
assert_eq!(
|
||||
d1_before(make_hash()),
|
||||
d1_after(make_hash()),
|
||||
"D1 content_hash differs"
|
||||
);
|
||||
|
||||
let before = measure(iters, || {
|
||||
black_box(d1_before(black_box(make_hash())));
|
||||
});
|
||||
let after = measure(iters, || {
|
||||
black_box(d1_after(black_box(make_hash())));
|
||||
});
|
||||
|
||||
println!("\n## [D1] FileDto::from content_hash (per file row of every listing)");
|
||||
header_footer("getter clone + drop moved vs reuse moved", &before, &after);
|
||||
gate_allocs("D1", &before, &after);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// [E1] CalendarEvent timed DTSTART/DTEND — chrono strftime vs compact_ical_utc.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// BEFORE: `format!("{}", t.format("%Y%m%dT%H%M%SZ"))` — chrono's strftime
|
||||
/// interpreter builds a `DelayedFormat` and formats six fields through
|
||||
/// `core::fmt`, heap-allocating.
|
||||
fn e1_before(dt: chrono::DateTime<Utc>) -> String {
|
||||
format!("{}", dt.format("%Y%m%dT%H%M%SZ"))
|
||||
}
|
||||
|
||||
fn section_e1() {
|
||||
let iters: usize = env_or("BENCH_ITERS", 200_000);
|
||||
let secs: i64 = 1_752_753_434; // 2025-07-17T11:57:14Z
|
||||
let dt = Utc.timestamp_opt(secs, 0).unwrap();
|
||||
|
||||
// Equivalence: the stack render equals the chrono strftime output.
|
||||
let mut ebuf = [0u8; 16];
|
||||
let after_str = oxicloud::common::fmt::compact_ical_utc(&mut ebuf, secs).expect("in range");
|
||||
assert_eq!(e1_before(dt), after_str, "E1 stamp differs");
|
||||
|
||||
let before = measure(iters, || {
|
||||
black_box(e1_before(black_box(dt)));
|
||||
});
|
||||
// AFTER: stack render via the shipped helper; the `&str` is passed straight
|
||||
// to `update_ical_property` in the source — 0 allocs.
|
||||
let after = measure(iters, || {
|
||||
let mut buf = [0u8; 16];
|
||||
black_box(oxicloud::common::fmt::compact_ical_utc(
|
||||
&mut buf,
|
||||
black_box(secs),
|
||||
));
|
||||
});
|
||||
|
||||
println!("\n## [E1] CalendarEvent timed DTSTART/DTEND (per event-edit PUT)");
|
||||
header_footer("chrono %Y%m%dT%H%M%SZ vs compact_ical_utc", &before, &after);
|
||||
gate_allocs("E1", &before, &after);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// [S1] ShareItemType::try_from — to_lowercase() String vs eq_ignore_ascii_case.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// BEFORE: `s.to_lowercase().as_str()` — a throwaway Unicode-lowercased String
|
||||
/// (always allocates) — matched against two ASCII literals.
|
||||
fn s1_before(s: &str) -> u8 {
|
||||
match s.to_lowercase().as_str() {
|
||||
"file" => 0,
|
||||
"folder" => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// AFTER: `eq_ignore_ascii_case` — allocation-free, byte-identical acceptance
|
||||
/// for the ASCII targets.
|
||||
fn s1_after(s: &str) -> u8 {
|
||||
if s.eq_ignore_ascii_case("file") {
|
||||
0
|
||||
} else if s.eq_ignore_ascii_case("folder") {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn section_s1() {
|
||||
let iters: usize = env_or("BENCH_ITERS", 200_000);
|
||||
|
||||
// Equivalence across mixed case + invalid input.
|
||||
for s in ["file", "File", "FOLDER", "folder", "Folder", "bogus", ""] {
|
||||
assert_eq!(s1_before(s), s1_after(s), "S1 verdict differs for {s:?}");
|
||||
}
|
||||
|
||||
let sample = "Folder"; // mixed-case → to_lowercase allocates
|
||||
let before = measure(iters, || {
|
||||
black_box(s1_before(black_box(sample)));
|
||||
});
|
||||
let after = measure(iters, || {
|
||||
black_box(s1_after(black_box(sample)));
|
||||
});
|
||||
|
||||
println!("\n## [S1] ShareItemType::try_from (per share item-type parse)");
|
||||
header_footer(
|
||||
"to_lowercase() String vs eq_ignore_ascii_case",
|
||||
&before,
|
||||
&after,
|
||||
);
|
||||
gate_allocs("S1", &before, &after);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("# Round-22 micro-pack — BEFORE/AFTER (counting allocator, release)");
|
||||
println!("# allocs/op is the deterministic gate; a non-winning AFTER exits 1 (rollback).");
|
||||
section_h1();
|
||||
section_w1();
|
||||
section_c1();
|
||||
section_d1();
|
||||
section_e1();
|
||||
section_s1();
|
||||
println!("\nAll Round-22 sections passed their allocation gate.");
|
||||
}
|
||||
@@ -17,6 +17,23 @@ use crate::application::adapters::webdav_adapter::{
|
||||
};
|
||||
use crate::application::dtos::calendar_dto::{CalendarDto, CalendarEventDto};
|
||||
|
||||
/// Emit a WebDAV `getetag` body as `"…"` with the surrounding quotes written as
|
||||
/// borrowed pre-escaped `"` text events around the escaped etag body.
|
||||
///
|
||||
/// Byte-identical to escaping a `"{etag}"` String — `quick_xml`'s
|
||||
/// `BytesText::new` escapes a literal `"` → `"`, re-allocating an owned
|
||||
/// `Cow` — but with 0 heap allocs (the NextCloud ROUND20 §C1 / CardDAV
|
||||
/// ROUND21 §R4 pattern, applied to the CalDAV emitter it missed). The caller
|
||||
/// writes the surrounding `<D:getetag>…</D:getetag>` tags. Every `etag` body
|
||||
/// here is a bare `Uuid` (`calendar.id` / `anchor.id`), so the escaped body is
|
||||
/// itself a borrow — 0 allocs/row.
|
||||
fn write_quoted_etag<W: Write>(xml_writer: &mut Writer<W>, etag: &str) -> Result<()> {
|
||||
xml_writer.write_event(Event::Text(BytesText::from_escaped(""")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(etag)))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::from_escaped(""")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a CalDAV `time-range` element's `start` / `end` attribute
|
||||
/// value into a UTC `DateTime`.
|
||||
///
|
||||
@@ -794,9 +811,9 @@ impl CalDavAdapter {
|
||||
Self::write_lastmodified_text(xml_writer, calendar.updated_at)?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
|
||||
// ETag
|
||||
// ETag (borrowed pre-escaped quotes, §C1/§R4 — was format! + escape)
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", calendar.id))))?;
|
||||
write_quoted_etag(xml_writer, &calendar.id)?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
|
||||
// Content type for calendar collection
|
||||
@@ -924,10 +941,7 @@ impl CalDavAdapter {
|
||||
}
|
||||
("DAV:", "getetag") => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"\"{}\"",
|
||||
calendar.id
|
||||
))))?;
|
||||
write_quoted_etag(xml_writer, &calendar.id)?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
}
|
||||
("DAV:", "getcontenttype") => {
|
||||
@@ -1117,11 +1131,11 @@ impl CalDavAdapter {
|
||||
events: &[CalendarEventDto],
|
||||
base_href: &str,
|
||||
) -> Result<()> {
|
||||
// Reused per-event buffers (cleared each iteration) so a whole PROPFIND
|
||||
// page allocates the href/etag storage once instead of twice per event
|
||||
// (benches/ROUND14.md §A6).
|
||||
// Reused per-event href buffer (cleared each iteration) so a whole
|
||||
// PROPFIND page allocates the href storage once instead of per event
|
||||
// (benches/ROUND14.md §A6). The etag no longer needs a buffer — it is
|
||||
// emitted via `write_quoted_etag` (borrowed pre-escaped quotes).
|
||||
let mut event_href = String::with_capacity(base_href.len() + 48);
|
||||
let mut etag = String::new();
|
||||
for bundle in group_events_by_uid(events) {
|
||||
// The master (sorted first by group_events_by_uid)
|
||||
// supplies the ETag anchor + getlastmodified. If
|
||||
@@ -1148,11 +1162,9 @@ impl CalDavAdapter {
|
||||
// resourcetype (empty for non-collection)
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
||||
|
||||
// getetag — anchor row's id (reused buffer, benches/ROUND14.md §A6)
|
||||
// getetag — anchor row's id (borrowed pre-escaped quotes, §C1/§R4)
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
etag.clear();
|
||||
let _ = std::fmt::Write::write_fmt(&mut etag, format_args!("\"{}\"", anchor.id));
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&etag)))?;
|
||||
write_quoted_etag(xml_writer, &anchor.id)?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
|
||||
// getcontenttype
|
||||
@@ -1215,10 +1227,10 @@ impl CalDavAdapter {
|
||||
CalDavReportType::CalendarMultiget { props, .. } => props,
|
||||
CalDavReportType::SyncCollection { props, .. } => props,
|
||||
};
|
||||
// Reused per-event href + etag buffers for the whole REPORT page
|
||||
// (benches/ROUND14.md §A6).
|
||||
// Reused per-event href buffer for the whole REPORT page
|
||||
// (benches/ROUND14.md §A6). The etag is emitted via `write_quoted_etag`
|
||||
// (borrowed pre-escaped quotes) and no longer needs a buffer.
|
||||
let mut href = String::with_capacity(base_href.len() + 48);
|
||||
let mut etag = String::new();
|
||||
for bundle in group_events_by_uid(events) {
|
||||
let anchor = match bundle.first() {
|
||||
Some(e) => *e,
|
||||
@@ -1229,7 +1241,7 @@ impl CalDavAdapter {
|
||||
&mut href,
|
||||
format_args!("{}{}.ics", base_href, anchor.ical_uid),
|
||||
);
|
||||
Self::write_event_response(xml_writer, &bundle, props, &href, &mut etag)?;
|
||||
Self::write_event_response(xml_writer, &bundle, props, &href)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1266,7 +1278,6 @@ impl CalDavAdapter {
|
||||
bundle: &[&CalendarEventDto],
|
||||
props: &[QualifiedName],
|
||||
href: &str,
|
||||
etag: &mut String,
|
||||
) -> Result<()> {
|
||||
let anchor = bundle
|
||||
.first()
|
||||
@@ -1289,10 +1300,10 @@ impl CalDavAdapter {
|
||||
|
||||
// If no specific props requested, return all common ones
|
||||
if props.is_empty() {
|
||||
Self::write_event_standard_props(xml_writer, anchor, bundle, etag)?;
|
||||
Self::write_event_standard_props(xml_writer, anchor, bundle)?;
|
||||
} else {
|
||||
// Write specifically requested properties
|
||||
Self::write_event_requested_props(xml_writer, anchor, bundle, props, etag)?;
|
||||
Self::write_event_requested_props(xml_writer, anchor, bundle, props)?;
|
||||
}
|
||||
|
||||
// End prop
|
||||
@@ -1320,22 +1331,17 @@ impl CalDavAdapter {
|
||||
xml_writer: &mut Writer<W>,
|
||||
anchor: &CalendarEventDto,
|
||||
bundle: &[&CalendarEventDto],
|
||||
etag: &mut String,
|
||||
) -> Result<()> {
|
||||
// Common WebDAV properties
|
||||
|
||||
// Resource type (empty for non-collection)
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
||||
|
||||
// ETag anchored on the master (or first exception in
|
||||
// a master-less bundle — pathological state today).
|
||||
// Reused buffer (benches/ROUND14.md §A6).
|
||||
// ETag anchored on the master (or first exception in a master-less
|
||||
// bundle — pathological state today). Borrowed pre-escaped quotes
|
||||
// (§C1/§R4), 0 allocs/event.
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
etag.clear();
|
||||
etag.push('"');
|
||||
etag.push_str(&anchor.id);
|
||||
etag.push('"');
|
||||
xml_writer.write_event(Event::Text(BytesText::new(etag.as_str())))?;
|
||||
write_quoted_etag(xml_writer, &anchor.id)?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
|
||||
// Content type
|
||||
@@ -1370,7 +1376,6 @@ impl CalDavAdapter {
|
||||
anchor: &CalendarEventDto,
|
||||
bundle: &[&CalendarEventDto],
|
||||
props: &[QualifiedName],
|
||||
etag: &mut String,
|
||||
) -> Result<()> {
|
||||
for prop in props {
|
||||
match (prop.namespace.as_str(), prop.name.as_str()) {
|
||||
@@ -1380,12 +1385,7 @@ impl CalDavAdapter {
|
||||
}
|
||||
("DAV:", "getetag") => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
// Reused buffer (benches/ROUND14.md §A6).
|
||||
etag.clear();
|
||||
etag.push('"');
|
||||
etag.push_str(&anchor.id);
|
||||
etag.push('"');
|
||||
xml_writer.write_event(Event::Text(BytesText::new(etag.as_str())))?;
|
||||
write_quoted_etag(xml_writer, &anchor.id)?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
}
|
||||
("DAV:", "getcontenttype") => {
|
||||
|
||||
@@ -809,12 +809,17 @@ impl WebDavAdapter {
|
||||
|
||||
fn write_etag_quoted<W: Write>(xml_writer: &mut Writer<W>, etag: &str) -> Result<()> {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
// One exactly-sized allocation instead of format!'s grow-from-empty.
|
||||
let mut quoted = String::with_capacity(etag.len() + 2);
|
||||
quoted.push('"');
|
||||
quoted.push_str(etag);
|
||||
quoted.push('"');
|
||||
xml_writer.write_event(Event::Text(BytesText::new("ed)))?;
|
||||
// Borrowed pre-escaped quotes (the ROUND20 §C1 NextCloud / ROUND21 §R4
|
||||
// CardDAV pattern the native WebDAV adapter never got): `BytesText::new`
|
||||
// escapes a literal `"` → `"`, re-allocating an owned `Cow`, so the
|
||||
// old `"{etag}"` String paid TWO allocs/row (the sized buffer + the
|
||||
// escape). Emit the two quotes as borrowed pre-escaped `"` text
|
||||
// events around the escaped body — byte-identical output, 0 allocs/row
|
||||
// on the hottest native-WebDAV PROPFIND path (per file AND per folder,
|
||||
// up to PROPFIND_BATCH_SIZE=500 rows/page).
|
||||
xml_writer.write_event(Event::Text(BytesText::from_escaped(""")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(etag)))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::from_escaped(""")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -93,10 +93,15 @@ impl From<File> for FileDto {
|
||||
// already-extracted parts. `content_hash` is just the raw
|
||||
// blob hash; `etag` is the cache token derived from it.
|
||||
let etag = file.etag();
|
||||
let content_hash = file.content_hash().to_string();
|
||||
|
||||
// Consume the entity by moving all fields — zero heap allocations
|
||||
// for id, name, path, folder_id (previously 4× .to_string()).
|
||||
// for id, name, path, folder_id (previously 4× .to_string()), and now
|
||||
// for `content_hash` too: `into_parts()` moves `blob_hash` out, so it is
|
||||
// reused verbatim below instead of cloning it through the
|
||||
// `content_hash()` getter. The moved `parts.blob_hash` was previously
|
||||
// dropped unused while the getter clone paid 1 alloc/row on every file
|
||||
// listing (folder browse, streaming PROPFIND, search/favorites/recent
|
||||
// hydration). `etag` is still computed first from the live entity.
|
||||
let parts = file.into_parts();
|
||||
|
||||
// Display fields come from closed static tables and MIME values
|
||||
@@ -123,7 +128,7 @@ impl From<File> for FileDto {
|
||||
category,
|
||||
size_formatted,
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
content_hash: parts.blob_hash,
|
||||
etag,
|
||||
created_by: parts.created_by,
|
||||
updated_by: parts.updated_by,
|
||||
|
||||
@@ -575,21 +575,38 @@ impl CalendarEvent {
|
||||
self.end_time = end_time;
|
||||
self.updated_at = Utc::now();
|
||||
|
||||
// Update iCalendar data
|
||||
let start_str = if self.all_day {
|
||||
format!("{}T000000Z", start_time.format("%Y%m%d"))
|
||||
// Update iCalendar data. Timed events stack-render the compact UTC
|
||||
// stamp via `fmt::compact_ical_utc` (the ROUND19 §V2 pattern: drops
|
||||
// chrono's `%Y%m%dT%H%M%SZ` strftime interpreter — ~3 → 0 allocs each),
|
||||
// with chrono kept as the out-of-range fallback. All-day keeps its
|
||||
// `%Y%m%d` + literal-suffix form. Byte-identical output either way.
|
||||
let (mut sbuf, mut ebuf) = ([0u8; 16], [0u8; 16]);
|
||||
let (start_owned, end_owned);
|
||||
let start_str: &str = if self.all_day {
|
||||
start_owned = format!("{}T000000Z", start_time.format("%Y%m%d"));
|
||||
&start_owned
|
||||
} else if let Some(s) =
|
||||
crate::common::fmt::compact_ical_utc(&mut sbuf, start_time.timestamp())
|
||||
{
|
||||
s
|
||||
} else {
|
||||
format!("{}", start_time.format("%Y%m%dT%H%M%SZ"))
|
||||
start_owned = format!("{}", start_time.format("%Y%m%dT%H%M%SZ"));
|
||||
&start_owned
|
||||
};
|
||||
let end_str: &str = if self.all_day {
|
||||
end_owned = format!("{}T000000Z", end_time.format("%Y%m%d"));
|
||||
&end_owned
|
||||
} else if let Some(e) =
|
||||
crate::common::fmt::compact_ical_utc(&mut ebuf, end_time.timestamp())
|
||||
{
|
||||
e
|
||||
} else {
|
||||
end_owned = format!("{}", end_time.format("%Y%m%dT%H%M%SZ"));
|
||||
&end_owned
|
||||
};
|
||||
|
||||
let end_str = if self.all_day {
|
||||
format!("{}T000000Z", end_time.format("%Y%m%d"))
|
||||
} else {
|
||||
format!("{}", end_time.format("%Y%m%dT%H%M%SZ"))
|
||||
};
|
||||
|
||||
self.update_ical_property("DTSTART", &start_str);
|
||||
self.update_ical_property("DTEND", &end_str);
|
||||
self.update_ical_property("DTSTART", start_str);
|
||||
self.update_ical_property("DTEND", end_str);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -603,21 +620,37 @@ impl CalendarEvent {
|
||||
self.all_day = all_day;
|
||||
self.updated_at = Utc::now();
|
||||
|
||||
// Update iCalendar data
|
||||
let start_str = if all_day {
|
||||
format!("VALUE=DATE:{}", self.start_time.format("%Y%m%d"))
|
||||
// Update iCalendar data. Timed events stack-render the compact UTC
|
||||
// stamp via `fmt::compact_ical_utc` (drops chrono's `%Y%m%dT%H%M%SZ`
|
||||
// strftime interpreter — ~3 → 0 allocs each), chrono fallback out of
|
||||
// range. All-day keeps its `VALUE=DATE:` + `%Y%m%d` form. Byte-identical.
|
||||
let (mut sbuf, mut ebuf) = ([0u8; 16], [0u8; 16]);
|
||||
let (start_owned, end_owned);
|
||||
let start_str: &str = if all_day {
|
||||
start_owned = format!("VALUE=DATE:{}", self.start_time.format("%Y%m%d"));
|
||||
&start_owned
|
||||
} else if let Some(s) =
|
||||
crate::common::fmt::compact_ical_utc(&mut sbuf, self.start_time.timestamp())
|
||||
{
|
||||
s
|
||||
} else {
|
||||
format!("{}", self.start_time.format("%Y%m%dT%H%M%SZ"))
|
||||
start_owned = format!("{}", self.start_time.format("%Y%m%dT%H%M%SZ"));
|
||||
&start_owned
|
||||
};
|
||||
let end_str: &str = if all_day {
|
||||
end_owned = format!("VALUE=DATE:{}", self.end_time.format("%Y%m%d"));
|
||||
&end_owned
|
||||
} else if let Some(e) =
|
||||
crate::common::fmt::compact_ical_utc(&mut ebuf, self.end_time.timestamp())
|
||||
{
|
||||
e
|
||||
} else {
|
||||
end_owned = format!("{}", self.end_time.format("%Y%m%dT%H%M%SZ"));
|
||||
&end_owned
|
||||
};
|
||||
|
||||
let end_str = if all_day {
|
||||
format!("VALUE=DATE:{}", self.end_time.format("%Y%m%d"))
|
||||
} else {
|
||||
format!("{}", self.end_time.format("%Y%m%dT%H%M%SZ"))
|
||||
};
|
||||
|
||||
self.update_ical_property("DTSTART", &start_str);
|
||||
self.update_ical_property("DTEND", &end_str);
|
||||
self.update_ical_property("DTSTART", start_str);
|
||||
self.update_ical_property("DTEND", end_str);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -180,13 +180,18 @@ impl TryFrom<&str> for ShareItemType {
|
||||
type Error = ShareError;
|
||||
|
||||
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"file" => Ok(ShareItemType::File),
|
||||
"folder" => Ok(ShareItemType::Folder),
|
||||
_ => Err(ShareError::ValidationError(format!(
|
||||
// ASCII case-insensitive compare against the two literals instead of a
|
||||
// throwaway Unicode `to_lowercase()` String — byte-identical acceptance
|
||||
// for the ASCII targets "file"/"folder" (1 → 0 allocs/call).
|
||||
if s.eq_ignore_ascii_case("file") {
|
||||
Ok(ShareItemType::File)
|
||||
} else if s.eq_ignore_ascii_case("folder") {
|
||||
Ok(ShareItemType::Folder)
|
||||
} else {
|
||||
Err(ShareError::ValidationError(format!(
|
||||
"Invalid item type: {}",
|
||||
s
|
||||
))),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,9 +361,9 @@ impl FileHandler {
|
||||
pub(super) async fn get_thumbnail_impl(
|
||||
State(state): State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
headers: HeaderMap,
|
||||
headers: &HeaderMap,
|
||||
Path((id, size)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
) -> impl IntoResponse + use<> {
|
||||
use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailSize};
|
||||
|
||||
// check first that user can access this resource
|
||||
@@ -665,8 +665,8 @@ impl FileHandler {
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
headers: &HeaderMap,
|
||||
) -> impl IntoResponse + use<> {
|
||||
let retrieval = &state.applications.file_retrieval_service;
|
||||
|
||||
// ── Get file metadata (ownership-scoped) ────────────────────────
|
||||
@@ -705,7 +705,7 @@ impl FileHandler {
|
||||
let etag = format!("\"{}\"", file_dto.etag);
|
||||
|
||||
// ── ETag (304 Not Modified) ──────────────────────────────────
|
||||
if let Some(resp) = not_modified_response(&headers, &etag) {
|
||||
if let Some(resp) = not_modified_response(headers, &etag) {
|
||||
return resp.into_response();
|
||||
}
|
||||
|
||||
@@ -830,9 +830,9 @@ impl FileHandler {
|
||||
pub(super) async fn list_files_query_impl(
|
||||
State(state): State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
headers: HeaderMap,
|
||||
headers: &HeaderMap,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
) -> impl IntoResponse + use<> {
|
||||
let folder_id = params.get("folder_id").map(|id| id.as_str());
|
||||
tracing::info!("API: Listing files with folder_id: {:?}", folder_id);
|
||||
|
||||
@@ -1217,10 +1217,14 @@ pub(super) fn build_content_disposition(name: &str, mime: &str, force_inline: bo
|
||||
pub async fn list_files_query(
|
||||
state: State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
headers: HeaderMap,
|
||||
query: Query<HashMap<String, String>>,
|
||||
req: axum::extract::Request,
|
||||
) -> impl IntoResponse {
|
||||
FileHandler::list_files_query_impl(state, auth_user, headers, query).await
|
||||
// Read headers by borrow (`req.headers()`) instead of the `HeaderMap`
|
||||
// extractor, which clones the whole request header table (~2 allocs) just to
|
||||
// read one If-None-Match — the ROUND14 §A4 middleware pattern applied to the
|
||||
// hot listing handler (benches/ROUND22.md §H1).
|
||||
FileHandler::list_files_query_impl(state, auth_user, req.headers(), query).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -1299,9 +1303,12 @@ pub async fn download_file(
|
||||
auth_user: AuthUser,
|
||||
path: Path<String>,
|
||||
query: Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
req: axum::extract::Request,
|
||||
) -> impl IntoResponse {
|
||||
FileHandler::download_file_impl(state, auth_user, path, query, headers).await
|
||||
// Borrow the headers (`req.headers()`) instead of the `HeaderMap` extractor's
|
||||
// full clone — every download AND every media Range seek hit this path
|
||||
// (benches/ROUND22.md §H1).
|
||||
FileHandler::download_file_impl(state, auth_user, path, query, req.headers()).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -1323,10 +1330,13 @@ pub async fn download_file(
|
||||
pub async fn get_thumbnail(
|
||||
state: State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
headers: HeaderMap,
|
||||
path: Path<(String, String)>,
|
||||
req: axum::extract::Request,
|
||||
) -> impl IntoResponse {
|
||||
FileHandler::get_thumbnail_impl(state, auth_user, headers, path).await
|
||||
// Borrow the headers (`req.headers()`) instead of the `HeaderMap` extractor's
|
||||
// full clone — thumbnails are the highest-frequency GET (one per grid tile),
|
||||
// and this handler reads only Accept + If-None-Match (benches/ROUND22.md §H1).
|
||||
FileHandler::get_thumbnail_impl(state, auth_user, req.headers(), path).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
||||
@@ -2,7 +2,7 @@ use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, Response, StatusCode, header},
|
||||
http::{Response, StatusCode, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -60,9 +60,12 @@ struct PhotoDto {
|
||||
pub async fn list_photos(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<PhotosQueryParams>,
|
||||
req: axum::extract::Request,
|
||||
) -> impl IntoResponse {
|
||||
// Borrow headers (`req.headers()`) instead of cloning the whole request
|
||||
// header table via the `HeaderMap` extractor to read one If-None-Match — the
|
||||
// gallery open + every pagination page hit this (benches/ROUND22.md §H1).
|
||||
let caller_id = auth_user.id;
|
||||
let limit = params.limit.unwrap_or(200).clamp(1, 500);
|
||||
|
||||
@@ -88,7 +91,7 @@ pub async fn list_photos(
|
||||
std::hash::Hash::hash(&count, &mut hasher);
|
||||
let etag = format!("\"{:x}\"", std::hash::Hasher::finish(&hasher));
|
||||
|
||||
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
|
||||
if let Some(inm) = req.headers().get(header::IF_NONE_MATCH)
|
||||
&& let Ok(client_etag) = inm.to_str()
|
||||
&& client_etag == etag
|
||||
{
|
||||
|
||||
@@ -230,10 +230,12 @@ pub async fn delete_shared_link(
|
||||
pub async fn access_shared_item(
|
||||
State(share_use_case): State<Arc<ShareService>>,
|
||||
Path(token): Path<String>,
|
||||
headers: HeaderMap,
|
||||
req: axum::extract::Request,
|
||||
) -> impl IntoResponse {
|
||||
// Honour an unlock cookie if one was issued by a prior `/verify` call.
|
||||
let unlock_jwt = unlock_jwt_from_headers(&headers, &token);
|
||||
// Borrow the headers (`req.headers()`) instead of the `HeaderMap` extractor's
|
||||
// full clone to read the unlock cookie (benches/ROUND22.md §H1).
|
||||
let unlock_jwt = unlock_jwt_from_headers(req.headers(), &token);
|
||||
|
||||
// The access-count increment doesn't gate the fetch — run both
|
||||
// round-trips concurrently instead of serially (one RTT saved on
|
||||
@@ -333,8 +335,11 @@ pub async fn verify_shared_item_password(
|
||||
pub async fn download_shared_file(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(token): Path<String>,
|
||||
headers: HeaderMap,
|
||||
req: axum::extract::Request,
|
||||
) -> impl IntoResponse {
|
||||
// Borrow the headers (`req.headers()`) instead of the `HeaderMap` extractor's
|
||||
// full clone — the public-share download + Range path (benches/ROUND22.md §H1).
|
||||
let headers = req.headers();
|
||||
// 1. Resolve share service
|
||||
let share_service = match &state.share_service {
|
||||
Some(s) => s.clone(),
|
||||
@@ -349,7 +354,7 @@ pub async fn download_shared_file(
|
||||
};
|
||||
|
||||
// 2. Validate the share token (handles expiry + password checks)
|
||||
let unlock_jwt = unlock_jwt_from_headers(&headers, &token);
|
||||
let unlock_jwt = unlock_jwt_from_headers(headers, &token);
|
||||
let share_dto = match share_service
|
||||
.get_shared_link_with_unlock(&token, unlock_jwt.as_deref())
|
||||
.await
|
||||
@@ -385,7 +390,7 @@ pub async fn download_shared_file(
|
||||
&state,
|
||||
&share_dto.item_id,
|
||||
share_dto.item_name.as_deref(),
|
||||
&headers,
|
||||
headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -39,7 +39,7 @@ pub async fn handle_preview(
|
||||
State(state): State<Arc<AppState>>,
|
||||
user: AuthUser,
|
||||
Query(params): Query<PreviewParams>,
|
||||
headers: HeaderMap,
|
||||
req: axum::extract::Request,
|
||||
) -> impl IntoResponse {
|
||||
// Parse the Nextcloud file ID — the NC app may append an instance suffix
|
||||
// (e.g. "00000326ocnca"), so strip non-digit characters first.
|
||||
@@ -155,7 +155,7 @@ pub async fn handle_preview(
|
||||
e.push('"');
|
||||
e
|
||||
};
|
||||
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
|
||||
if let Some(inm) = req.headers().get(header::IF_NONE_MATCH)
|
||||
&& let Ok(client_etag) = inm.to_str()
|
||||
&& (client_etag == etag || client_etag == "*")
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user