perf: round 14 — faces narrow projection, auth per-request allocs, CalDAV emit buffers, frontend set churn

Benchmark-gated (benches/ROUND14.md); every change ships a BEFORE/AFTER
benchmark with an equivalence gate and is rolled back on regression (the
rule is encoded as a GATE FAIL exit / threshold expect).

Backend
- Q1 faces_for_file → narrow face_boxes_for_file(id, person_id, bbox) with the
  caller filter pushed into SQL: drops the 2 KiB embedding BYTEA + 6 unused
  columns per face. 15-face lightbox open 0.312→0.219 ms, 32 KB→840 B/req.
- A1 cookie auth uses the borrow-only extract_cookie_str (already backs CSRF)
  instead of extract_cookie_value's owned String: -1 alloc/cookie request.
- A2 compute_relevance ASCII case-fold fast path vs name.to_lowercase() per
  result row (Unicode fallback preserved): 1.40x, 12→3 allocs/page.
- A3 sub pre-parsed to Uuid at decode time (TokenClaims.sub_id) vs re-parsing
  the 36-char claim on every request incl. cache hits: 22.7→0.7 ns.
- A4 auth + NextCloud middlewares borrow request.headers() instead of taking
  axum's HeaderMap extractor (a full map clone): 2→0 allocs/authed request.
- A5 CalDAV getlastmodified via the stack rfc2822_utc (byte-identical to
  chrono) vs a per-event to_rfc2822() heap String: 5→0 allocs.
- A6 CalDAV per-event href + quoted etag written into reused page buffers vs a
  fresh format! pair per event: 3.48x, 240→6 allocs/40-event page.

Frontend
- F1 t() shares one frozen EMPTY_PARAMS for the no-interpolation call forms vs
  a throwaway {} per call: -1 alloc/call.
- F2 favorites favoriteIds is a persistent SvelteSet with per-page add (clear
  on reset) vs a brand-new set over the whole accumulated list each page:
  22.3x over a 40-page drain (O(N^2)→O(N)).

