Article technique

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

Éditez un fichier QDF à la main, glissez un second marqueur de flux pour le même objet, puis lancez une réparation en place avec le code Delphi le plus naturel qui soit. L’étape de réparation remarque le dégât et refuse. Quand vous regardez le fichier ensuite, il fait 0 octet, parce que TFileStream.Create(OutputFileName, fmCreate) a tronqué ce chemin même une ligne avant que le validateur n’ait jamais vu l’entrée. Réparer des octets en mémoire est la moitié facile du travail. Les remettre sur disque sans jamais laisser un fichier de longueur nulle, à moitié écrit ou mal permissionné est l’autre moitié, et elle a ses propres modes de défaillance

Pourquoi une réparation en place vide-t-elle le fichier ?

Une réparation en place vide le fichier parce que l’ordre des opérations est faux : fmCreate tronque à l’ouverture, donc tout refus qui arrive après l’ouverture arrive trop tard. Quand InputFileName et OutputFileName sont le même chemin, une entrée rejetée devient un document perdu. Un analyseur soigneux ne peut pas vous sauver ici. Il peut laisser son flux de sortie intact quand il rejette des marqueurs ambigus, mais l’appelant a déjà détruit la cible un appel plus tôt

// La version évidente : la destination est tronquée avant que quoi que ce soit soit validé
Input := TMemoryStream.Create;
try
  Input.LoadFromFile(InputFileName);                       // même chemin que OutputFileName
  Output := TFileStream.Create(OutputFileName, fmCreate);  // le fichier fait maintenant 0 octet
  try
    if not MyRepairQDF(Input, Output) then  // votre propre validateur : trop tard pour dire non
      raise Exception.Create('QDF structure rejected');
  finally
    Output.Free;
  end;
finally
  Input.Free;
end;

La tentative suivante habituelle est de réparer dans un TMemoryStream et de n’ouvrir la sortie qu’après le succès de la réparation. Cela bouche le trou de la mauvaise entrée et rien d’autre. La phase d’écriture reste tronquer-puis-copier, donc un disque plein, une violation de partage à mi-course ou une exception entre la troncature et le dernier WriteBuffer laissent encore la destination endommagée. L’analyse et la publication sont deux frontières séparées et chacune a besoin de sa propre protection

Dans PDF Library for Delphi, TPDFlib.RepairQDFFile(InputFileName, OutputFileName) s’occupe des deux. Il lit l’entrée, la répare entièrement en mémoire, et retourne tôt sans rien avoir ouvert sur la destination si la structure QDF est rejetée. Seule une réparation réussie passe à la publication, et la publication n’ouvre jamais la destination en écriture

Trois façons de publier une réparation QDF en Delphi : ouvrir la sortie avec fmCreate la tronque avant que la réparation puisse rejeter l’entrée, réparer d’abord dans un TMemoryStream ne protège que contre une mauvaise entrée, et RepairQDFFile publie les octets réparés via un fichier temporaire et un renommage atomique
La sûreté de l’analyse et celle de la publication sont des 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 à mi-course d’écriture ne puisse laisser la destination endommagée

Quatre étapes entre les octets réparés et la destination

RepairQDFFile garantit que le chemin de destination contient soit le fichier ancien complet, soit le fichier nouveau complet, pour toute défaillance que le process lui-même peut observer. Il y parvient en quatre étapes, et aucune ne démarre tant que la précédente n’est pas terminée. La destination est résolue en chemin complet avec GetFullPathNameW, appelé une fois pour apprendre la longueur requise et une fois dans un tampon d’exactement cette taille, pour qu’un chemin long ne soit pas coupé à MAX_PATH. Ensuite un fichier temporaire nommé .pdflib-qdf- plus un GUID plus .tmp est créé dans le répertoire de destination avec CreateFileW et CREATE_NEW sous Windows, ou 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à. Deux process qui tireraient le même GUID ne peuvent pas finir par partager un handle

