Files
Oxicloud/src/interfaces/nextcloud/webdav_handler.rs
T
Claude 616e48b338 perf(nextcloud): batch oc:fileid resolution to kill PROPFIND N+1
Resolving the stable numeric oc:fileid for every child in a NextCloud
listing issued one `INSERT ... ON CONFLICT DO UPDATE` per entry — a write
(row rewrite + WAL + dead tuple) even when the mapping already existed.
A Depth:1 PROPFIND of a folder with N children meant N sequential write
round-trips on a read-only operation that sync clients repeat constantly.

- Repository: replace the single `get_or_create` (DO UPDATE) with
  `get_or_create_many` — one idempotent bulk `INSERT ... SELECT unnest(...)
  ON CONFLICT DO NOTHING` (existing rows untouched) plus a single
  `SELECT ... WHERE object_id = ANY(...)`. Two statements instead of N.
- Service: add an Arc-backed moka cache (uuid -> i64; the mapping is
  immutable, so warm entries never go stale) and batch APIs
  `get_or_create_file_ids` / `get_or_create_folder_ids` that only query
  the misses. Warm listings cost zero queries.
- Handlers (PROPFIND, REPORT favorites/search, trashbin, OCS unified
  search): pre-resolve all ids in two batched queries — file and folder
  run concurrently via `tokio::join!` — and turn the XML/JSON emission
  into a synchronous map lookup.

https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx
2026-06-10 08:39:06 +00:00

1461 lines
53 KiB
Rust

