Article technique

Publication atomique d'un QDF en Delphi : garder la DACL

PDF Library for Delphi publie la sortie de RepairQDFFile via un writer interne, TPDFQDFFileWriter, qui n'ouvre jamais la destination en écriture : les octets réparés partent dans un fichier temporaire créé en exclusivité dans le même répertoire, le fichier est vidé puis fermé, et seulement alors il est renommé par-dessus la cible avec MoveFileExW sous Windows ou rename(2) sous POSIX. Si quoi que ce soit échoue avant le renommage, la destination garde chaque octet qu'elle avait, et l'appelant voit LastErrorCode 305. Réparer un document en mémoire est la moitié facile d'une fonction de réparation. Déposer le résultat sur disque sans jamais laisser à l'utilisateur un fichier de longueur nulle ou à moitié écrit est la moitié dont traite cet article

Pourquoi une réparation qui échoue peut-elle quand même détruire le fichier cible ?

Parce que l'ordre des opérations était faux. Avant la v3.539.13, RepairQDFFile ouvrait la sortie avec PLCreateFileStream(OutputFileName, fmCreate) puis remettait ce flux à l'analyseur. fmCreate tronque à l'ouverture, donc au moment où le balayage QDF décidait que l'entrée n'était pas réparable, la destination était déjà vidée. La réparation en place, où InputFileName et OutputFileName sont le même chemin, transformait une entrée rejetée en fichier perdu. L'analyseur lui-même était correct : la fonction bas niveau PDFQDFRepair laisse le flux cible intact quand elle rejette des marqueurs ambigus. Cette protection était simplement sans objet, parce que l'API publique avait tronqué le fichier un appel plus tôt

Le correctif de la v3.539.13 a déplacé la réparation dans un TMemoryStream et n'ouvrait la sortie qu'après le succès de PDFQDFRepair. Cela bouche le trou de l'échec d'analyse, et rien de plus. La phase d'écriture restait fmCreate suivi de CopyFrom, donc un disque plein, une violation de partage en cours de route ou une exception entre la troncature et le dernier WriteBuffer laissait encore une destination endommagée. La réparation en mémoire protège contre une mauvaise entrée. La publication sur disque a besoin de sa propre frontière, et les v3.539.14 et v3.539.15 en ont construit une

Comment RepairQDFFile de PDF Library for Delphi a cessé de détruire sa propre cible : la v3.539.12 ouvrait la sortie avec PLCreateFileStream et fmCreate, qui tronque avant que PDFQDFRepair puisse rejeter l'entrée, la v3.539.13 réparait d'abord dans un TMemoryStream, et la v3.539.15 remet les octets à TPDFQDFFileWriter pour une publication atomique
Le correctif de l'échec d'analyse et celui de la publication sont deux frontières différentes : la réparation en mémoire protège contre une mauvaise entrée, tandis que le writer existe pour qu'un disque plein ou un échec en cours d'écriture ne puisse plus laisser la destination endommagée
// v3.539.12 : la destination est tronquée avant que l'entrée soit validée
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // trop tard pour dire non
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15 : réparer en mémoire, puis remettre les octets au writer de publication
Repaired := TMemoryStream.Create;
try
  if not PDFQDFRepair(Source, Repaired, QDFError) then
    Exit;                                          // destination jamais ouverte
  Writer := TPDFQDFFileWriter.Create;
  try
    Writer.Save(Repaired, OutputFileName);
    Result := 1;
  finally
    Writer.Free;
  end;
finally
  Repaired.Free;
end;

Que garantit réellement une publication atomique ?

TPDFQDFFileWriter.Save garantit que le chemin de destination est soit le fichier ancien complet, soit le fichier nouveau complet, jamais un mélange, pour toute défaillance que la bibliothèque peut observer elle-même. Le writer le fait en quatre étapes dont chacune refuse d'avancer si la précédente n'est pas terminée. D'abord il résout la destination avec GetFullPathNameW, en l'appelant deux fois et en dimensionnant le tampon d'après la longueur renvoyée plutôt qu'en supposant MAX_PATH, pour que les chemins longs ne soient pas coupés en silence. Ensuite il crée un fichier temporaire nommé .pdflib-qdf- plus un GUID plus .tmp dans le répertoire de destination, via CreateFileW avec CREATE_NEW sous Windows et open(2) avec O_CREAT or O_EXCL et le mode 0600 sous POSIX. Les deux drapeaux font échouer la création si le nom existe déjà, donc deux processus qui se disputent le même GUID ne peuvent pas partager un handle. Ensuite il copie le flux réparé par blocs de 64 Kio via WriteBuffer, qui lève une erreur sur une écriture courte au lieu de renvoyer un compte que personne ne vérifie, puis appelle FlushFileBuffers ou fsync(2) et ferme le handle. Enfin il renomme

