feat(sync): encrypt snippet sync snapshots
This commit is contained in:
parent
9c8daa2bd4
commit
4511920d86
|
|
@ -71,7 +71,10 @@ import {
|
|||
saveWebdavSyncSecretsPreference,
|
||||
saveWebdavSavedPassword,
|
||||
saveSnippetSavedToken,
|
||||
saveSnippetSyncId,
|
||||
retrySnippetLegacyCleanup,
|
||||
snippetSyncDownload,
|
||||
snippetSyncSettings,
|
||||
snippetSyncTest,
|
||||
snippetSyncUpload,
|
||||
snippetTokenStatus,
|
||||
|
|
@ -1670,22 +1673,35 @@ const webdavError = ref(false);
|
|||
const syncMethodTab = ref<"webdav" | "snippet">("webdav");
|
||||
|
||||
const snippetProvider = ref<SnippetProvider>((localStorage.getItem("dbx-snippet-provider") as SnippetProvider) || "github");
|
||||
const snippetId = ref(localStorage.getItem(`dbx-snippet-id-${snippetProvider.value}`) || "");
|
||||
const snippetId = ref("");
|
||||
const snippetToken = ref("");
|
||||
const snippetRememberToken = ref(localStorage.getItem(`dbx-snippet-remember-token-${snippetProvider.value}`) === "true");
|
||||
const snippetHasSavedToken = ref(false);
|
||||
const snippetBusy = ref<"" | "test" | "upload" | "download">("");
|
||||
const snippetPassphrase = ref("");
|
||||
const snippetSecretsPassphrase = ref("");
|
||||
const snippetIncludeSecrets = ref(false);
|
||||
const snippetRestoreSecrets = ref(false);
|
||||
const snippetBusy = ref<"" | "test" | "upload" | "download" | "migrate" | "cleanup">("");
|
||||
const snippetMessage = ref("");
|
||||
const snippetError = ref(false);
|
||||
const legacySnippetId = ref("");
|
||||
const pendingLegacyCleanupId = ref("");
|
||||
const snippetSyncSettingsLoading = ref(true);
|
||||
|
||||
const webdavReady = computed(() => !!webdavEndpoint.value.trim() && !webdavBusy.value && (!webdavSyncSecrets.value || !!webdavSecretsPassphrase.value.trim() || webdavHasSavedSecretsPassphrase.value));
|
||||
const snippetReady = computed(() => !snippetBusy.value && (!!snippetToken.value.trim() || snippetHasSavedToken.value));
|
||||
const snippetReady = computed(() => !snippetSyncSettingsLoading.value && !snippetBusy.value && (!!snippetToken.value.trim() || snippetHasSavedToken.value));
|
||||
const snippetUploadReady = computed(() => snippetReady.value && !!snippetPassphrase.value.trim() && (!snippetIncludeSecrets.value || !!snippetSecretsPassphrase.value.trim()));
|
||||
// Legacy plaintext snippets have no outer encryption password. Let the
|
||||
// backend require one only after it detects an encrypted envelope so those
|
||||
// snapshots remain recoverable for migration.
|
||||
const snippetDownloadReady = computed(() => snippetReady.value && (!snippetRestoreSecrets.value || !!snippetSecretsPassphrase.value.trim()));
|
||||
|
||||
function currentSnippetConfig(): SnippetSyncConfig {
|
||||
function currentSnippetConfig(replaceLegacySnippet = false): SnippetSyncConfig {
|
||||
return {
|
||||
provider: snippetProvider.value,
|
||||
token: snippetToken.value.trim() || undefined,
|
||||
snippetId: snippetId.value.trim() || undefined,
|
||||
replaceLegacySnippet: replaceLegacySnippet || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1703,6 +1719,36 @@ async function refreshSnippetTokenStatus() {
|
|||
}
|
||||
}
|
||||
|
||||
async function refreshSnippetSyncSettings(provider = snippetProvider.value) {
|
||||
try {
|
||||
const settings = await snippetSyncSettings(provider);
|
||||
if (provider !== snippetProvider.value) return;
|
||||
pendingLegacyCleanupId.value = settings.legacyCleanupRequiredId || "";
|
||||
if (settings.snippetId) {
|
||||
snippetId.value = settings.snippetId;
|
||||
return;
|
||||
}
|
||||
const legacyId = localStorage.getItem(`dbx-snippet-id-${provider}`)?.trim();
|
||||
if (legacyId) {
|
||||
await saveSnippetSyncId(provider, legacyId);
|
||||
localStorage.removeItem(`dbx-snippet-id-${provider}`);
|
||||
}
|
||||
if (provider !== snippetProvider.value) return;
|
||||
snippetId.value = legacyId || "";
|
||||
} catch {
|
||||
if (provider === snippetProvider.value) {
|
||||
snippetId.value = "";
|
||||
pendingLegacyCleanupId.value = "";
|
||||
}
|
||||
} finally {
|
||||
if (provider === snippetProvider.value) snippetSyncSettingsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistSnippetSyncId() {
|
||||
await saveSnippetSyncId(snippetProvider.value, snippetId.value.trim() || undefined);
|
||||
}
|
||||
|
||||
async function applySnippetTokenPreference() {
|
||||
const token = snippetToken.value.trim();
|
||||
if (snippetRememberToken.value && token) {
|
||||
|
|
@ -1716,19 +1762,21 @@ async function applySnippetTokenPreference() {
|
|||
}
|
||||
}
|
||||
|
||||
async function runSnippetAction(kind: "test" | "upload" | "download", action: () => Promise<string>) {
|
||||
async function runSnippetAction(kind: "test" | "upload" | "download" | "migrate" | "cleanup", action: () => Promise<string>, persistCurrentSnippetId = true) {
|
||||
snippetBusy.value = kind;
|
||||
snippetMessage.value = "";
|
||||
snippetError.value = false;
|
||||
try {
|
||||
localStorage.setItem("dbx-snippet-provider", snippetProvider.value);
|
||||
localStorage.setItem(`dbx-snippet-id-${snippetProvider.value}`, snippetId.value.trim());
|
||||
localStorage.setItem(`dbx-snippet-remember-token-${snippetProvider.value}`, String(snippetRememberToken.value));
|
||||
if (persistCurrentSnippetId) await persistSnippetSyncId();
|
||||
await applySnippetTokenPreference();
|
||||
await applyWebDavSyncSecretsPreference();
|
||||
snippetMessage.value = await action();
|
||||
} catch (e: any) {
|
||||
snippetMessage.value = e?.message || String(e);
|
||||
if (kind === "upload" && snippetMessage.value.includes("legacy unencrypted DBX snapshot")) {
|
||||
legacySnippetId.value = snippetId.value.trim();
|
||||
}
|
||||
snippetError.value = true;
|
||||
} finally {
|
||||
snippetBusy.value = "";
|
||||
|
|
@ -1743,10 +1791,15 @@ async function testSnippetSync() {
|
|||
}
|
||||
|
||||
async function uploadSnippetSnapshot() {
|
||||
if (legacySnippetId.value) {
|
||||
snippetMessage.value = t("settings.syncSnippetMigrateLegacyRequired");
|
||||
snippetError.value = true;
|
||||
return;
|
||||
}
|
||||
await runSnippetAction("upload", async () => {
|
||||
const summary = await snippetSyncUpload(currentSnippetConfig(), settingsStore.editorSettings, webdavSyncSecrets.value ? webdavSecretsPassphrase.value : undefined);
|
||||
const summary = await snippetSyncUpload(currentSnippetConfig(), settingsStore.editorSettings, snippetPassphrase.value, snippetIncludeSecrets.value, snippetIncludeSecrets.value ? snippetSecretsPassphrase.value : undefined);
|
||||
snippetId.value = summary.snippetId;
|
||||
localStorage.setItem(`dbx-snippet-id-${snippetProvider.value}`, summary.snippetId);
|
||||
await persistSnippetSyncId();
|
||||
return t("settings.syncSnippetUploadSuccess", {
|
||||
bytes: summary.bytes,
|
||||
id: summary.snippetId,
|
||||
|
|
@ -1754,10 +1807,46 @@ async function uploadSnippetSnapshot() {
|
|||
});
|
||||
}
|
||||
|
||||
async function migrateLegacySnippet() {
|
||||
const id = legacySnippetId.value;
|
||||
if (!id || !window.confirm(t("settings.syncSnippetMigrateLegacyConfirm", { id }))) return;
|
||||
await runSnippetAction(
|
||||
"migrate",
|
||||
async () => {
|
||||
const config = currentSnippetConfig(true);
|
||||
config.snippetId = id;
|
||||
const summary = await snippetSyncUpload(config, settingsStore.editorSettings, snippetPassphrase.value, snippetIncludeSecrets.value, snippetSecretsPassphrase.value || undefined);
|
||||
snippetId.value = summary.snippetId;
|
||||
await persistSnippetSyncId();
|
||||
legacySnippetId.value = "";
|
||||
pendingLegacyCleanupId.value = summary.legacyCleanupRequiredId || "";
|
||||
if (!summary.legacyCleanupRequiredId) {
|
||||
return t("settings.syncSnippetMigrateLegacySuccess", { id: summary.snippetId });
|
||||
}
|
||||
throw new Error(`${t("settings.syncSnippetMigrateLegacyCreated", { id: summary.snippetId })} ${t("settings.syncSnippetMigrateLegacyCleanupRequired", { id: summary.legacyCleanupRequiredId })}`);
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async function retryLegacySnippetCleanup() {
|
||||
const id = pendingLegacyCleanupId.value;
|
||||
if (!id) return;
|
||||
await runSnippetAction("cleanup", async () => {
|
||||
const settings = await retrySnippetLegacyCleanup(currentSnippetConfig());
|
||||
if (settings.snippetId) snippetId.value = settings.snippetId;
|
||||
pendingLegacyCleanupId.value = settings.legacyCleanupRequiredId || "";
|
||||
if (settings.legacyCleanupRequiredId) {
|
||||
throw new Error(t("settings.syncSnippetMigrateLegacyCleanupRequired", { id: settings.legacyCleanupRequiredId }));
|
||||
}
|
||||
return t("settings.syncSnippetLegacyCleanupSuccess", { id });
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadSnippetSnapshot() {
|
||||
if (!snippetId.value.trim() || !window.confirm(t("settings.syncDownloadConfirm"))) return;
|
||||
await runSnippetAction("download", async () => {
|
||||
const result = await snippetSyncDownload(currentSnippetConfig(), webdavSyncSecrets.value ? webdavSecretsPassphrase.value : undefined);
|
||||
const result = await snippetSyncDownload(currentSnippetConfig(), snippetPassphrase.value, snippetRestoreSecrets.value, snippetRestoreSecrets.value ? snippetSecretsPassphrase.value : undefined);
|
||||
if (result.editorSettings && typeof result.editorSettings === "object") settingsStore.updateEditorSettings(result.editorSettings as any);
|
||||
await settingsStore.updateDesktopSettings(result.desktopSettings);
|
||||
await connectionStore.initFromDisk();
|
||||
|
|
@ -1952,6 +2041,7 @@ watch(
|
|||
() => settingsVisible.value,
|
||||
async (open) => {
|
||||
if (open) {
|
||||
snippetSyncSettingsLoading.value = true;
|
||||
mcpPolicyLoading.value = true;
|
||||
mcpPolicyLoadError.value = "";
|
||||
aiConfigListMode.value = "list";
|
||||
|
|
@ -1999,6 +2089,7 @@ watch(
|
|||
await refreshWebDavPasswordStatus();
|
||||
await refreshWebDavSyncSecretsStatus();
|
||||
await refreshSnippetTokenStatus();
|
||||
await refreshSnippetSyncSettings();
|
||||
syncAiEditState();
|
||||
if (!isWeb && activeSettingsTab.value === "mcp") void refreshMcpStatus();
|
||||
if (!isWeb && activeSettingsTab.value === "ai" && aiIsCliProvider.value) void ensureCliMcpStatus();
|
||||
|
|
@ -2037,10 +2128,14 @@ watch([webdavAutoUploadEnabled, webdavAutoUploadIntervalMinutes], () => {
|
|||
});
|
||||
watch(snippetProvider, (provider) => {
|
||||
localStorage.setItem("dbx-snippet-provider", provider);
|
||||
snippetId.value = localStorage.getItem(`dbx-snippet-id-${provider}`) || "";
|
||||
snippetId.value = "";
|
||||
snippetRememberToken.value = localStorage.getItem(`dbx-snippet-remember-token-${provider}`) === "true";
|
||||
snippetToken.value = "";
|
||||
legacySnippetId.value = "";
|
||||
pendingLegacyCleanupId.value = "";
|
||||
snippetSyncSettingsLoading.value = true;
|
||||
void refreshSnippetTokenStatus();
|
||||
void refreshSnippetSyncSettings(provider);
|
||||
});
|
||||
|
||||
watch(activeSettingsTab, async (tab) => {
|
||||
|
|
@ -4879,7 +4974,7 @@ onUnmounted(cleanupPreviewEditor);
|
|||
<div class="grid gap-4 rounded-md border p-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("settings.syncSnippetProvider") }}</Label>
|
||||
<Select v-model="snippetProvider">
|
||||
<Select v-model="snippetProvider" :disabled="!!snippetBusy">
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="github">GitHub Gist</SelectItem>
|
||||
|
|
@ -4889,7 +4984,7 @@ onUnmounted(cleanupPreviewEditor);
|
|||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="snippet-sync-id">{{ t("settings.syncSnippetId") }}</Label>
|
||||
<Input id="snippet-sync-id" v-model="snippetId" autocomplete="off" :placeholder="t('settings.syncSnippetIdPlaceholder')" />
|
||||
<Input id="snippet-sync-id" v-model="snippetId" autocomplete="off" :disabled="snippetSyncSettingsLoading || !!snippetBusy" :placeholder="t('settings.syncSnippetIdPlaceholder')" @blur="persistSnippetSyncId" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<Label for="snippet-sync-token">{{ t("settings.syncSnippetToken") }}</Label>
|
||||
|
|
@ -4918,6 +5013,37 @@ onUnmounted(cleanupPreviewEditor);
|
|||
{{ t("settings.syncSnippetTokenDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<Label for="snippet-sync-passphrase">{{ t("settings.syncSnippetPassphrase") }}</Label>
|
||||
<PasswordInput id="snippet-sync-passphrase" v-model="snippetPassphrase" autocomplete="new-password" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.syncSnippetPassphraseDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input v-model="snippetIncludeSecrets" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" />
|
||||
<span>{{ t("settings.syncSnippetIncludeSecrets") }}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input v-model="snippetRestoreSecrets" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" />
|
||||
<span>{{ t("settings.syncSnippetRestoreSecrets") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="snippetIncludeSecrets || snippetRestoreSecrets || legacySnippetId" class="space-y-2 md:col-span-2">
|
||||
<Label for="snippet-sync-secrets-passphrase">{{ t("settings.syncSecretsPassphrase") }}</Label>
|
||||
<PasswordInput id="snippet-sync-secrets-passphrase" v-model="snippetSecretsPassphrase" autocomplete="new-password" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.syncSecretsPassphraseDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="pendingLegacyCleanupId" class="flex items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-xs text-destructive md:col-span-2">
|
||||
<span>{{ t("settings.syncSnippetMigrateLegacyCleanupRequired", { id: pendingLegacyCleanupId }) }}</span>
|
||||
<Button variant="destructive" size="sm" :disabled="!snippetReady" @click="retryLegacySnippetCleanup">
|
||||
<Loader2 v-if="snippetBusy === 'cleanup'" class="mr-1 h-3 w-3 animate-spin" />
|
||||
{{ t("settings.syncSnippetRetryLegacyCleanup") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 md:col-span-2">
|
||||
<div v-if="snippetMessage" class="min-w-0 flex-1 text-xs" :class="snippetError ? 'text-destructive' : 'text-green-600 dark:text-green-400'">
|
||||
{{ snippetMessage }}
|
||||
|
|
@ -4928,16 +5054,20 @@ onUnmounted(cleanupPreviewEditor);
|
|||
<Loader2 v-if="snippetBusy === 'test'" class="mr-1 h-3 w-3 animate-spin" />
|
||||
{{ t("settings.syncTest") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" :disabled="!snippetReady || !snippetId.trim()" @click="downloadSnippetSnapshot">
|
||||
<Button variant="outline" size="sm" :disabled="!snippetDownloadReady || !snippetId.trim()" @click="downloadSnippetSnapshot">
|
||||
<Loader2 v-if="snippetBusy === 'download'" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<Download v-else class="mr-1 h-3 w-3" />
|
||||
{{ t("settings.syncDownload") }}
|
||||
</Button>
|
||||
<Button size="sm" :disabled="!snippetReady" @click="uploadSnippetSnapshot">
|
||||
<Button size="sm" :disabled="!snippetUploadReady" @click="uploadSnippetSnapshot">
|
||||
<Loader2 v-if="snippetBusy === 'upload'" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<Upload v-else class="mr-1 h-3 w-3" />
|
||||
{{ t("settings.syncUpload") }}
|
||||
</Button>
|
||||
<Button v-if="legacySnippetId" variant="destructive" size="sm" :disabled="!snippetUploadReady" @click="migrateLegacySnippet">
|
||||
<Loader2 v-if="snippetBusy === 'migrate'" class="mr-1 h-3 w-3 animate-spin" />
|
||||
{{ t("settings.syncSnippetMigrateLegacy") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4784,7 +4784,7 @@ export default {
|
|||
syncSecretNotice: "By default, DBX syncs connection details and settings only. Database passwords, SSH passwords, proxy passwords, HTTP tunnel tokens, connection strings, and AI API keys stay local.",
|
||||
syncSecrets: "Sync encrypted secrets",
|
||||
syncSecretsDescription: "When enabled, DBX encrypts database passwords, SSH passwords, proxy passwords, HTTP tunnel tokens, and AI API keys before uploading them to remote storage.",
|
||||
syncSecretsSharedDescription: "Applies to WebDAV, GitHub Gist, and Gitee snippets. Secrets are encrypted before being included in the snapshot.",
|
||||
syncSecretsSharedDescription: "For WebDAV, this password encrypts included credentials. GitHub Gist and Gitee snippets use independent encryption and credential controls.",
|
||||
syncSecretsPassphrase: "Sync password",
|
||||
syncSecretsPassphraseDescription: "This password is only used to encrypt and restore sensitive data. DBX does not save it; enter it again on each device.",
|
||||
syncTest: "Test",
|
||||
|
|
@ -4810,9 +4810,21 @@ export default {
|
|||
syncSnippetToken: "Access token",
|
||||
syncSnippetRememberToken: "Store encrypted on this device",
|
||||
syncSnippetTokenDescription: "GitHub tokens need Gists write permission. Tokens are never included in the sync snapshot.",
|
||||
syncSnippetPassphrase: "Snippet encryption password",
|
||||
syncSnippetPassphraseDescription: "Encrypts the complete GitHub/Gitee snapshot. It is not saved and is separate from the password for synced credentials.",
|
||||
syncSnippetIncludeSecrets: "Include encrypted credentials when uploading",
|
||||
syncSnippetRestoreSecrets: "Restore encrypted credentials when downloading",
|
||||
syncSnippetTestSuccess: "The snippet sync target is accessible.",
|
||||
syncSnippetUploadSuccess: "Uploaded {bytes} bytes to snippet {id}.",
|
||||
syncSnippetDownloadSuccess: "Downloaded and applied {bytes} bytes from snippet {id}.",
|
||||
syncSnippetMigrateLegacy: "Migrate legacy snippet",
|
||||
syncSnippetMigrateLegacyRequired: "This legacy plaintext snippet must be migrated with the Migrate legacy snippet action.",
|
||||
syncSnippetMigrateLegacyConfirm: "DBX will first create an encrypted replacement, then permanently delete legacy plaintext snippet {id}. This cannot be undone. Continue?",
|
||||
syncSnippetMigrateLegacySuccess: "Created encrypted snippet {id} and removed the legacy plaintext snippet.",
|
||||
syncSnippetMigrateLegacyCreated: "Created encrypted snippet {id}.",
|
||||
syncSnippetMigrateLegacyCleanupRequired: "The encrypted snippet was created, but legacy snippet {id} could not be deleted. Delete it manually and rotate any exposed credentials.",
|
||||
syncSnippetRetryLegacyCleanup: "Retry legacy cleanup",
|
||||
syncSnippetLegacyCleanupSuccess: "Removed legacy plaintext snippet {id}.",
|
||||
syncSnippetGuide: "Setup guide",
|
||||
apply: "Apply",
|
||||
applyAndClose: "Apply and Close",
|
||||
|
|
|
|||
|
|
@ -4905,6 +4905,18 @@ export default withEnglishFallback({
|
|||
routineSourceOpenModeQueryTabDescription: "Abrir el código fuente en una nueva pestaña de datos, de la misma manera que al ejecutar consultas SQL.",
|
||||
routineSourceOpenModeDialog: "Ventana emergente",
|
||||
routineSourceOpenModeDialogDescription: "Abrir el código fuente en una ventana emergente para una visualización y edición rápidas.",
|
||||
syncSnippetPassphrase: "Contraseña de cifrado del fragmento",
|
||||
syncSnippetPassphraseDescription: "Cifra la instantánea completa de GitHub/Gitee. No se guarda y es independiente de la contraseña para las credenciales sincronizadas.",
|
||||
syncSnippetIncludeSecrets: "Incluir credenciales cifradas al subir",
|
||||
syncSnippetRestoreSecrets: "Restaurar credenciales cifradas al descargar",
|
||||
syncSnippetMigrateLegacy: "Migrar fragmento heredado",
|
||||
syncSnippetMigrateLegacyRequired: "Este fragmento heredado de texto sin cifrar debe migrarse mediante la acción Migrar fragmento heredado.",
|
||||
syncSnippetMigrateLegacyConfirm: "DBX creará primero un reemplazo cifrado y después eliminará permanentemente el fragmento heredado de texto sin cifrar {id}. Esta acción no se puede deshacer. ¿Continuar?",
|
||||
syncSnippetMigrateLegacySuccess: "Se creó el fragmento cifrado {id} y se eliminó el fragmento heredado de texto sin cifrar.",
|
||||
syncSnippetMigrateLegacyCreated: "Se creó el fragmento cifrado {id}.",
|
||||
syncSnippetMigrateLegacyCleanupRequired: "Se creó el fragmento cifrado, pero no se pudo eliminar el fragmento heredado {id}. Elimínelo manualmente y rote las credenciales que podrían haberse expuesto.",
|
||||
syncSnippetRetryLegacyCleanup: "Reintentar limpieza heredada",
|
||||
syncSnippetLegacyCleanupSuccess: "Se eliminó el fragmento heredado de texto sin formato {id}.",
|
||||
},
|
||||
driverStore: {
|
||||
jreDirRemoveFailed: "No se pudo eliminar el directorio JRE antiguo: {path} (error original: {error})",
|
||||
|
|
|
|||
|
|
@ -4905,6 +4905,18 @@ export default withEnglishFallback({
|
|||
routineSourceOpenModeQueryTabDescription: "Apre il codice sorgente in una nuova scheda dati, come per l'esecuzione di query SQL.",
|
||||
routineSourceOpenModeDialog: "Finestra a comparsa",
|
||||
routineSourceOpenModeDialogDescription: "Apre il codice sorgente in una finestra di dialogo a comparsa per una rapida visualizzazione e modifica.",
|
||||
syncSnippetPassphrase: "Password di crittografia dello snippet",
|
||||
syncSnippetPassphraseDescription: "Crittografa l'intero snapshot GitHub/Gitee. Non viene salvata ed è separata dalla password per le credenziali sincronizzate.",
|
||||
syncSnippetIncludeSecrets: "Includi credenziali crittografate durante il caricamento",
|
||||
syncSnippetRestoreSecrets: "Ripristina credenziali crittografate durante il download",
|
||||
syncSnippetMigrateLegacy: "Migra snippet legacy",
|
||||
syncSnippetMigrateLegacyRequired: "Questo snippet legacy in testo semplice deve essere migrato con l'azione Migra snippet legacy.",
|
||||
syncSnippetMigrateLegacyConfirm: "DBX creerà prima una sostituzione crittografata, quindi eliminerà definitivamente lo snippet legacy in testo semplice {id}. L'operazione non può essere annullata. Continuare?",
|
||||
syncSnippetMigrateLegacySuccess: "Creato lo snippet crittografato {id} e rimosso lo snippet legacy in testo semplice.",
|
||||
syncSnippetMigrateLegacyCreated: "Creato lo snippet crittografato {id}.",
|
||||
syncSnippetMigrateLegacyCleanupRequired: "Lo snippet crittografato è stato creato, ma non è stato possibile eliminare lo snippet legacy {id}. Eliminalo manualmente e ruota le credenziali che potrebbero essere state esposte.",
|
||||
syncSnippetRetryLegacyCleanup: "Riprova pulizia legacy",
|
||||
syncSnippetLegacyCleanupSuccess: "Lo snippet legacy in testo semplice {id} è stato eliminato.",
|
||||
},
|
||||
driverStore: {
|
||||
jreDirRemoveFailed: "Impossibile rimuovere la vecchia directory JRE: {path} (errore originale: {error})",
|
||||
|
|
|
|||
|
|
@ -4945,7 +4945,7 @@ export default withEnglishFallback({
|
|||
routineSourceOpenModeQueryTabDescription: "新しいデータタブでソースコードを開きます。SQLクエリの実行と同じ方法です。",
|
||||
routineSourceOpenModeDialog: "ポップアップウィンドウ",
|
||||
routineSourceOpenModeDialogDescription: "ポップアップダイアログでソースコードを開き、素早く表示・編集できます。",
|
||||
syncSecretsSharedDescription: "WebDAV、GitHub Gist、Gitee スニペットに適用されます。有効にすると、機密情報は暗号化されてスナップショットに同期されます。",
|
||||
syncSecretsSharedDescription: "WebDAV では、このパスワードで含める認証情報を暗号化します。GitHub Gist と Gitee スニペットでは独立した暗号化と認証情報の設定を使用します。",
|
||||
syncSnippetTitle: "GitHub / Gitee スニペット同期",
|
||||
syncSnippetDescription: "同一の DBX スナップショットを非公開スニペットに保存します。初回アップロード時にスニペットが自動作成され、ID が保存されます。",
|
||||
syncSnippetProvider: "プロバイダー",
|
||||
|
|
@ -4954,9 +4954,21 @@ export default withEnglishFallback({
|
|||
syncSnippetToken: "アクセストークン",
|
||||
syncSnippetRememberToken: "この端末に暗号化して保存",
|
||||
syncSnippetTokenDescription: "GitHub トークンには Gists の書き込み権限が必要です。トークンは同期スナップショットに含まれません。",
|
||||
syncSnippetPassphrase: "スニペット暗号化パスワード",
|
||||
syncSnippetPassphraseDescription: "GitHub/Gitee のスナップショット全体を暗号化します。保存されず、同期する認証情報用のパスワードとは別です。",
|
||||
syncSnippetIncludeSecrets: "アップロード時に暗号化された認証情報を含める",
|
||||
syncSnippetRestoreSecrets: "ダウンロード時に暗号化された認証情報を復元する",
|
||||
syncSnippetTestSuccess: "スニペット同期ターゲットにアクセス可能です。",
|
||||
syncSnippetUploadSuccess: "スニペット {id} に {bytes} バイトをアップロードしました。",
|
||||
syncSnippetDownloadSuccess: "スニペット {id} から {bytes} バイトをダウンロードして適用しました。",
|
||||
syncSnippetMigrateLegacy: "旧スニペットを移行",
|
||||
syncSnippetMigrateLegacyRequired: "この旧式の平文スニペットは、「旧スニペットを移行」操作で移行する必要があります。",
|
||||
syncSnippetMigrateLegacyConfirm: "DBX はまず暗号化された置換スニペットを作成し、その後、旧式の平文スニペット {id} を完全に削除します。この操作は取り消せません。続行しますか?",
|
||||
syncSnippetMigrateLegacySuccess: "暗号化されたスニペット {id} を作成し、旧式の平文スニペットを削除しました。",
|
||||
syncSnippetMigrateLegacyCreated: "暗号化されたスニペット {id} を作成しました。",
|
||||
syncSnippetMigrateLegacyCleanupRequired: "暗号化されたスニペットは作成されましたが、旧スニペット {id} を削除できませんでした。手動で削除し、公開された可能性のある認証情報をローテーションしてください。",
|
||||
syncSnippetRetryLegacyCleanup: "旧スニペットのクリーンアップを再試行",
|
||||
syncSnippetLegacyCleanupSuccess: "旧プレーンテキストスニペット {id} を削除しました。",
|
||||
syncSnippetGuide: "設定ガイド",
|
||||
insertSpaceAfterCompletion: "補全後に自動でスペースを挿入",
|
||||
insertSpaceAfterCompletionDescription: "キーワード、テーブル名、列名の補全確定時、次の文字が許容する場合に自動でスペースを補填します",
|
||||
|
|
|
|||
|
|
@ -4520,7 +4520,7 @@ export default withEnglishFallback({
|
|||
syncSecretNotice: "기본적으로 DBX는 연결 세부 정보와 설정만 동기화합니다. 데이터베이스 비밀번호, SSH 비밀번호, 프록시 비밀번호, HTTP 터널 토큰, 연결 문자열 및 AI API 키는 로컬에 유지됩니다.",
|
||||
syncSecrets: "암호화된 비밀 동기화",
|
||||
syncSecretsDescription: "활성화하면 DBX가 데이터베이스 비밀번호, SSH 비밀번호, 프록시 비밀번호, HTTP 터널 토큰 및 AI API 키를 암호화하여 원격 저장소에 업로드합니다.",
|
||||
syncSecretsSharedDescription: "WebDAV, GitHub Gist, Gitee 스니펫에 적용됩니다. 비밀은 스냅샷에 포함되기 전에 암호화됩니다.",
|
||||
syncSecretsSharedDescription: "WebDAV에서는 이 비밀번호로 포함할 자격 증명을 암호화합니다. GitHub Gist 및 Gitee 스니펫은 별도의 암호화 및 자격 증명 설정을 사용합니다.",
|
||||
syncSecretsPassphrase: "동기화 비밀번호",
|
||||
syncSecretsPassphraseDescription: "이 비밀번호는 민감한 데이터를 암호화하고 복원하는 데만 사용됩니다. DBX가 저장하지 않으므로 각 기기에서 다시 입력하세요.",
|
||||
syncTest: "테스트",
|
||||
|
|
@ -4549,6 +4549,14 @@ export default withEnglishFallback({
|
|||
syncSnippetTestSuccess: "스니펫 동기화 대상에 접근할 수 있습니다.",
|
||||
syncSnippetUploadSuccess: "스니펫 {id}에 {bytes}바이트를 업로드했습니다.",
|
||||
syncSnippetDownloadSuccess: "스니펫 {id}에서 {bytes}바이트를 다운로드하여 적용했습니다.",
|
||||
syncSnippetMigrateLegacy: "레거시 스니펫 마이그레이션",
|
||||
syncSnippetMigrateLegacyRequired: "이 레거시 일반 텍스트 스니펫은 레거시 스니펫 마이그레이션 작업으로 이전해야 합니다.",
|
||||
syncSnippetMigrateLegacyConfirm: "DBX는 먼저 암호화된 대체 스니펫을 만든 다음 레거시 일반 텍스트 스니펫 {id}를 영구 삭제합니다. 이 작업은 취소할 수 없습니다. 계속할까요?",
|
||||
syncSnippetMigrateLegacySuccess: "암호화된 스니펫 {id}를 만들고 레거시 일반 텍스트 스니펫을 삭제했습니다.",
|
||||
syncSnippetMigrateLegacyCreated: "암호화된 스니펫 {id}를 만들었습니다.",
|
||||
syncSnippetMigrateLegacyCleanupRequired: "암호화된 스니펫은 만들었지만 레거시 스니펫 {id}를 삭제하지 못했습니다. 수동으로 삭제하고 노출되었을 수 있는 자격 증명을 교체하세요.",
|
||||
syncSnippetRetryLegacyCleanup: "레거시 정리 다시 시도",
|
||||
syncSnippetLegacyCleanupSuccess: "레거시 일반 텍스트 스니펫 {id}를 삭제했습니다.",
|
||||
syncSnippetGuide: "설정 가이드",
|
||||
apply: "적용",
|
||||
applyAndClose: "적용 후 닫기",
|
||||
|
|
|
|||
|
|
@ -4907,6 +4907,18 @@ export default withEnglishFallback({
|
|||
routineSourceOpenModeQueryTabDescription: "Abrir código-fonte em uma nova aba de dados, da mesma forma que executar uma consulta SQL.",
|
||||
routineSourceOpenModeDialog: "Janela pop-up",
|
||||
routineSourceOpenModeDialogDescription: "Abrir código-fonte em uma caixa de diálogo pop-up, facilitando a visualização e edição rápida.",
|
||||
syncSnippetPassphrase: "Senha de criptografia do snippet",
|
||||
syncSnippetPassphraseDescription: "Criptografa todo o snapshot do GitHub/Gitee. Não é salva e é separada da senha das credenciais sincronizadas.",
|
||||
syncSnippetIncludeSecrets: "Incluir credenciais criptografadas ao enviar",
|
||||
syncSnippetRestoreSecrets: "Restaurar credenciais criptografadas ao baixar",
|
||||
syncSnippetMigrateLegacy: "Migrar snippet legado",
|
||||
syncSnippetMigrateLegacyRequired: "Este snippet legado em texto simples deve ser migrado pela ação Migrar snippet legado.",
|
||||
syncSnippetMigrateLegacyConfirm: "O DBX criará primeiro uma substituição criptografada e depois excluirá permanentemente o snippet legado em texto simples {id}. Esta ação não pode ser desfeita. Continuar?",
|
||||
syncSnippetMigrateLegacySuccess: "O snippet criptografado {id} foi criado e o snippet legado em texto simples foi removido.",
|
||||
syncSnippetMigrateLegacyCreated: "O snippet criptografado {id} foi criado.",
|
||||
syncSnippetMigrateLegacyCleanupRequired: "O snippet criptografado foi criado, mas não foi possível excluir o snippet legado {id}. Exclua-o manualmente e altere as credenciais que possam ter sido expostas.",
|
||||
syncSnippetRetryLegacyCleanup: "Tentar limpeza legada novamente",
|
||||
syncSnippetLegacyCleanupSuccess: "O snippet legado em texto simples {id} foi removido.",
|
||||
},
|
||||
driverStore: {
|
||||
jreDirRemoveFailed: "Não foi possível remover o diretório JRE antigo: {path} (erro original: {error})",
|
||||
|
|
|
|||
|
|
@ -4783,7 +4783,7 @@ export default withEnglishFallback({
|
|||
syncSecretNotice: "默认只同步连接信息和设置,不会同步数据库密码、SSH 密码、代理密码、HTTP 隧道 Token、连接串或 AI API Key。",
|
||||
syncSecrets: "同步加密后的敏感信息",
|
||||
syncSecretsDescription: "开启后,DBX 会先加密数据库密码、SSH 密码、代理密码、HTTP 隧道 Token 和 AI API Key,再上传到远端存储。",
|
||||
syncSecretsSharedDescription: "适用于 WebDAV、GitHub Gist 和 Gitee 代码片段。开启后,敏感信息会加密后随快照同步。",
|
||||
syncSecretsSharedDescription: "WebDAV 使用此密码加密包含的敏感信息。GitHub Gist 和 Gitee 代码片段使用独立的加密和敏感信息选项。",
|
||||
syncSecretsPassphrase: "同步密码",
|
||||
syncSecretsPassphraseDescription: "这个密码只用于加密和恢复敏感信息,DBX 不会保存;换设备恢复时需要再次输入。",
|
||||
syncTest: "测试",
|
||||
|
|
@ -4809,9 +4809,21 @@ export default withEnglishFallback({
|
|||
syncSnippetToken: "访问令牌",
|
||||
syncSnippetRememberToken: "加密保存在本机",
|
||||
syncSnippetTokenDescription: "GitHub 令牌需要 Gists 写权限;令牌不会写入同步快照。",
|
||||
syncSnippetPassphrase: "代码片段加密密码",
|
||||
syncSnippetPassphraseDescription: "用于加密完整的 GitHub/Gitee 快照,不会保存,且与敏感信息同步密码相互独立。",
|
||||
syncSnippetIncludeSecrets: "上传时包含加密敏感信息",
|
||||
syncSnippetRestoreSecrets: "下载时恢复加密敏感信息",
|
||||
syncSnippetTestSuccess: "代码片段同步目标可访问。",
|
||||
syncSnippetUploadSuccess: "已上传 {bytes} 字节到代码片段 {id}。",
|
||||
syncSnippetDownloadSuccess: "已从代码片段 {id} 下载并应用 {bytes} 字节。",
|
||||
syncSnippetMigrateLegacy: "迁移旧版代码片段",
|
||||
syncSnippetMigrateLegacyRequired: "此旧版明文代码片段必须通过“迁移旧版代码片段”操作处理。",
|
||||
syncSnippetMigrateLegacyConfirm: "DBX 会先创建新的加密代码片段,再永久删除旧明文代码片段 {id}。此操作无法撤销,是否继续?",
|
||||
syncSnippetMigrateLegacySuccess: "已创建加密代码片段 {id},并删除旧明文代码片段。",
|
||||
syncSnippetMigrateLegacyCreated: "已创建加密代码片段 {id}。",
|
||||
syncSnippetMigrateLegacyCleanupRequired: "已创建加密代码片段,但无法删除旧代码片段 {id}。请手动删除它,并轮换可能暴露的凭据。",
|
||||
syncSnippetRetryLegacyCleanup: "重试清理旧版片段",
|
||||
syncSnippetLegacyCleanupSuccess: "已删除旧版明文代码片段 {id}。",
|
||||
syncSnippetGuide: "配置指南",
|
||||
apply: "应用",
|
||||
applyAndClose: "应用并关闭",
|
||||
|
|
|
|||
|
|
@ -4371,6 +4371,18 @@ export default withEnglishFallback({
|
|||
routineSourceOpenModeQueryTabDescription: "在新的資料標籤頁中開啟原始碼,與執行 SQL 查詢方式相同。",
|
||||
routineSourceOpenModeDialog: "彈出視窗",
|
||||
routineSourceOpenModeDialogDescription: "在彈出對話方塊中開啟原始碼,方便快速檢視和編輯。",
|
||||
syncSnippetPassphrase: "程式碼片段加密密碼",
|
||||
syncSnippetPassphraseDescription: "用於加密完整的 GitHub/Gitee 快照。不會儲存,且與同步認證資訊的密碼分開。",
|
||||
syncSnippetIncludeSecrets: "上傳時包含加密認證資訊",
|
||||
syncSnippetRestoreSecrets: "下載時還原加密認證資訊",
|
||||
syncSnippetMigrateLegacy: "遷移舊版程式碼片段",
|
||||
syncSnippetMigrateLegacyRequired: "此舊版明文程式碼片段必須透過「遷移舊版程式碼片段」操作處理。",
|
||||
syncSnippetMigrateLegacyConfirm: "DBX 會先建立新的加密程式碼片段,再永久刪除舊明文程式碼片段 {id}。此操作無法復原,是否繼續?",
|
||||
syncSnippetMigrateLegacySuccess: "已建立加密程式碼片段 {id},並刪除舊明文程式碼片段。",
|
||||
syncSnippetMigrateLegacyCreated: "已建立加密程式碼片段 {id}。",
|
||||
syncSnippetMigrateLegacyCleanupRequired: "已建立加密程式碼片段,但無法刪除舊程式碼片段 {id}。請手動刪除它,並輪換可能已暴露的憑據。",
|
||||
syncSnippetRetryLegacyCleanup: "重試清理舊版程式碼片段",
|
||||
syncSnippetLegacyCleanupSuccess: "已刪除舊版純文字程式碼片段 {id}。",
|
||||
},
|
||||
driverStore: {
|
||||
jreDirRemoveFailed: "無法刪除舊的 JRE 目錄:{path}(原始錯誤:{error})",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const settingsDialogSource = readFileSync(new URL("../../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
const httpBackendSource = readFileSync(new URL("../../backend/http.ts", import.meta.url), "utf8");
|
||||
const tauriBackendSource = readFileSync(new URL("../../backend/tauri.ts", import.meta.url), "utf8");
|
||||
|
||||
describe("snippet migration cleanup recovery", () => {
|
||||
it("restores pending cleanup state and keeps it visible after restart", () => {
|
||||
expect(settingsDialogSource).toContain('pendingLegacyCleanupId.value = settings.legacyCleanupRequiredId || ""');
|
||||
expect(settingsDialogSource).toContain('v-if="pendingLegacyCleanupId"');
|
||||
expect(settingsDialogSource).toContain('t("settings.syncSnippetMigrateLegacyCleanupRequired", { id: pendingLegacyCleanupId })');
|
||||
});
|
||||
|
||||
it("clears the UI state only after the retry response confirms cleanup", () => {
|
||||
const retryStart = settingsDialogSource.indexOf("async function retryLegacySnippetCleanup()");
|
||||
const retryEnd = settingsDialogSource.indexOf("async function downloadSnippetSnapshot()", retryStart);
|
||||
const retrySource = settingsDialogSource.slice(retryStart, retryEnd);
|
||||
expect(retrySource).toContain("await retrySnippetLegacyCleanup(currentSnippetConfig())");
|
||||
expect(retrySource).toContain('pendingLegacyCleanupId.value = settings.legacyCleanupRequiredId || ""');
|
||||
expect(retrySource).toContain("if (settings.legacyCleanupRequiredId)");
|
||||
});
|
||||
|
||||
it("wires the retry operation through both Web and Tauri backends", () => {
|
||||
expect(httpBackendSource).toContain('post("/api/cloud-sync/snippet/retry-legacy-cleanup", { config })');
|
||||
expect(tauriBackendSource).toContain('invoke("retry_snippet_legacy_cleanup", { config })');
|
||||
expect(httpBackendSource).toContain("legacyCleanupRequiredId?: string");
|
||||
expect(tauriBackendSource).toContain("legacyCleanupRequiredId?: string");
|
||||
});
|
||||
});
|
||||
|
|
@ -302,6 +302,9 @@ export const snippetSyncTest = forward("snippetSyncTest");
|
|||
export const snippetTokenStatus = forward("snippetTokenStatus");
|
||||
export const saveSnippetSavedToken = forward("saveSnippetSavedToken");
|
||||
export const forgetSnippetSavedToken = forward("forgetSnippetSavedToken");
|
||||
export const snippetSyncSettings = forward("snippetSyncSettings");
|
||||
export const saveSnippetSyncId = forward("saveSnippetSyncId");
|
||||
export const retrySnippetLegacyCleanup = forward("retrySnippetLegacyCleanup");
|
||||
export const snippetSyncUpload = forward("snippetSyncUpload");
|
||||
export const snippetSyncDownload = forward("snippetSyncDownload");
|
||||
export const saveAiConversation = forward("saveAiConversation");
|
||||
|
|
@ -636,6 +639,7 @@ export type {
|
|||
WebDavDownloadResult,
|
||||
SnippetProvider,
|
||||
SnippetSyncConfig,
|
||||
SnippetSyncSettings,
|
||||
SnippetSyncSummary,
|
||||
SnippetDownloadResult,
|
||||
SnippetTokenStatus,
|
||||
|
|
|
|||
|
|
@ -1613,6 +1613,12 @@ export interface SnippetSyncConfig {
|
|||
provider: SnippetProvider;
|
||||
token?: string;
|
||||
snippetId?: string;
|
||||
replaceLegacySnippet?: boolean;
|
||||
}
|
||||
|
||||
export interface SnippetSyncSettings {
|
||||
snippetId?: string;
|
||||
legacyCleanupRequiredId?: string;
|
||||
}
|
||||
|
||||
export interface SnippetSyncSummary {
|
||||
|
|
@ -1621,6 +1627,7 @@ export interface SnippetSyncSummary {
|
|||
bytes: number;
|
||||
exportedAt?: string;
|
||||
appVersion?: string;
|
||||
legacyCleanupRequiredId?: string;
|
||||
}
|
||||
|
||||
export interface SnippetDownloadResult {
|
||||
|
|
@ -1693,17 +1700,33 @@ export async function forgetSnippetSavedToken(config: SnippetSyncConfig): Promis
|
|||
await post("/api/cloud-sync/snippet/forget-token", { config });
|
||||
}
|
||||
|
||||
export async function snippetSyncUpload(config: SnippetSyncConfig, editorSettings?: unknown, secretsPassphrase?: string): Promise<SnippetSyncSummary> {
|
||||
export async function snippetSyncSettings(provider: SnippetProvider): Promise<SnippetSyncSettings> {
|
||||
return post("/api/cloud-sync/snippet/settings", { provider });
|
||||
}
|
||||
|
||||
export async function saveSnippetSyncId(provider: SnippetProvider, snippetId?: string): Promise<void> {
|
||||
await post("/api/cloud-sync/snippet/save-id", { provider, snippetId });
|
||||
}
|
||||
|
||||
export async function retrySnippetLegacyCleanup(config: SnippetSyncConfig): Promise<SnippetSyncSettings> {
|
||||
return post("/api/cloud-sync/snippet/retry-legacy-cleanup", { config });
|
||||
}
|
||||
|
||||
export async function snippetSyncUpload(config: SnippetSyncConfig, editorSettings?: unknown, snippetPassphrase?: string, includeSecrets = false, secretsPassphrase?: string): Promise<SnippetSyncSummary> {
|
||||
return post("/api/cloud-sync/snippet/upload", {
|
||||
config,
|
||||
editorSettings,
|
||||
snippetPassphrase,
|
||||
includeSecrets,
|
||||
secretsPassphrase,
|
||||
});
|
||||
}
|
||||
|
||||
export async function snippetSyncDownload(config: SnippetSyncConfig, secretsPassphrase?: string): Promise<SnippetDownloadResult> {
|
||||
export async function snippetSyncDownload(config: SnippetSyncConfig, snippetPassphrase?: string, restoreSecrets = false, secretsPassphrase?: string): Promise<SnippetDownloadResult> {
|
||||
return post("/api/cloud-sync/snippet/download", {
|
||||
config,
|
||||
snippetPassphrase,
|
||||
restoreSecrets,
|
||||
secretsPassphrase,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,6 +238,12 @@ export interface SnippetSyncConfig {
|
|||
provider: SnippetProvider;
|
||||
token?: string;
|
||||
snippetId?: string;
|
||||
replaceLegacySnippet?: boolean;
|
||||
}
|
||||
|
||||
export interface SnippetSyncSettings {
|
||||
snippetId?: string;
|
||||
legacyCleanupRequiredId?: string;
|
||||
}
|
||||
|
||||
export interface SnippetSyncSummary {
|
||||
|
|
@ -246,6 +252,7 @@ export interface SnippetSyncSummary {
|
|||
bytes: number;
|
||||
exportedAt?: string;
|
||||
appVersion?: string;
|
||||
legacyCleanupRequiredId?: string;
|
||||
}
|
||||
|
||||
export interface SnippetDownloadResult {
|
||||
|
|
@ -677,16 +684,30 @@ export async function forgetSnippetSavedToken(config: SnippetSyncConfig): Promis
|
|||
return invoke("forget_snippet_saved_token", { config });
|
||||
}
|
||||
|
||||
export async function snippetSyncUpload(config: SnippetSyncConfig, editorSettings?: unknown, secretsPassphrase?: string): Promise<SnippetSyncSummary> {
|
||||
export async function snippetSyncSettings(provider: SnippetProvider): Promise<SnippetSyncSettings> {
|
||||
return invoke("snippet_sync_settings", { provider });
|
||||
}
|
||||
|
||||
export async function saveSnippetSyncId(provider: SnippetProvider, snippetId?: string): Promise<void> {
|
||||
return invoke("save_snippet_sync_id", { provider, snippetId });
|
||||
}
|
||||
|
||||
export async function retrySnippetLegacyCleanup(config: SnippetSyncConfig): Promise<SnippetSyncSettings> {
|
||||
return invoke("retry_snippet_legacy_cleanup", { config });
|
||||
}
|
||||
|
||||
export async function snippetSyncUpload(config: SnippetSyncConfig, editorSettings?: unknown, snippetPassphrase?: string, includeSecrets = false, secretsPassphrase?: string): Promise<SnippetSyncSummary> {
|
||||
return invoke("snippet_sync_upload", {
|
||||
config,
|
||||
editorSettings,
|
||||
snippetPassphrase,
|
||||
includeSecrets,
|
||||
secretsPassphrase,
|
||||
});
|
||||
}
|
||||
|
||||
export async function snippetSyncDownload(config: SnippetSyncConfig, secretsPassphrase?: string): Promise<SnippetDownloadResult> {
|
||||
return invoke("snippet_sync_download", { config, secretsPassphrase });
|
||||
export async function snippetSyncDownload(config: SnippetSyncConfig, snippetPassphrase?: string, restoreSecrets = false, secretsPassphrase?: string): Promise<SnippetDownloadResult> {
|
||||
return invoke("snippet_sync_download", { config, snippetPassphrase, restoreSecrets, secretsPassphrase });
|
||||
}
|
||||
|
||||
export async function loadPinnedTreeNodeIds(): Promise<string[]> {
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@ use crate::connection_secrets::{
|
|||
};
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType, TransportLayerConfig};
|
||||
use crate::saved_sql::SavedSqlLibrary;
|
||||
use crate::storage::{DesktopSettings, Storage};
|
||||
use crate::storage::{DesktopSettings, SnippetPendingCleanup, Storage};
|
||||
|
||||
const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
|
||||
const ENCRYPTED_SNIPPET_SNAPSHOT_FORMAT: &str = "dbx-encrypted-sync-snapshot";
|
||||
const ENCRYPTED_SNIPPET_SNAPSHOT_VERSION: u32 = 1;
|
||||
const DEFAULT_REMOTE_PATH: &str = "DBX/sync/snapshot.json";
|
||||
const DEFAULT_SNIPPET_FILE_NAME: &str = "dbx-sync.json";
|
||||
const GITHUB_API_BASE: &str = "https://api.github.com";
|
||||
|
|
@ -65,6 +67,11 @@ pub struct SnippetSyncConfig {
|
|||
pub provider: SnippetProvider,
|
||||
pub token: Option<String>,
|
||||
pub snippet_id: Option<String>,
|
||||
/// Explicitly requested one-time migration for a legacy plaintext snippet.
|
||||
/// The remote plaintext snippet is only deleted after a new encrypted one
|
||||
/// has been created successfully.
|
||||
#[serde(default)]
|
||||
pub replace_legacy_snippet: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
|
|
@ -73,6 +80,14 @@ pub struct SnippetTokenStatus {
|
|||
pub has_saved_token: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SnippetSyncSettings {
|
||||
pub snippet_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legacy_cleanup_required_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebDavPasswordStatus {
|
||||
|
|
@ -116,6 +131,14 @@ pub struct EncryptedSecretsBlob {
|
|||
pub ciphertext: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EncryptedSnippetSnapshot {
|
||||
format: String,
|
||||
version: u32,
|
||||
payload: EncryptedSecretsBlob,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SensitiveSyncPayload {
|
||||
|
|
@ -142,6 +165,10 @@ pub struct ConnectionSecretSnapshot {
|
|||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ApplySnapshotOptions<'a> {
|
||||
pub secrets_passphrase: Option<&'a str>,
|
||||
/// Whether encrypted secrets in the remote snapshot may replace local
|
||||
/// secrets. Metadata is always applied, but callers can explicitly keep
|
||||
/// device-local credentials while restoring the rest of a snapshot.
|
||||
pub restore_secrets: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
|
|
@ -168,6 +195,15 @@ pub struct SnippetSyncSummary {
|
|||
pub bytes: usize,
|
||||
pub exported_at: Option<String>,
|
||||
pub app_version: Option<String>,
|
||||
/// A new encrypted snippet was created, but the old plaintext snippet
|
||||
/// could not be removed. The caller must surface this id for manual cleanup.
|
||||
#[serde(default)]
|
||||
pub legacy_cleanup_required_id: Option<String>,
|
||||
/// Internal guard for a later legacy cleanup. It is deliberately omitted
|
||||
/// from the API response so remote snapshot content never leaves the
|
||||
/// process through a status payload.
|
||||
#[serde(skip)]
|
||||
legacy_cleanup_expected_content_hash: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn build_sync_snapshot(
|
||||
|
|
@ -232,10 +268,15 @@ pub async fn apply_sync_snapshot(
|
|||
}
|
||||
|
||||
let encrypted_secrets_present = snapshot.encrypted_secrets.is_some();
|
||||
let sensitive_payload = match (&snapshot.encrypted_secrets, normalized_passphrase(options.secrets_passphrase)) {
|
||||
(Some(blob), Some(passphrase)) => Some(decrypt_sensitive_payload(blob, passphrase)?),
|
||||
_ => None,
|
||||
};
|
||||
let sensitive_payload =
|
||||
match (options.restore_secrets, &snapshot.encrypted_secrets, normalized_passphrase(options.secrets_passphrase))
|
||||
{
|
||||
(true, Some(blob), Some(passphrase)) => Some(decrypt_sensitive_payload(blob, passphrase)?),
|
||||
// Restore intent is explicit. Do not silently leave a user with a
|
||||
// partial restore when the remote snapshot contains secrets.
|
||||
(true, Some(_), None) => return Err("A sync password is required to restore synced secrets.".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let mut connections = snapshot.connections.clone();
|
||||
for config in &mut connections {
|
||||
|
|
@ -267,6 +308,7 @@ pub struct WebDavClient {
|
|||
pub struct SnippetSyncClient {
|
||||
http: Client,
|
||||
config: SnippetSyncConfig,
|
||||
api_base: String,
|
||||
}
|
||||
|
||||
pub async fn webdav_saved_password_status(
|
||||
|
|
@ -320,6 +362,66 @@ pub async fn forget_snippet_token(storage: &Storage, config: &SnippetSyncConfig)
|
|||
storage.delete_webdav_password_blob(&snippet_token_account(config.provider)).await
|
||||
}
|
||||
|
||||
pub async fn snippet_sync_settings(
|
||||
storage: &Storage,
|
||||
provider: SnippetProvider,
|
||||
) -> Result<SnippetSyncSettings, String> {
|
||||
let state = storage.load_snippet_sync_state(snippet_provider_storage_key(provider)).await?;
|
||||
Ok(SnippetSyncSettings {
|
||||
snippet_id: state.snippet_id,
|
||||
legacy_cleanup_required_id: state.pending_cleanup.map(|cleanup| cleanup.snippet_id),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn save_snippet_sync_id(
|
||||
storage: &Storage,
|
||||
provider: SnippetProvider,
|
||||
snippet_id: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
storage.save_snippet_sync_id(snippet_provider_storage_key(provider), snippet_id).await
|
||||
}
|
||||
|
||||
pub async fn finalize_snippet_migration(
|
||||
storage: &Storage,
|
||||
client: &SnippetSyncClient,
|
||||
summary: &mut SnippetSyncSummary,
|
||||
) -> Result<(), String> {
|
||||
let Some(pending_cleanup) = snippet_pending_cleanup(summary)? else {
|
||||
return Ok(());
|
||||
};
|
||||
let provider_key = snippet_provider_storage_key(summary.provider);
|
||||
storage
|
||||
.save_snippet_migration_state(
|
||||
provider_key,
|
||||
&summary.snippet_id,
|
||||
&pending_cleanup.snippet_id,
|
||||
&pending_cleanup.expected_content_hash,
|
||||
)
|
||||
.await?;
|
||||
if client.delete_legacy_snippet_if_unchanged(&pending_cleanup).await.unwrap_or(false)
|
||||
&& storage.clear_snippet_pending_cleanup_if_matches(provider_key, &pending_cleanup).await?
|
||||
{
|
||||
summary.legacy_cleanup_required_id = None;
|
||||
summary.legacy_cleanup_expected_content_hash = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn retry_pending_snippet_cleanup(
|
||||
storage: &Storage,
|
||||
provider: SnippetProvider,
|
||||
client: &SnippetSyncClient,
|
||||
) -> Result<SnippetSyncSettings, String> {
|
||||
let provider_key = snippet_provider_storage_key(provider);
|
||||
let state = storage.load_snippet_sync_state(provider_key).await?;
|
||||
if let Some(pending_cleanup) = state.pending_cleanup {
|
||||
if client.delete_legacy_snippet_if_unchanged(&pending_cleanup).await? {
|
||||
storage.clear_snippet_pending_cleanup_if_matches(provider_key, &pending_cleanup).await?;
|
||||
}
|
||||
}
|
||||
snippet_sync_settings(storage, provider).await
|
||||
}
|
||||
|
||||
pub async fn resolve_snippet_token(storage: &Storage, config: &mut SnippetSyncConfig) -> Result<(), String> {
|
||||
if config.token.as_deref().is_some_and(|token| !token.trim().is_empty()) {
|
||||
return Ok(());
|
||||
|
|
@ -469,31 +571,87 @@ impl WebDavClient {
|
|||
|
||||
impl SnippetSyncClient {
|
||||
pub fn new(config: SnippetSyncConfig) -> Self {
|
||||
Self { http: Client::new(), config }
|
||||
let api_base = match config.provider {
|
||||
SnippetProvider::GitHub => GITHUB_API_BASE,
|
||||
SnippetProvider::Gitee => GITEE_API_BASE,
|
||||
};
|
||||
Self { http: Client::new(), config, api_base: api_base.to_string() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_api_base(config: SnippetSyncConfig, api_base: String) -> Self {
|
||||
Self { http: Client::new(), config, api_base }
|
||||
}
|
||||
|
||||
pub async fn test(&self) -> Result<(), String> {
|
||||
self.require_token()?;
|
||||
let url = match (self.config.provider, normalized_snippet_id(self.config.snippet_id.as_deref())) {
|
||||
(SnippetProvider::GitHub, Some(id)) => format!("{GITHUB_API_BASE}/gists/{id}"),
|
||||
(SnippetProvider::GitHub, None) => format!("{GITHUB_API_BASE}/user"),
|
||||
(SnippetProvider::Gitee, Some(id)) => format!("{GITEE_API_BASE}/gists/{id}"),
|
||||
(SnippetProvider::Gitee, None) => format!("{GITEE_API_BASE}/user"),
|
||||
(_, Some(id)) => format!("{}/gists/{id}", self.api_base),
|
||||
(_, None) => format!("{}/user", self.api_base),
|
||||
};
|
||||
let response = self.request(Method::GET, &url)?.send().await.map_err(|e| e.to_string())?;
|
||||
ensure_snippet_success(response.status(), "test")
|
||||
}
|
||||
|
||||
pub async fn put_snapshot(&self, snapshot: &SyncSnapshot) -> Result<SnippetSyncSummary, String> {
|
||||
pub async fn put_snapshot(
|
||||
&self,
|
||||
snapshot: &SyncSnapshot,
|
||||
snippet_passphrase: Option<&str>,
|
||||
secrets_passphrase: Option<&str>,
|
||||
) -> Result<SnippetSyncSummary, String> {
|
||||
self.require_token()?;
|
||||
let bytes = serde_json::to_vec_pretty(snapshot).map_err(|e| e.to_string())?;
|
||||
let content = String::from_utf8(bytes.clone()).map_err(|e| e.to_string())?;
|
||||
let passphrase = required_snippet_passphrase(snippet_passphrase)?;
|
||||
let existing_id = normalized_snippet_id(self.config.snippet_id.as_deref());
|
||||
let (method, url) = match (self.config.provider, existing_id) {
|
||||
(SnippetProvider::GitHub, Some(id)) => (Method::PATCH, format!("{GITHUB_API_BASE}/gists/{id}")),
|
||||
(SnippetProvider::GitHub, None) => (Method::POST, format!("{GITHUB_API_BASE}/gists")),
|
||||
(SnippetProvider::Gitee, Some(id)) => (Method::PATCH, format!("{GITEE_API_BASE}/gists/{id}")),
|
||||
(SnippetProvider::Gitee, None) => (Method::POST, format!("{GITEE_API_BASE}/gists")),
|
||||
let legacy_snapshot = if let Some(id) = existing_id {
|
||||
let existing_content = self.load_snippet_content(id).await?;
|
||||
if !is_encrypted_snippet_snapshot(&existing_content) {
|
||||
// Never delete an arbitrary snippet merely because it is not an
|
||||
// encrypted DBX envelope. It must first prove to be a legacy DBX
|
||||
// snapshot, and the caller must explicitly request migration.
|
||||
if !is_legacy_dbx_snapshot(&existing_content) {
|
||||
return Err("The selected snippet is not a DBX sync snapshot; refusing to replace or delete it."
|
||||
.to_string());
|
||||
}
|
||||
if !self.config.replace_legacy_snippet {
|
||||
return Err(
|
||||
"This snippet contains a legacy unencrypted DBX snapshot. Use the secure migration action to create an encrypted replacement and delete the legacy snippet only after the new one is created."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let legacy_snapshot = parse_legacy_dbx_snapshot(&existing_content)?;
|
||||
Some((
|
||||
id,
|
||||
prepare_legacy_snippet_snapshot(legacy_snapshot, secrets_passphrase)?,
|
||||
content_hash(&existing_content),
|
||||
))
|
||||
} else {
|
||||
if self.config.replace_legacy_snippet {
|
||||
return Err("The selected snippet is already encrypted; use the normal upload action.".to_string());
|
||||
}
|
||||
// Refuse a PATCH unless the supplied snippet encryption
|
||||
// password decrypts the currently stored envelope. Otherwise
|
||||
// an accidental password change would silently lock out the
|
||||
// user's other devices.
|
||||
parse_snippet_snapshot(&existing_content, Some(passphrase))?;
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Migration encrypts the already-read remote snapshot rather than the
|
||||
// caller's current local state. Otherwise a stale second device could
|
||||
// erase the only copy of newer remote settings or saved SQL.
|
||||
let snapshot_to_upload =
|
||||
snapshot_for_snippet_upload(snapshot, legacy_snapshot.as_ref().map(|(_, snapshot, _)| snapshot));
|
||||
let encrypted = encrypt_snippet_snapshot(snapshot_to_upload, passphrase)?;
|
||||
let bytes = serde_json::to_vec_pretty(&encrypted).map_err(|e| e.to_string())?;
|
||||
let content = String::from_utf8(bytes.clone()).map_err(|e| e.to_string())?;
|
||||
// A migration must create a separate snippet. Updating the legacy
|
||||
// snippet would retain its plaintext revision history on the provider.
|
||||
let update_id = if legacy_snapshot.is_some() { None } else { existing_id };
|
||||
let (method, url) = match (self.config.provider, update_id) {
|
||||
(_, Some(id)) => (Method::PATCH, format!("{}/gists/{id}", self.api_base)),
|
||||
(_, None) => (Method::POST, format!("{}/gists", self.api_base)),
|
||||
};
|
||||
|
||||
let response = match self.config.provider {
|
||||
|
|
@ -517,25 +675,49 @@ impl SnippetSyncClient {
|
|||
ensure_snippet_response_success(status, "upload", &response_body)?;
|
||||
let value: serde_json::Value = serde_json::from_str(&response_body).map_err(|e| e.to_string())?;
|
||||
let snippet_id = snippet_response_id(&value)
|
||||
.or_else(|| existing_id.map(str::to_string))
|
||||
.or_else(|| update_id.map(str::to_string))
|
||||
.ok_or_else(|| "Snippet API response did not include an id".to_string())?;
|
||||
// The command layer persists the replacement id before calling
|
||||
// `delete_legacy_snippet`. If the app stops before persistence, the
|
||||
// old plaintext snippet remains available instead of leaving users
|
||||
// without a pointer to either remote snippet.
|
||||
let exported_at = Some(snapshot_to_upload.exported_at.clone());
|
||||
let app_version = Some(snapshot_to_upload.app_version.clone());
|
||||
let legacy_cleanup_required_id = legacy_snapshot.as_ref().map(|(id, _, _)| (*id).to_string());
|
||||
Ok(SnippetSyncSummary {
|
||||
provider: self.config.provider,
|
||||
snippet_id,
|
||||
bytes: bytes.len(),
|
||||
exported_at: Some(snapshot.exported_at.clone()),
|
||||
app_version: Some(snapshot.app_version.clone()),
|
||||
exported_at,
|
||||
app_version,
|
||||
legacy_cleanup_required_id,
|
||||
legacy_cleanup_expected_content_hash: legacy_snapshot.map(|(_, _, hash)| hash),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_snapshot(&self) -> Result<(SyncSnapshot, SnippetSyncSummary), String> {
|
||||
pub async fn get_snapshot(
|
||||
&self,
|
||||
secrets_passphrase: Option<&str>,
|
||||
) -> Result<(SyncSnapshot, SnippetSyncSummary), String> {
|
||||
self.require_token()?;
|
||||
let snippet_id = normalized_snippet_id(self.config.snippet_id.as_deref())
|
||||
.ok_or_else(|| "Snippet id is required for download".to_string())?;
|
||||
let url = match self.config.provider {
|
||||
SnippetProvider::GitHub => format!("{GITHUB_API_BASE}/gists/{snippet_id}"),
|
||||
SnippetProvider::Gitee => format!("{GITEE_API_BASE}/gists/{snippet_id}"),
|
||||
let content = self.load_snippet_content(snippet_id).await?;
|
||||
let snapshot = parse_snippet_snapshot(&content, secrets_passphrase)?;
|
||||
let summary = SnippetSyncSummary {
|
||||
provider: self.config.provider,
|
||||
snippet_id: snippet_id.to_string(),
|
||||
bytes: content.len(),
|
||||
exported_at: Some(snapshot.exported_at.clone()),
|
||||
app_version: Some(snapshot.app_version.clone()),
|
||||
legacy_cleanup_required_id: None,
|
||||
legacy_cleanup_expected_content_hash: None,
|
||||
};
|
||||
Ok((snapshot, summary))
|
||||
}
|
||||
|
||||
async fn load_snippet_content(&self, snippet_id: &str) -> Result<String, String> {
|
||||
let url = format!("{}/gists/{snippet_id}", self.api_base);
|
||||
let response = self.request(Method::GET, &url)?.send().await.map_err(|e| e.to_string())?;
|
||||
let status = response.status();
|
||||
let response_body = response.text().await.map_err(|e| e.to_string())?;
|
||||
|
|
@ -551,15 +733,31 @@ impl SnippetSyncClient {
|
|||
response.text().await.map_err(|e| e.to_string())?
|
||||
}
|
||||
};
|
||||
let snapshot: SyncSnapshot = serde_json::from_str(&content).map_err(|e| e.to_string())?;
|
||||
let summary = SnippetSyncSummary {
|
||||
provider: self.config.provider,
|
||||
snippet_id: snippet_id.to_string(),
|
||||
bytes: content.len(),
|
||||
exported_at: Some(snapshot.exported_at.clone()),
|
||||
app_version: Some(snapshot.app_version.clone()),
|
||||
};
|
||||
Ok((snapshot, summary))
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
pub async fn delete_legacy_snippet_if_unchanged(
|
||||
&self,
|
||||
pending_cleanup: &SnippetPendingCleanup,
|
||||
) -> Result<bool, String> {
|
||||
// Neither provider documents a conditional DELETE for snippets. Read
|
||||
// again after creating the replacement and refuse cleanup when another
|
||||
// device has changed the legacy content in the meantime.
|
||||
if content_hash(&self.load_snippet_content(&pending_cleanup.snippet_id).await?)
|
||||
!= pending_cleanup.expected_content_hash
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let url = format!("{}/gists/{}", self.api_base, pending_cleanup.snippet_id);
|
||||
let response = self.request(Method::DELETE, &url)?.send().await.map_err(|e| e.to_string())?;
|
||||
// If the provider reports that the old snippet is already absent, the
|
||||
// cleanup goal is satisfied and the newly created encrypted snippet is
|
||||
// still safe to use.
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(true);
|
||||
}
|
||||
ensure_snippet_success(response.status(), "delete legacy snippet")?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn require_token(&self) -> Result<&str, String> {
|
||||
|
|
@ -889,6 +1087,86 @@ fn decrypt_sensitive_payload(blob: &EncryptedSecretsBlob, passphrase: &str) -> R
|
|||
serde_json::from_slice(&plaintext).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn encrypt_snippet_snapshot(snapshot: &SyncSnapshot, passphrase: &str) -> Result<EncryptedSnippetSnapshot, String> {
|
||||
let plaintext = serde_json::to_vec(snapshot).map_err(|e| e.to_string())?;
|
||||
Ok(EncryptedSnippetSnapshot {
|
||||
format: ENCRYPTED_SNIPPET_SNAPSHOT_FORMAT.to_string(),
|
||||
version: ENCRYPTED_SNIPPET_SNAPSHOT_VERSION,
|
||||
payload: encrypt_bytes_with_secret(&plaintext, passphrase)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_snippet_snapshot(content: &str, secrets_passphrase: Option<&str>) -> Result<SyncSnapshot, String> {
|
||||
if is_encrypted_snippet_snapshot(content) {
|
||||
let envelope: EncryptedSnippetSnapshot = serde_json::from_str(content).map_err(|e| e.to_string())?;
|
||||
if envelope.format != ENCRYPTED_SNIPPET_SNAPSHOT_FORMAT
|
||||
|| envelope.version != ENCRYPTED_SNIPPET_SNAPSHOT_VERSION
|
||||
{
|
||||
return Err("Unsupported encrypted sync snapshot format".to_string());
|
||||
}
|
||||
let passphrase = required_snippet_passphrase(secrets_passphrase)?;
|
||||
let plaintext = decrypt_bytes_with_secret(&envelope.payload, passphrase)
|
||||
.map_err(|_| "Failed to decrypt the synced snapshot. Check the snippet encryption password.".to_string())?;
|
||||
return serde_json::from_slice(&plaintext).map_err(|e| e.to_string());
|
||||
}
|
||||
serde_json::from_str(content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn is_encrypted_snippet_snapshot(content: &str) -> bool {
|
||||
serde_json::from_str::<EncryptedSnippetSnapshot>(content)
|
||||
.ok()
|
||||
.is_some_and(|envelope| envelope.format == ENCRYPTED_SNIPPET_SNAPSHOT_FORMAT)
|
||||
}
|
||||
|
||||
/// Keep the migration guard deliberately more tolerant than deserializing the
|
||||
/// current `SyncSnapshot`: older DBX releases may not contain fields added
|
||||
/// since their snapshot was written. At the same time, require the stable DBX
|
||||
/// snapshot markers before a destructive remote delete is allowed.
|
||||
fn is_legacy_dbx_snapshot(content: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(content).ok().is_some_and(|snapshot| {
|
||||
snapshot.get("schemaVersion").and_then(serde_json::Value::as_u64).is_some()
|
||||
&& snapshot.get("exportedAt").and_then(serde_json::Value::as_str).is_some()
|
||||
&& snapshot.get("appVersion").and_then(serde_json::Value::as_str).is_some()
|
||||
&& snapshot.get("connections").is_some_and(serde_json::Value::is_array)
|
||||
&& snapshot.get("savedSql").is_some_and(serde_json::Value::is_object)
|
||||
&& snapshot.get("desktopSettings").is_some_and(serde_json::Value::is_object)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_legacy_dbx_snapshot(content: &str) -> Result<SyncSnapshot, String> {
|
||||
if !is_legacy_dbx_snapshot(content) {
|
||||
return Err("The selected snippet is not a DBX sync snapshot; refusing to replace or delete it.".to_string());
|
||||
}
|
||||
serde_json::from_str(content).map_err(|_| {
|
||||
"The legacy DBX snapshot is incompatible with this version, so it will not be replaced or deleted.".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_legacy_snippet_snapshot(
|
||||
mut snapshot: SyncSnapshot,
|
||||
secrets_passphrase: Option<&str>,
|
||||
) -> Result<SyncSnapshot, String> {
|
||||
if let Some(encrypted_secrets) = snapshot.encrypted_secrets.as_ref() {
|
||||
// The legacy snapshot can contain an independently encrypted secrets
|
||||
// payload. Verify it with its own password before deleting the only
|
||||
// legacy copy; the outer snippet password is intentionally separate.
|
||||
let passphrase = required_sync_passphrase(secrets_passphrase)?;
|
||||
let secrets = decrypt_sensitive_payload(encrypted_secrets, passphrase).map_err(|_| {
|
||||
"The legacy snapshot contains encrypted secrets that cannot be verified with this sync password, so it will not be replaced or deleted."
|
||||
.to_string()
|
||||
})?;
|
||||
snapshot.encrypted_secrets = Some(encrypt_sensitive_payload(&secrets, passphrase)?);
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn snapshot_for_snippet_upload<'a>(
|
||||
local_snapshot: &'a SyncSnapshot,
|
||||
legacy_snapshot: Option<&'a SyncSnapshot>,
|
||||
) -> &'a SyncSnapshot {
|
||||
legacy_snapshot.unwrap_or(local_snapshot)
|
||||
}
|
||||
|
||||
fn encrypt_text_with_secret(value: &str, secret: &str) -> Result<EncryptedSecretsBlob, String> {
|
||||
encrypt_bytes_with_secret(value.as_bytes(), secret)
|
||||
}
|
||||
|
|
@ -949,6 +1227,40 @@ fn normalized_snippet_id(snippet_id: Option<&str>) -> Option<&str> {
|
|||
snippet_id.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn content_hash(content: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(content.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn snippet_pending_cleanup(summary: &SnippetSyncSummary) -> Result<Option<SnippetPendingCleanup>, String> {
|
||||
match (summary.legacy_cleanup_required_id.as_deref(), summary.legacy_cleanup_expected_content_hash.as_deref()) {
|
||||
(None, None) => Ok(None),
|
||||
(Some(snippet_id), Some(expected_content_hash)) => Ok(Some(SnippetPendingCleanup {
|
||||
snippet_id: snippet_id.to_string(),
|
||||
expected_content_hash: expected_content_hash.to_string(),
|
||||
})),
|
||||
_ => Err("Legacy snippet cleanup is missing its persisted verification state.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn required_snippet_passphrase(passphrase: Option<&str>) -> Result<&str, String> {
|
||||
normalized_passphrase(passphrase)
|
||||
.ok_or_else(|| "A snippet encryption password is required for GitHub and Gitee sync.".to_string())
|
||||
}
|
||||
|
||||
fn required_sync_passphrase(passphrase: Option<&str>) -> Result<&str, String> {
|
||||
normalized_passphrase(passphrase)
|
||||
.ok_or_else(|| "A sync password is required for GitHub and Gitee snippet sync.".to_string())
|
||||
}
|
||||
|
||||
fn snippet_provider_storage_key(provider: SnippetProvider) -> &'static str {
|
||||
match provider {
|
||||
SnippetProvider::GitHub => "github",
|
||||
SnippetProvider::Gitee => "gitee",
|
||||
}
|
||||
}
|
||||
|
||||
fn snippet_token_account(provider: SnippetProvider) -> String {
|
||||
match provider {
|
||||
SnippetProvider::GitHub => "snippet-token:github".to_string(),
|
||||
|
|
@ -1062,10 +1374,14 @@ fn parent_collection_paths(remote_path: &str) -> Vec<String> {
|
|||
mod tests {
|
||||
use super::{
|
||||
apply_sensitive_payload, apply_sync_snapshot, build_sync_snapshot, build_sync_snapshot_with_saved_secrets,
|
||||
decrypt_sensitive_payload, encrypt_sensitive_payload, forget_webdav_sync_secrets_passphrase,
|
||||
gitee_snippet_payload, normalized_remote_path, parent_collection_paths, resolve_webdav_sync_secrets_passphrase,
|
||||
save_webdav_sync_secrets_preference, scrub_connection_secrets, snippet_file_content, snippet_response_id,
|
||||
webdav_sync_secrets_status, ApplySnapshotOptions, ConnectionSecretSnapshot, SensitiveSyncPayload,
|
||||
decrypt_sensitive_payload, encrypt_sensitive_payload, encrypt_snippet_snapshot, finalize_snippet_migration,
|
||||
forget_webdav_sync_secrets_passphrase, gitee_snippet_payload, is_legacy_dbx_snapshot, normalized_remote_path,
|
||||
parent_collection_paths, parse_legacy_dbx_snapshot, parse_snippet_snapshot, prepare_legacy_snippet_snapshot,
|
||||
resolve_webdav_sync_secrets_passphrase, retry_pending_snippet_cleanup, save_snippet_sync_id,
|
||||
save_webdav_sync_secrets_preference, scrub_connection_secrets, snapshot_for_snippet_upload,
|
||||
snippet_file_content, snippet_response_id, snippet_sync_settings, webdav_sync_secrets_status,
|
||||
ApplySnapshotOptions, ConnectionSecretSnapshot, SensitiveSyncPayload, SnippetProvider, SnippetSyncClient,
|
||||
SnippetSyncConfig, DEFAULT_SNIPPET_FILE_NAME,
|
||||
};
|
||||
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiConfigItem};
|
||||
use crate::connection_secrets::NACOS_AUTH_PASSWORD_KEY;
|
||||
|
|
@ -1108,6 +1424,42 @@ mod tests {
|
|||
std::env::temp_dir().join(format!("dbx-cloud-sync-{name}-{}.db", uuid::Uuid::new_v4()))
|
||||
}
|
||||
|
||||
async fn spawn_snippet_server(responses: Vec<String>) -> (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 methods = Vec::new();
|
||||
for body 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();
|
||||
methods.push(request.lines().next().unwrap().to_string());
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
methods
|
||||
});
|
||||
(format!("http://{address}"), server)
|
||||
}
|
||||
|
||||
fn github_snippet_response(content: &str) -> String {
|
||||
serde_json::json!({
|
||||
"files": { DEFAULT_SNIPPET_FILE_NAME: { "content": content } }
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitee_snippet_payload_keeps_files_as_nested_object() {
|
||||
let payload = gitee_snippet_payload("snapshot".to_string());
|
||||
|
|
@ -1413,6 +1765,283 @@ mod tests {
|
|||
assert!(decrypt_sensitive_payload(&encrypted, "wrong-pass").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn encrypted_snippet_snapshot_hides_and_restores_the_full_snapshot() {
|
||||
let storage = Storage::open(&temp_db_path("encrypted-snippet-snapshot")).await.unwrap();
|
||||
storage.save_connections(&[postgres_connection("pg", "db-secret")]).await.unwrap();
|
||||
let snapshot = build_sync_snapshot(&storage, "test-version", None, Some("sync-pass")).await.unwrap();
|
||||
|
||||
let encrypted = encrypt_snippet_snapshot(&snapshot, "sync-pass").unwrap();
|
||||
let content = serde_json::to_string(&encrypted).unwrap();
|
||||
assert!(!content.contains("127.0.0.1"));
|
||||
assert!(!content.contains("app_db"));
|
||||
assert!(!content.contains("db-secret"));
|
||||
|
||||
let restored = parse_snippet_snapshot(&content, Some("sync-pass")).unwrap();
|
||||
assert_eq!(restored.connections[0].database.as_deref(), Some("app_db"));
|
||||
assert!(parse_snippet_snapshot(&content, Some("wrong-pass")).is_err());
|
||||
assert!(parse_snippet_snapshot(&content, None).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn encrypted_snippet_can_exclude_secrets_and_keep_local_credentials_on_restore() {
|
||||
let source = Storage::open(&temp_db_path("snippet-without-secrets-source")).await.unwrap();
|
||||
source.save_connections(&[postgres_connection("pg", "remote-secret")]).await.unwrap();
|
||||
let snapshot = build_sync_snapshot(&source, "test-version", None, None).await.unwrap();
|
||||
assert!(snapshot.encrypted_secrets.is_none());
|
||||
|
||||
let encrypted = encrypt_snippet_snapshot(&snapshot, "snippet-password").unwrap();
|
||||
let restored =
|
||||
parse_snippet_snapshot(&serde_json::to_string(&encrypted).unwrap(), Some("snippet-password")).unwrap();
|
||||
let target = Storage::open(&temp_db_path("snippet-without-secrets-target")).await.unwrap();
|
||||
target.save_connections(&[postgres_connection("pg", "local-secret")]).await.unwrap();
|
||||
|
||||
let summary = apply_sync_snapshot(
|
||||
&target,
|
||||
&restored,
|
||||
ApplySnapshotOptions { secrets_passphrase: None, restore_secrets: false },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!summary.encrypted_secrets_present);
|
||||
assert!(!summary.secrets_applied);
|
||||
assert_eq!(target.load_connections().await.unwrap()[0].password, "local-secret");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skipping_snippet_secret_restore_keeps_local_credentials() {
|
||||
let source = Storage::open(&temp_db_path("snippet-skip-secrets-source")).await.unwrap();
|
||||
source.save_connections(&[postgres_connection("pg", "remote-secret")]).await.unwrap();
|
||||
let snapshot = build_sync_snapshot(&source, "test-version", None, Some("secrets-password")).await.unwrap();
|
||||
assert!(snapshot.encrypted_secrets.is_some());
|
||||
|
||||
let encrypted = encrypt_snippet_snapshot(&snapshot, "snippet-password").unwrap();
|
||||
let restored =
|
||||
parse_snippet_snapshot(&serde_json::to_string(&encrypted).unwrap(), Some("snippet-password")).unwrap();
|
||||
let target = Storage::open(&temp_db_path("snippet-skip-secrets-target")).await.unwrap();
|
||||
target.save_connections(&[postgres_connection("pg", "local-secret")]).await.unwrap();
|
||||
|
||||
let summary = apply_sync_snapshot(
|
||||
&target,
|
||||
&restored,
|
||||
ApplySnapshotOptions { secrets_passphrase: None, restore_secrets: false },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(summary.encrypted_secrets_present);
|
||||
assert!(!summary.secrets_applied);
|
||||
assert_eq!(target.load_connections().await.unwrap()[0].password, "local-secret");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_encrypted_snippet_rejects_wrong_password_without_patch() {
|
||||
let storage = Storage::open(&temp_db_path("snippet-password-guard")).await.unwrap();
|
||||
let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap();
|
||||
let encrypted = encrypt_snippet_snapshot(&snapshot, "correct-password").unwrap();
|
||||
let (base, server) =
|
||||
spawn_snippet_server(vec![github_snippet_response(&serde_json::to_string(&encrypted).unwrap())]).await;
|
||||
let client = SnippetSyncClient::with_api_base(
|
||||
SnippetSyncConfig {
|
||||
provider: SnippetProvider::GitHub,
|
||||
token: Some("test-token".to_string()),
|
||||
snippet_id: Some("existing-id".to_string()),
|
||||
replace_legacy_snippet: false,
|
||||
},
|
||||
base,
|
||||
);
|
||||
|
||||
assert!(client.put_snapshot(&snapshot, Some("wrong-password"), None).await.is_err());
|
||||
assert_eq!(server.await.unwrap(), vec!["GET /gists/existing-id HTTP/1.1"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_encrypted_snippet_accepts_correct_password_before_patch() {
|
||||
let storage = Storage::open(&temp_db_path("snippet-password-update")).await.unwrap();
|
||||
let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap();
|
||||
let encrypted = encrypt_snippet_snapshot(&snapshot, "correct-password").unwrap();
|
||||
let (base, server) = spawn_snippet_server(vec![
|
||||
github_snippet_response(&serde_json::to_string(&encrypted).unwrap()),
|
||||
serde_json::json!({ "id": "existing-id" }).to_string(),
|
||||
])
|
||||
.await;
|
||||
let client = SnippetSyncClient::with_api_base(
|
||||
SnippetSyncConfig {
|
||||
provider: SnippetProvider::GitHub,
|
||||
token: Some("test-token".to_string()),
|
||||
snippet_id: Some("existing-id".to_string()),
|
||||
replace_legacy_snippet: false,
|
||||
},
|
||||
base,
|
||||
);
|
||||
|
||||
client.put_snapshot(&snapshot, Some("correct-password"), None).await.unwrap();
|
||||
assert_eq!(server.await.unwrap(), vec!["GET /gists/existing-id HTTP/1.1", "PATCH /gists/existing-id HTTP/1.1"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_migration_skips_delete_when_remote_content_changes() {
|
||||
let storage = Storage::open(&temp_db_path("legacy-snippet-change-guard")).await.unwrap();
|
||||
let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap();
|
||||
let legacy_content = serde_json::to_string(&snapshot).unwrap();
|
||||
let changed_content =
|
||||
serde_json::to_string(&build_sync_snapshot(&storage, "newer-version", None, None).await.unwrap()).unwrap();
|
||||
let (base, server) = spawn_snippet_server(vec![
|
||||
github_snippet_response(&legacy_content),
|
||||
serde_json::json!({ "id": "new-id" }).to_string(),
|
||||
github_snippet_response(&changed_content),
|
||||
])
|
||||
.await;
|
||||
let client = SnippetSyncClient::with_api_base(
|
||||
SnippetSyncConfig {
|
||||
provider: SnippetProvider::GitHub,
|
||||
token: Some("test-token".to_string()),
|
||||
snippet_id: Some("legacy-id".to_string()),
|
||||
replace_legacy_snippet: true,
|
||||
},
|
||||
base,
|
||||
);
|
||||
|
||||
let mut summary = client.put_snapshot(&snapshot, Some("snippet-password"), None).await.unwrap();
|
||||
assert_eq!(summary.snippet_id, "new-id");
|
||||
assert_eq!(summary.legacy_cleanup_required_id.as_deref(), Some("legacy-id"));
|
||||
finalize_snippet_migration(&storage, &client, &mut summary).await.unwrap();
|
||||
assert_eq!(summary.legacy_cleanup_required_id.as_deref(), Some("legacy-id"));
|
||||
let settings = snippet_sync_settings(&storage, SnippetProvider::GitHub).await.unwrap();
|
||||
assert_eq!(settings.snippet_id.as_deref(), Some("new-id"));
|
||||
assert_eq!(settings.legacy_cleanup_required_id.as_deref(), Some("legacy-id"));
|
||||
assert_eq!(
|
||||
server.await.unwrap(),
|
||||
vec!["GET /gists/legacy-id HTTP/1.1", "POST /gists HTTP/1.1", "GET /gists/legacy-id HTTP/1.1",]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_legacy_cleanup_survives_response_loss_and_retries_after_restart() {
|
||||
let db = temp_db_path("legacy-snippet-cleanup-retry");
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap();
|
||||
let legacy_content = serde_json::to_string(&snapshot).unwrap();
|
||||
let (base, server) = spawn_snippet_server(vec![
|
||||
github_snippet_response(&legacy_content),
|
||||
serde_json::json!({ "id": "new-id" }).to_string(),
|
||||
])
|
||||
.await;
|
||||
let client = SnippetSyncClient::with_api_base(
|
||||
SnippetSyncConfig {
|
||||
provider: SnippetProvider::GitHub,
|
||||
token: Some("test-token".to_string()),
|
||||
snippet_id: Some("legacy-id".to_string()),
|
||||
replace_legacy_snippet: true,
|
||||
},
|
||||
base,
|
||||
);
|
||||
let mut summary = client.put_snapshot(&snapshot, Some("snippet-password"), None).await.unwrap();
|
||||
|
||||
finalize_snippet_migration(&storage, &client, &mut summary).await.unwrap();
|
||||
assert_eq!(summary.legacy_cleanup_required_id.as_deref(), Some("legacy-id"));
|
||||
assert_eq!(server.await.unwrap(), vec!["GET /gists/legacy-id HTTP/1.1", "POST /gists HTTP/1.1"]);
|
||||
drop(storage);
|
||||
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
let settings = snippet_sync_settings(&storage, SnippetProvider::GitHub).await.unwrap();
|
||||
assert_eq!(settings.snippet_id.as_deref(), Some("new-id"));
|
||||
assert_eq!(settings.legacy_cleanup_required_id.as_deref(), Some("legacy-id"));
|
||||
|
||||
let (retry_base, retry_server) =
|
||||
spawn_snippet_server(vec![github_snippet_response(&legacy_content), "{}".to_string()]).await;
|
||||
let retry_client = SnippetSyncClient::with_api_base(
|
||||
SnippetSyncConfig {
|
||||
provider: SnippetProvider::GitHub,
|
||||
token: Some("test-token".to_string()),
|
||||
snippet_id: Some("new-id".to_string()),
|
||||
replace_legacy_snippet: false,
|
||||
},
|
||||
retry_base,
|
||||
);
|
||||
let settings = retry_pending_snippet_cleanup(&storage, SnippetProvider::GitHub, &retry_client).await.unwrap();
|
||||
assert_eq!(settings.snippet_id.as_deref(), Some("new-id"));
|
||||
assert_eq!(settings.legacy_cleanup_required_id, None);
|
||||
assert_eq!(
|
||||
retry_server.await.unwrap(),
|
||||
vec!["GET /gists/legacy-id HTTP/1.1", "DELETE /gists/legacy-id HTTP/1.1"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_snippet_migration_preserves_remote_snapshot() {
|
||||
let storage = Storage::open(&temp_db_path("legacy-snippet-migration-guard")).await.unwrap();
|
||||
let local_snapshot = build_sync_snapshot(&storage, "local-version", None, None).await.unwrap();
|
||||
let remote_snapshot = build_sync_snapshot(&storage, "remote-version", None, None).await.unwrap();
|
||||
let mut legacy = serde_json::to_value(remote_snapshot).unwrap();
|
||||
// This field did not exist in snapshots written by older DBX versions.
|
||||
legacy.as_object_mut().unwrap().remove("tunnelProfiles");
|
||||
|
||||
let content = serde_json::to_string(&legacy).unwrap();
|
||||
assert!(is_legacy_dbx_snapshot(&content));
|
||||
let parsed_legacy = parse_legacy_dbx_snapshot(&content).unwrap();
|
||||
let selected = snapshot_for_snippet_upload(&local_snapshot, Some(&parsed_legacy));
|
||||
assert_eq!(selected.app_version, "remote-version");
|
||||
|
||||
let encrypted = encrypt_snippet_snapshot(selected, "sync-pass").unwrap();
|
||||
let restored = parse_snippet_snapshot(&serde_json::to_string(&encrypted).unwrap(), Some("sync-pass")).unwrap();
|
||||
assert_eq!(restored.app_version, "remote-version");
|
||||
assert!(!is_legacy_dbx_snapshot(r#"{"schemaVersion":1,"connections":[]}"#));
|
||||
assert!(parse_legacy_dbx_snapshot(r#"{"schemaVersion":1,"connections":[]}"#).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_snippet_migration_refuses_unverifiable_encrypted_secrets() {
|
||||
let storage = Storage::open(&temp_db_path("legacy-snippet-migration-secrets")).await.unwrap();
|
||||
storage.save_connections(&[postgres_connection("pg", "db-secret")]).await.unwrap();
|
||||
let remote_snapshot = build_sync_snapshot(&storage, "remote-version", None, Some("remote-pass")).await.unwrap();
|
||||
let content = serde_json::to_string(&remote_snapshot).unwrap();
|
||||
|
||||
let legacy = parse_legacy_dbx_snapshot(&content).unwrap();
|
||||
assert!(prepare_legacy_snippet_snapshot(legacy.clone(), Some("wrong-pass")).is_err());
|
||||
let prepared = prepare_legacy_snippet_snapshot(legacy, Some("remote-pass")).unwrap();
|
||||
let secrets = decrypt_sensitive_payload(prepared.encrypted_secrets.as_ref().unwrap(), "remote-pass").unwrap();
|
||||
assert!(secrets.connection_secrets.iter().any(|secret| secret.secret == "db-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_snippet_sync_requests_default_to_no_remote_deletion() {
|
||||
let config: SnippetSyncConfig = serde_json::from_value(serde_json::json!({
|
||||
"provider": "github",
|
||||
"token": "token",
|
||||
"snippetId": "legacy-id"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert!(!config.replace_legacy_snippet);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snippet_sync_id_is_persisted_per_provider() {
|
||||
let storage = Storage::open(&temp_db_path("snippet-sync-id")).await.unwrap();
|
||||
|
||||
save_snippet_sync_id(&storage, SnippetProvider::GitHub, Some("github-id")).await.unwrap();
|
||||
save_snippet_sync_id(&storage, SnippetProvider::Gitee, Some("gitee-id")).await.unwrap();
|
||||
assert_eq!(
|
||||
snippet_sync_settings(&storage, SnippetProvider::GitHub).await.unwrap().snippet_id.as_deref(),
|
||||
Some("github-id")
|
||||
);
|
||||
assert_eq!(
|
||||
snippet_sync_settings(&storage, SnippetProvider::GitHub).await.unwrap().legacy_cleanup_required_id,
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
snippet_sync_settings(&storage, SnippetProvider::Gitee).await.unwrap().snippet_id.as_deref(),
|
||||
Some("gitee-id")
|
||||
);
|
||||
|
||||
save_snippet_sync_id(&storage, SnippetProvider::GitHub, None).await.unwrap();
|
||||
assert_eq!(snippet_sync_settings(&storage, SnippetProvider::GitHub).await.unwrap().snippet_id, None);
|
||||
assert_eq!(
|
||||
snippet_sync_settings(&storage, SnippetProvider::Gitee).await.unwrap().snippet_id.as_deref(),
|
||||
Some("gitee-id")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_response_id_supports_github_object_and_gitee_array() {
|
||||
assert_eq!(snippet_response_id(&serde_json::json!({ "id": "github-id" })).as_deref(), Some("github-id"));
|
||||
|
|
@ -1552,9 +2181,13 @@ mod tests {
|
|||
|
||||
// Applying with the passphrase restores the full profile on the target.
|
||||
let target = Storage::open(&temp_db_path("tunnel-profiles-dst")).await.unwrap();
|
||||
apply_sync_snapshot(&target, &snapshot, ApplySnapshotOptions { secrets_passphrase: Some("sync-pass") })
|
||||
.await
|
||||
.unwrap();
|
||||
apply_sync_snapshot(
|
||||
&target,
|
||||
&snapshot,
|
||||
ApplySnapshotOptions { secrets_passphrase: Some("sync-pass"), restore_secrets: true },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(target.load_tunnel_profiles().await.unwrap(), vec![profile]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ const MCP_GLOBAL_POLICY_KEY: &str = "mcp_global_policy";
|
|||
const MAX_RETRIES_KEY: &str = "max_retries";
|
||||
const APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY: &str = "ai_global_custom_instructions";
|
||||
const APP_STATE_AI_CHAT_SELECTION_KEY: &str = "ai_chat_selection_v1";
|
||||
const SNIPPET_SYNC_IDS_KEY: &str = "snippet_sync_ids";
|
||||
const SNIPPET_PENDING_CLEANUPS_KEY: &str = "snippet_pending_legacy_cleanups";
|
||||
const USER_DATA_TABLES: &[&str] = &[
|
||||
"connections",
|
||||
"connection_secrets",
|
||||
|
|
@ -52,6 +54,34 @@ pub enum DataDbImportResult {
|
|||
SkippedTargetHasData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SnippetPendingCleanup {
|
||||
pub snippet_id: String,
|
||||
pub expected_content_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SnippetSyncState {
|
||||
pub snippet_id: Option<String>,
|
||||
pub pending_cleanup: Option<SnippetPendingCleanup>,
|
||||
}
|
||||
|
||||
fn required_snippet_state_value<'a>(value: &'a str, label: &str) -> Result<&'a str, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err(format!("{label} cannot be empty"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn validate_snippet_pending_cleanup(mut cleanup: SnippetPendingCleanup) -> Result<SnippetPendingCleanup, String> {
|
||||
cleanup.snippet_id = required_snippet_state_value(&cleanup.snippet_id, "legacy snippet id")?.to_string();
|
||||
cleanup.expected_content_hash =
|
||||
required_snippet_state_value(&cleanup.expected_content_hash, "legacy content hash")?.to_string();
|
||||
Ok(cleanup)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SqliteDbFileState {
|
||||
Missing,
|
||||
|
|
@ -1796,6 +1826,113 @@ impl Storage {
|
|||
self.save_app_settings_json(&settings).await
|
||||
}
|
||||
|
||||
pub async fn save_snippet_sync_id(&self, provider: &str, snippet_id: Option<&str>) -> Result<(), String> {
|
||||
let mut settings = self.load_app_settings_json().await?;
|
||||
let mut ids =
|
||||
settings.remove(SNIPPET_SYNC_IDS_KEY).and_then(|value| value.as_object().cloned()).unwrap_or_default();
|
||||
match snippet_id.map(str::trim).filter(|id| !id.is_empty()) {
|
||||
Some(id) => {
|
||||
ids.insert(provider.to_string(), serde_json::Value::String(id.to_string()));
|
||||
}
|
||||
None => {
|
||||
ids.remove(provider);
|
||||
}
|
||||
}
|
||||
settings.insert(SNIPPET_SYNC_IDS_KEY.to_string(), serde_json::Value::Object(ids));
|
||||
self.save_app_settings_json(&settings).await
|
||||
}
|
||||
|
||||
pub async fn load_snippet_sync_id(&self, provider: &str) -> Result<Option<String>, String> {
|
||||
Ok(self.load_snippet_sync_state(provider).await?.snippet_id)
|
||||
}
|
||||
|
||||
pub async fn save_snippet_migration_state(
|
||||
&self,
|
||||
provider: &str,
|
||||
replacement_snippet_id: &str,
|
||||
legacy_snippet_id: &str,
|
||||
expected_content_hash: &str,
|
||||
) -> Result<(), String> {
|
||||
let replacement_snippet_id = required_snippet_state_value(replacement_snippet_id, "replacement snippet id")?;
|
||||
let legacy_snippet_id = required_snippet_state_value(legacy_snippet_id, "legacy snippet id")?;
|
||||
let expected_content_hash = required_snippet_state_value(expected_content_hash, "legacy content hash")?;
|
||||
let mut settings = self.load_app_settings_json().await?;
|
||||
let mut ids =
|
||||
settings.remove(SNIPPET_SYNC_IDS_KEY).and_then(|value| value.as_object().cloned()).unwrap_or_default();
|
||||
ids.insert(provider.to_string(), serde_json::Value::String(replacement_snippet_id.to_string()));
|
||||
settings.insert(SNIPPET_SYNC_IDS_KEY.to_string(), serde_json::Value::Object(ids));
|
||||
|
||||
let mut pending_cleanups = settings
|
||||
.remove(SNIPPET_PENDING_CLEANUPS_KEY)
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
pending_cleanups.insert(
|
||||
provider.to_string(),
|
||||
serde_json::to_value(SnippetPendingCleanup {
|
||||
snippet_id: legacy_snippet_id.to_string(),
|
||||
expected_content_hash: expected_content_hash.to_string(),
|
||||
})
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
settings.insert(SNIPPET_PENDING_CLEANUPS_KEY.to_string(), serde_json::Value::Object(pending_cleanups));
|
||||
self.save_app_settings_json(&settings).await
|
||||
}
|
||||
|
||||
pub async fn load_snippet_sync_state(&self, provider: &str) -> Result<SnippetSyncState, String> {
|
||||
let settings = self.load_app_settings_json().await?;
|
||||
let snippet_id = settings
|
||||
.get(SNIPPET_SYNC_IDS_KEY)
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|ids| ids.get(provider))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_string);
|
||||
let pending_cleanup = settings
|
||||
.get(SNIPPET_PENDING_CLEANUPS_KEY)
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|cleanups| cleanups.get(provider))
|
||||
.cloned()
|
||||
.map(serde_json::from_value::<SnippetPendingCleanup>)
|
||||
.transpose()
|
||||
.map_err(|e| format!("invalid pending snippet cleanup state: {e}"))?
|
||||
.map(validate_snippet_pending_cleanup)
|
||||
.transpose()?;
|
||||
Ok(SnippetSyncState { snippet_id, pending_cleanup })
|
||||
}
|
||||
|
||||
pub async fn clear_snippet_pending_cleanup_if_matches(
|
||||
&self,
|
||||
provider: &str,
|
||||
expected: &SnippetPendingCleanup,
|
||||
) -> Result<bool, String> {
|
||||
let mut settings = self.load_app_settings_json().await?;
|
||||
let mut pending_cleanups = settings
|
||||
.remove(SNIPPET_PENDING_CLEANUPS_KEY)
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
let Some(current) = pending_cleanups
|
||||
.get(provider)
|
||||
.cloned()
|
||||
.map(serde_json::from_value::<SnippetPendingCleanup>)
|
||||
.transpose()
|
||||
.map_err(|e| format!("invalid pending snippet cleanup state: {e}"))?
|
||||
.map(validate_snippet_pending_cleanup)
|
||||
.transpose()?
|
||||
else {
|
||||
settings.insert(SNIPPET_PENDING_CLEANUPS_KEY.to_string(), serde_json::Value::Object(pending_cleanups));
|
||||
return Ok(false);
|
||||
};
|
||||
if current != *expected {
|
||||
settings.insert(SNIPPET_PENDING_CLEANUPS_KEY.to_string(), serde_json::Value::Object(pending_cleanups));
|
||||
return Ok(false);
|
||||
}
|
||||
pending_cleanups.remove(provider);
|
||||
settings.insert(SNIPPET_PENDING_CLEANUPS_KEY.to_string(), serde_json::Value::Object(pending_cleanups));
|
||||
self.save_app_settings_json(&settings).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn save_max_agent_turns(&self, max_agent_turns: u32) -> Result<(), String> {
|
||||
let mut settings = self.load_app_settings_json().await?;
|
||||
settings.insert(
|
||||
|
|
@ -5622,4 +5759,28 @@ mod tests {
|
|||
|
||||
std::fs::remove_file(&db).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_snippet_cleanup_survives_restart_and_clears_only_when_matched() {
|
||||
let db = temp_db_path("snippet-cleanup-restart");
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
storage.save_snippet_migration_state("github", "replacement-id", "legacy-id", "content-hash").await.unwrap();
|
||||
drop(storage);
|
||||
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
let state = storage.load_snippet_sync_state("github").await.unwrap();
|
||||
assert_eq!(state.snippet_id.as_deref(), Some("replacement-id"));
|
||||
let pending = state.pending_cleanup.unwrap();
|
||||
assert_eq!(pending.snippet_id, "legacy-id");
|
||||
assert_eq!(pending.expected_content_hash, "content-hash");
|
||||
|
||||
let mut wrong_pending = pending.clone();
|
||||
wrong_pending.expected_content_hash = "newer-content-hash".to_string();
|
||||
assert!(!storage.clear_snippet_pending_cleanup_if_matches("github", &wrong_pending).await.unwrap());
|
||||
assert!(storage.load_snippet_sync_state("github").await.unwrap().pending_cleanup.is_some());
|
||||
assert!(storage.clear_snippet_pending_cleanup_if_matches("github", &pending).await.unwrap());
|
||||
assert!(storage.load_snippet_sync_state("github").await.unwrap().pending_cleanup.is_none());
|
||||
|
||||
std::fs::remove_file(&db).ok();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -757,6 +757,9 @@ async fn main() {
|
|||
.route("/cloud-sync/snippet/token-status", post(routes::cloud_sync::snippet_token_status))
|
||||
.route("/cloud-sync/snippet/save-token", post(routes::cloud_sync::save_snippet_saved_token))
|
||||
.route("/cloud-sync/snippet/forget-token", post(routes::cloud_sync::forget_snippet_saved_token))
|
||||
.route("/cloud-sync/snippet/settings", post(routes::cloud_sync::snippet_sync_settings))
|
||||
.route("/cloud-sync/snippet/save-id", post(routes::cloud_sync::save_snippet_sync_id))
|
||||
.route("/cloud-sync/snippet/retry-legacy-cleanup", post(routes::cloud_sync::retry_snippet_legacy_cleanup))
|
||||
.route("/cloud-sync/snippet/upload", post(routes::cloud_sync::snippet_sync_upload))
|
||||
.route("/cloud-sync/snippet/download", post(routes::cloud_sync::snippet_sync_download));
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@ use std::sync::Arc;
|
|||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use dbx_core::cloud_sync::{
|
||||
apply_sync_snapshot, build_sync_snapshot_with_saved_secrets, forget_snippet_token, forget_webdav_password,
|
||||
apply_sync_snapshot, build_sync_snapshot, build_sync_snapshot_with_saved_secrets, finalize_snippet_migration,
|
||||
forget_snippet_token, forget_webdav_password,
|
||||
forget_webdav_sync_secrets_passphrase as core_forget_webdav_sync_secrets_passphrase, resolve_snippet_token,
|
||||
resolve_webdav_password, resolve_webdav_sync_secrets_passphrase, save_snippet_token, save_webdav_password,
|
||||
resolve_webdav_password, resolve_webdav_sync_secrets_passphrase, retry_pending_snippet_cleanup,
|
||||
save_snippet_sync_id as core_save_snippet_sync_id, save_snippet_token, save_webdav_password,
|
||||
save_webdav_sync_secrets_preference as core_save_webdav_sync_secrets_preference, snippet_saved_token_status,
|
||||
webdav_saved_password_status, webdav_sync_secrets_status as core_webdav_sync_secrets_status, ApplySnapshotOptions,
|
||||
ApplySnapshotSummary, SnippetSyncClient, SnippetSyncConfig, SnippetSyncSummary, SnippetTokenStatus, WebDavClient,
|
||||
WebDavConfig, WebDavPasswordStatus, WebDavSyncSecretsStatus, WebDavSyncSummary,
|
||||
snippet_sync_settings as core_snippet_sync_settings, webdav_saved_password_status,
|
||||
webdav_sync_secrets_status as core_webdav_sync_secrets_status, ApplySnapshotOptions, ApplySnapshotSummary,
|
||||
SnippetProvider, SnippetSyncClient, SnippetSyncConfig, SnippetSyncSettings, SnippetSyncSummary, SnippetTokenStatus,
|
||||
WebDavClient, WebDavConfig, WebDavPasswordStatus, WebDavSyncSecretsStatus, WebDavSyncSummary,
|
||||
};
|
||||
use dbx_core::storage::DesktopSettings;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -88,6 +91,9 @@ pub struct SaveSnippetTokenRequest {
|
|||
pub struct SnippetUploadRequest {
|
||||
pub config: SnippetSyncConfig,
|
||||
pub editor_settings: Option<serde_json::Value>,
|
||||
pub snippet_passphrase: Option<String>,
|
||||
#[serde(default)]
|
||||
pub include_secrets: bool,
|
||||
pub secrets_passphrase: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -95,9 +101,25 @@ pub struct SnippetUploadRequest {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SnippetDownloadRequest {
|
||||
pub config: SnippetSyncConfig,
|
||||
pub snippet_passphrase: Option<String>,
|
||||
#[serde(default)]
|
||||
pub restore_secrets: bool,
|
||||
pub secrets_passphrase: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SnippetSyncSettingsRequest {
|
||||
pub provider: SnippetProvider,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveSnippetSyncIdRequest {
|
||||
pub provider: SnippetProvider,
|
||||
pub snippet_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn webdav_sync_test(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(mut req): Json<WebDavConfigRequest>,
|
||||
|
|
@ -182,7 +204,10 @@ pub async fn webdav_sync_download(
|
|||
let apply_summary = apply_sync_snapshot(
|
||||
&state.app.storage,
|
||||
&snapshot,
|
||||
ApplySnapshotOptions { secrets_passphrase: explicit_passphrase.or(saved_passphrase.as_deref()) },
|
||||
ApplySnapshotOptions {
|
||||
secrets_passphrase: explicit_passphrase.or(saved_passphrase.as_deref()),
|
||||
restore_secrets: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
|
@ -226,20 +251,60 @@ pub async fn forget_snippet_saved_token(
|
|||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn snippet_sync_settings(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<SnippetSyncSettingsRequest>,
|
||||
) -> Result<Json<SnippetSyncSettings>, AppError> {
|
||||
core_snippet_sync_settings(&state.app.storage, req.provider).await.map(Json).map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub async fn save_snippet_sync_id(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<SaveSnippetSyncIdRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
core_save_snippet_sync_id(&state.app.storage, req.provider, req.snippet_id.as_deref())
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn retry_snippet_legacy_cleanup(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(mut req): Json<SnippetConfigRequest>,
|
||||
) -> Result<Json<SnippetSyncSettings>, AppError> {
|
||||
resolve_snippet_token(&state.app.storage, &mut req.config).await.map_err(AppError::from)?;
|
||||
let provider = req.config.provider;
|
||||
let client = SnippetSyncClient::new(req.config);
|
||||
retry_pending_snippet_cleanup(&state.app.storage, provider, &client).await.map(Json).map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub async fn snippet_sync_upload(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(mut req): Json<SnippetUploadRequest>,
|
||||
) -> Result<Json<SnippetSyncSummary>, AppError> {
|
||||
resolve_snippet_token(&state.app.storage, &mut req.config).await.map_err(AppError::from)?;
|
||||
let snapshot = build_sync_snapshot_with_saved_secrets(
|
||||
&state.app.storage,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
req.editor_settings,
|
||||
req.secrets_passphrase.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
SnippetSyncClient::new(req.config).put_snapshot(&snapshot).await.map(Json).map_err(AppError::from)
|
||||
let secrets_passphrase = if req.include_secrets {
|
||||
Some(
|
||||
req.secrets_passphrase
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| AppError::from("A sync password is required when including synced secrets."))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let snapshot =
|
||||
build_sync_snapshot(&state.app.storage, env!("CARGO_PKG_VERSION"), req.editor_settings, secrets_passphrase)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
let client = SnippetSyncClient::new(req.config);
|
||||
let mut summary = client
|
||||
.put_snapshot(&snapshot, req.snippet_passphrase.as_deref(), secrets_passphrase)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
finalize_snippet_migration(&state.app.storage, &client, &mut summary).await.map_err(AppError::from)?;
|
||||
Ok(Json(summary))
|
||||
}
|
||||
|
||||
pub async fn snippet_sync_download(
|
||||
|
|
@ -247,17 +312,17 @@ pub async fn snippet_sync_download(
|
|||
Json(mut req): Json<SnippetDownloadRequest>,
|
||||
) -> Result<Json<SnippetDownloadResult>, AppError> {
|
||||
resolve_snippet_token(&state.app.storage, &mut req.config).await.map_err(AppError::from)?;
|
||||
let (snapshot, summary) = SnippetSyncClient::new(req.config).get_snapshot().await.map_err(AppError::from)?;
|
||||
let explicit_passphrase = req.secrets_passphrase.as_deref().map(str::trim).filter(|value| !value.is_empty());
|
||||
let saved_passphrase = if explicit_passphrase.is_some() {
|
||||
None
|
||||
} else {
|
||||
resolve_webdav_sync_secrets_passphrase(&state.app.storage).await.map_err(AppError::from)?
|
||||
};
|
||||
let (snapshot, summary) = SnippetSyncClient::new(req.config)
|
||||
.get_snapshot(req.snippet_passphrase.as_deref())
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
let apply_summary = apply_sync_snapshot(
|
||||
&state.app.storage,
|
||||
&snapshot,
|
||||
ApplySnapshotOptions { secrets_passphrase: explicit_passphrase.or(saved_passphrase.as_deref()) },
|
||||
ApplySnapshotOptions {
|
||||
secrets_passphrase: req.secrets_passphrase.as_deref(),
|
||||
restore_secrets: req.restore_secrets,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
|
@ -268,3 +333,40 @@ pub async fn snippet_sync_download(
|
|||
apply_summary,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use dbx_core::cloud_sync::SnippetProvider;
|
||||
use dbx_core::connection::AppState;
|
||||
use dbx_core::storage::Storage;
|
||||
|
||||
use crate::state::WebState;
|
||||
|
||||
#[tokio::test]
|
||||
async fn snippet_settings_surfaces_pending_cleanup_after_restart() {
|
||||
let dir = std::env::temp_dir().join(format!("dbx-web-snippet-cleanup-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db = dir.join("storage.db");
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
storage.save_snippet_migration_state("github", "replacement-id", "legacy-id", "content-hash").await.unwrap();
|
||||
drop(storage);
|
||||
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
let app = Arc::new(AppState::new_with_plugin_dir(storage, dir.join("plugins")));
|
||||
let state = Arc::new(WebState::for_tests(app, dir.clone()));
|
||||
let Json(settings) = super::snippet_sync_settings(
|
||||
State(state),
|
||||
Json(super::SnippetSyncSettingsRequest { provider: SnippetProvider::GitHub }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.snippet_id.as_deref(), Some("replacement-id"));
|
||||
assert_eq!(settings.legacy_cleanup_required_id.as_deref(), Some("legacy-id"));
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,11 @@ DBX 可以把连接配置、界面设置和保存的 SQL 组成一个快照,
|
|||
<Step>### 创建 Gist 将 **代码片段 ID** 留空,点击 **上传**。DBX 会创建私有 Gist,并自动保存返回的 ID。</Step>
|
||||
</Steps>
|
||||
|
||||
DBX 会在 Gist 中保存一个 `dbx-sync.json` 文件。后续上传会更新同一个 Gist,GitHub 会保留 Gist 的修改历史。
|
||||
DBX 会在 Gist 中保存一个经过**代码片段加密密码**加密的 `dbx-sync.json` 文件。该密码保护完整快照,片段链接本身不会暴露连接信息、SQL 或设置;历史明文片段仍可下载一次以便迁移。
|
||||
|
||||
<Callout type="warn">
|
||||
旧版本创建的明文片段不能安全地原地迁移:更新同一个 Gist 不会删除历史修订。请使用 **迁移旧版代码片段** 操作。DBX 会先将已有的远端快照加密写入新片段并保存新 ID,然后重新读取旧片段后才删除它;如果另一台设备已更新旧内容,则停止自动删除并提示手动清理。若旧快照包含已加密的敏感信息,请输入其原敏感信息同步密码以完成删除前验证。请轮换旧片段中可能已经暴露的凭据。
|
||||
</Callout>
|
||||
|
||||
### 在另一台设备恢复
|
||||
|
||||
|
|
@ -84,19 +88,21 @@ DBX 会在 Gist 中保存一个 `dbx-sync.json` 文件。后续上传会更新
|
|||
|
||||
Gitee 代码片段中同样使用 `dbx-sync.json` 文件。
|
||||
|
||||
## 同步敏感信息
|
||||
## 同步密码与敏感信息
|
||||
|
||||
GitHub Gist 和 Gitee 代码片段始终使用**代码片段加密密码**加密整个快照。该密码不会上传或保存,也无法找回;它与可选敏感信息的同步密码相互独立。
|
||||
|
||||
默认快照不会包含数据库密码等密钥。如果需要在新设备直接恢复密码:
|
||||
|
||||
1. 开启 **同步加密后的敏感信息**。
|
||||
2. 输入单独的 **同步密码**。
|
||||
1. 开启 **上传时包含加密敏感信息**。
|
||||
2. 输入单独的 **敏感信息同步密码**。
|
||||
3. 上传快照。
|
||||
4. 在另一台设备下载时输入相同的同步密码。
|
||||
4. 在另一台设备开启 **下载时恢复加密敏感信息** 并输入同一密码;保持关闭即可保留本地密码。
|
||||
|
||||
敏感信息使用 **Argon2id** 派生密钥,并使用 **AES-256-GCM** 加密。访问令牌只会使用设备密钥加密保存在本机,永远不会写入 `dbx-sync.json`。
|
||||
同步密码使用 **Argon2id** 派生密钥,并使用 **AES-256-GCM** 加密。访问令牌只会使用设备密钥加密保存在本机,永远不会写入 `dbx-sync.json`。
|
||||
|
||||
<Callout type="warn">
|
||||
同步密码无法找回。丢失同步密码后,仍可恢复普通连接信息和 SQL,但无法解密远端敏感信息。
|
||||
两种密码都无法找回。GitHub/Gitee 的加密代码片段没有代码片段加密密码就无法恢复;WebDAV 中不含加密敏感信息的快照仍可不使用同步密码恢复。
|
||||
</Callout>
|
||||
|
||||
## WebDAV
|
||||
|
|
|
|||
|
|
@ -54,7 +54,11 @@ A classic token also works, but grant only the `gist` scope.
|
|||
<Step>### Create the Gist Leave **Snippet ID** empty and select **Upload**. DBX creates a private Gist and stores the returned ID.</Step>
|
||||
</Steps>
|
||||
|
||||
DBX stores the snapshot as `dbx-sync.json`. Later uploads update the same Gist, and GitHub keeps its revision history.
|
||||
DBX stores an encrypted `dbx-sync.json` file in the Gist. The snippet encryption password protects the complete snapshot, so the URL does not expose connection details, SQL, or settings. Existing plain-text snippets can still be downloaded once for migration.
|
||||
|
||||
<Callout type="warn">
|
||||
Plain-text snippets created by older versions cannot be safely migrated in place: updating the same Gist does not remove its revision history. Use **Migrate legacy snippet** instead. DBX encrypts the existing remote snapshot into a new snippet first, stores the new ID locally, and then rereads the old snippet before deleting it. If another device changed the old content, DBX stops automatic cleanup and tells you which old snippet must be deleted manually. If the old snapshot contains encrypted credentials, enter its original credentials password so DBX can verify them before cleanup. Rotate any credentials the old snippet may have exposed.
|
||||
</Callout>
|
||||
|
||||
### Restore on Another Device
|
||||
|
||||
|
|
@ -84,19 +88,21 @@ DBX stores the snapshot as `dbx-sync.json`. Later uploads update the same Gist,
|
|||
|
||||
Gitee snippets also use a `dbx-sync.json` file.
|
||||
|
||||
## Synchronizing Secrets
|
||||
## Sync Password and Secrets
|
||||
|
||||
GitHub Gist and Gitee snippets always encrypt the entire snapshot with a **snippet encryption password**. The password is never uploaded or saved and cannot be recovered. This password is independent from the password that encrypts optional synced credentials.
|
||||
|
||||
Snapshots exclude credentials by default. To restore passwords on another device:
|
||||
|
||||
1. Enable **Synchronize encrypted secrets**.
|
||||
2. Enter a separate **sync passphrase**.
|
||||
1. Enable **Include encrypted credentials when uploading**.
|
||||
2. Enter a separate **sync password** for credentials.
|
||||
3. Upload the snapshot.
|
||||
4. Enter the same passphrase when downloading on another device.
|
||||
4. On another device, enable **Restore encrypted credentials when downloading** and enter the same credentials password. Leave it off to keep local passwords unchanged.
|
||||
|
||||
DBX derives the encryption key with **Argon2id** and encrypts secrets using **AES-256-GCM**. Provider access tokens are encrypted with a device-local key and are never written to `dbx-sync.json`.
|
||||
DBX derives the encryption key with **Argon2id** and uses **AES-256-GCM** encryption. Provider access tokens are encrypted with a device-local key and are never written to `dbx-sync.json`.
|
||||
|
||||
<Callout type="warn">
|
||||
The sync passphrase cannot be recovered. Without it, DBX can still restore non-secret connection settings and saved SQL, but not the encrypted secrets.
|
||||
Neither password can be recovered. An encrypted GitHub/Gitee snippet cannot be restored without its snippet encryption password. For WebDAV, a snapshot without encrypted secrets can still be restored without a passphrase.
|
||||
</Callout>
|
||||
|
||||
## WebDAV
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use dbx_core::cloud_sync::{
|
||||
apply_sync_snapshot, build_sync_snapshot_with_saved_secrets, forget_snippet_token, forget_webdav_password,
|
||||
apply_sync_snapshot, build_sync_snapshot, build_sync_snapshot_with_saved_secrets, finalize_snippet_migration,
|
||||
forget_snippet_token, forget_webdav_password,
|
||||
forget_webdav_sync_secrets_passphrase as core_forget_webdav_sync_secrets_passphrase, resolve_snippet_token,
|
||||
resolve_webdav_password, resolve_webdav_sync_secrets_passphrase, save_snippet_token, save_webdav_password,
|
||||
resolve_webdav_password, resolve_webdav_sync_secrets_passphrase, retry_pending_snippet_cleanup,
|
||||
save_snippet_sync_id as core_save_snippet_sync_id, save_snippet_token, save_webdav_password,
|
||||
save_webdav_sync_secrets_preference as core_save_webdav_sync_secrets_preference, snippet_saved_token_status,
|
||||
webdav_saved_password_status, webdav_sync_secrets_status as core_webdav_sync_secrets_status, ApplySnapshotOptions,
|
||||
ApplySnapshotSummary, SnippetSyncClient, SnippetSyncConfig, SnippetSyncSummary, SnippetTokenStatus, WebDavClient,
|
||||
WebDavConfig, WebDavPasswordStatus, WebDavSyncSecretsStatus, WebDavSyncSummary,
|
||||
snippet_sync_settings as core_snippet_sync_settings, webdav_saved_password_status,
|
||||
webdav_sync_secrets_status as core_webdav_sync_secrets_status, ApplySnapshotOptions, ApplySnapshotSummary,
|
||||
SnippetProvider, SnippetSyncClient, SnippetSyncConfig, SnippetSyncSettings, SnippetSyncSummary, SnippetTokenStatus,
|
||||
WebDavClient, WebDavConfig, WebDavPasswordStatus, WebDavSyncSecretsStatus, WebDavSyncSummary,
|
||||
};
|
||||
use dbx_core::storage::DesktopSettings;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -115,7 +118,10 @@ pub async fn webdav_sync_download(
|
|||
let apply_summary = apply_sync_snapshot(
|
||||
&state.storage,
|
||||
&snapshot,
|
||||
ApplySnapshotOptions { secrets_passphrase: explicit_passphrase.or(saved_passphrase.as_deref()) },
|
||||
ApplySnapshotOptions {
|
||||
secrets_passphrase: explicit_passphrase.or(saved_passphrase.as_deref()),
|
||||
restore_secrets: true,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(WebDavDownloadResult {
|
||||
|
|
@ -157,42 +163,77 @@ pub async fn forget_snippet_saved_token(
|
|||
forget_snippet_token(&state.storage, &config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn snippet_sync_settings(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
provider: SnippetProvider,
|
||||
) -> Result<SnippetSyncSettings, String> {
|
||||
core_snippet_sync_settings(&state.storage, provider).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_snippet_sync_id(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
provider: SnippetProvider,
|
||||
snippet_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
core_save_snippet_sync_id(&state.storage, provider, snippet_id.as_deref()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn retry_snippet_legacy_cleanup(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
mut config: SnippetSyncConfig,
|
||||
) -> Result<SnippetSyncSettings, String> {
|
||||
resolve_snippet_token(&state.storage, &mut config).await?;
|
||||
let provider = config.provider;
|
||||
let client = SnippetSyncClient::new(config);
|
||||
retry_pending_snippet_cleanup(&state.storage, provider, &client).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn snippet_sync_upload(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
mut config: SnippetSyncConfig,
|
||||
editor_settings: Option<serde_json::Value>,
|
||||
snippet_passphrase: Option<String>,
|
||||
include_secrets: bool,
|
||||
secrets_passphrase: Option<String>,
|
||||
) -> Result<SnippetSyncSummary, String> {
|
||||
resolve_snippet_token(&state.storage, &mut config).await?;
|
||||
let snapshot = build_sync_snapshot_with_saved_secrets(
|
||||
&state.storage,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
editor_settings,
|
||||
secrets_passphrase.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
SnippetSyncClient::new(config).put_snapshot(&snapshot).await
|
||||
let secrets_passphrase = if include_secrets {
|
||||
Some(
|
||||
secrets_passphrase
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "A sync password is required when including synced secrets.".to_string())?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let snapshot =
|
||||
build_sync_snapshot(&state.storage, env!("CARGO_PKG_VERSION"), editor_settings, secrets_passphrase).await?;
|
||||
let client = SnippetSyncClient::new(config);
|
||||
let mut summary = client.put_snapshot(&snapshot, snippet_passphrase.as_deref(), secrets_passphrase).await?;
|
||||
finalize_snippet_migration(&state.storage, &client, &mut summary).await?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn snippet_sync_download(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
mut config: SnippetSyncConfig,
|
||||
snippet_passphrase: Option<String>,
|
||||
restore_secrets: bool,
|
||||
secrets_passphrase: Option<String>,
|
||||
) -> Result<SnippetDownloadResult, String> {
|
||||
resolve_snippet_token(&state.storage, &mut config).await?;
|
||||
let (snapshot, summary) = SnippetSyncClient::new(config).get_snapshot().await?;
|
||||
let explicit_passphrase = secrets_passphrase.as_deref().map(str::trim).filter(|value| !value.is_empty());
|
||||
let saved_passphrase = if explicit_passphrase.is_some() {
|
||||
None
|
||||
} else {
|
||||
resolve_webdav_sync_secrets_passphrase(&state.storage).await?
|
||||
};
|
||||
let (snapshot, summary) = SnippetSyncClient::new(config).get_snapshot(snippet_passphrase.as_deref()).await?;
|
||||
let apply_summary = apply_sync_snapshot(
|
||||
&state.storage,
|
||||
&snapshot,
|
||||
ApplySnapshotOptions { secrets_passphrase: explicit_passphrase.or(saved_passphrase.as_deref()) },
|
||||
ApplySnapshotOptions { secrets_passphrase: secrets_passphrase.as_deref(), restore_secrets },
|
||||
)
|
||||
.await?;
|
||||
Ok(SnippetDownloadResult {
|
||||
|
|
@ -202,3 +243,36 @@ pub async fn snippet_sync_download(
|
|||
apply_summary,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use dbx_core::cloud_sync::SnippetProvider;
|
||||
use dbx_core::storage::Storage;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::AppState;
|
||||
|
||||
#[tokio::test]
|
||||
async fn snippet_settings_surfaces_pending_cleanup_after_restart() {
|
||||
let dir = std::env::temp_dir().join(format!("dbx-tauri-snippet-cleanup-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db = dir.join("storage.db");
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
storage.save_snippet_migration_state("github", "replacement-id", "legacy-id", "content-hash").await.unwrap();
|
||||
drop(storage);
|
||||
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
let app_state = Arc::new(AppState::new_with_plugin_dir(storage, dir.join("plugins")));
|
||||
let app = tauri::test::mock_app();
|
||||
app.manage(app_state);
|
||||
let state: tauri::State<'_, Arc<AppState>> = app.state();
|
||||
let settings = super::snippet_sync_settings(state, SnippetProvider::GitHub).await.unwrap();
|
||||
|
||||
assert_eq!(settings.snippet_id.as_deref(), Some("replacement-id"));
|
||||
assert_eq!(settings.legacy_cleanup_required_id.as_deref(), Some("legacy-id"));
|
||||
drop(app);
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1600,6 +1600,9 @@ pub fn run() {
|
|||
commands::cloud_sync::snippet_token_status,
|
||||
commands::cloud_sync::save_snippet_saved_token,
|
||||
commands::cloud_sync::forget_snippet_saved_token,
|
||||
commands::cloud_sync::snippet_sync_settings,
|
||||
commands::cloud_sync::save_snippet_sync_id,
|
||||
commands::cloud_sync::retry_snippet_legacy_cleanup,
|
||||
commands::cloud_sync::snippet_sync_upload,
|
||||
commands::cloud_sync::snippet_sync_download,
|
||||
commands::connection::test_connection,
|
||||
|
|
|
|||
Loading…
Reference in New Issue