perf(round29): cache-serve borrow-probe, NC REPORT href buffer, auth per-req allocs, DB over-fetch
Seven behaviour-preserving allocation / copy / bandwidth cuts, each behind a counting-allocator BEFORE/AFTER gate that exit(1)s unless AFTER allocates strictly fewer than BEFORE (benches/ROUND29.md, examples/bench_round29_micro.rs). - [B] Content-cache serve fast path (optimized_inner Tier 1 + get_file_range_preloaded — the video-scrub hot path): probe the cache with a borrow first and build the owned get_or_load args (quoted-etag / key / id Strings) only on a miss, instead of allocating them before every probe and discarding them on a hit. 6 -> 0 allocs per cache hit. Splits get_or_load into get + load_and_cache so the miss path is not re-probed and the hit/miss stat counters stay byte-identical. Also drops the unconditional content_hash/name clones that ran for the >=10 MB streaming tier that used neither. - [A] NextCloud REPORT emit loops: per-row href String (and format! per folder row) -> one reused href_buf via nc_href_into / nc_collection_href_into with the URL-encoded user computed once per page. 1497 fewer allocs on a 500-row page. - [C] read_full: a single-frame blob is returned zero-copy instead of a second whole-payload memcpy into a fresh BytesMut; multi-frame path unchanged. - [D] login-lockout key: to_lowercase()+format! -> one pre-sized ASCII buffer (non-ASCII keeps str::to_lowercase). 3 -> 1 alloc/req, byte-identical key. - [E] NC composite-username parse: owned clone/to_string -> &str borrow of the already-owned raw_username. 1 -> 0 alloc on the common no-marker path. - [F] get_contacts_in_group: stop SELECTing the discarded multi-KB vcard column (the live method ROUND25 §Q2 missed; ContactDto has no vcard field). - [G] count_admin_users: add count_users_by_role -> scalar COUNT(*) instead of hydrating every admin's full row (incl. up-to-512 KiB avatar + ui_preferences JSONB) only to .len() it, on a bootstrap-polled status endpoint. All seven gates pass; cargo fmt --check and cargo clippy --all-features --all-targets -D warnings clean. §F/§G additionally validated against a live PostgreSQL 16 with the full migration set (query validity, result equivalence, 600000 -> 8 byte wire delta on the admin count). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhpDZxSQTAGnAqCHUdtG5N
This commit is contained in:
@@ -172,6 +172,12 @@ pub trait UserStoragePort: Send + Sync + 'static {
|
||||
/// Lists users by role (e.g., "admin" or "user")
|
||||
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError>;
|
||||
|
||||
/// Counts users with a given role WITHOUT hydrating their rows — a scalar
|
||||
/// `COUNT(*)` instead of fetching every full user row (incl. the up-to-512
|
||||
/// KiB avatar `image` and the `ui_preferences` JSONB) only to `.len()` them
|
||||
/// (benches/ROUND29.md §G).
|
||||
async fn count_users_by_role(&self, role: &str) -> Result<i64, DomainError>;
|
||||
|
||||
/// Deletes a user by their ID
|
||||
async fn delete_user(&self, user_id: Uuid) -> Result<(), DomainError>;
|
||||
|
||||
|
||||
@@ -2118,21 +2118,11 @@ impl AuthApplicationService {
|
||||
// Method to count how many admin users exist in the system
|
||||
// Used to determine if we have multiple admins or just the default one
|
||||
pub async fn count_admin_users(&self) -> Result<i64, DomainError> {
|
||||
// Use the list_users_by_role method or similar from user_storage port
|
||||
// For now, we'll use a basic implementation that counts all users with role = "admin"
|
||||
let admin_users = self
|
||||
.user_storage
|
||||
.list_users_by_role("admin")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"User",
|
||||
format!("Error counting admin users: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(admin_users.len() as i64)
|
||||
// Scalar COUNT(*) — the old form fetched every admin's FULL row (incl.
|
||||
// the up-to-512 KiB avatar `image` + `ui_preferences` JSONB) only to
|
||||
// call `.len()`, on a status/init endpoint that is polled at bootstrap
|
||||
// (benches/ROUND29.md §G).
|
||||
self.user_storage.count_users_by_role("admin").await
|
||||
}
|
||||
|
||||
/// Lists internal users only. External (grant-only) users are filtered
|
||||
|
||||
@@ -111,7 +111,26 @@ impl FileRetrievalService {
|
||||
) -> Result<Bytes, DomainError> {
|
||||
let stream = file_read.get_file_stream(id).await?;
|
||||
let mut stream = Pin::from(stream);
|
||||
let mut buf = BytesMut::with_capacity(capacity);
|
||||
// Most sub-threshold reads arrive as ONE owned contiguous frame from the
|
||||
// backend (the local ReaderStream emits ≤256 KiB frames, and a
|
||||
// sub-threshold blob fits in one). Return that frame directly instead of
|
||||
// copying the whole payload a second time into a fresh BytesMut; only a
|
||||
// multi-frame read pays the pre-sized concat — byte-identical output
|
||||
// (benches/ROUND29.md §C).
|
||||
let Some(first) = stream.next().await else {
|
||||
return Ok(Bytes::new());
|
||||
};
|
||||
let first = first.map_err(|e| {
|
||||
DomainError::internal_error("File", format!("Stream read error: {}", e))
|
||||
})?;
|
||||
let Some(second) = stream.next().await else {
|
||||
return Ok(first);
|
||||
};
|
||||
let mut buf = BytesMut::with_capacity(capacity.max(first.len()));
|
||||
buf.extend_from_slice(&first);
|
||||
buf.extend_from_slice(&second.map_err(|e| {
|
||||
DomainError::internal_error("File", format!("Stream read error: {}", e))
|
||||
})?);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
buf.extend_from_slice(&chunk.map_err(|e| {
|
||||
DomainError::internal_error("File", format!("Stream read error: {}", e))
|
||||
@@ -202,7 +221,6 @@ impl FileRetrievalService {
|
||||
) -> Result<(FileDto, OptimizedFileContent), DomainError> {
|
||||
let mime_type = dto.mime_type.clone();
|
||||
let file_size = dto.size;
|
||||
let file_name = dto.name.clone();
|
||||
// The content cache is content-addressed: keyed by the blob hash, not
|
||||
// the file id. Identical content deduplicated to one blob on disk is
|
||||
// then cached ONCE in RAM and shared by every file/user that references
|
||||
@@ -210,34 +228,39 @@ impl FileRetrievalService {
|
||||
// construction, so entries never go stale (no invalidation needed). A
|
||||
// stub DTO without a hash disables caching for that request rather than
|
||||
// colliding every hash-less file on the key "".
|
||||
let cache_key = dto.content_hash.clone();
|
||||
let cacheable = !cache_key.is_empty();
|
||||
let cacheable = !dto.content_hash.is_empty();
|
||||
let do_transcode = accept_webp && !prefer_original;
|
||||
|
||||
// ── Tier 1: Hot cache + transcode (<10 MB) ──────────
|
||||
if file_size < CACHE_THRESHOLD {
|
||||
// Fetch the raw blob bytes. When cacheable, `get_or_load` serves
|
||||
// from the content cache on a hit and, on a miss, coalesces every
|
||||
// concurrent request for the same blob hash into a SINGLE disk read
|
||||
// (single-flight) — no thundering herd under load. Hash-less stub
|
||||
// DTOs are uncacheable and stream straight from disk.
|
||||
// Probe the content cache with a BORROW first: a hit serves the blob
|
||||
// straight from RAM, and only a miss builds the owned load arguments
|
||||
// (the quoted-etag / key / id Strings) that a hit would otherwise
|
||||
// allocate and immediately discard (benches/ROUND29.md §B). On a miss
|
||||
// `load_and_cache` still coalesces concurrent requests for the same
|
||||
// blob hash into a SINGLE disk read (single-flight) — no thundering
|
||||
// herd. Hash-less stub DTOs are uncacheable and stream from disk.
|
||||
let content_bytes = if cacheable && let Some(cache) = &self.content_cache {
|
||||
let etag: Arc<str> = format!("\"{}\"", cache_key).into();
|
||||
let ct: Arc<str> = mime_type.clone();
|
||||
let file_read = Arc::clone(&self.file_read);
|
||||
let id_owned = id.to_string();
|
||||
let cap = file_size as usize;
|
||||
let (bytes, _etag, _ct) = cache
|
||||
.get_or_load(cache_key.clone(), etag, ct, async move {
|
||||
debug!("💾 TIER 1 Cache MISS: {} – loading from disk", id_owned);
|
||||
Self::read_full(&file_read, &id_owned, cap).await
|
||||
})
|
||||
.await?;
|
||||
bytes
|
||||
if let Some((bytes, ..)) = cache.get(&dto.content_hash).await {
|
||||
bytes
|
||||
} else {
|
||||
let etag: Arc<str> = format!("\"{}\"", dto.content_hash).into();
|
||||
let ct: Arc<str> = mime_type.clone();
|
||||
let file_read = Arc::clone(&self.file_read);
|
||||
let id_owned = id.to_string();
|
||||
let cap = file_size as usize;
|
||||
let (bytes, ..) = cache
|
||||
.load_and_cache(dto.content_hash.to_string(), etag, ct, async move {
|
||||
debug!("💾 TIER 1 Cache MISS: {} – loading from disk", id_owned);
|
||||
Self::read_full(&file_read, &id_owned, cap).await
|
||||
})
|
||||
.await?;
|
||||
bytes
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
"💾 TIER 1 (uncacheable): {} – streaming from disk",
|
||||
file_name
|
||||
dto.name
|
||||
);
|
||||
Self::read_full(&self.file_read, id, file_size as usize).await?
|
||||
};
|
||||
@@ -269,7 +292,7 @@ impl FileRetrievalService {
|
||||
// ── Tier 2 + 3: Streaming (≥10 MB) ──────────────────
|
||||
info!(
|
||||
"📡 TIER 2 STREAMING: {} ({} MB)",
|
||||
file_name,
|
||||
dto.name,
|
||||
file_size / (1024 * 1024)
|
||||
);
|
||||
let stream = self.file_read.get_file_stream(id).await?;
|
||||
@@ -353,17 +376,27 @@ impl FileRetrievalService {
|
||||
) -> Result<RangeContent, DomainError> {
|
||||
let cacheable = dto.size < CACHE_THRESHOLD && !dto.content_hash.is_empty();
|
||||
if cacheable && let Some(cache) = &self.content_cache {
|
||||
let etag: Arc<str> = format!("\"{}\"", dto.content_hash).into();
|
||||
let ct: Arc<str> = dto.mime_type.clone();
|
||||
let file_read = Arc::clone(&self.file_read);
|
||||
let id_owned = dto.id.clone();
|
||||
let cap = dto.size as usize;
|
||||
let (bytes, _etag, _ct) = cache
|
||||
.get_or_load(dto.content_hash.to_string(), etag, ct, async move {
|
||||
debug!("💾 Range cache MISS: {} – loading from disk", id_owned);
|
||||
Self::read_full(&file_read, &id_owned, cap).await
|
||||
})
|
||||
.await?;
|
||||
// Probe with a BORROW first: the video-scrub steady state is a cache
|
||||
// hit, and a hit must not allocate the owned load args (quoted-etag /
|
||||
// key / id Strings) it would immediately discard — those are built
|
||||
// only on the miss branch (benches/ROUND29.md §B). A miss still
|
||||
// populates via the same single-flight coalescing.
|
||||
let bytes = if let Some((bytes, ..)) = cache.get(&dto.content_hash).await {
|
||||
bytes
|
||||
} else {
|
||||
let etag: Arc<str> = format!("\"{}\"", dto.content_hash).into();
|
||||
let ct: Arc<str> = dto.mime_type.clone();
|
||||
let file_read = Arc::clone(&self.file_read);
|
||||
let id_owned = dto.id.clone();
|
||||
let cap = dto.size as usize;
|
||||
let (bytes, ..) = cache
|
||||
.load_and_cache(dto.content_hash.to_string(), etag, ct, async move {
|
||||
debug!("💾 Range cache MISS: {} – loading from disk", id_owned);
|
||||
Self::read_full(&file_read, &id_owned, cap).await
|
||||
})
|
||||
.await?;
|
||||
bytes
|
||||
};
|
||||
let len = bytes.len() as u64;
|
||||
let s = start.min(len) as usize;
|
||||
let e = end.unwrap_or(len).min(len) as usize;
|
||||
|
||||
@@ -111,6 +111,10 @@ pub trait UserRepository: Send + Sync + 'static {
|
||||
/// Lists users by role (admin or user)
|
||||
async fn list_users_by_role(&self, role: &str) -> UserRepositoryResult<Vec<User>>;
|
||||
|
||||
/// Counts users with a given role via a scalar `COUNT(*)` — no row
|
||||
/// hydration (benches/ROUND29.md §G).
|
||||
async fn count_users_by_role(&self, role: &str) -> UserRepositoryResult<i64>;
|
||||
|
||||
/// Deletes a user
|
||||
async fn delete_user(&self, user_id: Uuid) -> UserRepositoryResult<()>;
|
||||
|
||||
|
||||
@@ -196,10 +196,10 @@ impl ContactGroupRepository for ContactGroupPgRepository {
|
||||
) -> ContactRepositoryResult<Vec<Contact>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
SELECT
|
||||
c.id, c.address_book_id, c.uid, c.full_name, c.first_name, c.last_name, c.nickname,
|
||||
c.email, c.phone, c.address, c.organization, c.title, c.notes, c.photo_url,
|
||||
c.birthday, c.anniversary, c.vcard, c.etag, c.created_at, c.updated_at
|
||||
c.birthday, c.anniversary, c.etag, c.created_at, c.updated_at
|
||||
FROM carddav.contacts c
|
||||
INNER JOIN carddav.group_memberships gm ON c.id = gm.contact_id
|
||||
WHERE gm.group_id = $1
|
||||
@@ -253,7 +253,13 @@ impl ContactGroupRepository for ContactGroupPgRepository {
|
||||
row.get::<Option<String>, _>("photo_url"),
|
||||
row.get("birthday"),
|
||||
row.get("anniversary"),
|
||||
row.get("vcard"),
|
||||
// vcard column intentionally NOT selected — the sole live caller
|
||||
// (`list_contacts_in_group`) maps to `ContactDto`, which has no
|
||||
// vcard field, so fetching the multi-KB serialized vCard (with an
|
||||
// embedded base64 PHOTO) only to drop it wastes bandwidth + a
|
||||
// per-row String. Mirrors `row_to_contact_lite` (benches/ROUND29.md
|
||||
// §F / ROUND25 §Q2, applied to the LIVE group method this time).
|
||||
String::new(),
|
||||
row.get("etag"),
|
||||
row.get("created_at"),
|
||||
row.get("updated_at"),
|
||||
|
||||
@@ -810,6 +810,15 @@ impl UserRepository for UserPgRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Counts users by role with a scalar `COUNT(*)` — no row hydration.
|
||||
async fn count_users_by_role(&self, role: &str) -> UserRepositoryResult<i64> {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM auth.users WHERE role::text = $1")
|
||||
.bind(role)
|
||||
.fetch_one(&*self.pool)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)
|
||||
}
|
||||
|
||||
/// Lists users by role
|
||||
async fn list_users_by_role(&self, role: &str) -> UserRepositoryResult<Vec<User>> {
|
||||
let rows = sqlx::query(
|
||||
@@ -1157,6 +1166,12 @@ impl UserStoragePort for UserPgRepository {
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn count_users_by_role(&self, role: &str) -> Result<i64, DomainError> {
|
||||
UserRepository::count_users_by_role(self, role)
|
||||
.await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn delete_user(&self, user_id: Uuid) -> Result<(), DomainError> {
|
||||
UserRepository::delete_user(self, user_id)
|
||||
.await
|
||||
|
||||
@@ -182,7 +182,30 @@ impl FileContentCache {
|
||||
if let Some(hit) = self.get(&cache_key).await {
|
||||
return Ok(hit);
|
||||
}
|
||||
self.load_and_cache(cache_key, etag, content_type, load)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The populate-on-miss half of [`Self::get_or_load`], with single-flight
|
||||
/// coalescing but WITHOUT the leading `get` probe.
|
||||
///
|
||||
/// Hot read paths that have *already* probed the cache with [`Self::get`]
|
||||
/// (a borrow) call this directly on the miss branch — they then build the
|
||||
/// owned `cache_key` / `etag` / `content_type` (each a heap allocation)
|
||||
/// only when they are actually needed to populate, so a cache HIT allocates
|
||||
/// none of them (benches/ROUND29.md §B). Because the caller's own `get`
|
||||
/// already counted the hit/miss, this method does not re-probe — keeping the
|
||||
/// hit/miss stat counts identical to a single `get_or_load` call.
|
||||
pub async fn load_and_cache<F>(
|
||||
&self,
|
||||
cache_key: String,
|
||||
etag: Arc<str>,
|
||||
content_type: Arc<str>,
|
||||
load: F,
|
||||
) -> Result<(Bytes, Arc<str>, Arc<str>), DomainError>
|
||||
where
|
||||
F: Future<Output = Result<Bytes, DomainError>>,
|
||||
{
|
||||
// Slow path: coalesce concurrent misses into a single `load`.
|
||||
let entry = self
|
||||
.cache
|
||||
|
||||
@@ -68,7 +68,24 @@ impl LoginLockoutService {
|
||||
fn key(username: &str, client_ip: &str) -> String {
|
||||
// `|` is not valid in either a username or an IP literal so it makes
|
||||
// the username/ip boundary unambiguous.
|
||||
format!("{}|{}", username.to_lowercase(), client_ip)
|
||||
//
|
||||
// The lowercased composite is written into ONE pre-sized buffer instead
|
||||
// of the `to_lowercase()` (alloc) + `format!` (alloc) two-step. App
|
||||
// passwords authenticate with an already-lowercase ASCII username in
|
||||
// ~all traffic, so the fast branch covers it; the rare non-ASCII branch
|
||||
// keeps `str::to_lowercase` for exact Unicode (e.g. final-sigma)
|
||||
// semantics. Byte-identical key either way (benches/ROUND29.md §D).
|
||||
if username.is_ascii() {
|
||||
let mut k = String::with_capacity(username.len() + 1 + client_ip.len());
|
||||
for &b in username.as_bytes() {
|
||||
k.push(b.to_ascii_lowercase() as char);
|
||||
}
|
||||
k.push('|');
|
||||
k.push_str(client_ip);
|
||||
k
|
||||
} else {
|
||||
format!("{}|{}", username.to_lowercase(), client_ip)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the (account, IP) pair is currently locked.
|
||||
|
||||
@@ -109,7 +109,12 @@ pub async fn basic_auth_middleware(
|
||||
// at the auth boundary rather than treating them as "missing
|
||||
// marker" — they are unambiguous typos that would otherwise
|
||||
// silently fall into a different code path.
|
||||
let (username, drive_marker): (String, Option<String>) = match raw_username.split_once('~') {
|
||||
// Borrow the prefix / marker out of the already-owned `raw_username`
|
||||
// (`split_once` yields `&str` slices) instead of allocating a duplicate
|
||||
// `String` per request — `username` is only ever passed by reference, and
|
||||
// `raw_username` outlives every use before it moves into `NcSession`
|
||||
// (benches/ROUND29.md §E).
|
||||
let (username, drive_marker): (&str, Option<&str>) = match raw_username.split_once('~') {
|
||||
Some(("", _)) => {
|
||||
tracing::warn!(
|
||||
"[NC] 401 malformed composite username (empty prefix): {}",
|
||||
@@ -124,15 +129,15 @@ pub async fn basic_auth_middleware(
|
||||
);
|
||||
return Err(NextcloudAuthError::Unauthorized);
|
||||
}
|
||||
Some((u, m)) => (u.to_string(), Some(m.to_string())),
|
||||
None => (raw_username.clone(), None),
|
||||
Some((u, m)) => (u, Some(m)),
|
||||
None => (raw_username.as_str(), None),
|
||||
};
|
||||
|
||||
// Check account lockout before attempting password verification (saves CPU).
|
||||
// The lockout is per (account, IP), see #323 for rationale.
|
||||
let client_ip = crate::interfaces::middleware::rate_limit::extract_client_ip(&request);
|
||||
if let Some(auth_svc) = state.auth_service.as_ref()
|
||||
&& let Err(secs) = auth_svc.login_lockout.check(&username, &client_ip)
|
||||
&& let Err(secs) = auth_svc.login_lockout.check(username, &client_ip)
|
||||
{
|
||||
tracing::warn!(
|
||||
username = %username,
|
||||
@@ -150,13 +155,13 @@ pub async fn basic_auth_middleware(
|
||||
|
||||
match nextcloud
|
||||
.app_passwords
|
||||
.verify_basic_auth(&username, &password)
|
||||
.verify_basic_auth(username, &password)
|
||||
.await
|
||||
{
|
||||
Ok((user_id, uname, email, role)) => {
|
||||
// Reset lockout counter on success
|
||||
if let Some(auth_svc) = state.auth_service.as_ref() {
|
||||
auth_svc.login_lockout.record_success(&username, &client_ip);
|
||||
auth_svc.login_lockout.record_success(username, &client_ip);
|
||||
}
|
||||
// External users must never authenticate against the NC
|
||||
// surface — that whole subtree (WebDAV files, uploads,
|
||||
@@ -222,7 +227,7 @@ pub async fn basic_auth_middleware(
|
||||
// is the right one: name-independent, secondary-drive-safe.
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
let chroot = match drive_marker.as_deref() {
|
||||
let chroot = match drive_marker {
|
||||
None => {
|
||||
match state
|
||||
.drive_repo
|
||||
@@ -287,7 +292,7 @@ pub async fn basic_auth_middleware(
|
||||
Err(_) => {
|
||||
// Record failed attempt for lockout tracking
|
||||
if let Some(auth_svc) = state.auth_service.as_ref() {
|
||||
auth_svc.login_lockout.record_failure(&username, &client_ip);
|
||||
auth_svc.login_lockout.record_failure(username, &client_ip);
|
||||
}
|
||||
Err(NextcloudAuthError::Unauthorized)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ use crate::interfaces::api::handlers::webdav_handler::{
|
||||
};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::nextcloud::webdav_handler::{
|
||||
batch_resolve_ids, format_oc_id_into, nc_href, nc_id_of, write_file_response,
|
||||
write_folder_response,
|
||||
batch_resolve_ids, format_oc_id_into, nc_collection_href_into, nc_href_into, nc_id_of,
|
||||
write_file_response, write_folder_response,
|
||||
};
|
||||
|
||||
/// Handle WebDAV REPORT and SEARCH methods for Nextcloud compatibility.
|
||||
@@ -177,6 +177,12 @@ async fn handle_filter_files(
|
||||
// owner-id stays canonical via `&user.username`.
|
||||
// One oc:id buffer reused across both emit loops (benches/ROUND27.md §H1).
|
||||
let mut oc_buf = String::new();
|
||||
// One href buffer reused across both emit loops, with the URL-encoded
|
||||
// user computed once for the page instead of re-encoded per row — the
|
||||
// reused-buffer shape the PROPFIND child loop already uses
|
||||
// (benches/ROUND29.md §A).
|
||||
let encoded_user = urlencoding::encode(url_user);
|
||||
let mut href_buf = String::new();
|
||||
for file in &files {
|
||||
// Skip favorites that live outside the caller's chroot
|
||||
// (other-drive favorites); reachable via REST if needed.
|
||||
@@ -189,7 +195,7 @@ async fn handle_filter_files(
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let href = nc_href(url_user, subpath);
|
||||
nc_href_into(&mut href_buf, &encoded_user, subpath);
|
||||
let fid = nc_id_of(&file_id_map, &file.id);
|
||||
let oc_id: Option<&str> = match fid {
|
||||
Some(id) => {
|
||||
@@ -202,7 +208,7 @@ async fn handle_filter_files(
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
file,
|
||||
&href,
|
||||
&href_buf,
|
||||
(fid, oc_id),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
@@ -221,7 +227,7 @@ async fn handle_filter_files(
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let href = format!("{}/", nc_href(url_user, subpath));
|
||||
nc_collection_href_into(&mut href_buf, &encoded_user, subpath);
|
||||
let fid = nc_id_of(&folder_id_map, &folder.id);
|
||||
let oc_id: Option<&str> = match fid {
|
||||
Some(id) => {
|
||||
@@ -234,7 +240,7 @@ async fn handle_filter_files(
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
folder,
|
||||
&href,
|
||||
&href_buf,
|
||||
(fid, oc_id),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
@@ -334,6 +340,12 @@ async fn handle_search(
|
||||
// Files.
|
||||
// One oc:id buffer reused across both emit loops (benches/ROUND27.md §H1).
|
||||
let mut oc_buf = String::new();
|
||||
// One href buffer reused across both emit loops, with the URL-encoded
|
||||
// user computed once for the page instead of re-encoded per row — the
|
||||
// reused-buffer shape the PROPFIND child loop already uses
|
||||
// (benches/ROUND29.md §A).
|
||||
let encoded_user = urlencoding::encode(url_user);
|
||||
let mut href_buf = String::new();
|
||||
for file in &files {
|
||||
let Some(subpath) = strip_home_prefix(chroot, &file.path, home_prefix) else {
|
||||
tracing::debug!(
|
||||
@@ -344,7 +356,7 @@ async fn handle_search(
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let href = nc_href(url_user, subpath);
|
||||
nc_href_into(&mut href_buf, &encoded_user, subpath);
|
||||
let fid = nc_id_of(&file_id_map, &file.id);
|
||||
let oc_id: Option<&str> = match fid {
|
||||
Some(id) => {
|
||||
@@ -357,7 +369,7 @@ async fn handle_search(
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
file,
|
||||
&href,
|
||||
&href_buf,
|
||||
(fid, oc_id),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
@@ -377,7 +389,7 @@ async fn handle_search(
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let href = format!("{}/", nc_href(url_user, subpath));
|
||||
nc_collection_href_into(&mut href_buf, &encoded_user, subpath);
|
||||
let fid = nc_id_of(&folder_id_map, &folder.id);
|
||||
let oc_id: Option<&str> = match fid {
|
||||
Some(id) => {
|
||||
@@ -390,7 +402,7 @@ async fn handle_search(
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
folder,
|
||||
&href,
|
||||
&href_buf,
|
||||
(fid, oc_id),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
|
||||
@@ -167,12 +167,10 @@ pub fn strip_drive_root_segment(internal_path: &str) -> &str {
|
||||
/// surfaces as `Network request error "Erreur inconnue" HTTP status
|
||||
/// 207` in the client log. Files use [`nc_href`] (no trailing slash).
|
||||
pub fn nc_collection_href(username: &str, subpath: &str) -> String {
|
||||
let h = nc_href(username, subpath);
|
||||
if h.ends_with('/') {
|
||||
h
|
||||
} else {
|
||||
format!("{}/", h)
|
||||
}
|
||||
let encoded_user = urlencoding::encode(username);
|
||||
let mut out = String::new();
|
||||
nc_collection_href_into(&mut out, &encoded_user, subpath);
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the Nextcloud DAV href for a resource.
|
||||
@@ -184,17 +182,33 @@ pub fn nc_collection_href(username: &str, subpath: &str) -> String {
|
||||
/// a **collection** must use [`nc_collection_href`] (or append `/`
|
||||
/// manually) to satisfy RFC 4918 §5.2 and the NC client's parser.
|
||||
pub fn nc_href(username: &str, subpath: &str) -> String {
|
||||
let subpath = subpath.trim_matches('/');
|
||||
let encoded_user = urlencoding::encode(username);
|
||||
let mut out = String::new();
|
||||
nc_href_into(&mut out, &encoded_user, subpath);
|
||||
out
|
||||
}
|
||||
|
||||
/// Per-row form of [`nc_href`]: write the href into a REUSED buffer given the
|
||||
/// already-URL-encoded username.
|
||||
///
|
||||
/// The emit loops (PROPFIND children, REPORT results) call this instead of
|
||||
/// [`nc_href`] so each row rewrites one buffer rather than allocating a fresh
|
||||
/// `String`, and the constant `encoded_user` is encoded ONCE per page instead of
|
||||
/// re-encoded for every row (benches/ROUND29.md §A — the same reused-buffer shape
|
||||
/// the PROPFIND child loop already uses for its href prefix). Byte-identical to
|
||||
/// [`nc_href`].
|
||||
pub fn nc_href_into(out: &mut String, encoded_user: &str, subpath: &str) {
|
||||
let subpath = subpath.trim_matches('/');
|
||||
// Write the prefix, user and each encoded segment straight into one
|
||||
// pre-sized buffer — avoids the per-segment `Vec<Cow>`, the joined String and
|
||||
// the `format!` result the previous `.map(...).collect().join("/")` allocated
|
||||
// on every NC PROPFIND/REPORT href (mirrors the native `encode_uri_path`).
|
||||
// Keeps `urlencoding::encode` so the emitted bytes are unchanged.
|
||||
const PREFIX: &str = "/remote.php/dav/files/";
|
||||
let mut out = String::with_capacity(PREFIX.len() + encoded_user.len() + subpath.len() + 8);
|
||||
out.clear();
|
||||
out.reserve(PREFIX.len() + encoded_user.len() + subpath.len() + 8);
|
||||
out.push_str(PREFIX);
|
||||
out.push_str(&encoded_user);
|
||||
out.push_str(encoded_user);
|
||||
out.push('/');
|
||||
// No empty-segment filter: `split('/')` on an empty (root) subpath yields a
|
||||
// single "" whose encode is "" — leaving the trailing slash above intact —
|
||||
@@ -206,7 +220,16 @@ pub fn nc_href(username: &str, subpath: &str) -> String {
|
||||
}
|
||||
out.push_str(&urlencoding::encode(seg));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Per-row form of [`nc_collection_href`]: [`nc_href_into`] plus the trailing
|
||||
/// `/` RFC 4918 §5.2 / the NC client require for a collection. Byte-identical to
|
||||
/// [`nc_collection_href`].
|
||||
pub fn nc_collection_href_into(out: &mut String, encoded_user: &str, subpath: &str) {
|
||||
nc_href_into(out, encoded_user, subpath);
|
||||
if !out.ends_with('/') {
|
||||
out.push('/');
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch Nextcloud WebDAV request to the appropriate handler.
|
||||
|
||||
Reference in New Issue
Block a user