feat(android): the write transport, and an error that says why

TSyncClient.Put publishes a container to the Gitea contents API: token header,
JSON envelope with the file base64 inside, sha omitted rather than empty when
creating, branch omitted so the repo default is used. 409 becomes its own
status, ssConflict, because it means re-read and re-apply rather than retry.

The first PUT is where a mistake costs data, so the probe writes to
write-test.json rather than the vault. Same repository, same token, same
envelope, same concurrency check - and the vault is never at stake. Reachable
from a debug-only button; the page asks Delphi whether to show it.

Two mistakes of mine on the way, both about replacing information with a guess:

- the probe did not push the config first, unlike Sync, so a token that had
  just been pasted had not reached Delphi and the 403 blamed the wrong thing.
- 401 and 403 were rewritten as "token lacks write access", discarding Gitea's
  body. That is an interpretation, not an observation: the same code covers a
  read-only repository, a protected branch, a token from another account. The
  body says which - here, plainly, "token scope=read:repository".

Also recorded: the sha comparison now short-circuits (304 where it used to
decrypt), so the difference was case or whitespace, absorbed by SameText on
trimmed values. The offending character itself was never seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-17 17:47:55 +01:00
parent ae48fadb6b
commit 90e8fabf58
4 changed files with 191 additions and 2 deletions
+54
View File
@@ -187,6 +187,7 @@ type
procedure PushConfig;
{$IFDEF DEBUG}
procedure CryptoSelfCheck;
procedure WriteTest;
{$ENDIF}
procedure OpenEntry(AVaultIndex: Integer);
procedure OnClipTimer(Sender: TObject);
@@ -290,6 +291,7 @@ begin
ShowUnlock;
{$IFDEF DEBUG}
WebCall('PM.setDebug(true)');
// VERIFICATION DU CHIFFREMENT AVANT TOUTE ECRITURE REELLE (§4). Elle depose
// aussi un conteneur sur le disque prive, pour qu'il soit relu par l'AUTRE
// implementation : un aller-retour avec soi-meme ne prouve que la coherence
@@ -464,6 +466,54 @@ begin
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
/// perdues : un corps mal forme, un sha mal place, et vault-sync.json est
/// remplace par quelque chose que personne ne sait relire. L'ecrire d'abord
/// sous un autre nom prouve exactement les memes choses - enveloppe, jeton,
/// droits, sha rendu - sans mettre le coffre en jeu une seule fois.
procedure TForm8.WriteTest;
var
LUrl: string;
begin
if FBusy then Exit;
SetBusy(True);
// Meme depot, autre fichier : on remplace le dernier segment du chemin.
LUrl := FUrl.Substring(0, FUrl.LastIndexOf('/') + 1) + 'write-test.json';
TThread.CreateAnonymousThread(
procedure
var
LClient: TSyncClient;
LRes: TSyncResult;
LBody: string;
begin
LClient := TSyncClient.Create(LUrl, FToken);
try
LBody := EncryptContainer(
Format('{"probe":"write path","at":"%s"}',
[FormatDateTime('yyyy-mm-dd"T"hh:nn:ss"Z"',
TTimeZone.Local.ToUniversalTime(Now))]),
FSyncPwd, 1000);
Log('PUT ' + LUrl);
Log(' token ' + IntToStr(Length(FToken)) + ' chars, body ' +
IntToStr(Length(LBody)) + ' chars');
// Sha vide : on cree le fichier, ou on echoue en 409 s'il existe deja -
// ce qui prouverait le controle de concurrence aussi surement.
LRes := LClient.Put(LBody, '', 'write path probe from phone');
case LRes.Status of
ssOK: Log(' OK ' + LRes.HttpCode.ToString + ' - new sha ' + LRes.ETag);
ssConflict: Log(' 409 - file already there, concurrency control works');
else
Log(' failed: ' + LRes.HttpCode.ToString + ' ' + LRes.Error);
end;
finally
LClient.Free;
TThread.Queue(nil, procedure begin SetBusy(False); end);
end;
end).Start;
end;
/// Descend les coordonnees du serveur dans les champs de la page.
procedure TForm8.PushConfig;
var
@@ -570,6 +620,10 @@ begin
else if LAction = 'sync' then
DoSync(nil)
{$IFDEF DEBUG}
else if LAction = 'writetest' then
WriteTest
{$ENDIF}
else if LAction = 'copy' then
begin