Verified: cargo check --all-targets, cargo clippy -D warnings, both bench
packs (GATE PASS), frontend npm run check + vitest (4/4). ROUND14.md also
records the investigated-but-deferred backlog (music N+1, contact vcard
over-fetch, CachedBlobBackend syscalls, ResourceList.sections builder, etc.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PymgCdK78NzUF3oRAQCJfN
This commit is contained in:
Claude
2026-07-19 10:22:12 +00:00
parent 3d578e4fc2
commit c930f865b0
19 changed files with 1345 additions and 73 deletions
+69 -24
View File
@@ -789,11 +789,9 @@ impl CalDavAdapter {
xml_writer.write_event(Event::Text(BytesText::new(&calendar.name)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
// Last modified
// Last modified (stack render, benches/ROUND14.md §A5)
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
xml_writer.write_event(Event::Text(BytesText::new(
&calendar.updated_at.to_rfc2822(),
)))?;
Self::write_lastmodified_text(xml_writer, calendar.updated_at)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
// ETag
@@ -920,9 +918,8 @@ impl CalDavAdapter {
}
("DAV:", "getlastmodified") => {
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
xml_writer.write_event(Event::Text(BytesText::new(
&calendar.updated_at.to_rfc2822(),
)))?;
// Stack render (benches/ROUND14.md §A5).
Self::write_lastmodified_text(xml_writer, calendar.updated_at)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
}
("DAV:", "getetag") => {
@@ -1097,11 +1094,34 @@ impl CalDavAdapter {
/// response per DB row made clients dedupe the shared href and the
/// exception appeared to vanish. Callers guarantee same-UID rows
/// arrive within a single page.
/// Emit an RFC 2822 `getlastmodified` text node with the allocation-free
/// stack renderer (byte-identical to `chrono::to_rfc2822` — the parity
/// gate lives in `common::fmt`), falling back to chrono only for
/// out-of-4-digit-year timestamps. Mirrors the CardDAV emitter; replaces
/// the per-event `updated_at.to_rfc2822()` heap `String`
/// (benches/ROUND14.md §A5).
fn write_lastmodified_text<W: Write>(
xml_writer: &mut Writer<W>,
ts: DateTime<Utc>,
) -> Result<()> {
let mut buf = [0u8; 31];
match crate::common::fmt::rfc2822_utc(&mut buf, ts.timestamp()) {
Some(s) => xml_writer.write_event(Event::Text(BytesText::new(s)))?,
None => xml_writer.write_event(Event::Text(BytesText::new(&ts.to_rfc2822())))?,
}
Ok(())
}
pub fn write_collection_event_page<W: Write>(
xml_writer: &mut Writer<W>,
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).
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
@@ -1111,7 +1131,11 @@ impl CalDavAdapter {
Some(e) => *e,
None => continue,
};
let event_href = format!("{}{}.ics", base_href, anchor.ical_uid);
event_href.clear();
let _ = std::fmt::Write::write_fmt(
&mut event_href,
format_args!("{}{}.ics", base_href, anchor.ical_uid),
);
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
@@ -1124,9 +1148,11 @@ impl CalDavAdapter {
// resourcetype (empty for non-collection)
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
// getetag — anchor row's id
// getetag — anchor row's id (reused buffer, benches/ROUND14.md §A6)
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?;
etag.clear();
let _ = std::fmt::Write::write_fmt(&mut etag, format_args!("\"{}\"", anchor.id));
xml_writer.write_event(Event::Text(BytesText::new(&etag)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
// getcontenttype
@@ -1136,9 +1162,9 @@ impl CalDavAdapter {
)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
// getlastmodified — anchor row's updated_at
// getlastmodified — anchor row's updated_at (stack render, §A5)
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
xml_writer.write_event(Event::Text(BytesText::new(&anchor.updated_at.to_rfc2822())))?;
Self::write_lastmodified_text(xml_writer, anchor.updated_at)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
@@ -1189,13 +1215,21 @@ 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).
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,
None => continue,
};
let href = format!("{}{}.ics", base_href, anchor.ical_uid);
Self::write_event_response(xml_writer, &bundle, props, &href)?;
href.clear();
let _ = std::fmt::Write::write_fmt(
&mut href,
format_args!("{}{}.ics", base_href, anchor.ical_uid),
);
Self::write_event_response(xml_writer, &bundle, props, &href, &mut etag)?;
}
Ok(())
}
@@ -1232,6 +1266,7 @@ impl CalDavAdapter {
bundle: &[&CalendarEventDto],
props: &[QualifiedName],
href: &str,
etag: &mut String,
) -> Result<()> {
let anchor = bundle
.first()
@@ -1254,10 +1289,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)?;
Self::write_event_standard_props(xml_writer, anchor, bundle, etag)?;
} else {
// Write specifically requested properties
Self::write_event_requested_props(xml_writer, anchor, bundle, props)?;
Self::write_event_requested_props(xml_writer, anchor, bundle, props, etag)?;
}
// End prop
@@ -1285,6 +1320,7 @@ impl CalDavAdapter {
xml_writer: &mut Writer<W>,
anchor: &CalendarEventDto,
bundle: &[&CalendarEventDto],
etag: &mut String,
) -> Result<()> {
// Common WebDAV properties
@@ -1293,8 +1329,13 @@ impl CalDavAdapter {
// ETag anchored on the master (or first exception in
// a master-less bundle — pathological state today).
// Reused buffer (benches/ROUND14.md §A6).
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?;
etag.clear();
etag.push('"');
etag.push_str(&anchor.id);
etag.push('"');
xml_writer.write_event(Event::Text(BytesText::new(etag.as_str())))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
// Content type
@@ -1304,9 +1345,9 @@ impl CalDavAdapter {
)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
// Last modified
// Last modified (stack render, benches/ROUND14.md §A5)
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
xml_writer.write_event(Event::Text(BytesText::new(&anchor.updated_at.to_rfc2822())))?;
Self::write_lastmodified_text(xml_writer, anchor.updated_at)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
// CalDAV calendar-data — the whole bundle emitted as one
@@ -1329,6 +1370,7 @@ 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()) {
@@ -1338,8 +1380,12 @@ impl CalDavAdapter {
}
("DAV:", "getetag") => {
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer
.write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?;
// 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())))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
}
("DAV:", "getcontenttype") => {
@@ -1351,9 +1397,8 @@ impl CalDavAdapter {
}
("DAV:", "getlastmodified") => {
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
xml_writer.write_event(Event::Text(BytesText::new(
&anchor.updated_at.to_rfc2822(),
)))?;
// Stack render (benches/ROUND14.md §A5).
Self::write_lastmodified_text(xml_writer, anchor.updated_at)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
}
+7
View File
@@ -37,6 +37,13 @@ pub trait PasswordHasherPort: Send + Sync + 'static {
pub struct TokenClaims {
/// Subject identifier (user ID)
pub sub: String,
/// `sub` pre-parsed to a `Uuid` at decode time so the auth middleware
/// reads it as a `Copy` on every request instead of re-parsing the
/// 36-char string per request — even on validation-cache hits, which
/// return the same `Arc<TokenClaims>` (benches/ROUND14.md §A3). Nil only
/// if a verified token somehow carried a non-UUID `sub` (unreachable for
/// tokens we sign); the middleware rejects nil defensively.
pub sub_id: Uuid,
/// Expiration timestamp (seconds since Unix epoch)
pub exp: i64,
/// Issued at timestamp (seconds since Unix epoch)
+11 -2
View File
@@ -4,7 +4,7 @@ use async_trait::async_trait;
use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::entities::face::{DetectedFace, Face, Person};
use crate::domain::entities::face::{DetectedFace, Face, FaceBox, Person};
/// Detects faces in an image and produces an aligned, L2-normalized embedding
/// for each. Takes raw encoded bytes (it decodes internally) so the
@@ -29,7 +29,16 @@ pub trait FaceAnalyzerPort: Send + Sync + 'static {
pub trait FaceRepository: Send + Sync + 'static {
// ── faces ──────────────────────────────────────────────────────
async fn save_faces(&self, faces: &[Face]) -> Result<(), DomainError>;
async fn faces_for_file(&self, file_id: Uuid) -> Result<Vec<Face>, DomainError>;
/// Face boxes for a photo, caller-scoped — the lightbox tagging overlay
/// needs only `(id, person_id, bbox)`, so this narrow projection drops the
/// 2 KiB embedding BYTEA (+ det_score/quality/blob_hash/created_at) a full
/// `Face` fetch hydrates, and pushes the caller filter into SQL instead of
/// filtering in Rust. See benches/ROUND14.md §Q1.
async fn face_boxes_for_file(
&self,
file_id: Uuid,
user_id: Uuid,
) -> Result<Vec<FaceBox>, DomainError>;
async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError>;
async fn faces_for_user(&self, user_id: Uuid) -> Result<Vec<Face>, DomainError>;
/// Faces previously computed for any file sharing this content hash —
+4 -3
View File
@@ -245,10 +245,11 @@ impl PeopleService {
caller_id: Uuid,
file_id: Uuid,
) -> Result<Vec<FaceBoxDto>, DomainError> {
let faces = self.repo.faces_for_file(file_id).await?;
Ok(faces
// The narrow projection scopes to the caller in SQL (WHERE user_id),
// so no post-filter is needed here. See benches/ROUND14.md §Q1.
let boxes = self.repo.face_boxes_for_file(file_id, caller_id).await?;
Ok(boxes
.into_iter()
.filter(|f| f.user_id == caller_id)
.map(|f| FaceBoxDto {
id: f.id.to_string(),
person_id: f.person_id.map(|u| u.to_string()),
+46 -11
View File
@@ -146,22 +146,57 @@ pub fn build_search_results_cache(
///
/// `query_lower` **must** already be lowercased by the caller so that the
/// allocation happens once per search, not once per result.
///
/// The overwhelmingly common all-ASCII filename takes an allocation-free
/// ASCII case-fold fast path — `name.to_lowercase()` (full Unicode) is pure
/// waste there, and it ran once *per result row* (and per keystroke on the
/// suggest path). Non-ASCII names fall back to the exact Unicode-lowercase
/// comparison, so behavior is unchanged (for ASCII, lowercasing preserves
/// length, so the `contains` length ratio is identical). See benches/ROUND14.md §A2.
fn compute_relevance(name: &str, query_lower: &str) -> u32 {
let name_lower = name.to_lowercase();
if name_lower == query_lower {
100
} else if name_lower.starts_with(query_lower) {
80
} else if name_lower.contains(query_lower) {
// Bonus for shorter names (more specific match)
let ratio = query_lower.len() as f64 / name_lower.len() as f64;
50 + (ratio * 20.0) as u32
if name.is_ascii() {
let (nb, qb) = (name.as_bytes(), query_lower.as_bytes());
if nb.eq_ignore_ascii_case(qb) {
100
} else if nb.len() >= qb.len() && nb[..qb.len()].eq_ignore_ascii_case(qb) {
80
} else if ascii_ci_contains(nb, qb) {
// Bonus for shorter names (more specific match). ASCII lowercase
// preserves length, so `name.len()` == the old `name_lower.len()`.
let ratio = query_lower.len() as f64 / name.len() as f64;
50 + (ratio * 20.0) as u32
} else {
0
}
} else {
0
let name_lower = name.to_lowercase();
if name_lower == query_lower {
100
} else if name_lower.starts_with(query_lower) {
80
} else if name_lower.contains(query_lower) {
let ratio = query_lower.len() as f64 / name_lower.len() as f64;
50 + (ratio * 20.0) as u32
} else {
0
}
}
}
/// ASCII case-insensitive substring test — the allocation-free equivalent of
/// `haystack_lower.contains(needle_lower)` when both are ASCII.
fn ascii_ci_contains(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
if needle.len() > haystack.len() {
return false;
}
haystack
.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle))
}
/// Max content-index candidates fetched per search. Hydration re-filters
/// them in ONE SQL round-trip, so this bounds both index and DB work.
const CONTENT_HITS_LIMIT: usize = 200;
+13
View File
@@ -32,6 +32,19 @@ impl BoundingBox {
}
}
/// A face box for the lightbox tagging overlay — the narrow projection of a
/// persisted [`Face`] that the People API's `faces_for_file` needs (`id`,
/// `person_id`, `bbox`). Fetching this instead of a full [`Face`] keeps the
/// 2 KiB `embedding` BYTEA (plus det_score/quality/blob_hash/created_at) off
/// the wire on every lightbox open of a face-tagged photo. See
/// benches/ROUND14.md §Q1.
#[derive(Debug, Clone)]
pub struct FaceBox {
pub id: Uuid,
pub person_id: Option<Uuid>,
pub bbox: BoundingBox,
}
/// A face produced by the analyzer but not yet persisted: where it is, how
/// confident the detector was, an optional quality score, and a 512-d,
/// L2-normalized embedding.
@@ -13,7 +13,7 @@ use uuid::Uuid;
use crate::application::ports::face_ports::FaceRepository;
use crate::common::errors::DomainError;
use crate::domain::entities::face::{BoundingBox, Face, Person};
use crate::domain::entities::face::{BoundingBox, Face, FaceBox, Person};
/// Row shape for `faces.faces` selects (avoids `clippy::type_complexity`).
type FaceRow = (
@@ -182,14 +182,31 @@ impl FaceRepository for FacePgRepository {
Ok(())
}
async fn faces_for_file(&self, file_id: Uuid) -> Result<Vec<Face>, DomainError> {
let sql = format!("SELECT {FACE_COLS} FROM faces.faces WHERE file_id = $1");
let rows: Vec<FaceRow> = sqlx::query_as(&sql)
.bind(file_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("faces_for_file", e))?;
Ok(rows.into_iter().map(row_to_face).collect())
async fn face_boxes_for_file(
&self,
file_id: Uuid,
user_id: Uuid,
) -> Result<Vec<FaceBox>, DomainError> {
// Narrow projection: the lightbox needs only (id, person_id, bbox), so
// the 2 KiB embedding BYTEA + 6 unused columns stay in the DB and the
// caller filter runs in SQL (idx_faces_file drives it) rather than in
// Rust after a full-row fetch. See benches/ROUND14.md §Q1.
let rows: Vec<(Uuid, Option<Uuid>, Vec<f32>)> = sqlx::query_as(
"SELECT id, person_id, bbox FROM faces.faces WHERE file_id = $1 AND user_id = $2",
)
.bind(file_id)
.bind(user_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("face_boxes_for_file", e))?;
Ok(rows
.into_iter()
.map(|(id, person_id, bbox)| FaceBox {
id,
person_id,
bbox: BoundingBox::from_slice(&bbox),
})
.collect())
}
async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError> {
@@ -49,7 +49,14 @@ struct JwtClaims {
impl From<JwtClaims> for TokenClaims {
fn from(claims: JwtClaims) -> Self {
// Pre-parse the subject once at decode time (amortized over the
// validation-cache TTL) so the auth middleware reads a `Copy` instead
// of re-parsing the 36-char string per request. A verified token we
// signed always carries a UUID `sub`; nil is a safe sentinel the
// middleware rejects. See benches/ROUND14.md §A3.
let sub_id = uuid::Uuid::parse_str(&claims.sub).unwrap_or_else(|_| uuid::Uuid::nil());
TokenClaims {
sub_id,
sub: claims.sub,
exp: claims.exp,
iat: claims.iat,
+26 -11
View File
@@ -1,6 +1,6 @@
use axum::{
extract::{FromRequestParts, Request, State},
http::{HeaderMap, StatusCode, header, request::Parts},
http::{StatusCode, header, request::Parts},
middleware::Next,
response::{IntoResponse, Response},
};
@@ -163,11 +163,17 @@ impl IntoResponse for AuthError {
/// then the cookie fallback.
pub async fn auth_middleware(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Result<Response, AuthError> {
let auth_header = headers
// Borrow the Authorization header straight from the request instead of
// taking axum's `HeaderMap` extractor, which clones the whole map (~2
// allocs) on every authenticated request purely to read it
// (benches/ROUND14.md §A4). The borrow is dead by the time each arm
// reaches `request.extensions_mut()` / `next.run(request)` (NLL), so no
// owned copy is needed.
let auth_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok());
@@ -186,9 +192,14 @@ pub async fn auth_middleware(
"Token validated successfully for user: {}",
claims.username
);
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
AuthError::InvalidToken("Invalid user ID in token".to_string())
})?;
// Pre-parsed at decode time (benches/ROUND14.md §A3);
// nil only for a malformed sub, which we reject as before.
let user_id = claims.sub_id;
if user_id.is_nil() {
return Err(AuthError::InvalidToken(
"Invalid user ID in token".to_string(),
));
}
// A cryptographically valid token must not outlive the
// account: re-check the live record so deactivation,
// deletion and demotion take effect within the flags-cache
@@ -296,19 +307,23 @@ pub async fn auth_middleware(
use crate::interfaces::api::cookie_auth;
if let Some(token_str) =
cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE)
cookie_auth::extract_cookie_str(request.headers(), cookie_auth::ACCESS_COOKIE)
&& !token_str.is_empty()
{
tracing::debug!("Processing cookie-based authentication");
if let Some(auth_service) = state.auth_service.as_ref() {
let token_service = &auth_service.token_service;
match token_service.validate_token(&token_str) {
match token_service.validate_token(token_str) {
Ok(claims) => {
tracing::debug!("Cookie token validated for user: {}", claims.username);
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
AuthError::InvalidToken("Invalid user ID in token".to_string())
})?;
// Pre-parsed at decode time (benches/ROUND14.md §A3).
let user_id = claims.sub_id;
if user_id.is_nil() {
return Err(AuthError::InvalidToken(
"Invalid user ID in token".to_string(),
));
}
// Same live-account re-check as the Bearer path. On
// revocation we fall through (rather than erroring) so the
// browser receives the standard 401 and redirects to
@@ -1,6 +1,6 @@
use axum::{
extract::{Request, State},
http::{HeaderMap, StatusCode, header},
http::{StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
@@ -70,13 +70,16 @@ impl IntoResponse for NextcloudAuthError {
pub async fn basic_auth_middleware(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Result<Response, NextcloudAuthError> {
tracing::debug!("[NC] {} {}", request.method(), request.uri());
let auth_header = headers
// Borrow the Authorization header directly rather than cloning the whole
// HeaderMap per NC sync request; the borrow ends at `parse_basic_auth`
// below, before any request mutation (benches/ROUND14.md §A4).
let auth_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {