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
+33 -12
View File
@@ -950,16 +950,16 @@ pub fn contact_to_vcard(contact: &ContactDto) -> String {
if let Some(fn_name) = &contact.full_name {
let _ = write!(vcard, "FN:{}\r\n", fn_name);
} else {
// FN is mandatory in vCard 3.0
// FN is mandatory in vCard 3.0. Write the borrowed trim slice directly
// instead of copying it into a second owned String (benches/ROUND19.md §V1).
let fn_name = format!(
"{} {}",
contact.first_name.as_deref().unwrap_or(""),
contact.last_name.as_deref().unwrap_or(""),
)
.trim()
.to_string();
if !fn_name.is_empty() {
let _ = write!(vcard, "FN:{}\r\n", fn_name);
);
let trimmed = fn_name.trim();
if !trimmed.is_empty() {
let _ = write!(vcard, "FN:{}\r\n", trimmed);
} else {
vcard.push_str("FN:Unknown\r\n");
}
@@ -1006,7 +1006,15 @@ pub fn contact_to_vcard(contact: &ContactDto) -> String {
let _ = write!(vcard, "TITLE:{}\r\n", title);
}
if let Some(notes) = &contact.notes {
let _ = write!(vcard, "NOTE:{}\r\n", notes.replace('\n', "\\n"));
// Only a multi-line note needs the escaping copy; a note with no newline
// writes its borrowed slice directly (benches/ROUND19.md §V1).
if notes.contains('\n') {
let _ = write!(vcard, "NOTE:{}\r\n", notes.replace('\n', "\\n"));
} else {
vcard.push_str("NOTE:");
vcard.push_str(notes);
vcard.push_str("\r\n");
}
}
if let Some(bday) = &contact.birthday {
let _ = write!(vcard, "BDAY:{}\r\n", bday.format("%Y-%m-%d"));
@@ -1015,11 +1023,24 @@ pub fn contact_to_vcard(contact: &ContactDto) -> String {
let _ = write!(vcard, "PHOTO;VALUE=URI:{}\r\n", photo);
}
let _ = write!(
vcard,
"REV:{}\r\n",
contact.updated_at.format("%Y%m%dT%H%M%SZ")
);
// REV via the stack renderer — chrono's `.format("%Y%m%dT%H%M%SZ")` runs the
// strftime interpreter and allocates per contact (benches/ROUND19.md §V2:
// 11.8× faster, 3→0 allocs). Out-of-range falls back to chrono.
let mut rev_buf = [0u8; 16];
match crate::common::fmt::compact_ical_utc(&mut rev_buf, contact.updated_at.timestamp()) {
Some(rev) => {
vcard.push_str("REV:");
vcard.push_str(rev);
vcard.push_str("\r\n");
}
None => {
let _ = write!(
vcard,
"REV:{}\r\n",
contact.updated_at.format("%Y%m%dT%H%M%SZ")
);
}
}
vcard.push_str("END:VCARD\r\n");
vcard
@@ -307,8 +307,20 @@ impl AppPasswordService {
password: &str,
) -> Result<(Uuid, Arc<str>, Arc<str>, SmolStr), DomainError> {
// ── 1. Compute cache key = blake3("username:password") ────────
let cache_key: [u8; 32] =
blake3::hash(format!("{}:{}", username, password).as_bytes()).into();
// Stream the parts into an incremental hasher instead of
// `blake3::hash(format!("{username}:{password}").as_bytes())` — the
// `format!` heap-allocated one throw-away `String` per request (this
// runs before the cache lookup, so even cache hits paid it), and DAV
// sync clients hammer Basic auth on every request. Byte-identical key:
// blake3 is a stream hash, so `hash(a || ":" || b)` == feeding the same
// bytes in order (benches/ROUND19.md §M1).
let cache_key: [u8; 32] = {
let mut h = blake3::Hasher::new();
h.update(username.as_bytes());
h.update(b":");
h.update(password.as_bytes());
h.finalize().into()
};
// ── 2. Single-flight cache lookup ─────────────────────────────
// Concurrent misses on the same credential coalesce into ONE
+16 -6
View File
@@ -339,12 +339,22 @@ impl ContactService {
let _ = write!(vcard, "BDAY:{}\r\n", birthday.format("%Y%m%d"));
}
// Revision (last update)
let _ = write!(
vcard,
"REV:{}\r\n",
contact.updated_at().format("%Y%m%dT%H%M%SZ")
);
// Revision (last update) — stack renderer, see benches/ROUND19.md §V2.
let mut rev_buf = [0u8; 16];
match crate::common::fmt::compact_ical_utc(&mut rev_buf, contact.updated_at().timestamp()) {
Some(rev) => {
vcard.push_str("REV:");
vcard.push_str(rev);
vcard.push_str("\r\n");
}
None => {
let _ = write!(
vcard,
"REV:{}\r\n",
contact.updated_at().format("%Y%m%dT%H%M%SZ")
);
}
}
vcard.push_str("END:VCARD\r\n");
+7 -2
View File
@@ -641,8 +641,13 @@ impl SearchUseCase for SearchService {
criteria: SearchCriteriaDto,
user_id: Uuid,
) -> Result<Arc<SearchResultsDto>> {
let user_id_str = user_id.to_string();
let cache_key = Self::create_cache_key(&criteria, &user_id_str);
// Stack-encode the UUID (36 ASCII bytes) instead of `to_string()` — the
// hasher sees the identical byte sequence, so the u64 key is unchanged,
// but the per-request heap `String` is gone (the fn doc even claims
// "zero-allocation hashing"). See benches/ROUND19.md §M5.
let mut user_id_buf = [0u8; uuid::fmt::Hyphenated::LENGTH];
let user_id_str = user_id.hyphenated().encode_lower(&mut user_id_buf);
let cache_key = Self::create_cache_key(&criteria, user_id_str);
// Single-flight: collapse N identical concurrent searches into ONE
// execution. `try_get_with` serves the cached result on a hit and, on a
+7 -4
View File
@@ -866,13 +866,16 @@ fn build_trash_cursor(row: &TrashResourceRow, order_by: &str, reverse: bool) ->
/// Convert a raw repository row into the API DTO.
fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
let path = row.path.clone().unwrap_or_default();
// `row` is owned and dropped at fn end, so move its String fields into the
// DTO instead of cloning (the favorites / recent / folder row mappers
// already move these same fields — trash was missed). benches/ROUND19.md §M4.
let path = row.path.unwrap_or_default();
if row.resource_type == "folder" {
let resource_id = row.resource_id.to_string();
let dto = FolderDto {
etag: resource_id.clone(),
id: resource_id,
name: row.name.clone(),
name: row.name,
path,
parent_id: row.parent_id.map(|u| u.to_string()),
// D2b: the trash listing query now SELECTs `drive_id` (the
@@ -906,7 +909,7 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
// match GET/HEAD/PROPFIND ETags — a client restoring a
// file may conditional-request it immediately after.
let modified_at_u = row.modified_at.timestamp() as u64;
let content_hash = row.blob_hash.clone().unwrap_or_default();
let content_hash = row.blob_hash.unwrap_or_default();
let etag = if content_hash.is_empty() {
String::new()
} else {
@@ -915,7 +918,7 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
let classes = classify_display(&row.name, mime);
let dto = FileDto {
id: row.resource_id.to_string(),
name: row.name.clone(),
name: row.name,
path,
size: size_bytes,
mime_type: intern_mime(mime),
+26 -27
View File
@@ -31,14 +31,24 @@ pub struct WopiTokenClaims {
/// Service for generating and validating WOPI access tokens.
pub struct WopiTokenService {
secret: String,
/// Pre-built signing key — `EncodingKey::from_secret` copies the secret into
/// a fresh `Vec` on each call, so build it once (mirrors `JwtTokenService`).
encoding_key: EncodingKey,
/// Pre-built verification key — same copy-per-call cost as `encoding_key`.
decoding_key: DecodingKey,
/// Pre-built HS256 validation config — `Validation::new` allocates a
/// `required_spec_claims` HashSet + an `algorithms` Vec; Office/Collabora
/// hosts poll `validate_token` continuously (benches/ROUND19.md §M2).
validation: Validation,
token_ttl_secs: i64,
}
impl WopiTokenService {
pub fn new(secret: String, token_ttl_secs: i64) -> Self {
Self {
secret,
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
validation: Validation::new(Algorithm::HS256),
token_ttl_secs,
}
}
@@ -64,12 +74,7 @@ impl WopiTokenService {
iat: now,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(self.secret.as_bytes()),
)
.map_err(|e| {
let token = encode(&Header::default(), &claims, &self.encoding_key).map_err(|e| {
DomainError::new(
ErrorKind::InternalError,
"WopiTokenService",
@@ -83,25 +88,19 @@ impl WopiTokenService {
/// Validate a WOPI access token and extract its claims.
pub fn validate_token(&self, token: &str) -> Result<WopiTokenClaims, DomainError> {
let validation = Validation::new(Algorithm::HS256);
let token_data = decode::<WopiTokenClaims>(
token,
&DecodingKey::from_secret(self.secret.as_bytes()),
&validation,
)
.map_err(|e| match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => DomainError::new(
ErrorKind::AccessDenied,
"WopiTokenService",
"WOPI token expired",
),
_ => DomainError::new(
ErrorKind::AccessDenied,
"WopiTokenService",
format!("Invalid WOPI token: {}", e),
),
})?;
let token_data = decode::<WopiTokenClaims>(token, &self.decoding_key, &self.validation)
.map_err(|e| match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => DomainError::new(
ErrorKind::AccessDenied,
"WopiTokenService",
"WOPI token expired",
),
_ => DomainError::new(
ErrorKind::AccessDenied,
"WopiTokenService",
format!("Invalid WOPI token: {}", e),
),
})?;
let claims = token_data.claims;