From 088cca545c5ebb9a92ec52db184cea4795f1f25c Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 17 Aug 2026 06:52:31 +0000 Subject: [PATCH] fix(sync): terminate WebDAV collection paths Closes #6448 --- crates/dbx-core/src/cloud_sync.rs | 145 +++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 2 deletions(-) diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs index 4b489ce5fd..27a5edd946 100644 --- a/crates/dbx-core/src/cloud_sync.rs +++ b/crates/dbx-core/src/cloud_sync.rs @@ -588,7 +588,8 @@ impl WebDavClient { async fn ensure_parent_collections(&self, remote_path: &str) -> Result<(), String> { let method = Method::from_bytes(b"MKCOL").map_err(|e| e.to_string())?; for parent in parent_collection_paths(remote_path) { - let response = self.request(method.clone(), &parent)?.send().await.map_err(|e| e.to_string())?; + let collection_path = format!("{parent}/"); + let response = self.request(method.clone(), &collection_path)?.send().await.map_err(|e| e.to_string())?; let status = response.status(); if status.is_success() || status == StatusCode::METHOD_NOT_ALLOWED { continue; @@ -1608,7 +1609,7 @@ mod tests { save_webdav_sync_secrets_preference, scrub_connection_secrets, snapshot_for_snippet_upload, snippet_file_content, snippet_response_id, snippet_sync_settings, webdav_endpoint_uses_direct_connection, webdav_sync_secrets_status, ApplySnapshotOptions, ConnectionSecretSnapshot, SensitiveSyncPayload, - SnippetProvider, SnippetSyncClient, SnippetSyncConfig, DEFAULT_SNIPPET_FILE_NAME, + SnippetProvider, SnippetSyncClient, SnippetSyncConfig, WebDavClient, WebDavConfig, DEFAULT_SNIPPET_FILE_NAME, }; use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiConfigItem}; use crate::connection_secrets::NACOS_AUTH_PASSWORD_KEY; @@ -1690,6 +1691,48 @@ mod tests { (format!("http://{address}"), server) } + async fn spawn_webdav_server( + responses: Vec, + reject_non_collection_mkcol: bool, + ) -> (String, tokio::task::JoinHandle>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let mut request_lines = Vec::new(); + for configured_status in responses { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0_u8; 4096]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = socket.read(&mut chunk).await.unwrap(); + assert!(read > 0, "request ended before headers were complete"); + request.extend_from_slice(&chunk[..read]); + } + let request = String::from_utf8(request).unwrap(); + let request_line = request.lines().next().unwrap().to_string(); + let invalid_nginx_collection_uri = reject_non_collection_mkcol + && request_line.starts_with("MKCOL ") + && request_line.split_whitespace().nth(1).is_some_and(|target| !target.ends_with('/')); + let status = if invalid_nginx_collection_uri { 409 } else { configured_status }; + let reason = match status { + 200 => "OK", + 201 => "Created", + 207 => "Multi-Status", + 405 => "Method Not Allowed", + 409 => "Conflict", + _ => "Test Response", + }; + request_lines.push(request_line); + let response = format!("HTTP/1.1 {status} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + socket.write_all(response.as_bytes()).await.unwrap(); + } + request_lines + }); + (format!("http://{address}/"), server) + } + fn github_snippet_response(content: &str) -> String { serde_json::json!({ "files": { DEFAULT_SNIPPET_FILE_NAME: { "content": content } } @@ -1897,6 +1940,104 @@ mod tests { ); } + #[tokio::test] + async fn webdav_upload_terminates_collection_request_paths() { + let storage = Storage::open(&temp_db_path("webdav-collection-paths")).await.unwrap(); + let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap(); + let (endpoint, server) = spawn_webdav_server(vec![201, 201, 200], true).await; + let client = WebDavClient::new(WebDavConfig { + endpoint, + username: None, + password: None, + remote_path: Some("DBX-home/sync/snapshot.json".to_string()), + }); + + client.put_snapshot(&snapshot).await.unwrap(); + + assert_eq!( + server.await.unwrap(), + vec![ + "MKCOL /DBX-home/ HTTP/1.1", + "MKCOL /DBX-home/sync/ HTTP/1.1", + "PUT /DBX-home/sync/snapshot.json HTTP/1.1", + ] + ); + } + + #[tokio::test] + async fn webdav_upload_accepts_existing_collections() { + let storage = Storage::open(&temp_db_path("webdav-existing-collections")).await.unwrap(); + let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap(); + let (endpoint, server) = spawn_webdav_server(vec![405, 405, 200], false).await; + let client = WebDavClient::new(WebDavConfig { + endpoint, + username: None, + password: None, + remote_path: Some("DBX-home/sync/snapshot.json".to_string()), + }); + + client.put_snapshot(&snapshot).await.unwrap(); + + assert_eq!( + server.await.unwrap(), + vec![ + "MKCOL /DBX-home/ HTTP/1.1", + "MKCOL /DBX-home/sync/ HTTP/1.1", + "PUT /DBX-home/sync/snapshot.json HTTP/1.1", + ] + ); + } + + #[tokio::test] + async fn webdav_upload_preserves_collection_conflicts() { + let storage = Storage::open(&temp_db_path("webdav-collection-conflict")).await.unwrap(); + let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap(); + let (endpoint, server) = spawn_webdav_server(vec![409], false).await; + let client = WebDavClient::new(WebDavConfig { + endpoint, + username: None, + password: None, + remote_path: Some("DBX-home/sync/snapshot.json".to_string()), + }); + + let error = client.put_snapshot(&snapshot).await.unwrap_err(); + + assert_eq!(error, "Failed to create WebDAV collection 'DBX-home' with HTTP 409 Conflict"); + assert_eq!(server.await.unwrap(), vec!["MKCOL /DBX-home/ HTTP/1.1"]); + } + + #[tokio::test] + async fn webdav_upload_skips_collection_requests_for_single_file() { + let storage = Storage::open(&temp_db_path("webdav-single-file")).await.unwrap(); + let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap(); + let (endpoint, server) = spawn_webdav_server(vec![200], false).await; + let client = WebDavClient::new(WebDavConfig { + endpoint, + username: None, + password: None, + remote_path: Some("snapshot.json".to_string()), + }); + + client.put_snapshot(&snapshot).await.unwrap(); + + assert_eq!(server.await.unwrap(), vec!["PUT /snapshot.json HTTP/1.1"]); + } + + #[tokio::test] + async fn webdav_test_keeps_propfind_request() { + let (endpoint, server) = spawn_webdav_server(vec![207], false).await; + let client = WebDavClient::new(WebDavConfig { + endpoint, + username: None, + password: None, + remote_path: Some("DBX-home/sync/snapshot.json".to_string()), + }); + + client.test().await.unwrap(); + + assert_eq!(server.await.unwrap(), vec!["PROPFIND / HTTP/1.1"]); + } + #[test] fn scrubs_connection_secret_fields() { let mut config = ConnectionConfig {