fix(desktop): stop pushing a snapshot that changed nothing

Measured on the phone, opening a cached vault: 121 ms to read the sealed file,
14 ms to display it - and then 5718 ms decrypting a snapshot it already had.

The cause is upstream. A sync pushes unconditionally at the end, and the push
is never a no-op: the container is re-encrypted with a fresh salt and IV, so an
identical vault produces completely different bytes. On Gitea that is one more
commit and a new blob sha, so every other device sees a "new" snapshot and pays
a full key derivation to arrive back where it started. It also grew the
repository on every sync that changed nothing, which is the history cost the
plan warns about.

The merged state is now compared against what was pulled, as plaintext -
the only place equality is observable - ignoring snapshot_at, which changes by
construction. Equal means the push is skipped and the sync still counts as done,
timestamp included: having nothing to write is a success, not an absence.

The comparison is deliberately strict. A false negative costs one commit; a
false positive would silence a push that carried a change and lose it for the
other devices. Four tests fix where that line sits, including a modified
updated_at (which arbitrates the merge elsewhere) and a tombstone (a deletion
that has to propagate).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-17 15:41:44 +01:00
parent aa87580443
commit f97e44768a
4 changed files with 121 additions and 1 deletions
+40
View File
@@ -67,6 +67,26 @@ function giteaUnwrap(bodyText) {
} catch (e) { return null; } } 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 // 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. // 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 // 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 await loadEntries(); // pull latest after applying remote changes
const snap = await buildSyncSnapshot(); const snap = await buildSyncSnapshot();
pushedCount = Array.isArray(snap.entries) ? snap.entries.length : 0; 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…'); syncStatus('Encrypting…');
const container = await encryptExportPayload(snap, cfg.encPwd); const container = await encryptExportPayload(snap, cfg.encPwd);
const containerBytes = new TextEncoder().encode(JSON.stringify(container, null, 2)); const containerBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
+1 -1
View File
@@ -153,7 +153,7 @@ function loadApp(overrides = {}) {
// merge (async, coupled — tests stub the io seams below) // merge (async, coupled — tests stub the io seams below)
applyRemoteSnapshot, buildSyncSnapshot, applyRemoteSnapshot, buildSyncSnapshot,
// gitea transport // gitea transport
syncIsGitea, giteaUnwrap, syncIsGitea, giteaUnwrap, syncSnapshotsEqual,
// metadata-at-rest // metadata-at-rest
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS, withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
}; };
+65
View File
@@ -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);
});
+15
View File
@@ -574,10 +574,23 @@ end;
procedure TForm8.OpenLocalNow; procedure TForm8.OpenLocalNow;
var var
LPlain, LETag, LPwd: string; LPlain, LETag, LPwd: string;
LSw: TStopwatch;
begin 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 if LoadVault(LPlain, LETag, LPwd) then
begin begin
Log('Vault opened from cache in ' + LSw.ElapsedMilliseconds.ToString + ' ms');
FETag := LETag; 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; if LPwd <> '' then FSyncPwd := LPwd;
// LA PROTECTION RATTRAPE LE CACHE. Elle se decidait a l'ecriture seule, // LA PROTECTION RATTRAPE LE CACHE. Elle se decidait a l'ecriture seule,
@@ -610,7 +623,9 @@ begin
end; end;
try try
LSw := TStopwatch.StartNew;
AdoptVault(LPlain); AdoptVault(LPlain);
Log(' displayed in ' + LSw.ElapsedMilliseconds.ToString + ' ms');
// Afficher d'abord, synchroniser ensuite : le coffre est a l'ecran, le // Afficher d'abord, synchroniser ensuite : le coffre est a l'ecran, le
// reseau se debrouille derriere. Sans mot de passe range, on ne peut // reseau se debrouille derriere. Sans mot de passe range, on ne peut
// rien ouvrir de nouveau - inutile d'aller voir. // rien ouvrir de nouveau - inutile d'aller voir.