Les quatre étapes atomiques de TPDFQDFFileWriter.Save dans PDF Library for Delphi : résoudre le chemin deux fois avec GetFullPathNameW, créer le fichier temporaire .pdflib-qdf avec CREATE_NEW ou O_EXCL pour que des processus concurrents ne partagent pas de handle, copier par blocs WriteBuffer de 64 Kio et vider, puis MoveFileExW avec REPLACE_EXISTING et WRITE_THROUGH
Chaque étape refuse d'avancer si la précédente n'est pas terminée, le fichier temporaire vit sur le volume de destination par construction, aucune fenêtre de suppression préalable n'existe, et le nettoyage dans un finally ne laisse aucun débris .tmp
procedure TPDFQDFFileWriter.Flush(Target: TStream);
begin
  if not FlushFileBuffers(THandleStream(Target).Handle) then
    raise EWriteError.Create('Unable to flush QDF output');
end;

procedure TPDFQDFFileWriter.Publish(const TempFileName, FileName: WideString);
begin
  // Interdire la copie entre volumes et la suppression préalable de la destination
  if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
    MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
    raise EWriteError.Create('Unable to publish QDF output');
end;

L'étape de renommage est celle où la plupart des routines « sauvegarde sûre » maison cassent en silence. MoveFileExW avec MOVEFILE_REPLACE_EXISTING remplace la cible en une seule opération du système de fichiers sur le même volume. Le writer omet délibérément MOVEFILE_COPY_ALLOWED, parce qu'un déplacement entre volumes dégénère en copie puis suppression, ce qui est précisément la séquence non atomique que toute la conception vise à éviter. Comme le fichier temporaire vit dans le répertoire de destination, il est sur le volume de destination par construction. Le writer ne supprime pas non plus l'ancien fichier d'abord ; un couple suppression-puis-renommage a une fenêtre pendant laquelle le chemin n'existe plus du tout, et un crash dans cette fenêtre perd le document. MOVEFILE_WRITE_THROUGH demande à l'appel de ne pas revenir avant que le renommage ait atteint le disque, ce qui va de pair avec le vidage explicite des données. Sous POSIX, rename(2) garantit déjà que le nouveau nom remplace atomiquement tout fichier existant, et le même placement dans le répertoire évite l'échec en EXDEV. Le nettoyage est symétrique. Le nom temporaire est supprimé dans un bloc finally sur tous les chemins, ce qui en cas de succès ne fait rien puisque le renommage l'a déjà consommé, et en cas d'échec retire le fichier partiel pour que le répertoire n'accumule pas de débris .tmp. La régression dans Tests\QDFFileRegression.inc vérifie exactement cela : après chaque défaillance injectée, les octets de la destination correspondent à l'original, ceux de la source correspondent à l'original, et le répertoire ne contient rien d'autre que les deux fixtures

Pourquoi un fichier temporaire assouplit-il les permissions sous Windows ?

Un fichier créé avec un descripteur de sécurité nil hérite sa DACL du répertoire parent, pas du fichier qu'il s'apprête à remplacer. C'est le bon défaut pour un document tout neuf et le mauvais pour une réparation en place. Supposez qu'un opérateur ait verrouillé contract.pdf sur un seul compte avec une DACL protégée et non héritée. Un fichier temporaire à côté hérite des permissions plus larges du répertoire, et une fois renommé par-dessus contract.pdf, le fichier renommé porte la DACL large, parce que la sécurité NTFS voyage avec l'objet fichier, pas avec le nom. La réparation réussit, les octets sont bons, et le contrôle d'accès configuré par l'opérateur a silencieusement disparu. Rien dans la valeur de retour ne le laisse deviner

