fix(sync): 持久化冲突决定前验证复制目标
This commit is contained in:
@@ -50,6 +50,17 @@ impl Workspace {
|
|||||||
if current.is_empty() || self.resolve(destination)?.exists() {
|
if current.is_empty() || self.resolve(destination)?.exists() {
|
||||||
return Err(HostError::new("PATH_CONFLICT"));
|
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() {
|
} else if !destination.is_empty() {
|
||||||
return Err(HostError::new("SYNC_RESOLUTION_INVALID"));
|
return Err(HostError::new("SYNC_RESOLUTION_INVALID"));
|
||||||
}
|
}
|
||||||
@@ -265,6 +276,72 @@ mod tests {
|
|||||||
ws.sync_apply_pending(binding).unwrap();
|
ws.sync_apply_pending(binding).unwrap();
|
||||||
}
|
}
|
||||||
#[test]
|
#[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() {
|
fn hundred_mib_conflicts_resolve_all_choices_and_reopen_without_duplicate_jobs() {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
let fixtures = tempfile::tempdir().unwrap();
|
let fixtures = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -560,7 +560,7 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
while client.push_one(&workspace, &binding).await.unwrap() {}
|
while client.push_one(&workspace, &binding).await.unwrap() {}
|
||||||
client_b.pull_page(&workspace_b, &binding_b).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();
|
let mut hashes = Vec::new();
|
||||||
for (side, target) in [(0, &workspace), (1, &workspace_b)] {
|
for (side, target) in [(0, &workspace), (1, &workspace_b)] {
|
||||||
let mut ws = target.lock().unwrap();
|
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() {}
|
while client.push_one(&workspace, &binding).await.unwrap() {}
|
||||||
client_b.pull_page(&workspace_b, &binding_b).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 mut ws = workspace_b.lock().unwrap();
|
||||||
let conflicts = ws.sync_conflicts(&binding_b.id).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();
|
let sequence = conflicts[0]["sequence"].as_i64().unwrap();
|
||||||
assert_eq!(ws.read(&path).unwrap().entry.hash, hashes[1]);
|
assert_eq!(ws.read(&path).unwrap().entry.hash, hashes[1]);
|
||||||
assert_eq!(
|
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()
|
.unwrap_err()
|
||||||
.code,
|
.code,
|
||||||
"REVISION_CONFLICT"
|
"REVISION_CONFLICT"
|
||||||
);
|
);
|
||||||
ws.sync_resolve(&binding_b.id, sequence, choice, "", &hashes[1])
|
ws.sync_resolve(&binding_b.id, sequence, choice, destination, &hashes[1])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
ws.sync_resolve(&binding_b.id, sequence, choice, "", &hashes[1])
|
ws.sync_resolve(&binding_b.id, sequence, choice, destination, &hashes[1])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
while client_b.push_one(&workspace_b, &binding_b).await.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();
|
.unwrap();
|
||||||
assert_eq!(a, b);
|
assert_eq!(a, b);
|
||||||
assert_eq!(a["hash"], hashes[usize::from(choice == "local")]);
|
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
|
assert!(workspace
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ onUnmounted(() => { mounted = false; clearInterval(timer); password.value = '' }
|
|||||||
<article v-for="conflict in status.conflicts" :key="conflict.sequence" class="sync-conflict">
|
<article v-for="conflict in status.conflicts" :key="conflict.sequence" class="sync-conflict">
|
||||||
<h3>{{ conflict.local_path }}</h3><p>{{ t('远端版本', 'Remote revision') }} {{ conflict.sequence }} · {{ conflict.remote.operation }} · {{ conflict.remote.path }}</p>
|
<h3>{{ conflict.local_path }}</h3><p>{{ t('远端版本', 'Remote revision') }} {{ conflict.sequence }} · {{ conflict.remote.operation }} · {{ conflict.remote.path }}</p>
|
||||||
<div class="inline-actions"><button :disabled="busy" @click="resolve(conflict, 'local')">{{ t('保留本地', 'Keep local') }}</button><button :disabled="busy" @click="resolve(conflict, 'remote')">{{ t('采用远端', 'Use remote') }}</button></div>
|
<div class="inline-actions"><button :disabled="busy" @click="resolve(conflict, 'local')">{{ t('保留本地', 'Keep local') }}</button><button :disabled="busy" @click="resolve(conflict, 'remote')">{{ t('采用远端', 'Use remote') }}</button></div>
|
||||||
<label>{{ t('副本相对路径', 'Relative copy path') }}<input v-model="copies[conflict.sequence]" placeholder="conflicts/note-copy.md" /></label><button :disabled="busy || !copies[conflict.sequence]" @click="resolve(conflict, 'copy')">{{ t('另存本地副本并采用远端', 'Save local copy and use remote') }}</button>
|
<p v-if="conflict.local_path.startsWith('opennexus-records/')">{{ t('可将本地设置保留为 attachments 目录下的文本副本;副本供查看和恢复,不会自动应用。', 'Keep local settings as a text copy under attachments for inspection and recovery; the copy is not applied automatically.') }}</p>
|
||||||
|
<label>{{ t('副本相对路径', 'Relative copy path') }}<input v-model="copies[conflict.sequence]" :placeholder="conflict.local_path.startsWith('opennexus-records/') ? 'attachments/settings-copy.txt' : 'conflicts/note-copy.md'" /></label><button :disabled="busy || !copies[conflict.sequence]" @click="resolve(conflict, 'copy')">{{ t('另存本地副本并采用远端', 'Save local copy and use remote') }}</button>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user