perf: round 20 — iCal/vCard parse allocs, owned-DTO moves, Result-collect pre-size, NC etag/favorites emit
Benchmark-gated (benches/ROUND20.md), same rule as rounds 2-19: every change ships with a BEFORE/AFTER counting-allocator micro-benchmark and a byte-value equivalence gate; a non-winning AFTER is rolled back (never applied). The rollback rule is encoded in the harness (GATE FAIL exit). All 8 sections pass. Reproduce: cargo run --release --features bench --example bench_round20_micro - A1 CalendarEvent iCal parse: replace the throwaway per-property HashMap<String,Vec<String>> (DTSTART/DTEND/RECURRENCE-ID) with a direct VALUE=DATE scan; prop_with_params kept #[cfg(test)] (6->2 allocs/event, 4.2x) - A2 UserDto::from: add User::into_parts and MOVE image (<=512 KiB data URI) + ui_preferences JSON instead of cloning on every /api/auth/me (27->14 allocs) - A3 parse_vcard: drop the per-line to_ascii_uppercase copy + the lines Vec; promote ascii_ci_contains to common::text and share it (8->1 allocs/contact) - A4 Calendar/AddressBook DTO: into_parts move incl. custom_properties map (18->10) - I1 file-listing repos: collect::<Result<Vec>>() size-hints to 0 and grows from capacity 0; pre-size with Vec::with_capacity (8->1 container reallocs, 4 sites) - I4 plaintext_stream: lazy emit iterator instead of eager Vec collect (43x wall) - C1 NC write_etag_element: borrowed pre-escaped quote events, no owned quoted String/escape re-alloc; byte-identical output (3->0 allocs/PROPFIND row) - C3 NC favorites REPORT: map.remove() move instead of get().clone() (~7 allocs/fav) Deferred (documented in ROUND20.md): NC oc:id/trashbin buffer reuse, I1 sibling CardDAV/CalDAV listing paths, Contact JSONB Json<Vec<_>> decode, dedup settle_batch &str bind, and a fast DoS-resistant hasher for hot trusted-key maps (needs a dependency decision). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JsJjcVX9RoN96DMa35Wqzd
This commit is contained in:
@@ -31,15 +31,18 @@ impl Default for AddressBookDto {
|
||||
|
||||
impl From<AddressBook> for AddressBookDto {
|
||||
fn from(book: AddressBook) -> Self {
|
||||
// Owned entity → move the owned fields instead of cloning through the
|
||||
// borrowing accessors (benches/ROUND20.md §A4).
|
||||
let p = book.into_parts();
|
||||
Self {
|
||||
id: book.id().to_string(),
|
||||
name: book.name().to_string(),
|
||||
owner_id: book.owner_id().to_string(),
|
||||
description: book.description().map(|s| s.to_string()),
|
||||
color: book.color().map(|s| s.to_string()),
|
||||
is_public: book.is_public(),
|
||||
created_at: *book.created_at(),
|
||||
updated_at: *book.updated_at(),
|
||||
id: p.id.to_string(),
|
||||
name: p.name,
|
||||
owner_id: p.owner_id,
|
||||
description: p.description,
|
||||
color: p.color,
|
||||
is_public: p.is_public,
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,16 +36,20 @@ impl Default for CalendarDto {
|
||||
|
||||
impl From<Calendar> for CalendarDto {
|
||||
fn from(calendar: Calendar) -> Self {
|
||||
// `calendar` is owned and dropped here — move the heap fields (notably
|
||||
// the `custom_properties` HashMap) instead of cloning them through the
|
||||
// borrowing accessors (benches/ROUND20.md §A4).
|
||||
let p = calendar.into_parts();
|
||||
Self {
|
||||
id: calendar.id().to_string(),
|
||||
name: calendar.name().to_string(),
|
||||
owner_id: calendar.owner_id().to_string(),
|
||||
description: calendar.description().map(|s| s.to_string()),
|
||||
color: calendar.color().map(|s| s.to_string()),
|
||||
id: p.id.to_string(),
|
||||
name: p.name,
|
||||
owner_id: p.owner_id.to_string(),
|
||||
description: p.description,
|
||||
color: p.color,
|
||||
is_public: false, // This needs to be set separately as it's not part of the domain entity
|
||||
created_at: *calendar.created_at(),
|
||||
updated_at: *calendar.updated_at(),
|
||||
custom_properties: calendar.custom_properties().clone(),
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
custom_properties: p.custom_properties,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,27 +75,37 @@ pub struct UserDto {
|
||||
|
||||
impl From<User> for UserDto {
|
||||
fn from(user: User) -> Self {
|
||||
// `user` is owned and dropped here, so every owned field is MOVED out
|
||||
// via `into_parts` rather than cloned through the borrowing accessors —
|
||||
// the accessor form deep-cloned `image` (a data URI up to 512 KiB) and
|
||||
// the whole `ui_preferences` JSON tree on every `/api/auth/me` and admin
|
||||
// user listing (benches/ROUND20.md §A2). The two derived values read the
|
||||
// entity before the move.
|
||||
let role = format!("{}", user.role());
|
||||
let can_edit_image = !user.is_oidc_user();
|
||||
let p = user.into_parts();
|
||||
Self {
|
||||
id: user.id().to_string(),
|
||||
username: user.username().map(str::to_string),
|
||||
email: user.email().to_string(),
|
||||
role: format!("{}", user.role()),
|
||||
storage_quota_bytes: user.storage_quota_bytes(),
|
||||
storage_used_bytes: user.storage_used_bytes(),
|
||||
created_at: user.created_at(),
|
||||
updated_at: user.updated_at(),
|
||||
last_login_at: user.last_login_at(),
|
||||
active: user.is_active(),
|
||||
auth_provider: user.oidc_provider().unwrap_or("local").to_string(),
|
||||
image: user.image().map(|s| s.to_string()),
|
||||
can_edit_image: !user.is_oidc_user(),
|
||||
is_external: user.is_external(),
|
||||
given_name: user.given_name().map(str::to_string),
|
||||
family_name: user.family_name().map(str::to_string),
|
||||
email_verified_at: user.email_verified_at(),
|
||||
preferred_locale: user.preferred_locale().map(str::to_string),
|
||||
notify_on_share: user.notify_on_share(),
|
||||
ui_preferences: user.ui_preferences().clone(),
|
||||
id: p.id.to_string(),
|
||||
username: p.username,
|
||||
email: p.email,
|
||||
role,
|
||||
storage_quota_bytes: p.storage_quota_bytes,
|
||||
storage_used_bytes: p.storage_used_bytes,
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
last_login_at: p.last_login_at,
|
||||
active: p.active,
|
||||
// Some(provider) moves the String; None still allocates "local".
|
||||
auth_provider: p.oidc_provider.unwrap_or_else(|| "local".to_string()),
|
||||
image: p.image,
|
||||
can_edit_image,
|
||||
is_external: p.is_external,
|
||||
given_name: p.given_name,
|
||||
family_name: p.family_name,
|
||||
email_verified_at: p.email_verified_at,
|
||||
preferred_locale: p.preferred_locale,
|
||||
notify_on_share: p.notify_on_share,
|
||||
ui_preferences: p.ui_preferences,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::application::ports::carddav_ports::{
|
||||
AddressBookUseCase, ContactStoragePort, ContactUseCase,
|
||||
};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::common::text::ascii_ci_contains;
|
||||
use crate::domain::entities::contact::{Address, AddressBook, Contact, ContactGroup, Email, Phone};
|
||||
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
|
||||
@@ -113,9 +114,11 @@ impl ContactService {
|
||||
|
||||
let mut contact = Contact::default();
|
||||
|
||||
let lines: Vec<&str> = vcard_data.lines().collect();
|
||||
|
||||
for line in &lines {
|
||||
// Iterate lines() directly — the previous `Vec<&str>` collect was only
|
||||
// ever iterated once. Per EMAIL/TEL/ADR line the `TYPE=` routing uses
|
||||
// the allocation-free `ascii_ci_contains` instead of a throwaway
|
||||
// `line.to_ascii_uppercase()` copy (benches/ROUND20.md §A3).
|
||||
for line in vcard_data.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
if let Some(stripped) = line.strip_prefix("FN:") {
|
||||
@@ -132,10 +135,10 @@ impl ContactService {
|
||||
// from value parsing.
|
||||
let value = line.split_once(':').map(|(_, v)| v.trim()).unwrap_or("");
|
||||
if !value.is_empty() {
|
||||
let params_upper = line.to_ascii_uppercase();
|
||||
let email_type = if params_upper.contains("TYPE=HOME") {
|
||||
let lb = line.as_bytes();
|
||||
let email_type = if ascii_ci_contains(lb, b"TYPE=HOME") {
|
||||
"home"
|
||||
} else if params_upper.contains("TYPE=WORK") {
|
||||
} else if ascii_ci_contains(lb, b"TYPE=WORK") {
|
||||
"work"
|
||||
} else {
|
||||
"other"
|
||||
@@ -167,16 +170,16 @@ impl ContactService {
|
||||
// and dropped lowercase to "other" — matches
|
||||
// the shape python-caldav / Apple Contacts
|
||||
// emit.
|
||||
let params_upper = line.to_ascii_uppercase();
|
||||
let phone_type = if params_upper.contains("TYPE=CELL")
|
||||
|| params_upper.contains("TYPE=MOBILE")
|
||||
let lb = line.as_bytes();
|
||||
let phone_type = if ascii_ci_contains(lb, b"TYPE=CELL")
|
||||
|| ascii_ci_contains(lb, b"TYPE=MOBILE")
|
||||
{
|
||||
"mobile"
|
||||
} else if params_upper.contains("TYPE=HOME") {
|
||||
} else if ascii_ci_contains(lb, b"TYPE=HOME") {
|
||||
"home"
|
||||
} else if params_upper.contains("TYPE=WORK") {
|
||||
} else if ascii_ci_contains(lb, b"TYPE=WORK") {
|
||||
"work"
|
||||
} else if params_upper.contains("TYPE=FAX") {
|
||||
} else if ascii_ci_contains(lb, b"TYPE=FAX") {
|
||||
"fax"
|
||||
} else {
|
||||
"other"
|
||||
@@ -209,10 +212,10 @@ impl ContactService {
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
};
|
||||
let params_upper = line.to_ascii_uppercase();
|
||||
let addr_type = if params_upper.contains("TYPE=HOME") {
|
||||
let lb = line.as_bytes();
|
||||
let addr_type = if ascii_ci_contains(lb, b"TYPE=HOME") {
|
||||
"home"
|
||||
} else if params_upper.contains("TYPE=WORK") {
|
||||
} else if ascii_ci_contains(lb, b"TYPE=WORK") {
|
||||
"work"
|
||||
} else {
|
||||
"other"
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::application::ports::content_index_ports::{ContentHitDto, ContentIndex
|
||||
use crate::application::ports::inbound::SearchUseCase;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::common::errors::Result;
|
||||
use crate::common::text::ascii_ci_contains;
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
@@ -183,20 +184,6 @@ fn compute_relevance(name: &str, query_lower: &str) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -6,3 +6,4 @@ pub mod locale;
|
||||
pub mod mime_detect;
|
||||
pub mod runtime;
|
||||
pub mod stubs;
|
||||
pub mod text;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Small allocation-free text predicates shared across the hot parse paths.
|
||||
|
||||
/// ASCII case-insensitive substring test — the allocation-free equivalent of
|
||||
/// `haystack_lower.contains(needle_lower)` when both are ASCII.
|
||||
///
|
||||
/// Callers pass an already-upper/lower-cased `needle` and get the same boolean
|
||||
/// `haystack.to_ascii_uppercase().contains(NEEDLE)` would, without the
|
||||
/// throwaway per-call `String`. Used by the search name-match classifier and by
|
||||
/// `ContactService::parse_vcard`'s per-line `TYPE=` routing
|
||||
/// (benches/ROUND20.md §A3).
|
||||
pub 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))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matches_uppercase_contains() {
|
||||
// Parity with the `to_ascii_uppercase().contains(NEEDLE)` shape it
|
||||
// replaced, across mixed case and the empty/oversize edge cases.
|
||||
let cases: &[(&str, &str)] = &[
|
||||
("EMAIL;TYPE=home:a@b.com", "TYPE=HOME"),
|
||||
("EMAIL;type=Work:a@b.com", "TYPE=WORK"),
|
||||
("TEL;TYPE=CELL:+1", "TYPE=CELL"),
|
||||
("TEL;TYPE=voice:+1", "TYPE=CELL"),
|
||||
("ADR;TYPE=Home:;;x", "TYPE=WORK"),
|
||||
("", "TYPE=HOME"),
|
||||
("short", "a-very-long-needle"),
|
||||
];
|
||||
for (hay, needle) in cases {
|
||||
let reference = hay.to_ascii_uppercase().contains(needle);
|
||||
assert_eq!(
|
||||
ascii_ci_contains(hay.as_bytes(), needle.as_bytes()),
|
||||
reference,
|
||||
"mismatch for haystack={hay:?} needle={needle:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_needle_is_true() {
|
||||
assert!(ascii_ci_contains(b"anything", b""));
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,48 @@ pub struct Calendar {
|
||||
custom_properties: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Owned decomposition of a [`Calendar`] (mirrors `FileParts`/`UserParts`).
|
||||
/// Lets `CalendarDto::from` MOVE the heap fields — notably the
|
||||
/// `custom_properties` map — instead of cloning them on every CalDAV discovery
|
||||
/// listing (benches/ROUND20.md §A4).
|
||||
pub struct CalendarParts {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub owner_id: Uuid,
|
||||
pub description: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub custom_properties: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Calendar {
|
||||
/// Decompose into [`CalendarParts`], moving every owned field out
|
||||
/// (exhaustive destructure — compiler-checked against added fields).
|
||||
pub fn into_parts(self) -> CalendarParts {
|
||||
let Calendar {
|
||||
id,
|
||||
name,
|
||||
owner_id,
|
||||
description,
|
||||
color,
|
||||
created_at,
|
||||
updated_at,
|
||||
custom_properties,
|
||||
} = self;
|
||||
CalendarParts {
|
||||
id,
|
||||
name,
|
||||
owner_id,
|
||||
description,
|
||||
color,
|
||||
created_at,
|
||||
updated_at,
|
||||
custom_properties,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Calendar {
|
||||
/**
|
||||
* Creates a new calendar with the given properties.
|
||||
|
||||
@@ -297,8 +297,12 @@ impl CalendarEvent {
|
||||
// rather than scanning the raw property line. The pre-parser-
|
||||
// rewrite substring scan couldn't see param-carrying lines at
|
||||
// all — see #528.
|
||||
let (dtstart_value, dtstart_params) = Self::prop_with_params(&event, "DTSTART")
|
||||
.ok_or_else(|| {
|
||||
// DTSTART carries the value AND the all-day flag: a `VALUE=DATE`
|
||||
// parameter (RFC 5545 §3.3.4) means date-only. Strict — only "DATE"
|
||||
// (case-insensitive) counts; "DATE-TIME" and anything else is timed.
|
||||
// The flag drives both the DTSTART and the DTEND datetime parse below.
|
||||
let (dtstart_value, all_day) =
|
||||
Self::prop_value_and_is_date(&event, "DTSTART").ok_or_else(|| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"CalendarEvent",
|
||||
@@ -306,25 +310,14 @@ impl CalendarEvent {
|
||||
)
|
||||
})?;
|
||||
|
||||
let (dtend_value, _dtend_params) =
|
||||
Self::prop_with_params(&event, "DTEND").ok_or_else(|| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"CalendarEvent",
|
||||
"Missing DTEND in iCalendar data",
|
||||
)
|
||||
})?;
|
||||
|
||||
// All-day detection: `VALUE=DATE` parameter on DTSTART.
|
||||
// Falls back to `false` when the parameter is absent, matching
|
||||
// RFC 5545 §3.3.4 ("If the property permits, multiple 'VALUE'
|
||||
// parameters can be specified as a comma-separated list") —
|
||||
// we're strict: only "DATE" (case-insensitive) counts, "DATE-TIME"
|
||||
// and anything else means timed.
|
||||
let all_day = dtstart_params
|
||||
.get("VALUE")
|
||||
.map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE")))
|
||||
.unwrap_or(false);
|
||||
// DTEND needs only its value (the all-day flag comes from DTSTART).
|
||||
let dtend_value = Self::prop_value(&event, "DTEND").ok_or_else(|| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"CalendarEvent",
|
||||
"Missing DTEND in iCalendar data",
|
||||
)
|
||||
})?;
|
||||
|
||||
let start_time = Self::parse_ical_datetime(&dtstart_value, all_day).map_err(|e| {
|
||||
DomainError::new(
|
||||
@@ -359,14 +352,8 @@ impl CalendarEvent {
|
||||
// gets stored, just as a plain event (worst case a client sync
|
||||
// treats it as a new master, which the DB uniqueness will
|
||||
// refuse; better a persistence error than a silent split).
|
||||
let recurrence_id = match Self::prop_with_params(&event, "RECURRENCE-ID") {
|
||||
Some((value, params)) => {
|
||||
let is_date = params
|
||||
.get("VALUE")
|
||||
.map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE")))
|
||||
.unwrap_or(false);
|
||||
Self::parse_ical_datetime(&value, is_date).ok()
|
||||
}
|
||||
let recurrence_id = match Self::prop_value_and_is_date(&event, "RECURRENCE-ID") {
|
||||
Some((value, is_date)) => Self::parse_ical_datetime(&value, is_date).ok(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
@@ -701,23 +688,20 @@ impl CalendarEvent {
|
||||
// (they need to know whether the value is a date or a datetime).
|
||||
let dtstart_pair = event
|
||||
.as_ref()
|
||||
.and_then(|e| Self::prop_with_params(e, "DTSTART"));
|
||||
.and_then(|e| Self::prop_value_and_is_date(e, "DTSTART"));
|
||||
let all_day = dtstart_pair
|
||||
.as_ref()
|
||||
.and_then(|(_v, params)| params.get("VALUE"))
|
||||
.map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE")))
|
||||
.map(|(_v, is_date)| *is_date)
|
||||
.unwrap_or(false);
|
||||
self.all_day = all_day;
|
||||
|
||||
if let Some((value, _params)) = &dtstart_pair
|
||||
if let Some((value, _is_date)) = &dtstart_pair
|
||||
&& let Ok(start_time) = Self::parse_ical_datetime(value, all_day)
|
||||
{
|
||||
self.start_time = start_time;
|
||||
}
|
||||
|
||||
if let Some((value, _params)) = event
|
||||
.as_ref()
|
||||
.and_then(|e| Self::prop_with_params(e, "DTEND"))
|
||||
if let Some(value) = event.as_ref().and_then(|e| Self::prop_value(e, "DTEND"))
|
||||
&& let Ok(end_time) = Self::parse_ical_datetime(&value, all_day)
|
||||
{
|
||||
self.end_time = end_time;
|
||||
@@ -861,12 +845,52 @@ impl CalendarEvent {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
|
||||
/// Read a property's trimmed value plus whether it carries a
|
||||
/// case-insensitive `VALUE=DATE` parameter (the all-day / date-only
|
||||
/// marker) — the ONLY thing `from_ical` / `update_ical_data` ever asked the
|
||||
/// parameter map for. Scans `prop.params` directly, so DTSTART / DTEND /
|
||||
/// RECURRENCE-ID no longer build a throwaway
|
||||
/// `HashMap<String, Vec<String>>` (uppercased keys + cloned value Vecs) per
|
||||
/// event on every CalDAV PUT / iCal import (benches/ROUND20.md §A1).
|
||||
///
|
||||
/// `.rev().find(...)` preserves the old map's last-insert-wins semantics for
|
||||
/// the (pathological) duplicate-`VALUE` case, so the flag is byte-identical.
|
||||
fn prop_value_and_is_date(
|
||||
event: &ical::parser::ical::component::IcalEvent,
|
||||
property_name: &str,
|
||||
) -> Option<(String, bool)> {
|
||||
let prop = event
|
||||
.properties
|
||||
.iter()
|
||||
.find(|p| p.name.eq_ignore_ascii_case(property_name))?;
|
||||
let trimmed = prop.value.as_deref()?.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let is_date = prop
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|list| {
|
||||
list.iter()
|
||||
.rev()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case("VALUE"))
|
||||
})
|
||||
.map(|(_, vs)| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE")))
|
||||
.unwrap_or(false);
|
||||
Some((trimmed.to_string(), is_date))
|
||||
}
|
||||
|
||||
/// Read a property's trimmed value AND parameter map from an
|
||||
/// already-parsed VEVENT. The map is keyed by parameter name
|
||||
/// (`"VALUE"`, `"TZID"`, `"CN"`, …) whose value is the list of
|
||||
/// parameter values (parameters can be multi-valued —
|
||||
/// `MEMBER="mailto:a@x","mailto:b@x"` — hence the `Vec<String>`
|
||||
/// per key).
|
||||
///
|
||||
/// Retained only for the `#[cfg(test)]` `extract_ical_property_with_params`
|
||||
/// wrapper; production parses once and uses [`Self::prop_value_and_is_date`]
|
||||
/// / [`Self::prop_value`].
|
||||
#[cfg(test)]
|
||||
fn prop_with_params(
|
||||
event: &ical::parser::ical::component::IcalEvent,
|
||||
property_name: &str,
|
||||
|
||||
@@ -13,6 +13,48 @@ pub struct AddressBook {
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Owned decomposition of an [`AddressBook`] (mirrors `FileParts`/`UserParts`).
|
||||
/// Lets `AddressBookDto::from` MOVE `name`/`description`/`color`/`owner_id`
|
||||
/// instead of cloning them on every CardDAV discovery listing
|
||||
/// (benches/ROUND20.md §A4).
|
||||
pub struct AddressBookParts {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub owner_id: String,
|
||||
pub description: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub is_public: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl AddressBook {
|
||||
/// Decompose into [`AddressBookParts`], moving every owned field out
|
||||
/// (exhaustive destructure — compiler-checked against added fields).
|
||||
pub fn into_parts(self) -> AddressBookParts {
|
||||
let AddressBook {
|
||||
id,
|
||||
name,
|
||||
owner_id,
|
||||
description,
|
||||
color,
|
||||
is_public,
|
||||
created_at,
|
||||
updated_at,
|
||||
} = self;
|
||||
AddressBookParts {
|
||||
id,
|
||||
name,
|
||||
owner_id,
|
||||
description,
|
||||
color,
|
||||
is_public,
|
||||
created_at,
|
||||
updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AddressBook {
|
||||
/// Creates a new AddressBook with generated id and timestamps
|
||||
pub fn new(
|
||||
|
||||
@@ -135,7 +135,88 @@ pub struct User {
|
||||
ui_preferences: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Owned decomposition of a [`User`] (mirrors `FileParts` / `FolderParts` /
|
||||
/// `ContactParts`). Lets a consumer MOVE the heap fields out instead of cloning
|
||||
/// them through the borrowing accessors — notably `image` (a data URI up to
|
||||
/// 512 KiB) and `ui_preferences` (a JSON tree). See `UserDto::from`
|
||||
/// (benches/ROUND20.md §A2).
|
||||
pub struct UserParts {
|
||||
pub id: Uuid,
|
||||
pub username: Option<String>,
|
||||
pub email: String,
|
||||
pub password_hash: Option<String>,
|
||||
pub role: UserRole,
|
||||
pub storage_quota_bytes: i64,
|
||||
pub storage_used_bytes: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
pub active: bool,
|
||||
pub oidc_provider: Option<String>,
|
||||
pub oidc_subject: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub is_external: bool,
|
||||
pub given_name: Option<String>,
|
||||
pub family_name: Option<String>,
|
||||
pub email_verified_at: Option<DateTime<Utc>>,
|
||||
pub preferred_locale: Option<String>,
|
||||
pub notify_on_share: bool,
|
||||
pub ui_preferences: serde_json::Value,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Decompose into [`UserParts`], moving every owned field out. The
|
||||
/// exhaustive destructure is compiler-checked, so a future field can't be
|
||||
/// silently dropped.
|
||||
pub fn into_parts(self) -> UserParts {
|
||||
let User {
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
role,
|
||||
storage_quota_bytes,
|
||||
storage_used_bytes,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_login_at,
|
||||
active,
|
||||
oidc_provider,
|
||||
oidc_subject,
|
||||
image,
|
||||
is_external,
|
||||
given_name,
|
||||
family_name,
|
||||
email_verified_at,
|
||||
preferred_locale,
|
||||
notify_on_share,
|
||||
ui_preferences,
|
||||
} = self;
|
||||
UserParts {
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
role,
|
||||
storage_quota_bytes,
|
||||
storage_used_bytes,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_login_at,
|
||||
active,
|
||||
oidc_provider,
|
||||
oidc_subject,
|
||||
image,
|
||||
is_external,
|
||||
given_name,
|
||||
family_name,
|
||||
email_verified_at,
|
||||
preferred_locale,
|
||||
notify_on_share,
|
||||
ui_preferences,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new user.
|
||||
///
|
||||
/// One unified constructor for every kind of user (internal, OIDC-linked,
|
||||
|
||||
@@ -293,16 +293,20 @@ impl FileBlobReadRepository {
|
||||
DomainError::internal_error("FileBlobRead", format!("hydrate by ids: {e}"))
|
||||
})?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| {
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("hydrate mapping: {e}"))
|
||||
})
|
||||
// Pre-size the result Vec. `collect::<Result<Vec<_>, _>>()` size-hints
|
||||
// to 0 (the Result shunt may short-circuit on any element), so the Vec
|
||||
// grows from capacity 0 — ~⌈log₂N⌉ reallocations, memcpy-ing the
|
||||
// accumulated File rows each grow (benches/ROUND20.md §I1).
|
||||
let mut files = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub) in rows {
|
||||
files.push(
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("hydrate mapping: {e}"))
|
||||
})?,
|
||||
);
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Batch-fetch files by id — the by-ids counterpart of [`get_file`],
|
||||
@@ -337,19 +341,21 @@ impl FileBlobReadRepository {
|
||||
DomainError::internal_error("FileBlobRead", format!("get_files_by_ids: {e}"))
|
||||
})?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| {
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FileBlobRead",
|
||||
format!("get_files_by_ids mapping: {e}"),
|
||||
)
|
||||
})
|
||||
// Pre-size the result Vec (see the size-hint note in `hydrate`,
|
||||
// benches/ROUND20.md §I1).
|
||||
let mut files = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub) in rows {
|
||||
files.push(
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FileBlobRead",
|
||||
format!("get_files_by_ids mapping: {e}"),
|
||||
)
|
||||
})?,
|
||||
);
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Returns `drive_id` for a given file. Drives the permission-floor
|
||||
@@ -1254,15 +1260,16 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
// total_count is the same in every row; 0 when result set is empty.
|
||||
let total_count = rows.first().map_or(0, |r| r.11) as usize;
|
||||
|
||||
let files = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total)| {
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("mapping: {e}")))?;
|
||||
// Pre-size the result Vec (size-hint note in `hydrate`, ROUND20 §I1).
|
||||
let mut files = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total) in rows {
|
||||
files.push(
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("mapping: {e}"))
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok((files, total_count))
|
||||
}
|
||||
@@ -1384,17 +1391,16 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
|
||||
let total_count = rows.first().map_or(0, |r| r.11) as usize;
|
||||
|
||||
let files = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total)| {
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("subtree mapping: {e}"))
|
||||
})?;
|
||||
// Pre-size the result Vec (size-hint note in `hydrate`, ROUND20 §I1).
|
||||
let mut files = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total) in rows {
|
||||
files.push(
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("subtree mapping: {e}"))
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok((files, total_count))
|
||||
}
|
||||
|
||||
@@ -140,13 +140,18 @@ where
|
||||
}
|
||||
|
||||
/// Turn a decrypted payload into a stream of bounded, zero-copy slices.
|
||||
///
|
||||
/// The emit-slice iterator is handed to `stream::iter` lazily — the closure
|
||||
/// owns `data` (a refcounted `Bytes`), so each `slice` is produced on demand
|
||||
/// as the consumer polls, rather than eagerly `collect`ing a `Vec` of
|
||||
/// ⌈len/64 KiB⌉ slice handles up front (benches/ROUND20.md §I4).
|
||||
fn plaintext_stream(data: Bytes) -> BlobStream {
|
||||
let len = data.len();
|
||||
let slices: Vec<Result<Bytes, std::io::Error>> = (0..len)
|
||||
.step_by(PLAINTEXT_EMIT_SIZE)
|
||||
.map(|off| Ok(data.slice(off..len.min(off + PLAINTEXT_EMIT_SIZE))))
|
||||
.collect();
|
||||
Box::pin(futures::stream::iter(slices))
|
||||
Box::pin(futures::stream::iter(
|
||||
(0..len)
|
||||
.step_by(PLAINTEXT_EMIT_SIZE)
|
||||
.map(move |off| Ok(data.slice(off..len.min(off + PLAINTEXT_EMIT_SIZE)))),
|
||||
))
|
||||
}
|
||||
|
||||
impl BlobStorageBackend for EncryptedBlobBackend {
|
||||
|
||||
@@ -114,14 +114,14 @@ async fn handle_filter_files(
|
||||
}
|
||||
}
|
||||
|
||||
let file_map: HashMap<String, FileDto> = file_service
|
||||
let mut file_map: HashMap<String, FileDto> = file_service
|
||||
.get_files_by_ids(&file_ids)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to resolve favorite files: {e}")))?
|
||||
.into_iter()
|
||||
.map(|f| (f.id.clone(), f))
|
||||
.collect();
|
||||
let folder_map: HashMap<String, FolderDto> = folder_service
|
||||
let mut folder_map: HashMap<String, FolderDto> = folder_service
|
||||
.get_folders_by_ids(&folder_ids)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to resolve favorite folders: {e}")))?
|
||||
@@ -131,16 +131,21 @@ async fn handle_filter_files(
|
||||
|
||||
let mut files: Vec<FileDto> = Vec::new();
|
||||
let mut folders: Vec<FolderDto> = Vec::new();
|
||||
// Move the DTO out of the map instead of cloning it: the maps are built
|
||||
// just above solely to hydrate `files`/`folders` in favorites order and are
|
||||
// dropped at fn end, so the clone was pure waste. `favorites.item_id` is
|
||||
// unique per user, so `remove` drops nothing needed and the favorites order
|
||||
// is preserved (benches/ROUND20.md §C3).
|
||||
for fav in &favorites {
|
||||
match fav.item_type.as_str() {
|
||||
"file" => {
|
||||
if let Some(f) = file_map.get(&fav.item_id) {
|
||||
files.push(f.clone());
|
||||
if let Some(f) = file_map.remove(&fav.item_id) {
|
||||
files.push(f);
|
||||
}
|
||||
}
|
||||
"folder" => {
|
||||
if let Some(f) = folder_map.get(&fav.item_id) {
|
||||
folders.push(f.clone());
|
||||
if let Some(f) = folder_map.remove(&fav.item_id) {
|
||||
folders.push(f);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -1990,18 +1990,30 @@ pub fn write_date_element<W: std::io::Write>(
|
||||
}
|
||||
}
|
||||
|
||||
/// `d:getetag` with the HTTP quoting — one exactly-sized allocation
|
||||
/// instead of `format!`'s grow-from-empty.
|
||||
/// `d:getetag` with the HTTP quoting — zero allocations.
|
||||
///
|
||||
/// The two `"` quotes are emitted as borrowed pre-escaped text events around
|
||||
/// the escaped etag body. `quick_xml` renders a literal `"` as `"`, so
|
||||
/// this is byte-identical to escaping `"{etag}"` as one owned string — but with
|
||||
/// no `with_capacity` quoted String and no escape re-allocation (the whole-string
|
||||
/// escape re-allocated an owned Cow because the string contained `"`). On a
|
||||
/// 500-child PROPFIND page this is called per file AND per folder row
|
||||
/// (benches/ROUND20.md §C1: 3 → 0 allocs/row).
|
||||
pub fn write_etag_element<W: std::io::Write>(
|
||||
xml: &mut Writer<W>,
|
||||
tag: &str,
|
||||
etag: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut quoted = String::with_capacity(etag.len() + 2);
|
||||
quoted.push('"');
|
||||
quoted.push_str(etag);
|
||||
quoted.push('"');
|
||||
write_text_element(xml, tag, "ed)
|
||||
xml.write_event(Event::Start(BytesStart::new(tag)))
|
||||
.xml_err()?;
|
||||
xml.write_event(Event::Text(BytesText::from_escaped(""")))
|
||||
.xml_err()?;
|
||||
xml.write_event(Event::Text(BytesText::new(etag)))
|
||||
.xml_err()?;
|
||||
xml.write_event(Event::Text(BytesText::from_escaped(""")))
|
||||
.xml_err()?;
|
||||
xml.write_event(Event::End(BytesEnd::new(tag))).xml_err()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_text_element<W: std::io::Write>(
|
||||
|
||||
Reference in New Issue
Block a user