PDF Library for Delphi lit donc la DACL de la destination avant de créer le fichier temporaire et la passe en argument lpSecurityAttributes à CreateFileW, pour que le nouveau fichier naisse avec les permissions de l'ancien et que le renommage ne change rien que l'opérateur remarquerait. La lecture utilise GetFileSecurityW avec DACL_SECURITY_INFORMATION, en dimensionnant le tampon d'après le résultat ERROR_INSUFFICIENT_BUFFER du premier appel. Trois conditions font échouer le writer en mode fermé plutôt que de deviner. Si la DACL ne peut pas être lue, la publication s'arrête avec une EWriteError, que l'API publique ramène à 305. Si le descripteur revient sans SE_DACL_PRESENT positionné, la publication s'arrête aussi, parce que passer un tel descripteur à CreateFileW laisserait le noyau retomber sur la DACL par défaut du processus et changer la sémantique d'accès sans que personne ne l'ait demandé. Et si la cible porte FILE_ATTRIBUTE_ENCRYPTED, le writer refuse d'emblée : le fichier temporaire serait en clair, et renommer un fichier en clair par-dessus un fichier protégé par EFS publie un remplacement non chiffré de quelque chose que l'utilisateur avait choisi de chiffrer au niveau du système de fichiers. EFS n'a rien à voir avec les gestionnaires de sécurité standard PDF, qui font l'objet de l'article sur le chargement des documents chiffrés, mais le mode de défaillance est le même genre de déclassement silencieux

Pourquoi le writer de publication QDF copie la DACL de la destination avant de créer son fichier temporaire : un descripteur nil hériterait des permissions plus larges du répertoire et le renommage élargirait l'accès en silence, donc GetFileSecurityW lit la DACL, un bit SE_DACL_PRESENT absent ou un attribut EFS arrête la publication avec 305, et CreateFileW fait naître le fichier avec les anciennes permissions
La sécurité NTFS voyage avec l'objet fichier, pas avec le nom : passer le descripteur lu en lpSecurityAttributes fait que le renommage ne change rien de ce que l'opérateur a configuré, et chaque garde échoue en mode fermé plutôt que de deviner
Attributes := GetFileAttributesW(PWideChar(Destination));
if Attributes <> INVALID_FILE_ATTRIBUTES then
begin
  if (Attributes and FILE_ATTRIBUTE_ENCRYPTED) <> 0 then
    raise EWriteError.Create('QDF replacement of an EFS encrypted file is not supported');
  // dimensionner le descripteur, puis n'en lire que la partie DACL
  if not GetFileSecurityW(PWideChar(Destination), DACL_SECURITY_INFORMATION,
    @Security[0], SecuritySize, SecuritySize) then
    raise EWriteError.Create('Unable to read QDF destination permissions');
  if not QDFGetSecurityDescriptorControl(@Security[0], Control, Revision) or
     ((Control and SE_DACL_PRESENT) = 0) then
    raise EWriteError.Create('QDF destination has no explicit DACL');
  SecurityAttributes.lpSecurityDescriptor := @Security[0];
  SecurityPointer := @SecurityAttributes;   // remis à CreateFileW / CREATE_NEW
end;

Un détail de la régression mérite d'être gardé en tête si vous écrivez un test similaire. Pour construire la fixture restreinte, le test applique une DACL réservée au propriétaire et doit positionner explicitement SE_DACL_PROTECTED dans le contrôle du descripteur ; le simple fait de passer le drapeau protected dans l'argument SecurityInformation de SetFileSecurityW ne transforme pas un descripteur non protégé en descripteur protégé. L'assertion qui suit est que le fichier publié rapporte toujours le bit protected et une DACL explicite non nulle, à la fois pour un chemin de sortie distinct et pour une réparation par-dessus le fichier source lui-même

Quel LastErrorCode vous dit ce qui a échoué ?

