From ebb73e272a9fe845bb38570b9348b6444fe7cc85 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Wed, 9 Sep 2026 07:09:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(sync):=20=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E5=86=B3=E5=AE=9A=E5=89=8D=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E7=9B=AE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src-tauri/src/sync_resolution.rs | 77 +++++++++++++++++++ frontend/src-tauri/tests/sync_push.rs | 20 +++-- .../src/features/settings/SyncSettings.vue | 3 +- 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/frontend/src-tauri/src/sync_resolution.rs b/frontend/src-tauri/src/sync_resolution.rs index 27e64dd..5063597 100644 --- a/frontend/src-tauri/src/sync_resolution.rs +++ b/frontend/src-tauri/src/sync_resolution.rs @@ -50,6 +50,17 @@ impl Workspace { if current.is_empty() || self.resolve(destination)?.exists() { return Err(HostError::new("PATH_CONFLICT")); } + if !crate::sync_discovery::allowed(destination) { + return Err(HostError::new("SYNC_PATH_DENIED")); + } + // Validate the intended record path before freezing the decision. + // A typo must not leave an unchangeable, unappliable resolution. + if crate::records::is_record(destination) { + let spool = self.sync_spool(¤t)?; + let size = fs::metadata(&spool)?.len(); + let mut file = crate::payloads::open_verified(&spool, ¤t, size)?; + crate::payloads::validate_record(&mut file, destination, size)?; + } } else if !destination.is_empty() { return Err(HostError::new("SYNC_RESOLUTION_INVALID")); } @@ -265,6 +276,72 @@ mod tests { ws.sync_apply_pending(binding).unwrap(); } #[test] + fn invalid_record_copy_destination_does_not_freeze_conflict_decision() { + let root = tempfile::tempdir().unwrap(); + let mut ws = Workspace::open(root.path()).unwrap(); + let binding = ws + .sync_bind_download("https://sync.example", "remote-vault", "account") + .unwrap(); + let record = |name: &str| { + serde_json::to_vec(&serde_json::json!({"schema":1,"kind":"persona","id":"default","data":{"version":1,"name":name,"system_prompt":"","dialogue_pairs":[]}})).unwrap() + }; + let base = record("base"); + let local = record("local"); + let remote = record("remote"); + let path = crate::records::path_for("persona", "default").unwrap(); + let mut revision = RemoteRevision { + vault_id: "remote-vault".into(), + sequence: 1, + file_id: Uuid::new_v4().to_string(), + base_revision: 0, + path: path.clone(), + operation: "put".into(), + hash: Some(ws.sync_store_bytes(&base).unwrap()), + size: base.len() as i64, + operation_id: Uuid::new_v4().to_string(), + }; + receive(&mut ws, &binding.id, &revision); + let local_hash = ws + .write(&path, revision.hash.as_deref().unwrap(), &local, "local") + .unwrap() + .hash; + revision.sequence = 2; + revision.base_revision = 1; + revision.hash = Some(ws.sync_store_bytes(&remote).unwrap()); + revision.size = remote.len() as i64; + revision.operation_id = Uuid::new_v4().to_string(); + receive(&mut ws, &binding.id, &revision); + for destination in ["copy.json", "opennexus-records/v1/layout/sidebars.json"] { + assert!(ws + .sync_resolve(&binding.id, 2, "copy", destination, &local_hash) + .is_err()); + assert_eq!( + ws.db + .query_row("SELECT COUNT(*) FROM sync_resolutions", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + assert_eq!(fs::read(root.path().join(&path)).unwrap(), local); + } + ws.sync_resolve( + &binding.id, + 2, + "copy", + "attachments/persona-copy.txt", + &local_hash, + ) + .unwrap(); + drop(ws); + let ws = Workspace::open(root.path()).unwrap(); + assert_eq!( + fs::read(root.path().join("attachments/persona-copy.txt")).unwrap(), + local + ); + assert_eq!(fs::read(root.path().join(path)).unwrap(), remote); + assert!(ws.sync_conflicts(&binding.id).unwrap().is_empty()); + } + #[test] fn hundred_mib_conflicts_resolve_all_choices_and_reopen_without_duplicate_jobs() { use std::io::Write; let fixtures = tempfile::tempdir().unwrap(); diff --git a/frontend/src-tauri/tests/sync_push.rs b/frontend/src-tauri/tests/sync_push.rs index d5b069d..cf2c395 100644 --- a/frontend/src-tauri/tests/sync_push.rs +++ b/frontend/src-tauri/tests/sync_push.rs @@ -560,7 +560,7 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate .unwrap(); while client.push_one(&workspace, &binding).await.unwrap() {} client_b.pull_page(&workspace_b, &binding_b).await.unwrap(); - for round in 0..20 { + for round in 0..60 { let mut hashes = Vec::new(); for (side, target) in [(0, &workspace), (1, &workspace_b)] { let mut ws = target.lock().unwrap(); @@ -583,7 +583,9 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate } while client.push_one(&workspace, &binding).await.unwrap() {} client_b.pull_page(&workspace_b, &binding_b).await.unwrap(); - let choice = if round % 2 == 0 { "local" } else { "remote" }; + let choice = ["local", "remote", "copy"][round % 3]; + let copy = format!("attachments/{kind}-copy-{round}.txt"); + let destination = if choice == "copy" { copy.as_str() } else { "" }; { let mut ws = workspace_b.lock().unwrap(); let conflicts = ws.sync_conflicts(&binding_b.id).unwrap(); @@ -591,14 +593,14 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate let sequence = conflicts[0]["sequence"].as_i64().unwrap(); assert_eq!(ws.read(&path).unwrap().entry.hash, hashes[1]); assert_eq!( - ws.sync_resolve(&binding_b.id, sequence, choice, "", &hashes[0]) + ws.sync_resolve(&binding_b.id, sequence, choice, destination, &hashes[0]) .unwrap_err() .code, "REVISION_CONFLICT" ); - ws.sync_resolve(&binding_b.id, sequence, choice, "", &hashes[1]) + ws.sync_resolve(&binding_b.id, sequence, choice, destination, &hashes[1]) .unwrap(); - ws.sync_resolve(&binding_b.id, sequence, choice, "", &hashes[1]) + ws.sync_resolve(&binding_b.id, sequence, choice, destination, &hashes[1]) .unwrap(); } while client_b.push_one(&workspace_b, &binding_b).await.unwrap() {} @@ -618,6 +620,14 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate .unwrap(); assert_eq!(a, b); assert_eq!(a["hash"], hashes[usize::from(choice == "local")]); + if choice == "copy" { + let copy_a = workspace.lock().unwrap().read(©).unwrap(); + let copy_b = workspace_b.lock().unwrap().read(©).unwrap(); + assert_eq!(copy_a.content, copy_b.content); + assert_eq!(copy_a.entry.hash, hashes[1]); + assert_eq!(copy_a.entry.file_id, copy_b.entry.file_id); + assert_ne!(copy_a.entry.file_id, a["file_id"].as_str().unwrap()); + } assert!(workspace .lock() .unwrap() diff --git a/frontend/src/features/settings/SyncSettings.vue b/frontend/src/features/settings/SyncSettings.vue index 2059d83..dc0d37f 100644 --- a/frontend/src/features/settings/SyncSettings.vue +++ b/frontend/src/features/settings/SyncSettings.vue @@ -136,7 +136,8 @@ onUnmounted(() => { mounted = false; clearInterval(timer); password.value = '' }

{{ conflict.local_path }}

{{ t('远端版本', 'Remote revision') }} {{ conflict.sequence }} · {{ conflict.remote.operation }} · {{ conflict.remote.path }}

- +

{{ t('可将本地设置保留为 attachments 目录下的文本副本;副本供查看和恢复,不会自动应用。', 'Keep local settings as a text copy under attachments for inspection and recovery; the copy is not applied automatically.') }}

+