diff --git a/js/app.sync.js b/js/app.sync.js index 2613098..6f83419 100644 --- a/js/app.sync.js +++ b/js/app.sync.js @@ -67,6 +67,26 @@ function giteaUnwrap(bodyText) { } catch (e) { return null; } } +// Do these two snapshots carry the same vault? Compared as plaintext, because +// the ciphertext never repeats — a fresh salt and IV per encryption make two +// containers of an identical vault differ completely. +// +// `snapshot_at` is stripped: it is the time of the build, not part of what the +// vault contains, and keeping it would make every comparison fail. Everything +// else is compared verbatim, which is deliberately strict — a difference in key +// order or in a field we have not thought about falls on the side of pushing, +// which costs a commit rather than losing a change. +function syncSnapshotsEqual(a, b) { + if (!a || !b) return false; + const strip = s => { + const c = Object.assign({}, s); + delete c.snapshot_at; + delete c.exported_at; + return JSON.stringify(c); + }; + try { return strip(a) === strip(b); } catch (e) { return false; } +} + // method: 'get'|'put'|'test'. opts.ifMatch → sent as If-Match on a put so // the server rejects (412) a write when the remote changed since our pull. // On Gitea the sha travels inside the PUT body instead, and the bridge sends @@ -668,6 +688,26 @@ async function runSyncNow(_attempt) { await loadEntries(); // pull latest after applying remote changes const snap = await buildSyncSnapshot(); pushedCount = Array.isArray(snap.entries) ? snap.entries.length : 0; + + // RIEN A DIRE, RIEN A ECRIRE. Un push a lieu meme quand la fusion n'a + // rien change, et il ne passe pas inapercu : le conteneur est rechiffre + // avec un sel et un IV neufs, donc son contenu differe a chaque fois + // meme a coffre identique. Sur Gitea cela vaut un commit de plus et un + // nouveau sha ; les autres appareils voient alors un instantane + // "nouveau" et paient une derivation de cle complete - mesuree a 5,7 s + // sur le telephone - pour retrouver exactement ce qu'ils avaient deja. + // + // La comparaison porte sur le CLAIR, seul endroit ou l'egalite est + // observable, et ignore `snapshot_at`, qui change par construction. + if (remoteSnap && syncSnapshotsEqual(remoteSnap, snap)) { + syncStatus(''); + // La synchronisation a bien eu lieu, et l'horodatage le dit : ne + // rien avoir a ecrire est une reussite, pas une absence. + Bridge.setPref(SYNC_PREFS.last, new Date().toISOString()); + render(); + return toast('Already up to date · ' + pushedCount + ' entries'); + } + syncStatus('Encrypting…'); const container = await encryptExportPayload(snap, cfg.encPwd); const containerBytes = new TextEncoder().encode(JSON.stringify(container, null, 2)); diff --git a/js/tests/harness.js b/js/tests/harness.js index a7bbb40..9307a7b 100644 --- a/js/tests/harness.js +++ b/js/tests/harness.js @@ -153,7 +153,7 @@ function loadApp(overrides = {}) { // merge (async, coupled — tests stub the io seams below) applyRemoteSnapshot, buildSyncSnapshot, // gitea transport - syncIsGitea, giteaUnwrap, + syncIsGitea, giteaUnwrap, syncSnapshotsEqual, // metadata-at-rest withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS, }; diff --git a/js/tests/snapshot-equal.test.js b/js/tests/snapshot-equal.test.js new file mode 100644 index 0000000..402ccc7 --- /dev/null +++ b/js/tests/snapshot-equal.test.js @@ -0,0 +1,65 @@ +// L'egalite de deux instantanes, qui decide si un push a lieu. +// +// POURQUOI C'EST TESTE. Un faux positif est le cas dangereux : il ferait taire +// un push qui portait une modification, et la perdrait pour les autres +// appareils. La comparaison est donc volontairement stricte, et ces cas fixent +// ou est la limite. Un faux negatif ne coute qu'un commit inutile. +// +// `snapshot_at` doit etre ignore : il change a chaque construction, et sans cela +// deux instantanes identiques ne seraient jamais egaux - le correctif ne +// servirait a rien. + +const test = require('node:test'); +const assert = require('node:assert'); +const { loadApp } = require('./harness'); + +function snap(extra) { + return Object.assign({ + version: 1, + snapshot_at: '2026-08-17T10:00:00.000Z', + username: 'test', + folders: [{ name: 'folder test' }], + entries: [{ uuid: 'u1', title: 'a', updated_at: '2026-08-01T00:00:00Z' }], + tombstones: [] + }, extra || {}); +} + +test('syncSnapshotsEqual: identical vaults are equal despite the build time', async () => { + const { __test: t } = await loadApp(); + const a = snap(); + const b = snap({ snapshot_at: '2026-08-17T15:36:12.000Z' }); + assert.equal(t.syncSnapshotsEqual(a, b), true); +}); + +test('syncSnapshotsEqual: exported_at is ignored too', async () => { + const { __test: t } = await loadApp(); + assert.equal(t.syncSnapshotsEqual( + snap({ exported_at: '2026-01-01T00:00:00Z' }), + snap({ exported_at: '2026-08-17T00:00:00Z' })), true); +}); + +test('syncSnapshotsEqual: any real difference must push', async () => { + const { __test: t } = await loadApp(); + // Une entree modifiee. + assert.equal(t.syncSnapshotsEqual(snap(), snap({ + entries: [{ uuid: 'u1', title: 'b', updated_at: '2026-08-01T00:00:00Z' }] })), false); + // Un horodatage d'entree plus recent, meme titre : c'est ce qui arbitre la + // fusion chez les autres, donc ca doit voyager. + assert.equal(t.syncSnapshotsEqual(snap(), snap({ + entries: [{ uuid: 'u1', title: 'a', updated_at: '2026-08-17T00:00:00Z' }] })), false); + // Une entree en plus, une en moins. + assert.equal(t.syncSnapshotsEqual(snap(), snap({ entries: [] })), false); + // Un dossier renomme. + assert.equal(t.syncSnapshotsEqual(snap(), snap({ folders: [{ name: 'autre' }] })), false); + // Une pierre tombale : la suppression doit se propager. + assert.equal(t.syncSnapshotsEqual(snap(), snap({ + tombstones: [{ uuid: 'u9', deleted_at: '2026-08-17T00:00:00Z' }] })), false); +}); + +test('syncSnapshotsEqual: a missing side is never equal', async () => { + const { __test: t } = await loadApp(); + // 404 distant : il n'y a rien la-bas, donc il faut ecrire. + assert.equal(t.syncSnapshotsEqual(null, snap()), false); + assert.equal(t.syncSnapshotsEqual(snap(), null), false); + assert.equal(t.syncSnapshotsEqual(null, null), false); +}); diff --git a/mainApk.pas b/mainApk.pas index 058d08f..4b68e89 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -574,10 +574,23 @@ end; procedure TForm8.OpenLocalNow; var LPlain, LETag, LPwd: string; + LSw: TStopwatch; begin + // CHRONOMETRE EN TROIS TEMPS. Une ouverture depuis le cache doit etre + // instantanee : c'est toute la raison d'etre de PMA.Store. Quand elle ne + // l'est pas, il faut savoir LEQUEL des trois coute - la lecture du fichier + // scelle, la mise en forme vers la page, ou le rafraichissement de fond qui + // suit - sinon on cherche au hasard. + LSw := TStopwatch.StartNew; if LoadVault(LPlain, LETag, LPwd) then begin + Log('Vault opened from cache in ' + LSw.ElapsedMilliseconds.ToString + ' ms'); FETag := LETag; + if LETag = '' then + // Sans empreinte, le rafraichissement qui suit ne peut pas savoir que + // rien n'a change : il retelechargera et redechiffrera a chaque + // ouverture. C'est exactement le cout que le cache doit supprimer. + Log(' WARNING: cache has no fingerprint - the refresh will re-derive'); if LPwd <> '' then FSyncPwd := LPwd; // LA PROTECTION RATTRAPE LE CACHE. Elle se decidait a l'ecriture seule, @@ -610,7 +623,9 @@ begin end; try + LSw := TStopwatch.StartNew; AdoptVault(LPlain); + Log(' displayed in ' + LSw.ElapsedMilliseconds.ToString + ' ms'); // Afficher d'abord, synchroniser ensuite : le coffre est a l'ecran, le // reseau se debrouille derriere. Sans mot de passe range, on ne peut // rien ouvrir de nouveau - inutile d'aller voir.