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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -210,6 +210,37 @@ pub fn hex_lower(bytes: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// `chrono::DateTime<Utc>::format("%Y%m%dT%H%M%SZ")` for a whole-second
|
||||
/// timestamp: the compact iCal/vCard UTC form `20260717T114714Z` (16 bytes)
|
||||
/// written into `buf`.
|
||||
///
|
||||
/// This is the `DTSTAMP` / `REV` / `CREATED` / `LAST-MODIFIED` stamp emitted
|
||||
/// per contact in every CardDAV vCard (`contact_to_vcard` / `generate_vcard`)
|
||||
/// and per event on the calendar create path. chrono's `.format("%Y%m%dT%H%M%SZ")`
|
||||
/// builds a `DelayedFormat` that re-parses the strftime spec (`StrftimeItems`)
|
||||
/// and formats six zero-padded fields through `core::fmt` on every call — the
|
||||
/// exact interpreter cost [`rfc3339_utc`] / [`rfc2822_utc`] were added to
|
||||
/// remove, but neither covers this compact no-separator form.
|
||||
///
|
||||
/// Returns `None` when `secs` is outside the fixed-width range —
|
||||
/// callers fall back to chrono.
|
||||
pub fn compact_ical_utc(buf: &mut [u8; 16], secs: i64) -> Option<&str> {
|
||||
if !(0..=MAX_4DIGIT_YEAR_SECS).contains(&secs) {
|
||||
return None;
|
||||
}
|
||||
let (_days, y, m, d, hh, mm, ss) = split(secs);
|
||||
push4(buf, 0, y);
|
||||
push2(buf, 4, m);
|
||||
push2(buf, 6, d);
|
||||
buf[8] = b'T';
|
||||
push2(buf, 9, hh);
|
||||
push2(buf, 11, mm);
|
||||
push2(buf, 13, ss);
|
||||
buf[15] = b'Z';
|
||||
// SAFETY-free: every byte written above is ASCII.
|
||||
Some(std::str::from_utf8(&buf[..]).expect("ascii"))
|
||||
}
|
||||
|
||||
/// Append the upper-cased form of `s` to `buf` without a temporary `String`.
|
||||
///
|
||||
/// Byte-identical to `buf.push_str(&s.to_uppercase())` — same
|
||||
@@ -305,13 +336,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_ical_matches_chrono() {
|
||||
for &secs in &CASES {
|
||||
let dt = Utc.timestamp_opt(secs, 0).unwrap();
|
||||
let mut buf = [0u8; 16];
|
||||
assert_eq!(
|
||||
compact_ical_utc(&mut buf, secs).expect("in range"),
|
||||
dt.format("%Y%m%dT%H%M%SZ").to_string(),
|
||||
"secs={secs}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_falls_back() {
|
||||
let mut b3 = [0u8; 25];
|
||||
let mut b2 = [0u8; 31];
|
||||
let mut bc = [0u8; 16];
|
||||
assert!(rfc3339_utc(&mut b3, -1).is_none());
|
||||
assert!(rfc2822_utc(&mut b2, -1).is_none());
|
||||
assert!(compact_ical_utc(&mut bc, -1).is_none());
|
||||
assert!(rfc3339_utc(&mut b3, MAX_4DIGIT_YEAR_SECS + 1).is_none());
|
||||
assert!(compact_ical_utc(&mut bc, MAX_4DIGIT_YEAR_SECS + 1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -335,8 +382,13 @@ mod tests {
|
||||
let dt = Utc.timestamp_opt(secs, 0).unwrap();
|
||||
let mut b3 = [0u8; 25];
|
||||
let mut b2 = [0u8; 31];
|
||||
let mut bc = [0u8; 16];
|
||||
assert_eq!(rfc3339_utc(&mut b3, secs).unwrap(), dt.to_rfc3339());
|
||||
assert_eq!(rfc2822_utc(&mut b2, secs).unwrap(), dt.to_rfc2822());
|
||||
assert_eq!(
|
||||
compact_ical_utc(&mut bc, secs).unwrap(),
|
||||
dt.format("%Y%m%dT%H%M%SZ").to_string()
|
||||
);
|
||||
secs += 22_380; // 6h13m — walks through all times of day + weekdays
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,13 +816,15 @@ async fn build_streaming_propfind_response(
|
||||
let mut chunk = Vec::with_capacity(batch.len() * 800);
|
||||
{
|
||||
let mut w = Writer::new(&mut chunk);
|
||||
// One href buffer reused across the page instead of a fresh
|
||||
// `format!` String per child (benches/ROUND19.md §M6).
|
||||
let mut href = String::new();
|
||||
for subfolder in batch.iter() {
|
||||
let child_dead = dead_props_for(&subfolder.id, &subfolder_deads);
|
||||
let href = format!(
|
||||
"{}{}/",
|
||||
base_href,
|
||||
utf8_percent_encode(&subfolder.name, PATH_SEGMENT_ENCODE_SET)
|
||||
);
|
||||
href.clear();
|
||||
href.push_str(&base_href);
|
||||
href.extend(utf8_percent_encode(&subfolder.name, PATH_SEGMENT_ENCODE_SET));
|
||||
href.push('/');
|
||||
WebDavAdapter::write_folder_entry_with_dead_props(&mut w, subfolder, &propfind_request, &href, child_dead, quota)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
@@ -861,13 +863,13 @@ async fn build_streaming_propfind_response(
|
||||
let mut chunk = Vec::with_capacity(batch_len * 800);
|
||||
{
|
||||
let mut w = Writer::new(&mut chunk);
|
||||
// One href buffer reused across the page (benches/ROUND19.md §M6).
|
||||
let mut href = String::new();
|
||||
for file in batch.iter() {
|
||||
let child_dead = dead_props_for(&file.id, &file_deads);
|
||||
let href = format!(
|
||||
"{}{}",
|
||||
base_href,
|
||||
utf8_percent_encode(&file.name, PATH_SEGMENT_ENCODE_SET)
|
||||
);
|
||||
href.clear();
|
||||
href.push_str(&base_href);
|
||||
href.extend(utf8_percent_encode(&file.name, PATH_SEGMENT_ENCODE_SET));
|
||||
WebDavAdapter::write_file_entry_with_dead_props(&mut w, file, &propfind_request, &href, child_dead)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ impl NcSession {
|
||||
///
|
||||
/// Returns `None` for anything that doesn't follow this shape (notably
|
||||
/// the OCS surfaces, where there is no `{user}` segment to compare).
|
||||
fn extract_url_user(path: &str) -> Option<String> {
|
||||
fn extract_url_user(path: &str) -> Option<std::borrow::Cow<'_, str>> {
|
||||
let mut segments = path.split('/');
|
||||
if !segments.next()?.is_empty() {
|
||||
return None;
|
||||
@@ -103,7 +103,11 @@ fn extract_url_user(path: &str) -> Option<String> {
|
||||
if user_seg.is_empty() {
|
||||
return None;
|
||||
}
|
||||
urlencoding::decode(user_seg).ok().map(|s| s.into_owned())
|
||||
// Keep the `Cow` — a plain-ASCII username decodes to `Cow::Borrowed`, so the
|
||||
// common path allocates nothing; only a percent-encoded username owns. The
|
||||
// old `.into_owned()` forced a `String` on EVERY path-scoped NC DAV request
|
||||
// (benches/ROUND19.md §M7). The caller compares by slice.
|
||||
urlencoding::decode(user_seg).ok()
|
||||
}
|
||||
|
||||
/// Axum extractor: the shared handle to the request's [`NcSession`].
|
||||
@@ -151,7 +155,7 @@ impl<S: Send + Sync> FromRequestParts<S> for SharedNcSession {
|
||||
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
|
||||
|
||||
if let Some(url_user) = extract_url_user(parts.uri.path())
|
||||
&& url_user != session.raw_username
|
||||
&& url_user.as_ref() != session.raw_username.as_str()
|
||||
{
|
||||
return Err(StatusCode::FORBIDDEN.into_response());
|
||||
}
|
||||
|
||||
@@ -1609,14 +1609,18 @@ fn build_nc_streaming_propfind(
|
||||
let mut chunk = Vec::with_capacity(batch_len * 1024);
|
||||
{
|
||||
let mut xml = Writer::new(&mut chunk);
|
||||
// One href buffer reused across the page instead of a fresh
|
||||
// format! String per child (benches/ROUND19.md §M6).
|
||||
let mut href = String::new();
|
||||
for file in batch.iter() {
|
||||
let dead = dead_props_for(&file.id, &file_deads);
|
||||
// Only the name varies per row — the encoded
|
||||
// username + parent prefix is computed once
|
||||
// outside the loops (the old `nc_href` call
|
||||
// re-encoded both for every child).
|
||||
let href =
|
||||
format!("{}{}", child_href_prefix, urlencoding::encode(&file.name));
|
||||
href.clear();
|
||||
href.push_str(&child_href_prefix);
|
||||
href.push_str(&urlencoding::encode(&file.name));
|
||||
let fid = nc_id_of(&file_id_map, &file.id);
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_file_response(&mut xml, file, &href, (fid, oc_id.as_deref()), &username, &favs, dead)
|
||||
@@ -1671,12 +1675,16 @@ fn build_nc_streaming_propfind(
|
||||
let mut chunk = Vec::with_capacity(batch.len() * 1024);
|
||||
{
|
||||
let mut xml = Writer::new(&mut chunk);
|
||||
// One href buffer reused across the page (benches/ROUND19.md §M6).
|
||||
let mut href = String::new();
|
||||
for sf in batch.iter() {
|
||||
let dead = dead_props_for(&sf.id, &sub_deads);
|
||||
// Collections carry the trailing slash; prefix
|
||||
// precomputed once like the file loop above.
|
||||
let href =
|
||||
format!("{}{}/", child_href_prefix, urlencoding::encode(&sf.name));
|
||||
href.clear();
|
||||
href.push_str(&child_href_prefix);
|
||||
href.push_str(&urlencoding::encode(&sf.name));
|
||||
href.push('/');
|
||||
let fid = nc_id_of(&sub_id_map, &sf.id);
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_folder_response(&mut xml, sf, &href, (fid, oc_id.as_deref()), &username, &favs, quota, dead)
|
||||
|
||||
Reference in New Issue
Block a user