c51af68432
Benchmark-gated (benches/ROUND10.md; every change carries a BEFORE/AFTER harness with equivalence/safety gates — two designs were rejected or rewritten by their own benches before adoption): - Auth hot path: TokenClaims/CurrentUser display fields to Arc<str>, role to inline SmolStr end-to-end (Bearer, cookie, Basic-auth cache) — 4→1 allocs per authenticated request, 3→0 per warm DAV request; JWT Encoding/Decoding/Validation built once. - Cold shared-album herd: leader-inline parent batching in PgAclEngine (+ cascade try_get_with single-flight) — 100→2 parent queries per 100-thumb cold herd, herd wall 1.9x, sequential + warm paths unchanged, all ROUND8/9 safety gates plus new herd-equivalence gates. - Query-shape pack: share download double-fetch 2→1 (2.18x), contact-group COUNT(*) 14.9x, save_faces UNNEST 3.9x, playlist reorder UNNEST 63.7x (now atomic), search files∥folders join! 1.45x, move drive-lookup join! 2.14x, trash partial (drive_id, trashed_at) indexes, CalDAV event-gate narrow read, favorites/recents binary-decode port, dead count_files removed. - NC surface: preview + avatar honour If-None-Match (e2e: 5 KB and 197 KB → 0 bytes per revalidation), avatar WebP→PNG transcode memoised, PROPFIND/trashbin integer+date emits on stack formatters, folder-header enrichment join!, chunk-PUT retry stat folded into create_new open. - common::fmt integer rendering rewritten on the std 2-digit LUT after the round's own bench caught the div-loop losing to to_string (16.1 ns vs 22.5; speeds every prior-round call site). - Micro-pack: WebDAV scope probe borrow-only, ShareService base_url snapshot, cookie_secure OnceLock, Arc'd AES-GCM cipher, stack request-id, tantivy analyzer clone dropped. - SPA: search stale-guard + AbortController (10→1 completed round-trips, stale-clobber gone), getFolder in-flight dedup, gridColumns matchMedia hoist (10k→0 style reads). Backend: cargo fmt + clippy -D warnings clean, 524 tests green. Frontend: npm run check clean, 301 vitest green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DdM7V7M3QPW7HEHg3gLov
2309 lines
90 KiB
Rust
2309 lines
90 KiB
Rust
use axum::{
|
||
body::{self, Body},
|
||
http::{HeaderName, Request, StatusCode, header},
|
||
response::Response,
|
||
};
|
||
use bytes::{Buf, Bytes};
|
||
use chrono::Utc;
|
||
use quick_xml::{
|
||
Writer,
|
||
events::{BytesEnd, BytesStart, BytesText, Event},
|
||
};
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::sync::Arc;
|
||
use uuid::Uuid;
|
||
|
||
use crate::application::adapters::webdav_adapter::{
|
||
PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property,
|
||
};
|
||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
||
use crate::application::ports::file_ports::{
|
||
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
|
||
};
|
||
use crate::application::ports::folder_ports::FolderUseCase;
|
||
use crate::application::ports::trash_ports::TrashUseCase;
|
||
use crate::common::di::AppState;
|
||
use crate::common::mime_detect::filename_from_path;
|
||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||
use crate::infrastructure::services::webdav_dead_property_store::ResourceRef;
|
||
use crate::interfaces::api::handlers::webdav_handler::{
|
||
PROPFIND_BATCH_SIZE, dead_props_for, file_dead_props, files_dead_props_map, folder_dead_props,
|
||
folders_dead_props_map,
|
||
};
|
||
use crate::interfaces::errors::AppError;
|
||
use crate::interfaces::range_requests::{not_modified_response, range_response};
|
||
use crate::interfaces::upload_ingest::ingest_body_to_cas;
|
||
|
||
/// Extension trait to map XML write errors to `String` concisely.
|
||
trait XmlResultExt<T> {
|
||
fn xml_err(self) -> Result<T, String>;
|
||
}
|
||
|
||
impl<T, E: std::fmt::Display> XmlResultExt<T> for Result<T, E> {
|
||
fn xml_err(self) -> Result<T, String> {
|
||
self.map_err(|e| e.to_string())
|
||
}
|
||
}
|
||
|
||
/// Convert a `u64` timestamp to `i64` safely, falling back to 0 on overflow.
|
||
fn timestamp_to_i64(ts: u64) -> i64 {
|
||
i64::try_from(ts).unwrap_or(0)
|
||
}
|
||
|
||
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
|
||
|
||
/// Resolve the internal OxiCloud path from a NextCloud DAV subpath
|
||
/// and the storage chroot the request is confined to.
|
||
///
|
||
/// `chroot` is the storage path the request is "jailed" inside —
|
||
/// the route glue (`routes.rs::handle_dav_*`) computes it once per
|
||
/// request:
|
||
/// - Legacy `/files/{user}/…` or explicit `~{home_folder_uuid}` →
|
||
/// `"My Folder - {username}"` (no DB lookup needed).
|
||
/// - `~{some_other_folder_uuid}` → the folder's stored `path` after
|
||
/// a `get_folder_with_perms` check (404 if missing / no access).
|
||
///
|
||
/// By the time we get here `chroot` is known to be a legitimate
|
||
/// target — validation and permission live in the route layer, not
|
||
/// in the path mapper. This function stays sync and free of any
|
||
/// folder-service handle. The chroot's `path` is the canonical root
|
||
/// segment (e.g. `"Personal"` for default personal drives provisioned
|
||
/// by D0, the original sibling-root folder name for secondary drives).
|
||
/// Replaces the pre-D0 hardcoded `"My Folder - {username}/"` prefix.
|
||
pub fn nc_to_internal_path(chroot: &FolderDto, subpath: &str) -> Result<String, AppError> {
|
||
let subpath = subpath.trim_matches('/');
|
||
if subpath.is_empty() {
|
||
return Ok(chroot.path.clone());
|
||
}
|
||
// Reject path traversal attempts.
|
||
if subpath.split('/').any(|seg| seg == ".." || seg == ".") {
|
||
return Err(AppError::bad_request("Invalid path: traversal not allowed"));
|
||
}
|
||
|
||
Ok(format!("{}/{}", chroot.path, subpath))
|
||
}
|
||
|
||
/// Strip the caller's chroot prefix from an internal
|
||
/// `storage.folders.path` so the DAV subpath surfaced to the NC
|
||
/// client is chroot-relative. Handles multi-segment chroots
|
||
/// correctly (e.g. a future `"Personal/folderA/subfolder"` chroot
|
||
/// against an item at `"Personal/folderA/subfolder/file.txt"`
|
||
/// returns `"file.txt"`, not `"folderA/subfolder/file.txt"`).
|
||
///
|
||
/// Returns `None` when the path is NOT inside the chroot. Callers
|
||
/// should skip such items from the response (they belong to a
|
||
/// different drive or the caller's read scope has drifted) — do NOT
|
||
/// fall back to a naive segment strip, which would surface a
|
||
/// misleading display path.
|
||
///
|
||
/// **Defensive but not an AuthZ boundary.** Every current caller
|
||
/// reaches items through a `_with_perms` method upstream that
|
||
/// already gates Read; this helper is the display-string layer
|
||
/// that also serves as a "does this item belong under the chroot"
|
||
/// sanity check.
|
||
pub fn strip_chroot_prefix<'a>(chroot: &FolderDto, internal_path: &'a str) -> Option<&'a str> {
|
||
// Normalize both sides: `FolderDto.path` comes from
|
||
// `StoragePath::to_string()` which prepends a leading `/`
|
||
// (e.g. `"/Personal"`), but DB-side paths coming from
|
||
// `storage.folders.path` (composed by the `compute_folder_path`
|
||
// trigger) never have a leading slash. Trim both so `"/Personal"`
|
||
// vs `"Personal/g9-tree"` matches the intended prefix.
|
||
let root = chroot.path.trim_matches('/');
|
||
if root.is_empty() {
|
||
// Guard against a mis-set chroot with an empty root path —
|
||
// stripping "" from anything would return the whole path.
|
||
return None;
|
||
}
|
||
let path = internal_path.trim_start_matches('/');
|
||
let rest = path.strip_prefix(root)?;
|
||
// Reject a partial prefix match — a chroot of "Personal" must
|
||
// not match an item at "PersonalSecrets/…".
|
||
match rest.strip_prefix('/') {
|
||
Some(subpath) => Some(subpath),
|
||
// Item path equals the chroot exactly — the chroot itself
|
||
// (i.e. a folder) is not a legitimate response item, so
|
||
// treat as an empty subpath.
|
||
None if rest.is_empty() => Some(""),
|
||
None => None,
|
||
}
|
||
}
|
||
|
||
/// Naive fallback: strip the first path segment from an internal
|
||
/// `storage.folders.path`. Post-D0 every path starts with its drive's
|
||
/// root folder name (single segment), so for the current schema this
|
||
/// gives the drive-relative subpath.
|
||
///
|
||
/// Use this ONLY when the caller doesn't have a chroot in scope
|
||
/// (e.g. OCS unified search, whose results legitimately span every
|
||
/// drive the caller has Read on — no single chroot covers them all).
|
||
/// Every path-scoped NC handler that DOES have `session` in scope
|
||
/// should prefer [`strip_chroot_prefix`] — it validates the item
|
||
/// belongs under the chroot instead of trusting the schema
|
||
/// invariant, and it survives a future composed chroot like
|
||
/// `"Personal/folderA/subfolder"`.
|
||
///
|
||
/// **Not an AuthZ boundary.** Same caveat as `strip_chroot_prefix`
|
||
/// — AuthZ is enforced upstream via `_with_perms` methods; this
|
||
/// helper only formats display strings.
|
||
///
|
||
/// Returns `""` when the path is a single segment (i.e. the drive
|
||
/// root itself, which is never a legitimate item target).
|
||
pub fn strip_drive_root_segment(internal_path: &str) -> &str {
|
||
match internal_path.split_once('/') {
|
||
Some((_root, rest)) => rest,
|
||
None => "",
|
||
}
|
||
}
|
||
|
||
/// Build the Nextcloud DAV href for a **collection** (folder). Always
|
||
/// terminates with `/` — RFC 4918 §5.2 requires collection URLs to end
|
||
/// in a slash, and the Nextcloud desktop client strictly enforces this
|
||
/// for the "own entry" href in PROPFIND multi-status responses: a
|
||
/// PROPFIND on `/remote.php/dav/files/admin/ext/` whose first response
|
||
/// `<d:href>` doesn't end in `/` aborts the parse with
|
||
/// `Invalid href "<…>" expected starting with "<requested-url>"` and
|
||
/// 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)
|
||
}
|
||
}
|
||
|
||
/// Build the Nextcloud DAV href for a resource.
|
||
///
|
||
/// Each path segment is URL-encoded individually so filenames with spaces,
|
||
/// `#`, `%`, or non-ASCII characters produce valid PROPFIND hrefs.
|
||
///
|
||
/// Returns NO trailing slash for non-empty subpaths. Callers rendering
|
||
/// 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);
|
||
if subpath.is_empty() {
|
||
format!("/remote.php/dav/files/{}/", encoded_user)
|
||
} else {
|
||
let encoded_segments: Vec<_> = subpath
|
||
.split('/')
|
||
.map(|seg| urlencoding::encode(seg))
|
||
.collect();
|
||
format!(
|
||
"/remote.php/dav/files/{}/{}",
|
||
encoded_user,
|
||
encoded_segments.join("/")
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Dispatch Nextcloud WebDAV request to the appropriate handler.
|
||
///
|
||
/// `subpath` is everything after `/remote.php/dav/files/{user}/`.
|
||
/// `session.chroot` is the storage path the request is confined to
|
||
/// — see [`nc_to_internal_path`] for what gets resolved upstream.
|
||
/// `session.raw_username` is the literal wire identifier — bare
|
||
/// `admin` for single-drive sync, composite `admin~{drive_uuid}` for
|
||
/// multi-drive. **Hrefs in every response MUST be built from
|
||
/// `session.raw_username`, not from `session.user.username`** — the
|
||
/// NC desktop client validates that PROPFIND/MOVE response hrefs
|
||
/// share the requested URL's prefix and aborts the parse otherwise
|
||
/// (`Invalid href "<…>" expected starting with "<requested-url>"`).
|
||
/// The bare `session.user.username` is still the right value for
|
||
/// the storage-side owner identity (`oc:owner-id`).
|
||
pub async fn handle_nc_webdav(
|
||
state: Arc<AppState>,
|
||
req: Request<Body>,
|
||
session: crate::interfaces::nextcloud::session::SharedNcSession,
|
||
subpath: String,
|
||
) -> Result<Response<Body>, AppError> {
|
||
// Validate up-front that we have a chroot — every method below is
|
||
// path-scoped, so a missing chroot is a route-wiring bug we want to
|
||
// surface as a 500 immediately rather than re-checking inside each
|
||
// handler.
|
||
session.require_chroot()?;
|
||
let method = req.method().clone();
|
||
match method.as_str() {
|
||
"OPTIONS" => handle_options(),
|
||
"PROPFIND" => handle_propfind(state, req, &session, &subpath).await,
|
||
"GET" => handle_get(state, &session, &subpath, req.headers()).await,
|
||
"PUT" => handle_put(state, req, &session, &subpath).await,
|
||
"MKCOL" => handle_mkcol(state, &session, &subpath).await,
|
||
"DELETE" => handle_delete(state, &session, &subpath).await,
|
||
"MOVE" => handle_move(state, req, &session, &subpath).await,
|
||
"HEAD" => handle_head(state, &session, &subpath).await,
|
||
"PROPPATCH" => handle_proppatch(state, req, &session, &subpath).await,
|
||
"REPORT" | "SEARCH" => {
|
||
crate::interfaces::nextcloud::report_handler::handle_nc_report(
|
||
state, req, &session, &subpath,
|
||
)
|
||
.await
|
||
}
|
||
_ => Ok(Response::builder()
|
||
.status(StatusCode::METHOD_NOT_ALLOWED)
|
||
.body(Body::empty())
|
||
.unwrap()),
|
||
}
|
||
}
|
||
|
||
// ──────────────────── OPTIONS ────────────────────
|
||
|
||
fn handle_options() -> Result<Response<Body>, AppError> {
|
||
// Advertise WebDAV compliance classes 1 + 3 only.
|
||
// Class 2 (LOCK/UNLOCK) is intentionally omitted because the NC
|
||
// surface has no LOCK/UNLOCK dispatch arm — claiming class 2
|
||
// would invite clients (notably the NC desktop sync engine) to
|
||
// start sending LOCK requests we then 405. Class 3 covers the
|
||
// weak-resource-validators behaviour PROPFIND already implements.
|
||
// If LOCK is ever wired in here, restore "1, 2, 3" in the same
|
||
// commit as the LOCK arm — never split the advertisement from
|
||
// the implementation.
|
||
Ok(Response::builder()
|
||
.status(StatusCode::OK)
|
||
.header(HEADER_DAV, "1, 3")
|
||
.header(
|
||
header::ALLOW,
|
||
"OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, MOVE, PROPFIND, PROPPATCH, REPORT, SEARCH",
|
||
)
|
||
.body(Body::empty())
|
||
.unwrap())
|
||
}
|
||
|
||
// ──────────────────── PROPFIND ────────────────────
|
||
|
||
async fn handle_propfind(
|
||
state: Arc<AppState>,
|
||
req: Request<Body>,
|
||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||
subpath: &str,
|
||
) -> Result<Response<Body>, AppError> {
|
||
let user = &session.user;
|
||
let chroot = session.require_chroot()?;
|
||
let url_user = &session.raw_username;
|
||
let depth = req
|
||
.headers()
|
||
.get("depth")
|
||
.and_then(|v| v.to_str().ok())
|
||
.unwrap_or("1")
|
||
.to_string();
|
||
|
||
// Parse the PROPFIND XML body (or assume allprop if empty).
|
||
let body_bytes = body::to_bytes(req.into_body(), 64 * 1024)
|
||
.await
|
||
.map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?;
|
||
|
||
// Parse (and thereby validate) the PROPFIND body. The NC response
|
||
// always emits the full property set; the parsed request is consulted
|
||
// only to skip the quota DB round-trips when the client's explicit
|
||
// prop list never names a quota prop. Malformed XML still fails 400.
|
||
let propfind = if body_bytes.is_empty() {
|
||
PropFindRequest {
|
||
prop_find_type: crate::application::adapters::webdav_adapter::PropFindType::AllProp,
|
||
}
|
||
} else {
|
||
WebDavAdapter::parse_propfind(body_bytes.reader())
|
||
.map_err(|e| AppError::bad_request(format!("Invalid PROPFIND XML: {}", e)))?
|
||
};
|
||
|
||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||
|
||
// Single-query path resolution (drive-scoped) — same shared
|
||
// resolver as native `/webdav/…`. Post-D7 the resolver is not
|
||
// owner-scoped, so we `authz.require(Read, …)` on the returned
|
||
// resource explicitly before emitting the multistatus.
|
||
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||
.await
|
||
.ok_or_else(|| AppError::not_found("Resource not found"))?;
|
||
|
||
match resolved {
|
||
ResolvedResource::Folder(folder) => {
|
||
let folder_uuid = Uuid::parse_str(&folder.id)
|
||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||
state
|
||
.authorization
|
||
.require(
|
||
Subject::User(user.id),
|
||
Permission::Read,
|
||
Resource::Folder(folder_uuid),
|
||
)
|
||
.await?;
|
||
|
||
// It's a folder — stream the multistatus: children are fetched in
|
||
// pages and serialized chunk by chunk, so memory stays O(batch)
|
||
// regardless of how many entries the folder holds.
|
||
//
|
||
// Multi-drive POC: the hrefs in the response must echo the
|
||
// wire form (`{user}~{drive}`) the client requested, so we
|
||
// pass `url_user` (not `user.username`) as the streaming
|
||
// function's username arg. Refining the owner-id usages
|
||
// back to the canonical username is deferred to the
|
||
// NcSession commit.
|
||
// Explicit prop lists that never name a quota prop skip the
|
||
// 2-query quota resolution (benches/QUOTA-PATH.md).
|
||
let quota = if propfind.wants_quota() {
|
||
state.resolve_webdav_quota(user.id, chroot.drive_id).await
|
||
} else {
|
||
None
|
||
};
|
||
Ok(build_nc_streaming_propfind(
|
||
state.clone(),
|
||
folder,
|
||
depth,
|
||
user.id,
|
||
url_user.to_string(),
|
||
subpath.to_string(),
|
||
quota,
|
||
))
|
||
}
|
||
ResolvedResource::File(file) => {
|
||
let file_uuid =
|
||
Uuid::parse_str(&file.id).map_err(|_| AppError::not_found("Resource not found"))?;
|
||
state
|
||
.authorization
|
||
.require(
|
||
Subject::User(user.id),
|
||
Permission::Read,
|
||
Resource::File(file_uuid),
|
||
)
|
||
.await?;
|
||
|
||
// Batch-check favorites for this single file.
|
||
let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() {
|
||
let items: Vec<(&str, &str)> = vec![(&file.id, "file")];
|
||
fav_svc
|
||
.batch_check_favorites(user.id, &items)
|
||
.await
|
||
.unwrap_or_default()
|
||
} else {
|
||
HashSet::new()
|
||
};
|
||
|
||
let nc = state.nextcloud.as_ref();
|
||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||
|
||
let dead_props = file_dead_props(&state, &file).await;
|
||
|
||
let mut buf = Vec::new();
|
||
write_nc_file_multistatus(
|
||
&mut buf,
|
||
&file,
|
||
url_user,
|
||
&user.username,
|
||
subpath,
|
||
file_id_svc,
|
||
(&favorite_ids, &dead_props),
|
||
)
|
||
.await
|
||
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
||
|
||
Ok(Response::builder()
|
||
.status(StatusCode::MULTI_STATUS)
|
||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||
.body(Body::from(buf))
|
||
.unwrap())
|
||
}
|
||
}
|
||
}
|
||
|
||
/// NC-surface path resolution: try the single-query resolver, fall back
|
||
/// to the double-query `get_*_by_path` pair when the resolver isn't
|
||
/// configured. Same shape and drive-scope as the native surface —
|
||
/// callers `authz.require(…)` on the returned resource.
|
||
async fn nc_resolve_or_fallback(
|
||
state: &Arc<AppState>,
|
||
internal_path: &str,
|
||
drive_id: Uuid,
|
||
) -> Option<ResolvedResource> {
|
||
if let Some(resolver) = &state.path_resolver
|
||
&& let Ok(r) = resolver
|
||
.resolve_path_in_drive(internal_path, drive_id)
|
||
.await
|
||
{
|
||
return Some(r);
|
||
}
|
||
let folder_service = &state.applications.folder_service;
|
||
if let Ok(folder) = folder_service
|
||
.get_folder_by_path(internal_path, drive_id)
|
||
.await
|
||
{
|
||
return Some(ResolvedResource::Folder(folder));
|
||
}
|
||
let file_service = &state.applications.file_retrieval_service;
|
||
if let Ok(file) = file_service.get_file_by_path(internal_path, drive_id).await {
|
||
return Some(ResolvedResource::File(file));
|
||
}
|
||
None
|
||
}
|
||
|
||
// ──────────────────── GET ────────────────────
|
||
|
||
async fn handle_get(
|
||
state: Arc<AppState>,
|
||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||
subpath: &str,
|
||
headers: &axum::http::HeaderMap,
|
||
) -> Result<Response<Body>, AppError> {
|
||
let chroot = session.require_chroot()?;
|
||
// GET on root folder — NC clients use this as an existence check
|
||
if subpath.is_empty() || subpath == "/" {
|
||
return Ok(Response::builder()
|
||
.status(StatusCode::OK)
|
||
.header("DAV", "1, 3")
|
||
.body(Body::empty())
|
||
.unwrap());
|
||
}
|
||
|
||
let user = &session.user;
|
||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||
let file_service = &state.applications.file_retrieval_service;
|
||
|
||
// Single-query path resolution. NC clients use GET on a folder as
|
||
// an existence probe (returns 200 empty); file GETs serve content.
|
||
// Post-D7 the resolver is drive-scoped, so both branches
|
||
// `authz.require(Read, …)` before responding.
|
||
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||
.await
|
||
.ok_or_else(|| AppError::not_found("File not found"))?;
|
||
|
||
let file = match resolved {
|
||
ResolvedResource::Folder(folder) => {
|
||
let folder_uuid =
|
||
Uuid::parse_str(&folder.id).map_err(|_| AppError::not_found("File not found"))?;
|
||
state
|
||
.authorization
|
||
.require(
|
||
Subject::User(user.id),
|
||
Permission::Read,
|
||
Resource::Folder(folder_uuid),
|
||
)
|
||
.await?;
|
||
return Ok(Response::builder()
|
||
.status(StatusCode::OK)
|
||
.header("DAV", "1, 3")
|
||
.body(Body::empty())
|
||
.unwrap());
|
||
}
|
||
ResolvedResource::File(f) => {
|
||
let file_uuid =
|
||
Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("File not found"))?;
|
||
state
|
||
.authorization
|
||
.require(
|
||
Subject::User(user.id),
|
||
Permission::Read,
|
||
Resource::File(file_uuid),
|
||
)
|
||
.await?;
|
||
f
|
||
}
|
||
};
|
||
|
||
// ETag comes from `FileDto::etag` (populated from `File::etag()`
|
||
// in the `From<File>` impl) — single source of truth, so GET,
|
||
// HEAD, PUT-response, MOVE, and PROPFIND all emit byte-identical
|
||
// values for the same file. NC's sync engine compares cached
|
||
// PROPFIND ETags against GET/HEAD responses; using `file.id` here
|
||
// (a UUID) while PROPFIND emitted the blob hash made NC see
|
||
// every file as "remotely changed" on first descent.
|
||
let etag = format!("\"{}\"", file.etag);
|
||
|
||
// Conditional GET — sync clients revalidating get a 304, not the body.
|
||
if let Some(resp) = not_modified_response(headers, &etag) {
|
||
return Ok(resp);
|
||
}
|
||
|
||
// Recent recording deliberately does NOT fire here: NC's primary
|
||
// client (Nextcloud desktop, davx5, mobile NC apps) is a sync
|
||
// engine, and a first-time descent of a large library would push
|
||
// every file into Recent, drowning out the SPA's "what I actually
|
||
// opened" signal. See memory note
|
||
// `project_recent_session_intent.md` — the planned session-intent
|
||
// gate (interactive JWT vs app-password) will turn this back on
|
||
// for human-driven NC web access in the same browser session.
|
||
|
||
// Range Requests — serve 206/416 instead of the whole file on seeks.
|
||
if let Some(resp) = range_response(headers, &file, &etag, file_service).await {
|
||
return Ok(resp);
|
||
}
|
||
|
||
let stream = file_service
|
||
.get_file_stream(&file.id)
|
||
.await
|
||
.map_err(|e| AppError::internal_error(format!("Failed to read file: {}", e)))?;
|
||
|
||
let modified_at =
|
||
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
|
||
.unwrap_or_else(Utc::now);
|
||
|
||
Ok(Response::builder()
|
||
.status(StatusCode::OK)
|
||
.header(header::CONTENT_TYPE, file.mime_type.as_ref())
|
||
.header(header::CONTENT_LENGTH, file.size)
|
||
.header(header::ETAG, etag)
|
||
.header(header::ACCEPT_RANGES, "bytes")
|
||
.header(header::LAST_MODIFIED, modified_at.to_rfc2822())
|
||
.body(Body::from_stream(std::pin::Pin::from(stream)))
|
||
.unwrap())
|
||
}
|
||
|
||
// ──────────────────── HEAD ────────────────────
|
||
|
||
async fn handle_head(
|
||
state: Arc<AppState>,
|
||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||
subpath: &str,
|
||
) -> Result<Response<Body>, AppError> {
|
||
let chroot = session.require_chroot()?;
|
||
// HEAD on root folder — NC clients use this as an existence check
|
||
if subpath.is_empty() || subpath == "/" {
|
||
return Ok(Response::builder()
|
||
.status(StatusCode::OK)
|
||
.header("DAV", "1, 3")
|
||
.body(Body::empty())
|
||
.unwrap());
|
||
}
|
||
|
||
let user = &session.user;
|
||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||
|
||
// Single-query path resolution. Both branches `authz.require(Read, …)`
|
||
// on the returned resource before responding.
|
||
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||
.await
|
||
.ok_or_else(|| AppError::not_found("File not found"))?;
|
||
|
||
let file = match resolved {
|
||
ResolvedResource::Folder(folder) => {
|
||
let folder_uuid =
|
||
Uuid::parse_str(&folder.id).map_err(|_| AppError::not_found("File not found"))?;
|
||
state
|
||
.authorization
|
||
.require(
|
||
Subject::User(user.id),
|
||
Permission::Read,
|
||
Resource::Folder(folder_uuid),
|
||
)
|
||
.await?;
|
||
return Ok(Response::builder()
|
||
.status(StatusCode::OK)
|
||
.header("DAV", "1, 3")
|
||
.body(Body::empty())
|
||
.unwrap());
|
||
}
|
||
ResolvedResource::File(f) => {
|
||
let file_uuid =
|
||
Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("File not found"))?;
|
||
state
|
||
.authorization
|
||
.require(
|
||
Subject::User(user.id),
|
||
Permission::Read,
|
||
Resource::File(file_uuid),
|
||
)
|
||
.await?;
|
||
f
|
||
}
|
||
};
|
||
|
||
let modified_at =
|
||
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
|
||
.unwrap_or_else(Utc::now);
|
||
|
||
// ETag comes from `FileDto::etag` — see the same comment block on
|
||
// the GET handler. HEAD and GET must agree byte-for-byte; pulling
|
||
// both from the same DTO field guarantees that.
|
||
//
|
||
// We deliberately do NOT set `Content-Length: file.size` here even
|
||
// though RFC 7231 §4.3.2 says HEAD SHOULD return the same headers
|
||
// GET would. Our body is `Body::empty()`, so declaring a non-zero
|
||
// Content-Length tells the client "20 bytes are coming" — and on a
|
||
// keep-alive connection the client waits forever for them. Hyper
|
||
// derives `Content-Length: 0` from the empty body, which is honest
|
||
// about what's actually on the wire. Clients that need the file
|
||
// size use PROPFIND (which is what NC and Sabre clients do).
|
||
Ok(Response::builder()
|
||
.status(StatusCode::OK)
|
||
.header(header::CONTENT_TYPE, file.mime_type.as_ref())
|
||
.header(header::ETAG, format!("\"{}\"", file.etag))
|
||
.header(header::LAST_MODIFIED, modified_at.to_rfc2822())
|
||
.body(Body::empty())
|
||
.unwrap())
|
||
}
|
||
|
||
// ──────────────────── PROPPATCH ────────────────────
|
||
|
||
/// The `oc:favorite` element is live server state routed through the
|
||
/// favorites service, not a dead property — every other
|
||
/// namespace/local-name pair PROPPATCH sends is stored verbatim via
|
||
/// `DeadPropertyStore`.
|
||
const OC_FAVORITE_NS: &str = "http://owncloud.org/ns";
|
||
|
||
async fn handle_proppatch(
|
||
state: Arc<AppState>,
|
||
req: Request<Body>,
|
||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||
subpath: &str,
|
||
) -> Result<Response<Body>, AppError> {
|
||
let user = &session.user;
|
||
let chroot = session.require_chroot()?;
|
||
let url_user = &session.raw_username;
|
||
let body_bytes = body::to_bytes(req.into_body(), 64 * 1024)
|
||
.await
|
||
.map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?;
|
||
|
||
// Resolve the target resource — needed for three things:
|
||
// 1. The dead-property store key is the resource id (folder_id
|
||
// XOR file_id), so we need a `ResourceRef`.
|
||
// 2. Applying the oc:favorite mutation (`item_type` distinguishes
|
||
// file vs folder rows in the favorites table).
|
||
// 3. Picking the right `<d:href>` shape in the multi-status
|
||
// response: collection (folder) hrefs MUST end in `/` per
|
||
// RFC 4918 §5.2 — see `nc_collection_href` for the full
|
||
// reasoning.
|
||
//
|
||
// A missing resource is now always a 404: unlike the previous
|
||
// favorite-only implementation (which merely re-declared success
|
||
// without doing anything), this handler performs real writes, so
|
||
// silently no-opping on a nonexistent path would be a foot-gun —
|
||
// matches the native `/webdav/` handler's contract.
|
||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||
// Single-query path resolution — PROPPATCH may target either a
|
||
// folder or a file. Post-D7 the resolver is drive-scoped, so we
|
||
// `authz.require(Read, …)` on the returned resource before
|
||
// reading its type. The favorite mutation below itself doesn't
|
||
// require additional authz (favorites are per-user; the caller can
|
||
// favourite any resource they can see).
|
||
let (resource_ref, item_id, item_type, is_collection) =
|
||
match nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id).await {
|
||
Some(ResolvedResource::File(file)) => {
|
||
let id = Uuid::parse_str(&file.id)
|
||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||
state
|
||
.authorization
|
||
.require(Subject::User(user.id), Permission::Read, Resource::File(id))
|
||
.await?;
|
||
(ResourceRef::File(id), file.id, "file", false)
|
||
}
|
||
Some(ResolvedResource::Folder(folder)) => {
|
||
let id = Uuid::parse_str(&folder.id)
|
||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||
state
|
||
.authorization
|
||
.require(
|
||
Subject::User(user.id),
|
||
Permission::Read,
|
||
Resource::Folder(id),
|
||
)
|
||
.await?;
|
||
(ResourceRef::Folder(id), folder.id, "folder", true)
|
||
}
|
||
None => return Err(AppError::not_found("Resource not found")),
|
||
};
|
||
|
||
let ops = WebDavAdapter::parse_proppatch(body_bytes.reader())
|
||
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?;
|
||
|
||
let dead_props = &state.webdav_dead_props;
|
||
let mut results: Vec<(&QualifiedName, bool)> = Vec::new();
|
||
for op in &ops {
|
||
let is_favorite =
|
||
|name: &QualifiedName| name.namespace == OC_FAVORITE_NS && name.name == "favorite";
|
||
match op {
|
||
PropPatchOp::Set(pv) if is_favorite(&pv.name) => {
|
||
if let Some(fav_svc) = state.favorites_service.as_ref() {
|
||
if pv.value.as_deref().map(str::trim) == Some("1") {
|
||
fav_svc
|
||
.add_to_favorites(user.id, &item_id, item_type)
|
||
.await
|
||
.map_err(|e| {
|
||
AppError::internal_error(format!("Failed to add favorite: {e}"))
|
||
})?;
|
||
} else {
|
||
fav_svc
|
||
.remove_from_favorites(user.id, &item_id, item_type)
|
||
.await
|
||
.map_err(|e| {
|
||
AppError::internal_error(format!("Failed to remove favorite: {e}"))
|
||
})?;
|
||
}
|
||
}
|
||
results.push((&pv.name, true));
|
||
}
|
||
PropPatchOp::Remove(name) if is_favorite(name) => {
|
||
if let Some(fav_svc) = state.favorites_service.as_ref() {
|
||
fav_svc
|
||
.remove_from_favorites(user.id, &item_id, item_type)
|
||
.await
|
||
.map_err(|e| {
|
||
AppError::internal_error(format!("Failed to remove favorite: {e}"))
|
||
})?;
|
||
}
|
||
results.push((name, true));
|
||
}
|
||
PropPatchOp::Set(pv) if is_protected_property(&pv.name) => {
|
||
results.push((&pv.name, false));
|
||
}
|
||
PropPatchOp::Remove(name) if is_protected_property(name) => {
|
||
results.push((name, false));
|
||
}
|
||
PropPatchOp::Set(pv) => {
|
||
dead_props
|
||
.set(resource_ref, pv.name.clone(), pv.value.clone())
|
||
.await
|
||
.map_err(|e| {
|
||
AppError::internal_error(format!("Failed to store dead property: {e}"))
|
||
})?;
|
||
results.push((&pv.name, true));
|
||
}
|
||
PropPatchOp::Remove(name) => {
|
||
dead_props.remove(resource_ref, name).await.map_err(|e| {
|
||
AppError::internal_error(format!("Failed to remove dead property: {e}"))
|
||
})?;
|
||
results.push((name, true));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Collection vs file href chosen by resource type to satisfy the
|
||
// RFC 4918 §5.2 trailing-slash invariant — see the comment block
|
||
// at the top of this function.
|
||
let href = if is_collection {
|
||
nc_collection_href(url_user, subpath)
|
||
} else {
|
||
nc_href(url_user, subpath)
|
||
};
|
||
let mut response_body = Vec::new();
|
||
WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err(
|
||
|e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)),
|
||
)?;
|
||
|
||
Ok(Response::builder()
|
||
.status(StatusCode::MULTI_STATUS)
|
||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||
.body(Body::from(response_body))
|
||
.unwrap())
|
||
}
|
||
|
||
// ──────────────────── PUT ────────────────────
|
||
|
||
/// Strip the optional `W/` weak prefix and surrounding double-quotes
|
||
/// from one ETag value in an `If-Match` / `If-None-Match` list. Returns
|
||
/// `(is_weak, inner)`.
|
||
fn parse_etag_value(raw: &str) -> (bool, &str) {
|
||
let trimmed = raw.trim();
|
||
if let Some(rest) = trimmed.strip_prefix("W/") {
|
||
(true, rest.trim().trim_matches('"'))
|
||
} else {
|
||
(false, trimmed.trim_matches('"'))
|
||
}
|
||
}
|
||
|
||
/// RFC 7232 §3.2 — `If-None-Match` fails for PUT when:
|
||
/// - the header value is `*` and a current representation exists, OR
|
||
/// - any listed ETag matches the current representation (weak comparison
|
||
/// — weak validators in the request are equivalent to strong for the
|
||
/// match itself, only If-Match is required to be strong).
|
||
fn if_none_match_precondition_fails(header: &str, current_etag: Option<&str>) -> bool {
|
||
let v = header.trim();
|
||
if v == "*" {
|
||
return current_etag.is_some();
|
||
}
|
||
let Some(current) = current_etag else {
|
||
return false;
|
||
};
|
||
v.split(',').any(|tag| {
|
||
let (_, parsed) = parse_etag_value(tag);
|
||
!parsed.is_empty() && parsed == current
|
||
})
|
||
}
|
||
|
||
/// RFC 7232 §3.1 — `If-Match` fails for PUT when:
|
||
/// - the resource doesn't currently exist (no strong validator to match), OR
|
||
/// - the header isn't `*` and no listed ETag strong-matches the current one
|
||
/// (weak validators in the request never satisfy a strong-match).
|
||
fn if_match_precondition_fails(header: &str, current_etag: Option<&str>) -> bool {
|
||
let v = header.trim();
|
||
let Some(current) = current_etag else {
|
||
return true;
|
||
};
|
||
if v == "*" {
|
||
return false;
|
||
}
|
||
!v.split(',').any(|tag| {
|
||
let (is_weak, parsed) = parse_etag_value(tag);
|
||
!is_weak && !parsed.is_empty() && parsed == current
|
||
})
|
||
}
|
||
|
||
fn precondition_failed_response() -> Response<Body> {
|
||
Response::builder()
|
||
.status(StatusCode::PRECONDITION_FAILED)
|
||
.body(Body::empty())
|
||
.unwrap()
|
||
}
|
||
|
||
async fn handle_put(
|
||
state: Arc<AppState>,
|
||
req: Request<Body>,
|
||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||
subpath: &str,
|
||
) -> Result<Response<Body>, AppError> {
|
||
let chroot = session.require_chroot()?;
|
||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||
let file_service = &state.applications.file_retrieval_service;
|
||
let upload_service = &state.applications.file_upload_service;
|
||
|
||
let claimed_type = req
|
||
.headers()
|
||
.get(header::CONTENT_TYPE)
|
||
.and_then(|v| v.to_str().ok())
|
||
.unwrap_or("application/octet-stream")
|
||
.to_string();
|
||
|
||
let oc_mtime = req
|
||
.headers()
|
||
.get("x-oc-mtime")
|
||
.and_then(|v| v.to_str().ok())
|
||
.and_then(|v| v.parse::<i64>().ok());
|
||
|
||
// ── Conditional preconditions (RFC 7232 §3.1 / §3.2) ─────────────
|
||
// Evaluated BEFORE body ingestion so a rejected PUT doesn't waste
|
||
// bandwidth or disk I/O on a body the server is going to throw away.
|
||
// The lookup is reused for the create-vs-update distinction below,
|
||
// so this is also free of an extra DB hit.
|
||
let existing = file_service
|
||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||
.await
|
||
.ok();
|
||
let current_etag = existing.as_ref().map(|f| f.etag.as_str());
|
||
|
||
if let Some(value) = req
|
||
.headers()
|
||
.get(header::IF_NONE_MATCH)
|
||
.and_then(|v| v.to_str().ok())
|
||
&& if_none_match_precondition_fails(value, current_etag)
|
||
{
|
||
return Ok(precondition_failed_response());
|
||
}
|
||
if let Some(value) = req
|
||
.headers()
|
||
.get(header::IF_MATCH)
|
||
.and_then(|v| v.to_str().ok())
|
||
&& if_match_precondition_fails(value, current_etag)
|
||
{
|
||
return Ok(precondition_failed_response());
|
||
}
|
||
|
||
// ── Direct PUT cap ───────────────────────────────────────────────
|
||
// We use `direct_put_max_bytes` (default 1 GiB), not `max_upload_size`
|
||
// (default 10 GB). Larger files must come through the chunked upload
|
||
// protocol (`/dav/uploads/...`) which is resumable on failure and
|
||
// bounded per-request by `chunk_max_bytes`. Trying to stream a
|
||
// multi-GB body through a single PUT is a footgun: a connection drop
|
||
// at 95 % loses everything.
|
||
let max_upload = state.core.config.storage.direct_put_max_bytes;
|
||
|
||
// Stream the body straight into the CDC chunk store — never buffer the
|
||
// full upload in RAM and never spool it to disk. Chunking, hashing,
|
||
// dedup checks and MIME sniffing (magic bytes off the first frames)
|
||
// all run while the body arrives; chunks the store already has are
|
||
// never written at all. Shared with the native WebDAV PUT handler.
|
||
//
|
||
// `filename` is owned so we don't hold a borrow of the `subpath` param
|
||
// across the await (which would make the handler future non-Send).
|
||
let filename = filename_from_path(subpath).to_string();
|
||
let ingested = ingest_body_to_cas(
|
||
req.into_body(),
|
||
&state.core.dedup_service,
|
||
&filename,
|
||
&claimed_type,
|
||
max_upload,
|
||
)
|
||
.await?;
|
||
let content_type = ingested.content_type.clone();
|
||
|
||
// Distinguish create (201) vs update (204) for the response status,
|
||
// using the lookup already done above for the precondition check.
|
||
let existed = existing.is_some();
|
||
|
||
// Single streaming path — handles both update and create internally,
|
||
// swapping the file row onto the already-ingested blob.
|
||
// AuthZ audit #6 (2026-07-12): route `_with_perms` errors through
|
||
// `AppError::from` so authz denials surface as 404 (the anti-enum
|
||
// shape) instead of a `map_err → internal_error` 500 that gives a
|
||
// probing caller an "exists-but-denied" oracle. Also preserves
|
||
// `QuotaExceeded → 507`, `AlreadyExists → 409`, `InvalidInput → 400`.
|
||
let stored = upload_service
|
||
.update_file_streaming_with_perms(
|
||
&internal_path,
|
||
chroot.drive_id,
|
||
ingested.stored(),
|
||
&content_type,
|
||
oc_mtime,
|
||
session.user.id,
|
||
)
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
|
||
let status = if existed {
|
||
StatusCode::NO_CONTENT
|
||
} else {
|
||
StatusCode::CREATED
|
||
};
|
||
|
||
Ok(Response::builder()
|
||
.status(status)
|
||
.header(header::ETAG, format!("\"{}\"", stored.etag))
|
||
.header("oc-etag", format!("\"{}\"", stored.etag))
|
||
.body(Body::empty())
|
||
.unwrap())
|
||
}
|
||
|
||
// ──────────────────── MKCOL ────────────────────
|
||
|
||
async fn handle_mkcol(
|
||
state: Arc<AppState>,
|
||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||
subpath: &str,
|
||
) -> Result<Response<Body>, AppError> {
|
||
let user = &session.user;
|
||
let chroot = session.require_chroot()?;
|
||
use crate::application::dtos::folder_dto::CreateFolderDto;
|
||
|
||
let folder_service = &state.applications.folder_service;
|
||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||
|
||
// RFC 4918 §9.3.1:
|
||
// - target already exists → 405 Method Not Allowed
|
||
// - parent collection of the target does NOT exist → 409 Conflict
|
||
// - parent exists and target does not → 201 Created
|
||
//
|
||
// Previous behaviour effectively performed `mkdir -p` and returned
|
||
// 201 even when intermediate ancestors were missing. Sabre/DAV and
|
||
// the actual NC server both return 409 here, so the legacy
|
||
// auto-create deviated from the reference implementation. NC desktop
|
||
// walks ancestors one MKCOL at a time anyway, so dropping the
|
||
// auto-create doesn't break real clients.
|
||
|
||
if folder_service
|
||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||
.await
|
||
.is_ok()
|
||
{
|
||
return Ok(Response::builder()
|
||
.status(StatusCode::METHOD_NOT_ALLOWED)
|
||
.body(Body::empty())
|
||
.unwrap());
|
||
}
|
||
|
||
let segments: Vec<&str> = subpath.split('/').filter(|s| !s.is_empty()).collect();
|
||
if segments.is_empty() {
|
||
return Err(AppError::bad_request(
|
||
"MKCOL on the user root is not allowed",
|
||
));
|
||
}
|
||
let (target_name, parent_segments) = segments.split_last().expect("checked non-empty above");
|
||
|
||
// Take POC's `chroot`-based root resolution (drive-aware mount
|
||
// point) but keep HEAD's parent_path lookup pattern — the
|
||
// continuation below uses `get_folder_by_path(&parent_path,
|
||
// user.id)` (user-scoped lookup added in the D0 rewind).
|
||
let user_root = nc_to_internal_path(chroot, "")?;
|
||
let parent_path = if parent_segments.is_empty() {
|
||
user_root.clone()
|
||
} else {
|
||
format!("{}/{}", user_root, parent_segments.join("/"))
|
||
};
|
||
|
||
let parent_folder = match folder_service
|
||
.get_folder_by_path(&parent_path, chroot.drive_id)
|
||
.await
|
||
{
|
||
Ok(folder) => folder,
|
||
Err(_) => {
|
||
return Ok(Response::builder()
|
||
.status(StatusCode::CONFLICT)
|
||
.body(Body::empty())
|
||
.unwrap());
|
||
}
|
||
};
|
||
|
||
let dto = CreateFolderDto {
|
||
name: target_name.to_string(),
|
||
parent_id: Some(parent_folder.id.clone()),
|
||
};
|
||
// AuthZ audit #7 (2026-07-12): route `_with_perms` errors through
|
||
// `AppError::from` so authz denials surface as 404 (the anti-enum
|
||
// shape) instead of a `map_err → internal_error` 500. Also preserves
|
||
// `AlreadyExists → 409`, `QuotaExceeded → 507`, `InvalidInput → 400`.
|
||
folder_service
|
||
.create_folder_with_perms(dto, user.id)
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
|
||
Ok(Response::builder()
|
||
.status(StatusCode::CREATED)
|
||
.body(Body::empty())
|
||
.unwrap())
|
||
}
|
||
|
||
// ──────────────────── DELETE ────────────────────
|
||
|
||
async fn handle_delete(
|
||
state: Arc<AppState>,
|
||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||
subpath: &str,
|
||
) -> Result<Response<Body>, AppError> {
|
||
let user = &session.user;
|
||
let chroot = session.require_chroot()?;
|
||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||
let folder_service = &state.applications.folder_service;
|
||
|
||
// Single-query path resolution. Post-D7 the resolver is drive-scoped,
|
||
// so we `authz.require(Read, …)` on the returned resource before
|
||
// dispatching. The actual delete is authorised as `Permission::Delete`
|
||
// inside the downstream service (`trash_svc.move_to_trash` /
|
||
// `delete_folder_with_perms` / `delete_file_with_perms` all take
|
||
// `caller_id`).
|
||
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||
.await
|
||
.ok_or_else(|| AppError::not_found("Resource not found"))?;
|
||
|
||
match resolved {
|
||
ResolvedResource::Folder(folder) => {
|
||
let folder_uuid = Uuid::parse_str(&folder.id)
|
||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||
state
|
||
.authorization
|
||
.require(
|
||
Subject::User(user.id),
|
||
Permission::Read,
|
||
Resource::Folder(folder_uuid),
|
||
)
|
||
.await?;
|
||
// AuthZ audit #8 (2026-07-12): route service errors through
|
||
// `AppError::from` so authz denials surface as 404 (the
|
||
// anti-enum shape) instead of a `map_err → internal_error`
|
||
// 500 that gives a probing caller an "exists-but-denied"
|
||
// oracle. `move_to_trash` and `delete_folder_with_perms`
|
||
// both return `DomainError` and both call `authz.require`.
|
||
if let Some(trash_svc) = state.trash_service.as_ref() {
|
||
trash_svc
|
||
.move_to_trash(&folder.id, "folder", user.id)
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
} else {
|
||
folder_service
|
||
.delete_folder_with_perms(&folder.id, user.id)
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
}
|
||
}
|
||
ResolvedResource::File(file) => {
|
||
let file_uuid =
|
||
Uuid::parse_str(&file.id).map_err(|_| AppError::not_found("Resource not found"))?;
|
||
state
|
||
.authorization
|
||
.require(
|
||
Subject::User(user.id),
|
||
Permission::Read,
|
||
Resource::File(file_uuid),
|
||
)
|
||
.await?;
|
||
// AuthZ audit #8 (2026-07-12): same anti-enum fix as folder branch above.
|
||
if let Some(trash_svc) = state.trash_service.as_ref() {
|
||
trash_svc
|
||
.move_to_trash(&file.id, "file", user.id)
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
} else {
|
||
let file_mgmt = &state.applications.file_management_service;
|
||
file_mgmt
|
||
.delete_file_with_perms(&file.id, user.id)
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(Response::builder()
|
||
.status(StatusCode::NO_CONTENT)
|
||
.body(Body::empty())
|
||
.unwrap())
|
||
}
|
||
|
||
// ──────────────────── MOVE ────────────────────
|
||
|
||
async fn handle_move(
|
||
state: Arc<AppState>,
|
||
req: Request<Body>,
|
||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||
subpath: &str,
|
||
) -> Result<Response<Body>, AppError> {
|
||
let user = &session.user;
|
||
let chroot = session.require_chroot()?;
|
||
let url_user = &session.raw_username;
|
||
let destination = req
|
||
.headers()
|
||
.get("destination")
|
||
.and_then(|v| v.to_str().ok())
|
||
.ok_or_else(|| AppError::bad_request("Missing Destination header"))?
|
||
.to_string();
|
||
|
||
// RFC 4918 §9.9.3: the `Overwrite` header has the default value `T`.
|
||
// `F` MUST cause the request to fail with 412 when the destination
|
||
// already exists; `T` (or absent) MUST replace the destination as if
|
||
// it didn't exist (the response then drops from 201 Created to 204
|
||
// No Content per §9.9.4 because the URI's resource was replaced
|
||
// rather than newly created).
|
||
let overwrite_forbidden = req
|
||
.headers()
|
||
.get("overwrite")
|
||
.and_then(|v| v.to_str().ok())
|
||
.map(|v| v.trim().eq_ignore_ascii_case("F"))
|
||
.unwrap_or(false);
|
||
|
||
// Parse destination path: extract subpath after /remote.php/dav/files/{user}/
|
||
// — the URL user-segment carries the drive marker on multi-drive
|
||
// sessions, so we strip the *composite* prefix to find the real
|
||
// subpath. Using `user.username` here would fail to match for any
|
||
// request hitting a non-home drive.
|
||
let dest_subpath = extract_nc_subpath_from_dest(&destination, url_user)
|
||
.ok_or_else(|| AppError::bad_request("Invalid Destination URL"))?;
|
||
|
||
let src_internal = nc_to_internal_path(chroot, subpath)?;
|
||
let folder_service = &state.applications.folder_service;
|
||
let file_service = &state.applications.file_retrieval_service;
|
||
let file_mgmt = &state.applications.file_management_service;
|
||
|
||
// ── Destination-collision precondition (RFC 4918 §9.9.4) ──────────
|
||
// Single-query probe via the shared resolver — the destination is
|
||
// either a file, a folder, or absent. `dest_existed_before`
|
||
// becomes the 204-vs-201 selector at response time. Post-D7 the
|
||
// resolver is drive-scoped; on the overwrite path we
|
||
// `authz.require(Read, …)` explicitly and the downstream delete
|
||
// enforces `Permission::Delete`.
|
||
let dest_internal_precheck = nc_to_internal_path(chroot, &dest_subpath)?;
|
||
let dest_existing =
|
||
nc_resolve_or_fallback(&state, &dest_internal_precheck, chroot.drive_id).await;
|
||
let dest_existed_before = dest_existing.is_some();
|
||
|
||
if let Some(existing) = dest_existing {
|
||
if overwrite_forbidden {
|
||
return Ok(Response::builder()
|
||
.status(StatusCode::PRECONDITION_FAILED)
|
||
.body(Body::empty())
|
||
.unwrap());
|
||
}
|
||
// Overwrite: T (or absent) → delete the existing destination first,
|
||
// then proceed with the move. Trashing is fine: per RFC the source
|
||
// resource appears at the destination URI; what happens to the
|
||
// overwritten one is up to the server.
|
||
//
|
||
// AuthZ audit #9 (2026-07-12): route the `_with_perms` delete
|
||
// errors through `AppError::from` so authz denials surface as 404
|
||
// (anti-enum) instead of `map_err → internal_error` 500. Also
|
||
// preserves `QuotaExceeded → 507`, `AlreadyExists → 409`,
|
||
// `InvalidInput → 400`.
|
||
match existing {
|
||
ResolvedResource::File(existing_file) => {
|
||
let file_uuid = Uuid::parse_str(&existing_file.id).map_err(|_| {
|
||
AppError::internal_error("Failed to overwrite destination file")
|
||
})?;
|
||
state
|
||
.authorization
|
||
.require(
|
||
Subject::User(user.id),
|
||
Permission::Read,
|
||
Resource::File(file_uuid),
|
||
)
|
||
.await?;
|
||
file_mgmt
|
||
.delete_and_cleanup_with_perms(&existing_file.id, user.id)
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
}
|
||
ResolvedResource::Folder(existing_folder) => {
|
||
let folder_uuid = Uuid::parse_str(&existing_folder.id).map_err(|_| {
|
||
AppError::internal_error("Failed to overwrite destination folder")
|
||
})?;
|
||
state
|
||
.authorization
|
||
.require(
|
||
Subject::User(user.id),
|
||
Permission::Read,
|
||
Resource::Folder(folder_uuid),
|
||
)
|
||
.await?;
|
||
folder_service
|
||
.delete_folder_with_perms(&existing_folder.id, user.id)
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
}
|
||
}
|
||
}
|
||
|
||
let final_status = if dest_existed_before {
|
||
StatusCode::NO_CONTENT
|
||
} else {
|
||
StatusCode::CREATED
|
||
};
|
||
|
||
// Try as file first.
|
||
if let Ok(file) = file_service
|
||
.get_file_by_path(&src_internal, chroot.drive_id)
|
||
.await
|
||
{
|
||
let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') {
|
||
Some((parent, name)) => (parent, name),
|
||
None => ("", dest_subpath.as_str()),
|
||
};
|
||
let dest_parent_internal = nc_to_internal_path(chroot, dest_parent_sub)?;
|
||
|
||
// Rename if only the name changes (same parent).
|
||
let src_parent_sub = match subpath.rsplit_once('/') {
|
||
Some((parent, _)) => parent,
|
||
None => "",
|
||
};
|
||
|
||
// AuthZ audit #9 (2026-07-12): route `_with_perms` errors
|
||
// through `AppError::from` so authz denials surface as 404
|
||
// (anti-enum) instead of `map_err → internal_error` 500.
|
||
if src_parent_sub == dest_parent_sub {
|
||
// Same parent → rename.
|
||
file_mgmt
|
||
.rename_file_with_perms(&file.id, user.id, dest_name)
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
} else {
|
||
// Different parent → move.
|
||
let dest_parent = folder_service
|
||
.get_folder_by_path(&dest_parent_internal, chroot.drive_id)
|
||
.await
|
||
.map_err(|_| AppError::not_found("Destination folder not found"))?;
|
||
|
||
file_mgmt
|
||
.move_file_with_perms(&file.id, user.id, Some(dest_parent.id.clone()))
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
|
||
// If the filename changed too, rename after move.
|
||
if file.name != dest_name {
|
||
file_mgmt
|
||
.rename_file_with_perms(&file.id, user.id, dest_name)
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
}
|
||
}
|
||
|
||
// Return ETag and OC-ETag so Nextcloud clients can track the moved file.
|
||
// Take POC's chroot-based path resolution; keep HEAD's
|
||
// final_status (201 vs 204 depending on whether the destination
|
||
// existed — RFC 4918 §9.9.4 distinguishes create vs overwrite).
|
||
let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?;
|
||
let mut builder = Response::builder().status(final_status);
|
||
if let Ok(moved) = file_service
|
||
.get_file_by_path(&dest_internal, chroot.drive_id)
|
||
.await
|
||
{
|
||
// Route through `FileDto::etag` so the MOVE response
|
||
// matches what a subsequent PROPFIND on the destination
|
||
// will return — `moved.id` (UUID) would differ from the
|
||
// blob hash and trigger NC's "remote changed" detection.
|
||
builder = builder
|
||
.header(header::ETAG, format!("\"{}\"", moved.etag))
|
||
.header("oc-etag", format!("\"{}\"", moved.etag));
|
||
}
|
||
|
||
return Ok(builder.body(Body::empty()).unwrap());
|
||
}
|
||
|
||
// Try as folder.
|
||
if let Ok(folder) = folder_service
|
||
.get_folder_by_path(&src_internal, chroot.drive_id)
|
||
.await
|
||
{
|
||
let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') {
|
||
Some((parent, name)) => (parent, name),
|
||
None => ("", dest_subpath.as_str()),
|
||
};
|
||
let dest_parent_internal = nc_to_internal_path(chroot, dest_parent_sub)?;
|
||
|
||
let src_parent_sub = match subpath.rsplit_once('/') {
|
||
Some((parent, _)) => parent,
|
||
None => "",
|
||
};
|
||
|
||
// AuthZ audit #9 (2026-07-12): route `_with_perms` errors
|
||
// through `AppError::from` so authz denials surface as 404
|
||
// (anti-enum) instead of `map_err → internal_error` 500.
|
||
if src_parent_sub == dest_parent_sub {
|
||
// Same parent → rename.
|
||
use crate::application::dtos::folder_dto::RenameFolderDto;
|
||
folder_service
|
||
.rename_folder_with_perms(
|
||
&folder.id,
|
||
RenameFolderDto {
|
||
name: dest_name.to_string(),
|
||
},
|
||
user.id,
|
||
)
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
} else {
|
||
// Different parent → move.
|
||
let dest_parent = folder_service
|
||
.get_folder_by_path(&dest_parent_internal, chroot.drive_id)
|
||
.await
|
||
.map_err(|_| AppError::not_found("Destination parent not found"))?;
|
||
|
||
use crate::application::dtos::folder_dto::MoveFolderDto;
|
||
folder_service
|
||
.move_folder_with_perms(
|
||
&folder.id,
|
||
MoveFolderDto {
|
||
parent_id: Some(dest_parent.id.clone()),
|
||
},
|
||
user.id,
|
||
)
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
|
||
// If the name changed too, rename.
|
||
if folder.name != dest_name {
|
||
use crate::application::dtos::folder_dto::RenameFolderDto;
|
||
folder_service
|
||
.rename_folder_with_perms(
|
||
&folder.id,
|
||
RenameFolderDto {
|
||
name: dest_name.to_string(),
|
||
},
|
||
user.id,
|
||
)
|
||
.await
|
||
.map_err(AppError::from)?;
|
||
}
|
||
}
|
||
|
||
return Ok(Response::builder()
|
||
.status(final_status)
|
||
.body(Body::empty())
|
||
.unwrap());
|
||
}
|
||
|
||
Err(AppError::not_found("Source resource not found"))
|
||
}
|
||
|
||
/// Extract the subpath from a Destination header URL.
|
||
///
|
||
/// Only accepts relative paths or absolute URLs whose path starts with the
|
||
/// expected DAV prefix. For full URLs the host is ignored — the path alone is
|
||
/// used — so an attacker cannot redirect the server to a different host.
|
||
pub fn extract_nc_subpath_from_dest(dest: &str, username: &str) -> Option<String> {
|
||
let prefix = format!("/remote.php/dav/files/{}/", username);
|
||
// For full URLs, extract the path portion (everything after the authority).
|
||
let path = if dest.starts_with("http://") || dest.starts_with("https://") {
|
||
// Find the start of the path after "scheme://host".
|
||
let after_scheme = dest.split_once("://")?.1;
|
||
let path_start = after_scheme.find('/').unwrap_or(after_scheme.len());
|
||
&after_scheme[path_start..]
|
||
} else {
|
||
dest
|
||
};
|
||
let decoded = urlencoding::decode(path).ok()?;
|
||
let decoded = decoded.trim_end_matches('/');
|
||
decoded
|
||
.strip_prefix(prefix.trim_end_matches('/'))
|
||
.map(|s| s.trim_start_matches('/').to_string())
|
||
}
|
||
|
||
// ────────────── Nextcloud PROPFIND XML Generation ──────────────
|
||
|
||
use crate::application::dtos::file_dto::FileDto;
|
||
use crate::application::dtos::folder_dto::FolderDto;
|
||
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
|
||
|
||
/// Write the `<d:multistatus>` opening tag with the full NC namespace set.
|
||
/// Shared by the streaming folder PROPFIND and the single-file variant so
|
||
/// the namespace list can never diverge between the two.
|
||
fn write_nc_multistatus_open<W: std::io::Write>(xml: &mut Writer<W>) -> Result<(), String> {
|
||
let mut ms = BytesStart::new("d:multistatus");
|
||
ms.push_attribute(("xmlns:d", "DAV:"));
|
||
ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns"));
|
||
ms.push_attribute(("xmlns:nc", "http://nextcloud.org/ns"));
|
||
ms.push_attribute(("xmlns:ocs", "http://open-collaboration-services.org/ns"));
|
||
xml.write_event(Event::Start(ms)).xml_err()
|
||
}
|
||
|
||
/// Generate the multistatus XML for a single-file PROPFIND. The folder
|
||
/// case streams via [`build_nc_streaming_propfind`] instead.
|
||
///
|
||
/// `extras` bundles `(favorite_ids, dead_props)` — both are per-resource
|
||
/// decorations fetched by the caller — to stay under clippy's
|
||
/// argument-count lint.
|
||
async fn write_nc_file_multistatus<W: std::io::Write>(
|
||
writer: W,
|
||
file: &FileDto,
|
||
url_user: &str,
|
||
username: &str,
|
||
subpath: &str,
|
||
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||
extras: (&HashSet<String>, &[(QualifiedName, Option<String>)]),
|
||
) -> Result<(), String> {
|
||
let (favorite_ids, dead_props) = extras;
|
||
let (file_id_map, _) = batch_resolve_ids(file_id_svc, &[file.id.as_str()], &[]).await;
|
||
|
||
let mut xml = Writer::new(writer);
|
||
write_nc_multistatus_open(&mut xml)?;
|
||
|
||
// Single-file PROPFIND — subpath already points to the file.
|
||
// `url_user` is the wire identifier (may carry a `~{drive}`
|
||
// marker); the NC client validates that the returned `<d:href>`
|
||
// shares the requested URL's prefix. `username` is the canonical
|
||
// identity for the `oc:owner-id` field.
|
||
let href = nc_href(url_user, subpath);
|
||
let file_id = nc_id_of(&file_id_map, &file.id);
|
||
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
|
||
write_file_response(
|
||
&mut xml,
|
||
file,
|
||
&href,
|
||
(file_id, oc_id.as_deref()),
|
||
username,
|
||
favorite_ids,
|
||
dead_props,
|
||
)?;
|
||
|
||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||
.xml_err()?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Build a streaming 207 Multi-Status response for a folder PROPFIND.
|
||
///
|
||
/// Mirrors the native WebDAV handler's `build_streaming_propfind_response`:
|
||
/// children are fetched in pages of [`PROPFIND_BATCH_SIZE`], each page's
|
||
/// favorites and `oc:fileid`s are resolved with two batch queries, and the
|
||
/// XML is yielded chunk by chunk — memory stays O(batch) and the response
|
||
/// starts flowing immediately, instead of materializing the full listing
|
||
/// plus its entire multistatus (~2 KB/entry) in RAM before the first byte.
|
||
fn build_nc_streaming_propfind(
|
||
state: Arc<AppState>,
|
||
folder: FolderDto,
|
||
depth: String,
|
||
user_id: Uuid,
|
||
username: String,
|
||
subpath: String,
|
||
quota: Option<(i64, Option<i64>)>,
|
||
) -> Response<Body> {
|
||
let stream = async_stream::try_stream! {
|
||
let file_id_svc = state.nextcloud.as_ref().map(|n| &n.file_ids);
|
||
let fav_svc = state.favorites_service.as_ref();
|
||
let folder_service = &state.applications.folder_service;
|
||
let file_service = &state.applications.file_retrieval_service;
|
||
|
||
// ── <d:multistatus> + the folder's own entry ─────────────────
|
||
// Collection hrefs MUST end in `/` (RFC 4918 §5.2 + strict
|
||
// NC-client enforcement — see `nc_collection_href`).
|
||
// Same three independent reads as the per-page child triple below,
|
||
// and on the critical path of EVERY folder PROPFIND's first byte —
|
||
// overlapped with `join!` (ROUND10; the header trio was left serial
|
||
// when ROUND9 converted the page loops).
|
||
let folder_id_arr = [folder.id.as_str()];
|
||
let (folder_favs, (_, folder_id_map), folder_dead) = tokio::join!(
|
||
async {
|
||
if let Some(fav) = fav_svc {
|
||
fav.batch_check_favorites(user_id, &[(folder.id.as_str(), "folder")])
|
||
.await
|
||
.unwrap_or_default()
|
||
} else {
|
||
HashSet::new()
|
||
}
|
||
},
|
||
batch_resolve_ids(file_id_svc, &[], &folder_id_arr),
|
||
folder_dead_props(&state.webdav_dead_props, &folder),
|
||
);
|
||
|
||
let mut buf = Vec::with_capacity(4096);
|
||
{
|
||
let mut xml = Writer::new(&mut buf);
|
||
write_nc_multistatus_open(&mut xml).map_err(std::io::Error::other)?;
|
||
let href = nc_collection_href(&username, &subpath);
|
||
let fid = nc_id_of(&folder_id_map, &folder.id);
|
||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||
write_folder_response(&mut xml, &folder, &href, (fid, oc_id.as_deref()), &username, &folder_favs, quota, &folder_dead)
|
||
.map_err(std::io::Error::other)?;
|
||
}
|
||
yield Bytes::from(buf);
|
||
|
||
// ── Children (only if Depth != 0) ────────────────────────────
|
||
if depth != "0" {
|
||
// Encoded href prefix for every child: username + parent
|
||
// path encode ONCE here — the old per-row `nc_href` call
|
||
// re-split and re-encoded the constant prefix for each of
|
||
// the up-to-500 children of every page.
|
||
let child_href_prefix = {
|
||
let base = nc_href(&username, &subpath);
|
||
if base.ends_with('/') {
|
||
base
|
||
} else {
|
||
format!("{base}/")
|
||
}
|
||
};
|
||
|
||
// Files in pages (keyset cursor — O(page) per page instead of
|
||
// the quadratic LIMIT/OFFSET walk).
|
||
let mut after_name: Option<String> = None;
|
||
loop {
|
||
let batch = file_service
|
||
.list_files_batch_with_perms(
|
||
Some(&folder.id),
|
||
user_id,
|
||
after_name.as_deref(),
|
||
PROPFIND_BATCH_SIZE,
|
||
)
|
||
.await
|
||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||
if batch.is_empty() {
|
||
break;
|
||
}
|
||
let batch_len = batch.len();
|
||
|
||
// Per-page enrichment: favorites + oc:fileids + dead props —
|
||
// three independent reads over the same id batch, overlapped
|
||
// with `join!` so a page pays ~max(RTT) instead of 3×RTT
|
||
// (each query still batched per page: DEAD-PROPS.md). The
|
||
// round-7 deferred "serial pairs" item, adopted for this
|
||
// per-page triple after the injected-latency A/B in
|
||
// benches/ROUND9.md showed no local-PG regression.
|
||
let fav_items: Vec<(&str, &str)> =
|
||
batch.iter().map(|f| (f.id.as_str(), "file")).collect();
|
||
let file_uuids: Vec<&str> = batch.iter().map(|f| f.id.as_str()).collect();
|
||
let (favs, (file_id_map, _), file_deads) = tokio::join!(
|
||
async {
|
||
if let Some(fav) = fav_svc {
|
||
fav.batch_check_favorites(user_id, &fav_items)
|
||
.await
|
||
.unwrap_or_default()
|
||
} else {
|
||
HashSet::new()
|
||
}
|
||
},
|
||
batch_resolve_ids(file_id_svc, &file_uuids, &[]),
|
||
files_dead_props_map(&state.webdav_dead_props, &batch),
|
||
);
|
||
|
||
let mut chunk = Vec::with_capacity(batch_len * 1024);
|
||
{
|
||
let mut xml = Writer::new(&mut chunk);
|
||
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));
|
||
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)
|
||
.map_err(std::io::Error::other)?;
|
||
}
|
||
}
|
||
yield Bytes::from(chunk);
|
||
|
||
if (batch_len as i64) < PROPFIND_BATCH_SIZE {
|
||
break;
|
||
}
|
||
after_name = batch.last().map(|f| f.name.clone());
|
||
}
|
||
|
||
// Subfolders in pages — also collections, same trailing-slash
|
||
// rule. Keyset cursor: O(page) per page off
|
||
// idx_folders_unique_name instead of the quadratic
|
||
// COUNT(*) OVER() + LIMIT/OFFSET walk (benches/FOLDER-KEYSET.md).
|
||
let mut after_folder: Option<String> = None;
|
||
loop {
|
||
let batch = folder_service
|
||
.list_folders_batch_with_perms(
|
||
Some(&folder.id),
|
||
user_id,
|
||
after_folder.as_deref(),
|
||
PROPFIND_BATCH_SIZE as usize,
|
||
)
|
||
.await
|
||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||
if batch.is_empty() {
|
||
break;
|
||
}
|
||
|
||
// Same overlapped enrichment triple as the file pages above.
|
||
let fav_items: Vec<(&str, &str)> =
|
||
batch.iter().map(|sf| (sf.id.as_str(), "folder")).collect();
|
||
let folder_uuids: Vec<&str> = batch.iter().map(|sf| sf.id.as_str()).collect();
|
||
let (favs, (_, sub_id_map), sub_deads) = tokio::join!(
|
||
async {
|
||
if let Some(fav) = fav_svc {
|
||
fav.batch_check_favorites(user_id, &fav_items)
|
||
.await
|
||
.unwrap_or_default()
|
||
} else {
|
||
HashSet::new()
|
||
}
|
||
},
|
||
batch_resolve_ids(file_id_svc, &[], &folder_uuids),
|
||
folders_dead_props_map(&state.webdav_dead_props, &batch),
|
||
);
|
||
|
||
let mut chunk = Vec::with_capacity(batch.len() * 1024);
|
||
{
|
||
let mut xml = Writer::new(&mut chunk);
|
||
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));
|
||
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)
|
||
.map_err(std::io::Error::other)?;
|
||
}
|
||
}
|
||
let has_more = (batch.len() as i64) == PROPFIND_BATCH_SIZE;
|
||
after_folder = batch.last().map(|sf| sf.name.clone());
|
||
yield Bytes::from(chunk);
|
||
|
||
if !has_more {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── </d:multistatus> ─────────────────────────────────────────
|
||
let mut buf = Vec::with_capacity(32);
|
||
{
|
||
let mut xml = Writer::new(&mut buf);
|
||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||
}
|
||
yield Bytes::from(buf);
|
||
};
|
||
|
||
use futures::TryStreamExt;
|
||
let stream = stream
|
||
.map_err(|e: std::io::Error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) });
|
||
|
||
Response::builder()
|
||
.status(StatusCode::MULTI_STATUS)
|
||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||
.body(Body::from_stream(stream))
|
||
.unwrap()
|
||
}
|
||
|
||
/// `oc_ids` bundles `(file_id, oc_id)` — always fetched and passed
|
||
/// together (`oc_id` is derived from `file_id`) — to stay under
|
||
/// clippy's argument-count lint now that `dead_props` is also threaded
|
||
/// through.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn write_folder_response<W: std::io::Write>(
|
||
xml: &mut Writer<W>,
|
||
folder: &FolderDto,
|
||
href: &str,
|
||
oc_ids: (Option<i64>, Option<&str>),
|
||
owner: &str,
|
||
favorite_ids: &HashSet<String>,
|
||
quota: Option<(i64, Option<i64>)>,
|
||
dead_props: &[(QualifiedName, Option<String>)],
|
||
) -> Result<(), String> {
|
||
let (file_id, oc_id) = oc_ids;
|
||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||
.xml_err()?;
|
||
|
||
// href
|
||
write_text_element(xml, "d:href", href)?;
|
||
|
||
xml.write_event(Event::Start(BytesStart::new("d:propstat")))
|
||
.xml_err()?;
|
||
xml.write_event(Event::Start(BytesStart::new("d:prop")))
|
||
.xml_err()?;
|
||
|
||
// resourcetype
|
||
xml.write_event(Event::Start(BytesStart::new("d:resourcetype")))
|
||
.xml_err()?;
|
||
xml.write_event(Event::Empty(BytesStart::new("d:collection")))
|
||
.xml_err()?;
|
||
xml.write_event(Event::End(BytesEnd::new("d:resourcetype")))
|
||
.xml_err()?;
|
||
|
||
write_text_element(xml, "d:displayname", &folder.name)?;
|
||
|
||
write_date_element(
|
||
xml,
|
||
"d:getlastmodified",
|
||
timestamp_to_i64(folder.modified_at),
|
||
true,
|
||
)?;
|
||
// Route through `FolderDto::etag` (= `Folder::etag()`: the
|
||
// descendant-aware `{id[..16]}-{tree_modified_at}` — see the
|
||
// entity for the formula and the async-bump freshness contract).
|
||
write_etag_element(xml, "d:getetag", &folder.etag)?;
|
||
write_text_element(xml, "d:getcontenttype", "httpd/unix-directory")?;
|
||
write_text_element(xml, "d:getcontentlength", "0")?;
|
||
write_date_element(
|
||
xml,
|
||
"d:creationdate",
|
||
timestamp_to_i64(folder.created_at),
|
||
false,
|
||
)?;
|
||
|
||
// Nextcloud/ownCloud properties
|
||
if let Some(id) = file_id {
|
||
let mut buf = [0u8; 21];
|
||
write_text_element(xml, "oc:fileid", crate::common::fmt::i64_str(&mut buf, id))?;
|
||
}
|
||
if let Some(oid) = oc_id {
|
||
write_text_element(xml, "oc:id", oid)?;
|
||
}
|
||
write_text_element(xml, "oc:permissions", "RGDNVCK")?;
|
||
// Numeric share-permissions bitmask: Read=1 + Update=2 + Create=4 + Delete=8 + Share=16 = 31
|
||
write_text_element(xml, "ocs:share-permissions", "31")?;
|
||
write_text_element(xml, "oc:size", "0")?;
|
||
// RFC 4331 — same account/drive-wide value regardless of which
|
||
// folder entry is being described, mirroring the native WebDAV
|
||
// surface's `write_folder_standard_props` (see
|
||
// `AppState::resolve_webdav_quota`).
|
||
if let Some((used, available)) = quota {
|
||
let mut buf = [0u8; 21];
|
||
write_text_element(
|
||
xml,
|
||
"d:quota-used-bytes",
|
||
crate::common::fmt::i64_str(&mut buf, used),
|
||
)?;
|
||
if let Some(avail) = available {
|
||
write_text_element(
|
||
xml,
|
||
"d:quota-available-bytes",
|
||
crate::common::fmt::i64_str(&mut buf, avail),
|
||
)?;
|
||
}
|
||
}
|
||
write_text_element(xml, "oc:owner-id", owner)?;
|
||
write_text_element(xml, "oc:owner-display-name", owner)?;
|
||
write_text_element(xml, "nc:has-preview", "false")?;
|
||
write_text_element(xml, "nc:is-encrypted", "0")?;
|
||
write_text_element(xml, "nc:mount-type", "")?;
|
||
|
||
let is_fav = if favorite_ids.contains(&folder.id) {
|
||
"1"
|
||
} else {
|
||
"0"
|
||
};
|
||
write_text_element(xml, "oc:favorite", is_fav)?;
|
||
// Empty share-types (no sharing API yet)
|
||
xml.write_event(Event::Empty(BytesStart::new("oc:share-types")))
|
||
.xml_err()?;
|
||
|
||
xml.write_event(Event::End(BytesEnd::new("d:prop")))
|
||
.xml_err()?;
|
||
write_text_element(xml, "d:status", "HTTP/1.1 200 OK")?;
|
||
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
|
||
.xml_err()?;
|
||
|
||
WebDavAdapter::write_dead_props_propstat(xml, dead_props).xml_err()?;
|
||
|
||
xml.write_event(Event::End(BytesEnd::new("d:response")))
|
||
.xml_err()?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// See `write_folder_response` for why `(file_id, oc_id)` are bundled
|
||
/// into `oc_ids`.
|
||
pub fn write_file_response<W: std::io::Write>(
|
||
xml: &mut Writer<W>,
|
||
file: &FileDto,
|
||
href: &str,
|
||
oc_ids: (Option<i64>, Option<&str>),
|
||
owner: &str,
|
||
favorite_ids: &HashSet<String>,
|
||
dead_props: &[(QualifiedName, Option<String>)],
|
||
) -> Result<(), String> {
|
||
let (file_id, oc_id) = oc_ids;
|
||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||
.xml_err()?;
|
||
|
||
write_text_element(xml, "d:href", href)?;
|
||
|
||
xml.write_event(Event::Start(BytesStart::new("d:propstat")))
|
||
.xml_err()?;
|
||
xml.write_event(Event::Start(BytesStart::new("d:prop")))
|
||
.xml_err()?;
|
||
|
||
// resourcetype (empty for files)
|
||
xml.write_event(Event::Empty(BytesStart::new("d:resourcetype")))
|
||
.xml_err()?;
|
||
|
||
write_text_element(xml, "d:displayname", &file.name)?;
|
||
write_text_element(xml, "d:getcontenttype", &file.mime_type)?;
|
||
{
|
||
let mut buf = [0u8; 20];
|
||
write_text_element(
|
||
xml,
|
||
"d:getcontentlength",
|
||
crate::common::fmt::u64_str(&mut buf, file.size),
|
||
)?;
|
||
}
|
||
|
||
write_date_element(
|
||
xml,
|
||
"d:getlastmodified",
|
||
timestamp_to_i64(file.modified_at),
|
||
true,
|
||
)?;
|
||
write_etag_element(xml, "d:getetag", &file.etag)?;
|
||
write_date_element(
|
||
xml,
|
||
"d:creationdate",
|
||
timestamp_to_i64(file.created_at),
|
||
false,
|
||
)?;
|
||
|
||
// Nextcloud/ownCloud properties
|
||
if let Some(id) = file_id {
|
||
let mut buf = [0u8; 21];
|
||
write_text_element(xml, "oc:fileid", crate::common::fmt::i64_str(&mut buf, id))?;
|
||
}
|
||
if let Some(oid) = oc_id {
|
||
write_text_element(xml, "oc:id", oid)?;
|
||
}
|
||
write_text_element(xml, "oc:permissions", "RGDNVW")?;
|
||
// Numeric share-permissions bitmask: Read=1 + Update=2 + Delete=8 + Share=16 = 27
|
||
write_text_element(xml, "ocs:share-permissions", "27")?;
|
||
{
|
||
let mut buf = [0u8; 20];
|
||
write_text_element(
|
||
xml,
|
||
"oc:size",
|
||
crate::common::fmt::u64_str(&mut buf, file.size),
|
||
)?;
|
||
}
|
||
write_text_element(xml, "oc:owner-id", owner)?;
|
||
write_text_element(xml, "oc:owner-display-name", owner)?;
|
||
|
||
let is_fav = if favorite_ids.contains(&file.id) {
|
||
"1"
|
||
} else {
|
||
"0"
|
||
};
|
||
write_text_element(xml, "oc:favorite", is_fav)?;
|
||
// Empty share-types (no sharing API yet)
|
||
xml.write_event(Event::Empty(BytesStart::new("oc:share-types")))
|
||
.xml_err()?;
|
||
|
||
// Check if file is an image that can have previews
|
||
let has_preview = matches!(
|
||
&*file.mime_type,
|
||
"image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp"
|
||
);
|
||
write_text_element(
|
||
xml,
|
||
"nc:has-preview",
|
||
if has_preview { "true" } else { "false" },
|
||
)?;
|
||
|
||
write_text_element(xml, "nc:is-encrypted", "0")?;
|
||
write_text_element(xml, "nc:mount-type", "")?;
|
||
{
|
||
let mut buf = [0u8; 20];
|
||
write_text_element(
|
||
xml,
|
||
"nc:creation_time",
|
||
crate::common::fmt::u64_str(&mut buf, file.created_at),
|
||
)?;
|
||
write_text_element(
|
||
xml,
|
||
"nc:upload_time",
|
||
crate::common::fmt::u64_str(&mut buf, file.modified_at),
|
||
)?;
|
||
}
|
||
|
||
xml.write_event(Event::End(BytesEnd::new("d:prop")))
|
||
.xml_err()?;
|
||
write_text_element(xml, "d:status", "HTTP/1.1 200 OK")?;
|
||
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
|
||
.xml_err()?;
|
||
|
||
WebDavAdapter::write_dead_props_propstat(xml, dead_props).xml_err()?;
|
||
|
||
xml.write_event(Event::End(BytesEnd::new("d:response")))
|
||
.xml_err()?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Stack-rendered `d:getlastmodified` / `d:creationdate` bodies
|
||
/// (`common::fmt`) — the old per-row `to_rfc2822()` / `to_rfc3339()`
|
||
/// ran chrono's format interpreter and allocated a String each.
|
||
/// Out-of-range timestamps keep the chrono path, byte-identical.
|
||
pub fn write_date_element<W: std::io::Write>(
|
||
xml: &mut Writer<W>,
|
||
tag: &str,
|
||
secs: i64,
|
||
rfc2822: bool,
|
||
) -> Result<(), String> {
|
||
if rfc2822 {
|
||
let mut buf = [0u8; 31];
|
||
if let Some(s) = crate::common::fmt::rfc2822_utc(&mut buf, secs) {
|
||
return write_text_element(xml, tag, s);
|
||
}
|
||
let dt = chrono::DateTime::<Utc>::from_timestamp(secs, 0).unwrap_or_else(Utc::now);
|
||
write_text_element(xml, tag, &dt.to_rfc2822())
|
||
} else {
|
||
let mut buf = [0u8; 25];
|
||
if let Some(s) = crate::common::fmt::rfc3339_utc(&mut buf, secs) {
|
||
return write_text_element(xml, tag, s);
|
||
}
|
||
let dt = chrono::DateTime::<Utc>::from_timestamp(secs, 0).unwrap_or_else(Utc::now);
|
||
write_text_element(xml, tag, &dt.to_rfc3339())
|
||
}
|
||
}
|
||
|
||
/// `d:getetag` with the HTTP quoting — one exactly-sized allocation
|
||
/// instead of `format!`'s grow-from-empty.
|
||
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)
|
||
}
|
||
|
||
pub fn write_text_element<W: std::io::Write>(
|
||
xml: &mut Writer<W>,
|
||
tag: &str,
|
||
value: &str,
|
||
) -> Result<(), String> {
|
||
xml.write_event(Event::Start(BytesStart::new(tag)))
|
||
.xml_err()?;
|
||
xml.write_event(Event::Text(BytesText::new(value)))
|
||
.xml_err()?;
|
||
xml.write_event(Event::End(BytesEnd::new(tag))).xml_err()?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Resolve every `oc:fileid` for a listing in two batch queries (one per
|
||
/// object type) instead of one INSERT round-trip per child. Returns
|
||
/// `(file_map, folder_map)` keyed by parsed object UUID; entries are absent
|
||
/// when the service is disabled or an id can't be resolved, mirroring the
|
||
/// previous per-call `Option` behaviour. The two batches run concurrently.
|
||
/// Borrowed inputs + `Uuid` keys keep the whole resolution alloc-free.
|
||
pub async fn batch_resolve_ids(
|
||
svc: Option<&Arc<NextcloudFileIdService>>,
|
||
file_uuids: &[&str],
|
||
folder_uuids: &[&str],
|
||
) -> (HashMap<Uuid, i64>, HashMap<Uuid, i64>) {
|
||
let Some(svc) = svc else {
|
||
return (HashMap::new(), HashMap::new());
|
||
};
|
||
let (files, folders) = tokio::join!(
|
||
svc.get_or_create_file_ids(file_uuids),
|
||
svc.get_or_create_folder_ids(folder_uuids),
|
||
);
|
||
(files.unwrap_or_default(), folders.unwrap_or_default())
|
||
}
|
||
|
||
/// Look up a batch-resolved `oc:fileid` by a DTO's string UUID.
|
||
pub fn nc_id_of(map: &HashMap<Uuid, i64>, id: &str) -> Option<i64> {
|
||
Uuid::parse_str(id).ok().and_then(|u| map.get(&u).copied())
|
||
}
|
||
|
||
pub fn format_oc_id(id: i64, svc: Option<&Arc<NextcloudFileIdService>>) -> String {
|
||
match svc {
|
||
Some(s) => s.format_oc_id(id),
|
||
None => format!("{:08}ocnca", id),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// ── nc_to_internal_path ──
|
||
//
|
||
// The route glue resolves the `chroot` FolderDto once per request
|
||
// (legacy/home → user's home folder DTO; explicit `~{folder_uuid}` →
|
||
// folder's stored DTO after permission check). These tests cover only
|
||
// the path-mapping function itself; the resolver logic lives in
|
||
// `routes.rs::verify_url_user_and_resolve_chroot`.
|
||
|
||
/// Build a stub `FolderDto` carrying only the `path` field (all the
|
||
/// path mapper looks at). Keeps the tests focused on path mapping
|
||
/// without dragging in folder-construction machinery.
|
||
fn stub_folder(path: &str) -> FolderDto {
|
||
FolderDto {
|
||
id: "00000000-0000-0000-0000-000000000000".to_string(),
|
||
name: path.rsplit('/').next().unwrap_or("").to_string(),
|
||
path: path.to_string(),
|
||
parent_id: None,
|
||
// Test stub — path mapper doesn't read drive_id.
|
||
drive_id: uuid::Uuid::nil(),
|
||
created_at: 0,
|
||
modified_at: 0,
|
||
is_root: false,
|
||
icon_class: std::sync::Arc::from("fas fa-folder"),
|
||
icon_special_class: std::sync::Arc::from("folder-icon"),
|
||
category: std::sync::Arc::from("Folder"),
|
||
etag: String::new(),
|
||
// §14 provenance not relevant to path-mapper tests.
|
||
created_by: None,
|
||
updated_by: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_empty_subpath_returns_chroot() {
|
||
let home = stub_folder("My Folder - alice");
|
||
assert_eq!(nc_to_internal_path(&home, "").unwrap(), "My Folder - alice");
|
||
}
|
||
|
||
#[test]
|
||
fn test_subpath_appended_to_chroot() {
|
||
let home = stub_folder("My Folder - alice");
|
||
assert_eq!(
|
||
nc_to_internal_path(&home, "Documents/work").unwrap(),
|
||
"My Folder - alice/Documents/work"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_strips_surrounding_slashes() {
|
||
let home = stub_folder("My Folder - alice");
|
||
assert_eq!(
|
||
nc_to_internal_path(&home, "/Photos/").unwrap(),
|
||
"My Folder - alice/Photos"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_rejects_dot_dot_traversal() {
|
||
let home = stub_folder("My Folder - alice");
|
||
assert!(nc_to_internal_path(&home, "../etc/passwd").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_rejects_single_dot() {
|
||
let home = stub_folder("My Folder - alice");
|
||
assert!(nc_to_internal_path(&home, "foo/./bar").is_err());
|
||
}
|
||
|
||
/// Confines a subfolder chroot (the multi-drive form once
|
||
/// resolved). Same path-mapping logic — only the chroot differs.
|
||
#[test]
|
||
fn test_subfolder_chroot_with_subpath() {
|
||
let chroot = stub_folder("My Folder - alice/ext");
|
||
assert_eq!(
|
||
nc_to_internal_path(&chroot, "report.pdf").unwrap(),
|
||
"My Folder - alice/ext/report.pdf"
|
||
);
|
||
}
|
||
|
||
// ── strip_chroot_prefix ──
|
||
//
|
||
// Regression guard for the "chroot.path has a leading slash from
|
||
// StoragePath::to_string() but DB-side original_path doesn't" trap
|
||
// that broke the NC trashbin PROPFIND after Round 2 rolled out.
|
||
// Also pins the composed-chroot behaviour Ed asked about.
|
||
|
||
#[test]
|
||
fn strip_chroot_prefix_default_drive_root() {
|
||
// FolderDto.path carries a leading slash (StoragePath Display);
|
||
// DB paths do not. Both must normalise to the same prefix.
|
||
let chroot = stub_folder("/Personal");
|
||
assert_eq!(
|
||
strip_chroot_prefix(&chroot, "Personal/g9-tree"),
|
||
Some("g9-tree")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn strip_chroot_prefix_deep_path() {
|
||
let chroot = stub_folder("/Personal");
|
||
assert_eq!(
|
||
strip_chroot_prefix(&chroot, "Personal/inner/deep.txt"),
|
||
Some("inner/deep.txt")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn strip_chroot_prefix_out_of_chroot_returns_none() {
|
||
// Items on a different drive (whose root isn't "Personal")
|
||
// must NOT be surfaced under the caller's chroot.
|
||
let chroot = stub_folder("/Personal");
|
||
assert_eq!(strip_chroot_prefix(&chroot, "team-drive/report.pdf"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn strip_chroot_prefix_rejects_partial_prefix_match() {
|
||
// "Personal" is a prefix substring of "PersonalSecrets" but
|
||
// NOT a path-segment prefix — must reject.
|
||
let chroot = stub_folder("/Personal");
|
||
assert_eq!(
|
||
strip_chroot_prefix(&chroot, "PersonalSecrets/foo.txt"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn strip_chroot_prefix_composed_chroot() {
|
||
// The future composed-chroot case Ed raised: chroot points at
|
||
// a subfolder inside a drive. The strip must remove the ENTIRE
|
||
// composed prefix, not just the first segment.
|
||
let chroot = stub_folder("/Personal/folderA/subfolder");
|
||
assert_eq!(
|
||
strip_chroot_prefix(&chroot, "Personal/folderA/subfolder/foo.txt"),
|
||
Some("foo.txt")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn strip_chroot_prefix_composed_chroot_sibling_leaks_blocked() {
|
||
// Same composed chroot, but the item lives in a sibling
|
||
// subfolder — must be rejected, not naively strip 1 segment.
|
||
let chroot = stub_folder("/Personal/folderA/subfolder");
|
||
assert_eq!(
|
||
strip_chroot_prefix(&chroot, "Personal/folderA/other/foo.txt"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn strip_chroot_prefix_chroot_root_itself() {
|
||
// Item path equals chroot exactly — legitimate for a PROPFIND
|
||
// Depth:0 on the chroot itself. Subpath is empty.
|
||
let chroot = stub_folder("/Personal");
|
||
assert_eq!(strip_chroot_prefix(&chroot, "Personal"), Some(""));
|
||
}
|
||
|
||
#[test]
|
||
fn strip_chroot_prefix_empty_chroot_returns_none() {
|
||
// Defensive: a mis-set chroot with an empty path must not
|
||
// strip anything (stripping "" from any path would return
|
||
// the whole path — a silent leak).
|
||
let chroot = stub_folder("/");
|
||
assert_eq!(strip_chroot_prefix(&chroot, "Personal/foo.txt"), None);
|
||
}
|
||
|
||
// ── nc_href ──
|
||
|
||
#[test]
|
||
fn test_href_root() {
|
||
assert_eq!(nc_href("alice", ""), "/remote.php/dav/files/alice/");
|
||
}
|
||
|
||
#[test]
|
||
fn test_href_encodes_spaces() {
|
||
assert_eq!(
|
||
nc_href("alice", "My Photos/vacation pic.jpg"),
|
||
"/remote.php/dav/files/alice/My%20Photos/vacation%20pic.jpg"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_href_encodes_special_chars() {
|
||
let href = nc_href("alice", "file#1.txt");
|
||
assert!(href.contains("file%231.txt"));
|
||
}
|
||
|
||
// ── nc_collection_href ──
|
||
// RFC 4918 §5.2 requires a collection URL to end in '/'. The NC
|
||
// desktop client at `networkjobs.cpp:234` aborts the PROPFIND
|
||
// parse with `Invalid href "<…>" expected starting with
|
||
// "<requested-url>"` if the own-entry href is missing the slash.
|
||
// These tests pin the helper's behaviour so the regression can't
|
||
// come back silently.
|
||
|
||
#[test]
|
||
fn test_collection_href_appends_slash_when_missing() {
|
||
assert_eq!(
|
||
nc_collection_href("alice", "ext"),
|
||
"/remote.php/dav/files/alice/ext/"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_collection_href_idempotent_at_root() {
|
||
// Root subpath already ends in '/' — don't double-append.
|
||
assert_eq!(
|
||
nc_collection_href("alice", ""),
|
||
"/remote.php/dav/files/alice/"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_collection_href_preserves_encoding() {
|
||
// Wrapping must not re-encode or double-encode already-encoded
|
||
// segments.
|
||
assert_eq!(
|
||
nc_collection_href("alice", "My Photos/2024"),
|
||
"/remote.php/dav/files/alice/My%20Photos/2024/"
|
||
);
|
||
}
|
||
|
||
// ── extract_nc_subpath_from_dest ──
|
||
|
||
#[test]
|
||
fn test_extract_relative_path() {
|
||
let result = extract_nc_subpath_from_dest(
|
||
"/remote.php/dav/files/alice/Documents/moved.txt",
|
||
"alice",
|
||
);
|
||
assert_eq!(result.as_deref(), Some("Documents/moved.txt"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_absolute_url() {
|
||
let result = extract_nc_subpath_from_dest(
|
||
"https://cloud.example.com/remote.php/dav/files/alice/new.txt",
|
||
"alice",
|
||
);
|
||
assert_eq!(result.as_deref(), Some("new.txt"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_url_encoded() {
|
||
let result = extract_nc_subpath_from_dest(
|
||
"/remote.php/dav/files/alice/My%20Folder/file.txt",
|
||
"alice",
|
||
);
|
||
assert_eq!(result.as_deref(), Some("My Folder/file.txt"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_wrong_user_returns_none() {
|
||
let result = extract_nc_subpath_from_dest("/remote.php/dav/files/bob/secret.txt", "alice");
|
||
assert!(result.is_none());
|
||
}
|
||
|
||
// ── timestamp_to_i64 ──
|
||
|
||
#[test]
|
||
fn test_timestamp_normal() {
|
||
assert_eq!(timestamp_to_i64(1700000000), 1700000000i64);
|
||
}
|
||
|
||
#[test]
|
||
fn test_timestamp_overflow_returns_zero() {
|
||
assert_eq!(timestamp_to_i64(u64::MAX), 0);
|
||
}
|
||
}
|