Le flux réparé est ensuite copié 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. FlushFileBuffers ou fsync(2) pousse les données vers le bas, et le handle est fermé. La fermeture ne lève délibérément pas : une fois le vidage réussi, une fermeture en échec ne menace plus les données, et une exception là cacherait la vraie erreur d’écriture ou de vidage à celui qui doit diagnostiquer. La dernière étape est le renommage

Les quatre étapes atomiques de la publication 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

Le renommage est l’endroit où la plupart des routines de sauvegarde sûre maison cassent. Sous Windows, le fichier temporaire remplace la cible via MoveFileExW avec MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH, ce qui sur un seul volume est une seule opération du système de fichiers. MOVEFILE_COPY_ALLOWED est laissé de côté à dessein. Un déplacement entre volumes dégénère en silence en copie-puis-suppression, la séquence non atomique exacte que toute la conception évite, et je préfère de loin voir un renommage refusé qu’une copie « réussie » qui était à moitié faite quand la machine est tombée. Comme le fichier temporaire siège dans le répertoire de destination, il est de toute façon sur le volume de destination. L’ancien fichier n’est jamais supprimé d’abord non plus ; un couple suppression-puis-renommage laisse une fenêtre pendant laquelle le chemin n’existe pas, et un crash dedans perd le document. MOVEFILE_WRITE_THROUGH empêche l’appel de revenir avant que le renommage ait atteint le disque. Sous POSIX, rename(2) remplace déjà un nom existant de façon atomique, et le placement dans le même répertoire le tient à l’écart d’EXDEV

Le nettoyage tourne dans un bloc finally sur tous les chemins. Après un renommage réussi, le nom temporaire n’existe plus, donc la suppression ne fait rien ; après un échec, il retire le fichier partiel. Le répertoire finit avec exactement les fichiers qu’il avait avant l’appel

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 ; le fichier qu’il s’apprête à remplacer n’a pas son mot à dire. Pour un document tout neuf, ce défaut est le bon. Pour une réparation en place, il est faux. Imaginez un opérateur qui a verrouillé contract.pdf sur un seul compte avec une DACL protégée et non héritée. Un fichier temporaire ordinaire à côté ramasse les permissions plus larges du répertoire, et après le renommage le contract.pdf publié porte cette DACL large, parce que la sécurité NTFS appartient à l’objet fichier plutôt qu’au nom. Les octets sont corrects et la valeur de retour vaut 1. La restriction d’accès que l’opérateur avait posée a simplement disparu

RepairQDFFile lit donc la DACL de la destination avant de créer le fichier temporaire et la passe à CreateFileW via lpSecurityAttributes, pour que le nouveau fichier naisse avec les permissions de l’ancien fichier et que le renommage ne change rien que l’opérateur remarquerait. La lecture est un GetFileSecurityW avec DACL_SECURITY_INFORMATION, le tampon dimensionné d’après la réponse ERROR_INSUFFICIENT_BUFFER du premier appel. Quand la destination n’existe pas encore, ce qui est le cas normal pour un chemin de sortie séparé, aucun descripteur n’est passé et le nouveau fichier hérite de son répertoire comme tout nouveau fichier

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

Trois conditions arrêtent la publication au lieu de laisser le code deviner, et chacune rapporte LastErrorCode 305 :

  • La DACL ne peut pas être lue, par exemple parce que le compte appelant manque de READ_CONTROL sur la destination
  • Le descripteur renvoyé n’a pas SE_DACL_PRESENT positionné ; confier un tel descripteur à CreateFileW laisserait Windows retomber sur la DACL par défaut du process et changer une sémantique d’accès que personne n’a demandé de changer
  • La destination porte FILE_ATTRIBUTE_ENCRYPTED ; le fichier temporaire serait en clair, et le renommer par-dessus un fichier protégé par EFS publierait un remplacement non chiffré de quelque chose que l’utilisateur avait choisi de chiffrer

