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:
Claude
2026-07-19 22:29:48 +00:00
parent dc0c53ea0f
commit 9754aecfa9
13 changed files with 1240 additions and 70 deletions
+7 -3
View File
@@ -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());
}
+12 -4
View File
@@ -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)