RepairQDFFile renvoie 1 en cas de succès et 0 sur n'importe quel échec, et LastErrorCode dit quelle étape a refusé. Une source illisible, y compris une source qu'un autre processus détient avec un verrou exclusif, rapporte 401 ; la lecture est désormais enveloppée pour qu'une exception pendant l'entrée soit ramenée à 401 au lieu de fuir dans l'erreur d'écriture. Une structure QDF invalide ou ambiguë, comme un marqueur de flux dupliqué pour le même objet, rapporte PDFLIB_ERROR_QDF_REPAIR, qui vaut 107, et la destination n'a pas été touchée parce que le writer n'a jamais été construit. Tout ce qui suit la réparation, de la création du fichier temporaire au vidage et au renommage, rapporte PDFLIB_ERROR_QDF_WRITE, qui vaut 305. La régression exerce les cas réalistes : une destination ouverte par un autre handle sans partage de suppression, une destination en lecture seule, un répertoire de destination manquant, et chacune des trois étapes du writer échouant par injection. Dans tous, le retour vaut 0, le code vaut 305, et aucune cible nouvelle ou partielle n'existe ensuite. L'habitude générale de lire le code plutôt que seulement la valeur de retour est celle décrite dans l'article sur le diagnostic des échecs silencieux de la bibliothèque

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // Réparation en place : le même chemin est entrée et sortie
    if Pdf.RepairQDFFile('edited.qdf.pdf', 'edited.qdf.pdf') = 1 then
      Log('published; the previous bytes were replaced in one rename')
    else
      case Pdf.LastErrorCode of
        401: Log('could not read the input; it was not modified');
        107: Log('QDF structure rejected; the destination was never opened');
        305: Log('write, flush or replace failed; the destination still holds its old bytes');
      end;
  finally
    Pdf.Free;
  end;
end;

Où la garantie s'arrête

Le writer promet la cohérence face aux défaillances que le processus peut voir, et il est honnête sur celles qu'il ne peut pas voir. Si le processus est tué entre la création du fichier temporaire et le renommage, le bloc finally ne s'exécute jamais et un fichier .pdflib-qdf-<GUID>.tmp reste dans le répertoire ; la destination est toujours intacte, ce qui est la propriété qui compte, mais les débris sont à balayer par vous. Une coupure de courant sort elle aussi de la promesse : les données sont vidées et le renommage est en write-through, ce qu'une bibliothèque en mode utilisateur peut demander de mieux, mais le writer ne fsync pas l'entrée de répertoire et n'ajoute aucune prétention de durabilité au-delà de ce que fournit le système de fichiers. Un second writer qui modifierait la destination en parallèle n'est pas détecté, parce que la DACL et les attributs sont lus avant la création du fichier temporaire et que rien ne les revérifie au moment du renommage. Et un renommage réussi crée une nouvelle identité de fichier, donc les flux de données alternatifs et les attributs ordinaires comme le bit archive ou caché de l'ancien fichier ne survivent pas ; seule la DACL est reprise délibérément

La frontière plus étroite est de savoir quelle API utilise même ce chemin. Seul RepairQDFFile passe par TPDFQDFFileWriter. SaveQDFToFile et ConvertFileToQDF ouvrent toujours leur sortie avec PLCreateFileStream(FileName, fmCreate) et y écrivent la conversion QDF en flux direct, comme le chemin incrémental décrit dans l'article sur l'ajout de mises à jour à un flux écrit dans le flux que vous lui donnez. Ces deux appels produisent un nouvel artefact de débogage à partir d'un document déjà chargé et validé, donc le trou de l'échec d'analyse ne les concernait pas, mais ils n'héritent pas non plus de la publication par renommage. Ne lisez pas cet article comme « toute export QDF est atomique ». C'est une seule sortie, celle dont l'entrée est un fichier non fiable édité à la main et dont la sortie est couramment le même chemin, et c'est cette combinaison qui lui a valu la machinerie supplémentaire. L'injection de fautes qui prouve tout cela coûte peu, parce que les trois étapes du writer, WriteData, Flush et Publish, sont virtual. La sous-classe de test en surcharge une pour lever une exception une fois le vrai travail commencé, appelle Save sur un flux réparé, et vérifie que l'exception se propage, que les octets de la source et de la destination sont inchangés, et qu'aucun fichier temporaire ne subsiste. Aucune API de fichier globale n'est hookée, aucun vrai fichier utilisateur n'est touché, et les trois étapes correspondent une à une aux trois façons dont une publication peut échouer en production : le disque se remplit, le vidage est rejeté, ou le renommage est refusé parce que quelqu'un d'autre tient la cible

L'API RepairQDFFile, son writer de publication atomique et le reste du flux de débogage QDF font partie de PDF Library for Delphi, aux côtés des fonctions de récupération de références croisées, de mise à jour incrémentale et de chiffrement traitées ailleurs sur ce blog