feat(android): a vault born here can be given a repository

Until now the two situations never met: a local vault had no repository, a
pulled vault had no local container. The day both exist, the rule "the local
container is the authority" means saves never reach the repository - and the
two diverge in silence, which is the worst outcome available.

Sync now merges them once and then deletes the local container: the
repository becomes the authority and the device behaves like any other. That
makes divergence impossible rather than unlikely. Order matters - publish
first, delete after; the reverse would lose the whole vault if the publish
failed.

The remote is the base, never the local. It carries what the phone cannot
rebuild: attachment bodies, the avatar, folder colours, and whatever the
desktop adds next. Starting from the local would amputate all of it, and the
desktop would adopt the amputated version on its next pull.

Arbitration is the desktop's, term for term: last write wins on updated_at, a
tombstone beats an older entry but not one edited after it - that one was
revived on purpose. Timestamps are ISO 8601 UTC on both sides, milliseconds
included, so lexicographic order IS chronological order.

MergeSelfCheck asserts the four rules at startup in debug. The merge never
runs against a real vault before it has run there.
This commit is contained in:
r-zakarya
2026-08-21 17:47:58 +01:00
parent 0b0f1e3fcd
commit 2fa093d49c
4 changed files with 393 additions and 3 deletions
+223
View File
@@ -255,6 +255,7 @@ type
{$IFDEF DEBUG}
procedure CryptoSelfCheck;
procedure PatchSelfCheck;
procedure MergeSelfCheck;
{$ENDIF}
procedure OpenEntry(AVaultIndex: Integer);
/// Ouvre un conteneur en evitant la derivation quand la cle en main
@@ -271,6 +272,8 @@ type
procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string);
/// Supprime l'entree ouverte, pierre tombale comprise. Sans retour.
procedure DeleteCurrentEntry;
/// Fond le coffre ne ici dans le depot, puis passe l'autorite au depot.
procedure ReconcileWithRemote;
procedure ReopenEntryByUuid(const AUuid: string);
procedure OnClipTimer(Sender: TObject);
procedure OnDeadline(Sender: TObject);
@@ -598,6 +601,7 @@ begin
TFile.WriteAllText(LPath, LContainer, TEncoding.UTF8);
Log('selfcheck: container written for cross-check');
PatchSelfCheck;
MergeSelfCheck;
except
on E: Exception do
Log('selfcheck FAILED: ' + E.ClassName + ' - ' + E.Message);
@@ -654,6 +658,82 @@ end;
/// PAR UUID ET NON PAR INDICE : le coffre vient d'etre remplace par celui du
/// serveur, et rien ne garantit que la meme entree occupe le meme rang - un
/// tri se fait sur le nom affiche, qu'une autre machine a pu changer.
/// Verifie les QUATRE regles de la fusion, celles dont un ecart se paierait en
/// entrees perdues ou ressuscitees. Elle ne tourne jamais contre un vrai coffre
/// avant d'avoir tourne ici.
procedure TForm8.MergeSelfCheck;
const
LOCAL =
'{"entries":[' +
'{"uuid":"only-local","updated_at":"2026-01-01T00:00:00.000Z"},' +
'{"uuid":"both","updated_at":"2026-06-01T00:00:00.000Z","title":"newer here"},' +
'{"uuid":"killed-there","updated_at":"2026-01-01T00:00:00.000Z"},' +
'{"uuid":"revived","updated_at":"2026-07-01T00:00:00.000Z"}],' +
'"tombstones":[]}';
REMOTE =
'{"avatar_b64":"KEEPME","entries":[' +
'{"uuid":"only-remote","updated_at":"2026-01-01T00:00:00.000Z",' +
'"attachments":[{"content_b64":"KEEPTOO"}]},' +
'{"uuid":"both","updated_at":"2026-02-01T00:00:00.000Z","title":"older"},' +
'{"uuid":"killed-there","updated_at":"2026-01-01T00:00:00.000Z"},' +
'{"uuid":"revived","updated_at":"2026-01-01T00:00:00.000Z"}],' +
'"tombstones":[' +
'{"uuid":"killed-there","deleted_at":"2026-05-01T00:00:00.000Z"},' +
'{"uuid":"revived","deleted_at":"2026-05-01T00:00:00.000Z"}]}';
function Find(const AArr: TJSONArray; const AUuid: string): TJSONObject;
var
I: Integer;
begin
Result := nil;
for I := 0 to AArr.Count - 1 do
if (AArr.Items[I] is TJSONObject) and
((AArr.Items[I] as TJSONObject).GetValue<string>('uuid', '') = AUuid) then
Exit(AArr.Items[I] as TJSONObject);
end;
var
LOut: string;
LRoot: TJSONObject;
LArr: TJSONArray;
LOk: Boolean;
begin
try
LOut := MergeSnapshots(LOCAL, REMOTE);
LRoot := TJSONObject.ParseJSONValue(LOut) as TJSONObject;
if LRoot = nil then
begin
Log('merge selfcheck: result is not JSON');
Exit;
end;
try
LArr := LRoot.GetValue('entries') as TJSONArray;
LOk := True;
// Ce que le distant seul portait survit, contenu des pieces jointes
// compris - c'est la raison pour laquelle il sert de base.
LOk := LOk and (Find(LArr, 'only-remote') <> nil);
LOk := LOk and (LRoot.GetValue<string>('avatar_b64', '') = 'KEEPME');
// Ce que le local seul portait est ajoute.
LOk := LOk and (Find(LArr, 'only-local') <> nil);
// Meme uuid des deux cotes : le plus recent gagne.
LOk := LOk and (Find(LArr, 'both') <> nil) and
(Find(LArr, 'both').GetValue<string>('title', '') = 'newer here');
// Pierre tombale posterieure a l'entree : elle disparait.
LOk := LOk and (Find(LArr, 'killed-there') = nil);
// Pierre tombale ANTERIEURE : l'entree a ete ressuscitee volontairement,
// et la retuer effacerait ce geste.
LOk := LOk and (Find(LArr, 'revived') <> nil);
if LOk then Log('merge selfcheck: the four rules hold')
else Log('merge selfcheck: FAILED - ' + Copy(LOut, 1, 400));
finally
LRoot.Free;
end;
except
on E: Exception do
Log('merge selfcheck FAILED: ' + E.ClassName + ' - ' + E.Message);
end;
end;
{$ENDIF}
procedure TForm8.ReopenEntryByUuid(const AUuid: string);
@@ -953,6 +1033,139 @@ end;
/// au prochain tirage.
///
/// La page demande confirmation avant d'en arriver ici.
/// RECONCILIER - un coffre ne sur le telephone auquel on donne un depot.
///
/// Jusqu'ici les deux situations ne se rencontraient pas : un coffre local
/// n'avait pas de depot, un coffre tire d'un depot n'avait pas de conteneur
/// local. Le jour ou les deux existent, la regle "le conteneur local fait
/// autorite" fait que les enregistrements n'atteignent jamais le depot - et les
/// deux divergent en silence, ce qui est la pire des issues.
///
/// La fusion se fait UNE FOIS, puis le conteneur local est efface : le depot
/// devient l'autorite, et l'appareil se comporte des lors comme n'importe quel
/// autre. C'est ce qui rend la divergence impossible plutot qu'improbable.
///
/// L'ORDRE COMPTE. On publie d'abord, on efface ensuite : l'inverse perdrait le
/// coffre entier si la publication echouait.
procedure TForm8.ReconcileWithRemote;
begin
if FBusy then Exit;
if not HasLocalContainer then
begin
Status('No local vault to reconcile');
Exit;
end;
if FSyncPwd = '' then
begin
Status('Open the vault first');
Exit;
end;
SetBusy(True);
Status('Reconciling...');
TThread.CreateAnonymousThread(
procedure
var
LClient: TSyncClient;
LGet, LPut: TSyncResult;
LLocal, LRemote, LMerged, LBody, LErr: string;
LAttempt: Integer;
LDone, LFirstPush: Boolean;
begin
LClient := TSyncClient.Create(FUrl, FToken);
LDone := False;
LFirstPush := False;
LErr := '';
try
try
LLocal := OpenContainer(LoadLocalContainer, FSyncPwd);
for LAttempt := 1 to 3 do
begin
LGet := LClient.Fetch;
if LGet.Status = ssNoSnapshot then
begin
// Depot vide : rien a fusionner, seulement a publier. Le cas le
// plus frequent - on donne un depot neuf a un coffre.
//
// 404 EST AMBIGU chez Gitea : fichier absent, ou depot prive qu'on
// n'a pas le droit de voir. On ne cherche pas a trancher ici -
// publier tranche pour nous, puisqu'un jeton sans droit echouera au
// PUT et que rien n'aura ete efface d'ici la.
LMerged := LLocal;
LGet.ETag := '';
LFirstPush := True;
end
else if LGet.Status = ssOK then
begin
LRemote := OpenContainer(LGet.Container, FSyncPwd);
LMerged := MergeSnapshots(LLocal, LRemote);
end
else
begin
LErr := Format('read failed (HTTP %d) %s', [LGet.HttpCode, LGet.Error]);
Break;
end;
LBody := EncryptContainerWithKey(LMerged, FKey, FKeySalt, FKeyIters);
LPut := LClient.Put(LBody, LGet.ETag, 'reconciled from phone');
if LPut.Status = ssOK then
begin
FETag := LPut.ETag;
LDone := True;
Break;
end;
if LPut.Status = ssConflict then Continue;
LErr := Format('write failed (HTTP %d) %s', [LPut.HttpCode, LPut.Error]);
Break;
end;
if (not LDone) and (LErr = '') then
LErr := 'the remote kept changing - try again';
if LDone then
begin
// PUBLIE, DONC PLUS BESOIN DU CONTENEUR LOCAL. C'est ce geste qui
// passe l'autorite au depot ; le faire avant la publication aurait
// perdu le coffre.
DropLocalContainer;
try
SaveVault(LMerged, FETag, FSyncPwd, BiometricAvailable, False,
FPinSealed, FKey, FKeySalt, FKeyIters);
except
on E: Exception do Log(' cache not written: ' + E.Message);
end;
if LFirstPush then Log('Reconcile: repository was empty - published')
else Log('Reconcile: merged with the repository');
end;
TThread.Queue(nil,
procedure
begin
if LDone then
begin
AdoptVault(LMerged);
WebCall('PM.toast("Reconciled - the repository is now the source")');
end
else
WebCall('PM.toast(' + JsArg('Not reconciled - ' + LErr) + ')');
end);
except
on E: Exception do
begin
LErr := E.ClassName + ': ' + E.Message;
Log('Reconcile FAILED: ' + LErr);
TThread.Queue(nil, procedure
begin WebCall('PM.toast(' + JsArg('Not reconciled - ' + LErr) + ')'); end);
end;
end;
finally
LClient.Free;
TThread.Queue(nil, procedure begin SetBusy(False); ShowUnlock; end);
end;
end).Start;
end;
procedure TForm8.DeleteCurrentEntry;
var
LUuid, LTitle: string;
@@ -1610,6 +1823,16 @@ begin
end;
WebCall('PM.clearLog()');
// UN COFFRE NE ICI A QUI L'ON DONNE UN DEPOT : il faut fondre les deux, pas
// ecraser l'un par l'autre. Sans cela le conteneur local resterait
// l'autorite, les enregistrements n'atteindraient jamais le depot, et les
// deux divergeraient en silence.
if HasLocalContainer then
begin
ReconcileWithRemote;
Exit;
end;
// MEME PORTE QUE « Ouvrir le coffre ». Sans cela la protection ne tenait
// qu'a un bouton pres : synchroniser reconstruit le coffre depuis le serveur
// et l'affiche, ce qui revient exactement au meme resultat sans passer par