EFS est une fonctionnalité du système de fichiers et n’a rien à voir avec les gestionnaires de sécurité PDF, qui sont couverts dans l’article sur le chargement des documents chiffrés. La forme de l’échec est le même genre de déclassement silencieux, pourtant, et le refuser est le bon défaut. Une réparation qui dit parfois non sur un partage EFS est un ticket de support. Une réparation qui déchiffre un fichier que l’utilisateur a chiffré est un incident

Si vous écrivez vous-même un test de préservation de DACL, un détail Windows est facile à rater. Pour construire le fichier de test restreint, appliquez une DACL réservée au propriétaire et positionnez SE_DACL_PROTECTED dans le contrôle du descripteur explicitement ; passer PROTECTED_DACL_SECURITY_INFORMATION à SetFileSecurityW ne transforme pas un descripteur non protégé en descripteur protégé. Ensuite, vérifiez que le fichier publié rapporte toujours le bit protégé et une DACL explicite non nulle, une fois pour un chemin de sortie séparé et une fois pour une réparation par-dessus la source elle-même

Quel LastErrorCode vous dit ce qui a échoué ?

RepairQDFFile renvoie 1 en cas de succès et 0 en cas d’échec, et TPDFlib.LastErrorCode nomme l’étape qui a refusé. Une entrée qui n’existe pas, ne peut pas être ouverte, ou dépasse 2 Go rapporte 401, et une exception pendant la lecture est aussi ramenée à 401 au lieu de fuir dans le code d’écriture. Une structure QDF invalide ou ambiguë, comme ce marqueur de flux dupliqué, rapporte PDFLIB_ERROR_QDF_REPAIR (107) ; la destination n’a jamais été ouverte. Tout ce qui suit la réparation, de la résolution du chemin au vidage et au renommage, rapporte PDFLIB_ERROR_QDF_WRITE (305). Les échecs d’écriture réalistes atterrissent tous sur 305 : une destination tenue ouverte par un autre handle sans partage de suppression, une destination en lecture seule, un répertoire de destination manquant, plus les refus DACL et EFS ci-dessus. Dans chaque cas 305, aucune cible nouvelle ou partielle n’est laissée derrière

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');
        PDFLIB_ERROR_QDF_REPAIR:
          Log('QDF structure rejected; the destination was never opened');
        PDFLIB_ERROR_QDF_WRITE:
          Log('write, flush or replace refused; the destination keeps its old bytes');
      end;
  finally
    Pdf.Free;
  end;
end;

Le chemin 305 est facile à reproduire depuis votre propre projet de test sans hooker aucune API de fichier. Le fmShareDenyNone de Delphi ouvre un fichier avec partage lecture et écriture mais sans FILE_SHARE_DELETE, et Windows refuse de renommer quoi que ce soit par-dessus un fichier tenu ainsi. Gardez un tel handle ouvert pendant l’appel et le renommage échoue à la toute dernière étape, après que les données ont été écrites et vidées, ce qui est le cas que vous voulez le plus voir géré

var
  Before: TBytes;
  Holder: TFileStream;
  Search: TSearchRec;
begin
  Before := TFile.ReadAllBytes(Target);   // System.IOUtils
  Holder := TFileStream.Create(Target, fmOpenRead or fmShareDenyNone);
  try
    Assert(Pdf.RepairQDFFile(Source, Target) = 0);
    Assert(Pdf.LastErrorCode = PDFLIB_ERROR_QDF_WRITE);
  finally
    Holder.Free;
  end;
  // Ces vérifications sont des suggestions pour votre propre test, pas des appels de bibliothèque
  Assert(BytesEqual(Before, TFile.ReadAllBytes(Target)));  // BytesEqual : votre helper
  // Aucun fichier temporaire ne doit rester
  if FindFirst(ExtractFilePath(Target) + '.pdflib-qdf-*.tmp',
    faAnyFile, Search) = 0 then
  try
    Assert(False, 'temporary file left behind: ' + Search.Name);
  finally
    FindClose(Search);
  end;
end;

