fix(sync): terminate WebDAV collection paths

Closes #6448
This commit is contained in:
t8y2
2026-08-17 07:31:21 +00:00
parent dd82e8f9c8
commit 088cca545c
+143 -2
View File
@@ -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<u16>,
reject_non_collection_mkcol: bool,
) -> (String, tokio::task::JoinHandle<Vec<String>>) {
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 {