From c3c6bce4065d8eec99465b3a6eaa09b2a592bafb Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:38:07 +0100 Subject: [PATCH] fix(sync): a trashed entry is no longer resurrected as a duplicate The trash is a soft delete: no tombstone is written and the rows are absent from state.entries, so the merge read their uuids as missing locally and POSTed them back as fresh live entries. Trashing 30 entries then syncing produced 30 live duplicates alongside the trashed originals - two rows sharing one uuid. applyRemoteSnapshot now loads the trash and skips any uuid found there. Emptying the trash is what writes the tombstone that propagates the delete. --- TEST_SCENARIOS.md | 10 +++++++++- js/app.sync.js | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/TEST_SCENARIOS.md b/TEST_SCENARIOS.md index d3b35aa..7ba0a8d 100644 --- a/TEST_SCENARIOS.md +++ b/TEST_SCENARIOS.md @@ -233,6 +233,14 @@ plus bas. - **ACTION** : importer un CSV sans colonnes favorite/pinned. - **ATTENDU** : entrées importées ni épinglées ni favorites (défaut 0), pas d'erreur. +### F8 · mettre à la corbeille puis synchroniser ne duplique pas ✅ +- **ACTION** : synchroniser une première fois (l'instantané distant contient les entrées) → mettre **plusieurs** entrées à la corbeille → **Sync now**. +- **ATTENDU** : les entrées restent en corbeille, et **rien n'est recréé** dans la liste vivante. Le résumé ne doit pas annoncer `pulled N new`. +- **Le bug** : 30 entrées mises à la corbeille puis un sync → 30 **doublons vivants** à côté des originaux en corbeille, soit deux lignes partageant un même `uuid`. +- **Cause** : la corbeille est une suppression **douce**, sans tombstone (seul le hard-delete en écrit un), et les entrées supprimées sont absentes de `state.entries`. Le merge voyait donc leurs uuid comme « absents en local » et les repostait comme des entrées neuves. +- **Correctif** : `applyRemoteSnapshot` charge aussi la corbeille et saute tout uuid qui s'y trouve. La décision locale de mettre à la corbeille l'emporte ; c'est le **vidage** de la corbeille qui écrit le tombstone et propage la suppression. +- **Nettoyage si le bug a déjà frappé** : les doublons vivants sont à supprimer à la main. Vérifier aussi l'autre appareil, où ils ont pu être poussés. + ### F4 · le sync ne propage PAS les drapeaux (device-local, décidé 2026-07-11) - **ACTION** : épingler/favoriser une entrée sur le device A → *Sync now* → *Sync now* sur le device B. - **ATTENDU** : sur B, l'entrée **n'est pas** épinglée/favorite. Et un sync ne doit jamais **retirer** un drapeau posé localement sur B (le PUT serveur ne touche pas ces colonnes). @@ -366,7 +374,7 @@ ou le déchiffrement. - **Après le test** : supprimer les dumps. Ils contiennent des secrets en clair, sans chiffrement. - **Validation fonctionnelle obligatoire** — les **quatre** chemins qui lisent encore un secret, à repasser après toute modification de `loadAutoBackupConfig` / `loadSyncConfig` : *Backup now* **puis import du fichier produit** (le fichier seul ne prouve rien, cf. F2) · *Test connection* WebDAV **avec authentification** · *Sync now* (le compteur `updated` prouve que l'instantané distant a été **déchiffré**, pas seulement téléchargé) · le backup planifié au déverrouillage. - **Monter un WebDAV authentifié pour le test** : `python -m wsgidav.server.server_cli -c ` avec `simple_dc.user_mapping` et **`accept_basic: true`, `accept_digest: false`** — notre client n'envoie que du Basic. Le partage ne doit plus être marqué `(anonymous)` au démarrage. Un premier 401 suivi d'un 200 authentifié dans le journal est **normal** : WinHTTP n'envoie les identifiants qu'après le défi. -- **Piège de saisie** : les champs identifiant / mot de passe n'enregistrent que sur `change` (perte du focus). Taper puis cliquer aussitôt sur *Test connection* peut lire la préférence avant son écriture — les deux passent par des navigations `cmd://`, qui se coalescent. Faire Tab avant de cliquer. +- **Piège de saisie** : les champs identifiant / mot de passe n'enregistrent que sur `change` (perte du focus). Taper puis cliquer aussitôt sur *Test connection* peut lire la préférence avant son écriture — les deux passent par des navigations `cmd://`, qui se coalescent. Faire Tab avant de cliquer. **python -m wsgidav.server.server_cli -c "e:\webdav.json"** ### E5 · la clé du coffre n'est plus en clair dans le web storage ✅ - **ACTION** : coffre déverrouillé → console WebView2 : diff --git a/js/app.sync.js b/js/app.sync.js index 43fa426..4f1f315 100644 --- a/js/app.sync.js +++ b/js/app.sync.js @@ -349,6 +349,17 @@ async function applyRemoteSnapshot(remote) { const byUuid = new Map(); for (const e of state.entries) if (e.uuid) byUuid.set(e.uuid, e); + // Entries sitting in the TRASH are soft-deleted: no tombstone is written + // (that only happens on a hard delete), and they are absent from + // state.entries. Without this set the merge sees their uuids as "missing + // locally" and POSTs them back as fresh live rows — moving 30 entries to + // the trash then syncing produced 30 duplicates, live copies alongside the + // trashed originals, two rows sharing one uuid. The local decision to trash + // wins; emptying the trash writes the tombstone that propagates the delete. + await loadTrash(); + const trashedUuids = new Set(); + for (const e of (state.trashed || [])) if (e.uuid) trashedUuids.add(e.uuid); + // Local tombstones must veto ANY remote entry with a matching uuid — // otherwise a perm-delete on this device gets undone by the next // pull ("48 added" after wiping the vault, because the remote @@ -401,6 +412,9 @@ async function applyRemoteSnapshot(remote) { // Skip entries this device has already tombstoned — never // resurrect a deleted entry. if (localTombstones.has(r.uuid)) continue; + // Same for the trash: the entry exists locally, just not in the live + // list. Re-creating it would duplicate it, not restore it. + if (trashedUuids.has(r.uuid)) continue; const local = byUuid.get(r.uuid); if (!local) { // New entry on remote — encrypt locally + POST keeping the uuid.