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:
@@ -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
@@ -187,6 +187,7 @@ type
|
||||
procedure PushConfig;
|
||||
{$IFDEF DEBUG}
|
||||
procedure CryptoSelfCheck;
|
||||
procedure PatchSelfCheck;
|
||||
procedure WriteTest;
|
||||
{$ENDIF}
|
||||
procedure OpenEntry(AVaultIndex: Integer);
|
||||
@@ -212,7 +213,7 @@ implementation
|
||||
|
||||
uses
|
||||
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}
|
||||
|
||||
@@ -468,12 +469,58 @@ begin
|
||||
System.IOUtils.TPath.GetHomePath, 'selfcheck.json');
|
||||
TFile.WriteAllText(LPath, LContainer, TEncoding.UTF8);
|
||||
Log('selfcheck: container written for cross-check');
|
||||
PatchSelfCheck;
|
||||
except
|
||||
on E: Exception do
|
||||
Log('selfcheck FAILED: ' + E.ClassName + ' - ' + E.Message);
|
||||
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.
|
||||
///
|
||||
/// Le premier PUT est le moment ou une erreur de transport se paie en donnees
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@ uses
|
||||
PMA.ScreenGuard in 'android-client\PMA.ScreenGuard.pas',
|
||||
PMA.Clipboard in 'android-client\PMA.Clipboard.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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user