Pour l’habitude de lire le code au lieu de faire confiance au 0 seul, l’article sur le diagnostic des échecs silencieux parcourt le même schéma sur le reste de l’API

Où la garantie s’arrête

La promesse couvre les défaillances que le process peut voir. Si le process est tué entre la création du fichier temporaire et le renommage, le bloc finally ne tourne jamais et un fichier .pdflib-qdf-<GUID>.tmp reste dans le répertoire. La destination reste intacte, ce qui est ce qui compte, mais balayer ces débris est pour vous. La coupure de courant est une question de système de fichiers : les données sont vidées et le renommage est en write-through, pourtant l’entrée de répertoire n’est pas syncée séparément sous POSIX, donc la durabilité est ce que le système de fichiers fournit par-dessus

Un renommage réussi produit aussi un nouvel objet fichier, et quelques propriétés de l’ancien ne voyagent pas avec. La DACL est transportée délibérément. La propriété ne l’est pas, puisque seul DACL_SECURITY_INFORMATION est lu, donc le fichier publié appartient au compte qui a lancé la réparation. Le fichier temporaire est créé avec FILE_ATTRIBUTE_NORMAL, ce qui veut dire que les bits caché, archive et similaires repartent à zéro, et les flux de données alternatifs de l’ancien fichier sont perdus. Sous POSIX, le fichier publié garde le mode 0600 du fichier temporaire ; appelez chmod ensuite si un groupe a besoin d’un accès en lecture. Les attributs et la DACL de la destination sont lus avant que le fichier temporaire existe, donc un second écrivain sur le même chemin est à coordonner par vous

SaveQDFToFile et ConvertFileToQDF écrivent directement

RepairQDFFile est le seul point d’entrée QDF qui publie via un fichier temporaire. SaveQDFToFile et ConvertFileToQDF ouvrent leur sortie avec fmCreate et y envoient la conversion en flux direct, un peu comme le chemin incrémental de l’article sur l’ajout de mises à jour à un flux écrit dans le flux que vous fournissez. La scission est intentionnelle. Ces deux appels transforment un document déjà chargé et validé en un nouvel artefact de débogage, donc il n’y a aucune entrée non fiable qui pourrait être rejetée à mi-course, et leur chemin de sortie est d’ordinaire un fichier neuf. RepairQDFFile prend un fichier édité à la main, non fiable, et se voit couramment repointé vers le même chemin ; c’est cette combinaison qui justifie la machinerie supplémentaire

Quand vous voulez quand même un remplacement atomique pour un dump QDF, un court wrapper à vous suffit :

// Votre propre helper : écrire le QDF à côté de la cible, puis le remplacer en un renommage
function SaveQDFReplacing(Pdf: TPDFlib; const FileName: string): Boolean;
var
  ID: TGUID;
  TempName: string;
begin
  CreateGUID(ID);
  TempName := ExtractFilePath(ExpandFileName(FileName)) +
    '.myapp-qdf-' + GUIDToString(ID) + '.tmp';
  try
    Result := (Pdf.SaveQDFToFile(TempName) = 1) and
      MoveFileExW(PWideChar(WideString(TempName)), PWideChar(WideString(FileName)),
        MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH);
  finally
    if FileExists(TempName) then
      DeleteFile(TempName);   // présent seulement quand quelque chose a échoué
  end;
end;

Cette version saute la copie de DACL et le vidage explicite des données que RepairQDFFile effectue. Pour des dumps QDF qui atterrissent dans un dossier de travail ou de débogage, je pense que c’est la bonne quantité de machinerie ; si le dump remplace un fichier aux permissions verrouillées, passez par le chemin de réparation ou copiez le descripteur de sécurité vous-même

La sortie de réparation atomique, les appels de conversion QDF et les codes d’erreur ci-dessus sont tous livrés dans PDF Library for Delphi, à côté de la récupération de références croisées, de la mise à jour incrémentale et de la prise en charge du chiffrement traitées dans d’autres articles ici