feat(android): patch the snapshot in place, never rebuild it

Section 4 step 4. TVault reads a subset of the fields, so re-serialising the
vault from what it understood would drop attachment contents, created_at, the
account avatar, tombstones, and whatever the desktop adds next - and the
desktop would adopt that amputated version on its next pull, the merge being
last-write-wins on updated_at. Silent, and final.

So nothing is rebuilt: the received JSON is edited in place and the rest is
handed back untouched. What the phone does not understand travels intact by
construction rather than by vigilance.

A corollary worth naming, because it removes the step the plan feared most:
since the patch applies to the snapshot just pulled, there is no merge to
write. Anything untouched is already the server newest version. The only
possible conflict is another device publishing between our read and our write,
which Gitea refuses on the sha.

The self-check tests what is PRESERVED, not what changes: a field unknown to
TVault, an attachment content_b64, the untouched sibling entry, the
tombstones. It reports "patch keeps what it does not understand" on the device
at every debug launch.

Also confirmed by this log: the write token now survives a restart, so the 304
settings fix holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-17 19:35:12 +01:00
parent 5324785ed9
commit 6bb056220e
3 changed files with 150 additions and 2 deletions
+100
View File
@@ -0,0 +1,100 @@
unit PMA.VaultPatch;
{
Modifier UN champ d'UNE entree dans l'instantane, sans toucher au reste.
LA CONTRAINTE QUI GOUVERNE CETTE UNITE. TVault ne lit qu'une partie des
champs : il ignore `content_b64` des pieces jointes, `created_at`, l'avatar du
compte, les tombstones, et tout ce que le poste ajoutera demain. Reserialiser
le coffre a partir de ce qu'il a compris EFFACERAIT tout cela - et le poste
adopterait cette version amputee au tirage suivant, la fusion etant un
dernier-ecrit-gagne sur `updated_at`. La perte serait silencieuse et
definitive.
Donc on ne reconstruit rien : on prend le JSON RECU, on change une valeur
dedans, et on rend le reste tel quel. Ce que le telephone ne comprend pas
voyage intact, par construction plutot que par vigilance.
COROLLAIRE SUR LA FUSION. Comme le patch s'applique a l'instantane qu'on
vient de tirer, il n'y a pas de fusion a ecrire : ce qui n'est pas touche est
deja la version la plus recente du serveur. Le seul conflit possible est
qu'un autre appareil publie entre notre lecture et notre ecriture, et Gitea
le refuse par le sha (409) - il suffit alors de recommencer.
}
interface
uses
System.SysUtils, System.JSON, System.DateUtils;
type
EPMAPatch = class(Exception);
/// Remplace AField de l'entree AUuid par AValue, et bump `updated_at`.
/// Rend le JSON complet, tout le reste inchange. Leve si l'entree est absente -
/// ce qui veut dire que le distant ne la contient pas (supprimee ailleurs), et
/// ecrire quand meme la ferait reapparaitre.
function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string;
/// Horodatage UTC au format que tout le reste emploie (toISOString cote JS).
/// MELANGER heure locale et UTC a deja decale l'arbitrage de la fusion une
/// fois : il n'y a qu'une source d'heure ici, et elle est en UTC.
function NowUtcIso: string;
implementation
function NowUtcIso: string;
begin
Result := FormatDateTime('yyyy-mm-dd"T"hh:nn:ss.zzz"Z"',
TTimeZone.Local.ToUniversalTime(Now));
end;
function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string;
var
LRoot, LEntry: TJSONObject;
LEntries: TJSONArray;
I: Integer;
LFound: Boolean;
begin
if AUuid = '' then
raise EPMAPatch.Create('Entree sans uuid : rien a retrouver chez le distant');
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LRoot = nil then
raise EPMAPatch.Create('Instantane illisible');
try
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then
raise EPMAPatch.Create('Instantane sans tableau "entries"');
LFound := False;
for I := 0 to LEntries.Count - 1 do
begin
if not (LEntries.Items[I] is TJSONObject) then Continue;
LEntry := LEntries.Items[I] as TJSONObject;
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
// RemovePair puis AddPair : AddPair seul ajouterait un DOUBLON, et le
// lecteur prendrait le premier - donc l'ancienne valeur.
LEntry.RemovePair(AField).Free;
LEntry.AddPair(AField, AValue);
// Sans ce bump, la modification voyage mais les autres appareils
// l'ignorent : leur arbitrage compare `updated_at`, et le notre serait
// reste plus ancien que le leur.
LEntry.RemovePair('updated_at').Free;
LEntry.AddPair('updated_at', NowUtcIso);
LFound := True;
Break;
end;
if not LFound then
raise EPMAPatch.CreateFmt(
'Entree %s absente du distant - supprimee ailleurs ?', [AUuid]);
Result := LRoot.ToJSON;
finally
LRoot.Free;
end;
end;
end.
+48 -1
View File
@@ -187,6 +187,7 @@ type
procedure PushConfig; procedure PushConfig;
{$IFDEF DEBUG} {$IFDEF DEBUG}
procedure CryptoSelfCheck; procedure CryptoSelfCheck;
procedure PatchSelfCheck;
procedure WriteTest; procedure WriteTest;
{$ENDIF} {$ENDIF}
procedure OpenEntry(AVaultIndex: Integer); procedure OpenEntry(AVaultIndex: Integer);
@@ -212,7 +213,7 @@ implementation
uses uses
PMA.Crypto, PMA.SyncClient, PMA.Store, PMA.Biometric, PMA.Totp, PMA.Crypto, PMA.SyncClient, PMA.Store, PMA.Biometric, PMA.Totp,
PMA.ScreenGuard, PMA.Clipboard, PMA.Theme, PMA.WebUI, System.JSON; PMA.ScreenGuard, PMA.Clipboard, PMA.Theme, PMA.WebUI, PMA.VaultPatch, System.JSON;
{$R *.fmx} {$R *.fmx}
@@ -468,12 +469,58 @@ begin
System.IOUtils.TPath.GetHomePath, 'selfcheck.json'); System.IOUtils.TPath.GetHomePath, 'selfcheck.json');
TFile.WriteAllText(LPath, LContainer, TEncoding.UTF8); TFile.WriteAllText(LPath, LContainer, TEncoding.UTF8);
Log('selfcheck: container written for cross-check'); Log('selfcheck: container written for cross-check');
PatchSelfCheck;
except except
on E: Exception do on E: Exception do
Log('selfcheck FAILED: ' + E.ClassName + ' - ' + E.Message); Log('selfcheck FAILED: ' + E.ClassName + ' - ' + E.Message);
end; end;
end; end;
/// Verifie ce que le patch PRESERVE, pas seulement ce qu'il change.
///
/// C'est l'invariant de §4 : un champ que le telephone ne comprend pas doit
/// ressortir intact, sinon une modification faite ici efface silencieusement
/// des donnees chez tous les autres appareils. On le controle sur un champ
/// inconnu de TVault et sur une piece jointe, qui sont precisement les deux
/// que la reserialisation naive perdrait.
procedure TForm8.PatchSelfCheck;
const
SNAP =
'{"version":1,"username":"test","folders":[{"name":"f"}],' +
'"entries":[' +
'{"uuid":"u1","title":"a","password":"old","updated_at":"2020-01-01T00:00:00Z",' +
'"attachments":[{"filename":"x.pdf","content_b64":"KEEPME"}],' +
'"champ_inconnu":"KEEPTOO"},' +
'{"uuid":"u2","title":"b","password":"other"}],' +
'"tombstones":[{"uuid":"u9"}]}';
var
LOut: string;
LRoot: TJSONObject;
LEntry: TJSONObject;
LOk: Boolean;
begin
try
LOut := PatchEntryField(SNAP, 'u1', 'password', 'NEW');
LRoot := TJSONObject.ParseJSONValue(LOut) as TJSONObject;
try
LEntry := (LRoot.GetValue<TJSONArray>('entries').Items[0]) as TJSONObject;
LOk := (LEntry.GetValue<string>('password') = 'NEW')
and (LEntry.GetValue<string>('updated_at') <> '2020-01-01T00:00:00Z')
and (LEntry.GetValue<string>('champ_inconnu') = 'KEEPTOO')
and (LOut.Contains('KEEPME')) // piece jointe intacte
and (LOut.Contains('"tombstones"')) // et le reste du document
and (LOut.Contains('"other"')); // l'autre entree non touchee
if LOk then Log('selfcheck: patch keeps what it does not understand')
else Log('selfcheck: PATCH LOSES DATA - do not enable writing');
finally
LRoot.Free;
end;
except
on E: Exception do
Log('selfcheck patch FAILED: ' + E.ClassName + ' - ' + E.Message);
end;
end;
/// Essai du chemin d'ecriture, VERS UN AUTRE FICHIER que le coffre. /// Essai du chemin d'ecriture, VERS UN AUTRE FICHIER que le coffre.
/// ///
/// Le premier PUT est le moment ou une erreur de transport se paie en donnees /// Le premier PUT est le moment ou une erreur de transport se paie en donnees
+2 -1
View File
@@ -14,7 +14,8 @@ uses
PMA.ScreenGuard in 'android-client\PMA.ScreenGuard.pas', PMA.ScreenGuard in 'android-client\PMA.ScreenGuard.pas',
PMA.Clipboard in 'android-client\PMA.Clipboard.pas', PMA.Clipboard in 'android-client\PMA.Clipboard.pas',
PMA.Theme in 'android-client\PMA.Theme.pas', PMA.Theme in 'android-client\PMA.Theme.pas',
PMA.WebUI in 'android-client\PMA.WebUI.pas'; PMA.WebUI in 'android-client\PMA.WebUI.pas',
PMA.VaultPatch in 'android-client\PMA.VaultPatch.pas';
{$R *.res} {$R *.res}