use axum::{
body::{self, Body},
http::{HeaderName, Request, StatusCode, header},
response::Response,
};
use bytes::Buf;
use chrono::Utc;
use quick_xml::{
Writer,
events::{BytesEnd, BytesStart, BytesText, Event},
};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::application::adapters::webdav_adapter::{PropFindRequest, WebDavAdapter};
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, refine_content_type_from_file};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
use crate::interfaces::upload_spool::spool_body_to_temp;
/// 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.
///
/// Nextcloud: /remote.php/dav/files/{user}/{subpath}
/// Internal: My Folder - {username}/{subpath}
///
/// An empty subpath maps to the user's home folder root.
pub fn nc_to_internal_path(username: &str, subpath: &str) -> Result<String, AppError> {
let home = format!("My Folder - {}", username);
let subpath = subpath.trim_matches('/');
if subpath.is_empty() {
return Ok(home);
}
// Reject path traversal attempts.
if subpath.split('/').any(|seg| seg == ".." || seg == ".") {
return Err(AppError::bad_request("Invalid path: traversal not allowed"));
}
Ok(format!("{}/{}", home, subpath))
}
/// 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}/`.
pub async fn handle_nc_webdav(
state: Arc<AppState>,
req: Request<Body>,
user: AuthUser,
subpath: String,
) -> Result<Response<Body>, AppError> {
let method = req.method().clone();
match method.as_str() {
"OPTIONS" => handle_options(),
"PROPFIND" => handle_propfind(state, req, &user, &subpath).await,
"GET" => handle_get(state, &user, &subpath).await,
"PUT" => handle_put(state, req, &user, &subpath).await,
"MKCOL" => handle_mkcol(state, &user, &subpath).await,
"DELETE" => handle_delete(state, &user, &subpath).await,
"MOVE" => handle_move(state, req, &user, &subpath).await,
"HEAD" => handle_head(state, &user, &subpath).await,
"PROPPATCH" => handle_proppatch(state, req, &user, &subpath).await,
"REPORT" | "SEARCH" => {
crate::interfaces::nextcloud::report_handler::handle_nc_report(
state, req, &user, &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>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
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)))?;
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(&user.username, subpath)?;
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
// Try to resolve as folder first.
let folder_result = folder_service.get_folder_by_path(&internal_path).await;
if let Ok(folder) = folder_result {
// It's a folder.
let (files, subfolders) = if depth != "0" {
let files = file_service
.list_files(Some(&folder.id))
.await
.unwrap_or_default();
let subfolders = folder_service
.list_folders(Some(&folder.id))
.await
.unwrap_or_default();
(files, subfolders)
} else {
(vec![], vec![])
};
// Batch-check favorites for all items in this listing.
let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() {
let mut items: Vec<(&str, &str)> = Vec::new();
items.push((&folder.id, "folder"));
for f in &files {
items.push((&f.id, "file"));
}
for sf in &subfolders {
items.push((&sf.id, "folder"));
}
fav_svc
.batch_check_favorites(user.id, &items)
.await
.unwrap_or_default()
} else {
HashSet::new()
};
// Generate Nextcloud-aware XML.
let nc = state.nextcloud.as_ref();
let file_id_svc = nc.map(|n| &n.file_ids);
let mut buf = Vec::new();
write_nc_multistatus(
&mut buf,
Some(&folder),
&files,
&subfolders,
&propfind,
&depth,
&user.username,
subpath,
file_id_svc,
&favorite_ids,
)
.await
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(buf))
.unwrap());
}
// Not a folder — try as a file.
let file_result = file_service.get_file_by_path(&internal_path).await;
if let Ok(file) = file_result {
// 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 mut buf = Vec::new();
write_nc_multistatus(
&mut buf,
None,
&[file],
&[],
&propfind,
"0",
&user.username,
subpath,
file_id_svc,
&favorite_ids,
)
.await
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(buf))
.unwrap());
}
Err(AppError::not_found("Resource not found"))
}
// ──────────────────── GET ────────────────────
async fn handle_get(
state: Arc<AppState>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
// 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 internal_path = nc_to_internal_path(&user.username, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
// Check if path is a folder first (NC clients use GET as existence check)
if folder_service
.get_folder_by_path(&internal_path)
.await
.is_ok()
{
return Ok(Response::builder()
.status(StatusCode::OK)
.header("DAV", "1, 3")
.body(Body::empty())
.unwrap());
}
let file = file_service
.get_file_by_path(&internal_path)
.await
.map_err(|_| AppError::not_found("File not found"))?;
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);
// 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.
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, file.mime_type.as_ref())
.header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, format!("\"{}\"", file.etag))
.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>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
// 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 internal_path = nc_to_internal_path(&user.username, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
// Check if path is a folder (NC clients use HEAD as existence check)
if folder_service
.get_folder_by_path(&internal_path)
.await
.is_ok()
{
return Ok(Response::builder()
.status(StatusCode::OK)
.header("DAV", "1, 3")
.body(Body::empty())
.unwrap());
}
let file = file_service
.get_file_by_path(&internal_path)
.await
.map_err(|_| AppError::not_found("File not found"))?;
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.
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, file.mime_type.as_ref())
.header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, format!("\"{}\"", file.etag))
.header(header::LAST_MODIFIED, modified_at.to_rfc2822())
.body(Body::empty())
.unwrap())
}
// ──────────────────── PROPPATCH ────────────────────
async fn handle_proppatch(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let body_bytes = body::to_bytes(req.into_body(), 64 * 1024)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?;
let body_str = String::from_utf8_lossy(&body_bytes);
// Resolve the target resource once — needed for two things:
// 1. Applying the oc:favorite mutation when the PROPPATCH body
// carries one (`item_type` distinguishes file vs folder rows
// in the favorites table).
// 2. 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. Without this distinction the NC desktop client
// parser aborted on PROPFIND; PROPPATCH would hit the same
// wall the moment the user favourited a folder.
//
// When the resource is missing we tolerate it for the no-op
// PROPPATCH path (no favorite directive in the body) — matches
// the prior behaviour. A PROPPATCH that *does* try to set
// favorite on a missing resource still returns NotFound.
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
let resource = if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
Some((file.id, "file"))
} else if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
Some((folder.id, "folder"))
} else {
None
};
let is_collection = matches!(resource, Some((_, "folder")));
// Parse oc:favorite value from PROPPATCH XML.
let favorite_value = parse_proppatch_favorite(&body_str);
if let Some(value) = favorite_value {
let Some((item_id, item_type)) = resource else {
return Err(AppError::not_found("Resource not found"));
};
if let Some(fav_svc) = state.favorites_service.as_ref() {
if value == 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))
})?;
}
}
}
// Return 207 Multi-Status with success response using quick_xml
// for safe escaping. 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(&user.username, subpath)
} else {
nc_href(&user.username, subpath)
};
let mut buf = Vec::new();
{
let mut xml = Writer::new(&mut buf);
xml.write_event(Event::Text(BytesText::new(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>",
)))
.map_err(|e| AppError::internal_error(format!("XML write failed: {}", e)))?;
let mut ms = BytesStart::new("d:multistatus");
ms.push_attribute(("xmlns:d", "DAV:"));
ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns"));
xml.write_event(Event::Start(ms))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::Start(BytesStart::new("d:response")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
write_text_element(&mut xml, "d:href", &href)
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::Start(BytesStart::new("d:propstat")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::Start(BytesStart::new("d:prop")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::Empty(BytesStart::new("oc:favorite")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::End(BytesEnd::new("d:prop")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
write_text_element(&mut xml, "d:status", "HTTP/1.1 200 OK")
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::End(BytesEnd::new("d:response")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
}
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(buf))
.unwrap())
}
/// Parse the oc:favorite value from a PROPPATCH XML body using quick_xml.
fn parse_proppatch_favorite(body: &str) -> Option<u8> {
use quick_xml::Reader;
let mut reader = Reader::from_str(body);
let mut inside_favorite = false;
loop {
match reader.read_event() {
Ok(Event::Start(ref e)) => {
let local = e.local_name();
if local.as_ref() == b"favorite" {
inside_favorite = true;
}
}
Ok(Event::Text(ref e)) if inside_favorite => {
let text = e.decode().ok()?;
return text.trim().parse::<u8>().ok();
}
Ok(Event::End(ref e)) if e.local_name().as_ref() == b"favorite" => {
inside_favorite = false;
}
Ok(Event::Eof) => break,
Err(_) => break,
_ => {}
}
}
None
}
// ──────────────────── PUT ────────────────────
async fn handle_put(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let internal_path = nc_to_internal_path(&user.username, 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());
// ── 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 to a temp file + incremental hash — never buffer the
// full upload in RAM. The old `body::to_bytes` path loaded the entire
// file (e.g. an 800 MB ISO) into anonymous memory before any dedup logic,
// OOMKilling the process even on dedup hits. Shared with the native
// WebDAV PUT handler; peak heap ~one frame regardless of file size.
let spooled = spool_body_to_temp(
req.into_body(),
max_upload,
state.core.config.storage.upload_temp_dir.clone(),
)
.await?;
// Detect real MIME type from the first bytes on disk (no full read).
// `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 content_type =
refine_content_type_from_file(spooled.temp.path(), &filename, &claimed_type).await;
// Distinguish create (201) vs update (204) for the response status.
let existed = file_service.get_file_by_path(&internal_path).await.is_ok();
// Single streaming path — handles both update and create internally,
// passing the precomputed hash so the dedup fast path can short-circuit
// without re-reading the file.
let stored = upload_service
.update_file_streaming(
&internal_path,
spooled.temp.path(),
spooled.size,
&content_type,
Some(spooled.hash),
oc_mtime,
)
.await
.map_err(|e| AppError::internal_error(format!("Failed to store file: {}", e)))?;
// dedup may have already moved the temp on a new-blob store; ignore error.
let _ = tokio::fs::remove_file(spooled.temp.path()).await;
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>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
use crate::application::dtos::folder_dto::CreateFolderDto;
let folder_service = &state.applications.folder_service;
let internal_path = nc_to_internal_path(&user.username, subpath)?;
// If the folder already exists, return 405 per RFC 4918 §9.3.1
if folder_service
.get_folder_by_path(&internal_path)
.await
.is_ok()
{
return Ok(Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.body(Body::empty())
.unwrap());
}
// Collect path segments that need to be created (walk from root to leaf)
let segments: Vec<&str> = subpath.split('/').filter(|s| !s.is_empty()).collect();
let user_root = nc_to_internal_path(&user.username, "")?;
let mut current_path = user_root.clone();
let mut parent_id = folder_service
.get_folder_by_path(&user_root)
.await
.map_err(|_| AppError::not_found("User root folder not found"))?
.id
.clone();
for segment in &segments {
current_path = format!("{}/{}", current_path, segment);
match folder_service.get_folder_by_path(&current_path).await {
Ok(existing) => {
parent_id = existing.id.clone();
}
Err(_) => {
let dto = CreateFolderDto {
name: segment.to_string(),
parent_id: Some(parent_id.clone()),
};
match folder_service.create_folder_with_perms(dto, user.id).await {
Ok(created) => {
parent_id = created.id.clone();
}
Err(e)
if e.message.contains("already exists")
|| e.message.contains("Already Exists") =>
{
// Race condition — folder created concurrently
let folder = folder_service
.get_folder_by_path(&current_path)
.await
.map_err(|_| {
AppError::internal_error("Folder exists but cannot be found")
})?;
parent_id = folder.id.clone();
}
Err(e) => {
return Err(AppError::internal_error(format!(
"Failed to create folder: {}",
e
)));
}
}
}
}
}
Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
.unwrap())
}
// ──────────────────── DELETE ────────────────────
async fn handle_delete(
state: Arc<AppState>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
// Prefer soft-delete (move to trash) when trash service is available.
// This is what Nextcloud clients expect — items appear in the trashbin.
if let Some(trash_svc) = state.trash_service.as_ref() {
if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
trash_svc
.move_to_trash(&folder.id, "folder", user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to trash folder: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap());
}
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
trash_svc
.move_to_trash(&file.id, "file", user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to trash file: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap());
}
return Err(AppError::not_found("Resource not found"));
}
// Fallback: hard delete when trash service is not available.
let file_mgmt = &state.applications.file_management_service;
if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
folder_service
.delete_folder_with_perms(&folder.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap());
}
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
file_mgmt
.delete_file_with_perms(&file.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap());
}
Err(AppError::not_found("Resource not found"))
}
// ──────────────────── MOVE ────────────────────
async fn handle_move(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let destination = req
.headers()
.get("destination")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::bad_request("Missing Destination header"))?
.to_string();
// Parse destination path: extract subpath after /remote.php/dav/files/{user}/
let dest_subpath = extract_nc_subpath_from_dest(&destination, &user.username)
.ok_or_else(|| AppError::bad_request("Invalid Destination URL"))?;
let src_internal = nc_to_internal_path(&user.username, subpath)?;
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
let file_mgmt = &state.applications.file_management_service;
// Try as file first.
if let Ok(file) = file_service.get_file_by_path(&src_internal).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(&user.username, dest_parent_sub)?;
// Rename if only the name changes (same parent).
let src_parent_sub = match subpath.rsplit_once('/') {
Some((parent, _)) => parent,
None => "",
};
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(|e| AppError::internal_error(format!("Rename failed: {}", e)))?;
} else {
// Different parent → move.
let dest_parent = folder_service
.get_folder_by_path(&dest_parent_internal)
.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(|e| AppError::internal_error(format!("Move failed: {}", e)))?;
// 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(|e| AppError::internal_error(format!("Rename failed: {}", e)))?;
}
}
// Return ETag and OC-ETag so Nextcloud clients can track the moved file.
let dest_internal = nc_to_internal_path(&user.username, &dest_subpath)?;
let mut builder = Response::builder().status(StatusCode::CREATED);
if let Ok(moved) = file_service.get_file_by_path(&dest_internal).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).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(&user.username, dest_parent_sub)?;
let src_parent_sub = match subpath.rsplit_once('/') {
Some((parent, _)) => parent,
None => "",
};
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(|e| AppError::internal_error(format!("Rename failed: {}", e)))?;
} else {
// Different parent → move.
let dest_parent = folder_service
.get_folder_by_path(&dest_parent_internal)
.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(|e| AppError::internal_error(format!("Move failed: {}", e)))?;
// 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(|e| AppError::internal_error(format!("Rename failed: {}", e)))?;
}
}
return Ok(Response::builder()
.status(StatusCode::CREATED)
.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.
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;
/// Generate a complete Nextcloud-compatible multistatus XML response.
#[allow(clippy::too_many_arguments)]
async fn write_nc_multistatus<W: std::io::Write>(
writer: W,
folder: Option<&FolderDto>,
files: &[FileDto],
subfolders: &[FolderDto],
_request: &PropFindRequest,
depth: &str,
username: &str,
subpath: &str,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
favorite_ids: &HashSet<String>,
) -> Result<(), String> {
// When folder is None, files are the target resource itself (single-file
// PROPFIND) and must always be emitted. When folder is Some, files/subfolders
// are children and should only be listed when depth > 0.
let emit_children = folder.is_none() || depth != "0";
// Pre-resolve every oc:fileid up front in two batch queries (one per
// object type) instead of one INSERT round-trip per child. The XML writing
// below is then a pure synchronous map lookup.
let mut file_uuids: Vec<String> = Vec::new();
let mut folder_uuids: Vec<String> = Vec::new();
if let Some(f) = folder {
folder_uuids.push(f.id.clone());
}
if emit_children {
file_uuids.extend(files.iter().map(|f| f.id.clone()));
folder_uuids.extend(subfolders.iter().map(|sf| sf.id.clone()));
}
let (file_id_map, folder_id_map) =
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
let mut xml = Writer::new(writer);
// Root element with all required namespaces.
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()?;
// Current folder entry. Collection hrefs MUST end in `/` (RFC 4918
// §5.2 + strict NC-client enforcement — see `nc_collection_href`).
if let Some(f) = folder {
let href = nc_collection_href(username, subpath);
let file_id = folder_id_map.get(&f.id).copied();
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(
&mut xml,
f,
&href,
file_id,
oc_id.as_deref(),
username,
favorite_ids,
)?;
}
if emit_children {
// Files.
for file in files {
let child_sub = if folder.is_none() {
// Single-file PROPFIND — subpath already points to the file.
subpath.to_string()
} else if subpath.is_empty() {
file.name.clone()
} else {
format!("{}/{}", subpath.trim_end_matches('/'), file.name)
};
let href = nc_href(username, &child_sub);
let file_id = file_id_map.get(&file.id).copied();
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,
)?;
}
// Subfolders — also collections, same trailing-slash rule.
for sf in subfolders {
let child_sub = if subpath.is_empty() {
sf.name.clone()
} else {
format!("{}/{}", subpath.trim_end_matches('/'), sf.name)
};
let href = nc_collection_href(username, &child_sub);
let file_id = folder_id_map.get(&sf.id).copied();
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(
&mut xml,
sf,
&href,
file_id,
oc_id.as_deref(),
username,
favorite_ids,
)?;
}
}
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
.xml_err()?;
Ok(())
}
pub fn write_folder_response<W: std::io::Write>(
xml: &mut Writer<W>,
folder: &FolderDto,
href: &str,
file_id: Option<i64>,
oc_id: Option<&str>,
owner: &str,
favorite_ids: &HashSet<String>,
) -> Result<(), String> {
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)?;
let created_at =
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(folder.created_at), 0)
.unwrap_or_else(Utc::now);
let modified_at =
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(folder.modified_at), 0)
.unwrap_or_else(Utc::now);
write_text_element(xml, "d:getlastmodified", &modified_at.to_rfc2822())?;
// Route through `FolderDto::etag` (= `Folder::etag()`, currently
// the folder UUID — see the entity for the documented v1 formula
// and the follow-up plan to make it descendant-aware).
write_text_element(xml, "d:getetag", &format!("\"{}\"", folder.etag))?;
write_text_element(xml, "d:getcontenttype", "httpd/unix-directory")?;
write_text_element(xml, "d:getcontentlength", "0")?;
write_text_element(xml, "d:creationdate", &created_at.to_rfc3339())?;
// Nextcloud/ownCloud properties
if let Some(id) = file_id {
write_text_element(xml, "oc:fileid", &id.to_string())?;
}
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")?;
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()?;
xml.write_event(Event::End(BytesEnd::new("d:response")))
.xml_err()?;
Ok(())
}
pub fn write_file_response<W: std::io::Write>(
xml: &mut Writer<W>,
file: &FileDto,
href: &str,
file_id: Option<i64>,
oc_id: Option<&str>,
owner: &str,
favorite_ids: &HashSet<String>,
) -> Result<(), String> {
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)?;
write_text_element(xml, "d:getcontentlength", &file.size.to_string())?;
let created_at = chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.created_at), 0)
.unwrap_or_else(Utc::now);
let modified_at =
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
.unwrap_or_else(Utc::now);
write_text_element(xml, "d:getlastmodified", &modified_at.to_rfc2822())?;
write_text_element(xml, "d:getetag", &format!("\"{}\"", file.etag))?;
write_text_element(xml, "d:creationdate", &created_at.to_rfc3339())?;
// Nextcloud/ownCloud properties
if let Some(id) = file_id {
write_text_element(xml, "oc:fileid", &id.to_string())?;
}
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")?;
write_text_element(xml, "oc:size", &file.size.to_string())?;
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", "")?;
write_text_element(xml, "nc:creation_time", &file.created_at.to_string())?;
write_text_element(xml, "nc:upload_time", &file.modified_at.to_string())?;
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()?;
xml.write_event(Event::End(BytesEnd::new("d:response")))
.xml_err()?;
Ok(())
}
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 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.
pub async fn batch_resolve_ids(
svc: Option<&Arc<NextcloudFileIdService>>,
file_uuids: &[String],
folder_uuids: &[String],
) -> (HashMap<String, i64>, HashMap<String, 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())
}
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 ──
#[test]
fn test_empty_subpath_returns_home() {
assert_eq!(
nc_to_internal_path("alice", "").unwrap(),
"My Folder - alice"
);
}
#[test]
fn test_subpath_appended() {
assert_eq!(
nc_to_internal_path("alice", "Documents/work").unwrap(),
"My Folder - alice/Documents/work"
);
}
#[test]
fn test_strips_surrounding_slashes() {
assert_eq!(
nc_to_internal_path("alice", "/Photos/").unwrap(),
"My Folder - alice/Photos"
);
}
#[test]
fn test_rejects_dot_dot_traversal() {
assert!(nc_to_internal_path("alice", "../etc/passwd").is_err());
}
#[test]
fn test_rejects_single_dot() {
assert!(nc_to_internal_path("alice", "foo/./bar").is_err());
}
// ── 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);
}
}