Technical Article

Atomic PDF Repair Output in Delphi: Rename and DACL Safety

PDF Library for Delphi publishes the output of RepairQDFFile through an internal writer, TPDFQDFFileWriter, that never opens the destination for writing: the repaired bytes go into an exclusively created temporary file in the same directory, the file is flushed and closed, and only then is it renamed over the target with MoveFileExW on Windows or rename(2) on POSIX. If anything fails before the rename, the destination keeps every byte it had, and the caller sees LastErrorCode 305. Repairing a document in memory is the easy half of a repair feature. Getting the result onto disk without ever leaving the user with a zero-length or half-written file is the half this article is about

Why can a repair that fails still destroy the target file?

Because the order of operations was wrong. Before v3.539.13, RepairQDFFile opened the output with PLCreateFileStream(OutputFileName, fmCreate) and then handed that stream to the parser. fmCreate truncates on open, so by the time the QDF scan decided the input was not repairable, the destination had already been emptied. In-place repair, where InputFileName and OutputFileName are the same path, turned a rejected input into a lost file. The parser itself was well behaved: the low-level PDFQDFRepair function keeps the target stream untouched when it rejects ambiguous markers. That protection was simply irrelevant, because the public API had truncated the file one call earlier

The v3.539.13 fix moved the repair into a TMemoryStream and opened the output only after PDFQDFRepair had succeeded. That closes the parse-failure hole and nothing else. The write phase was still fmCreate followed by CopyFrom, so a disk-full condition, a sharing violation halfway through, or an exception between truncation and the last WriteBuffer still left a damaged destination. Memory-first repair protects against bad input. Disk publication needs its own boundary, and v3.539.14 and v3.539.15 built one

How RepairQDFFile in PDF Library for Delphi stopped destroying its own target: v3.539.12 opened the output with PLCreateFileStream and fmCreate, which truncates before PDFQDFRepair can reject the input, v3.539.13 repaired into a TMemoryStream first, and v3.539.15 hands the bytes to TPDFQDFFileWriter for atomic publication
The parse-failure fix and the publication fix are different boundaries: memory-first repair protects against bad input, while the writer exists so a full disk or a failure halfway through a write can no longer leave the destination damaged
// v3.539.12: the destination is truncated before the input is validated
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // too late to say no
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15: repair in memory, then hand the bytes to the publication writer
Repaired := TMemoryStream.Create;
try
  if not PDFQDFRepair(Source, Repaired, QDFError) then
    Exit;                                          // destination never opened
  Writer := TPDFQDFFileWriter.Create;
  try
    Writer.Save(Repaired, OutputFileName);
    Result := 1;
  finally
    Writer.Free;
  end;
finally
  Repaired.Free;
end;

What does atomic publication actually guarantee?

TPDFQDFFileWriter.Save guarantees that the destination path is either the complete old file or the complete new file, never a mixture, for every failure the library itself can observe. The writer does this in four steps that each refuse to proceed unless the previous one finished. First it resolves the destination with GetFullPathNameW, calling it twice and allocating the buffer from the returned length rather than assuming MAX_PATH, so long paths are not silently cut. Second it creates a temporary file named .pdflib-qdf- plus a GUID plus .tmp in the destination directory, using CreateFileW with CREATE_NEW on Windows and open(2) with O_CREAT or O_EXCL and mode 0600 on POSIX. Both flags make the create fail if the name already exists, so two processes racing on the same GUID cannot share a handle. Third it copies the repaired stream in 64 KiB chunks through WriteBuffer, which raises on a short write instead of returning a count nobody checks, then calls FlushFileBuffers or fsync(2) and closes the handle. Fourth it renames

The four atomic steps of TPDFQDFFileWriter.Save in PDF Library for Delphi: resolve the path twice with GetFullPathNameW, create the .pdflib-qdf temporary file with CREATE_NEW or O_EXCL so racing processes cannot share a handle, copy in 64 KiB WriteBuffer chunks and flush, then MoveFileExW with REPLACE_EXISTING and WRITE_THROUGH
Each step refuses to proceed unless the previous one finished, the temporary file lives on the destination volume by construction, a delete-first window never exists, and the cleanup in a finally leaves no .tmp debris behind
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
  // Do not permit a cross-volume copy or delete the destination first
  if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
    MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
    raise EWriteError.Create('Unable to publish QDF output');
end;

The rename step is where most home-grown "safe save" routines quietly break. MoveFileExW with MOVEFILE_REPLACE_EXISTING replaces the target in one filesystem operation on the same volume. The writer deliberately leaves out MOVEFILE_COPY_ALLOWED, because a cross-volume move degrades into copy-then-delete, which is precisely the non-atomic sequence the whole design exists to avoid. Since the temporary file lives in the destination directory, it is on the destination volume by construction. The writer also never deletes the old file first; a delete-then-rename pair has a window in which the path does not exist at all, and a crash inside that window loses the document. MOVEFILE_WRITE_THROUGH asks the call not to return until the rename has reached disk, which pairs with the explicit flush of the data. On POSIX, rename(2) already guarantees that the new name atomically replaces any existing file, and the same directory placement keeps it from failing with EXDEV. Cleanup is symmetric. The temporary name is removed in a finally block on every path, which on success is a no-op because the rename has already consumed it, and on failure removes the partial file so the directory does not accumulate .tmp debris. The regression in Tests\QDFFileRegression.inc checks exactly that: after every injected failure, the destination bytes match the original, the source bytes match the original, and the directory contains nothing but the two fixtures

Why does a temporary file loosen permissions on Windows?

A file created with a nil security descriptor inherits its DACL from the parent directory, not from the file it is about to replace. That is the correct default for a brand-new document and the wrong one for an in-place repair. Suppose an operator has locked contract.pdf down to a single account with a protected, non-inherited DACL. A temporary file next to it inherits the directory's broader permissions, and once it is renamed over contract.pdf the renamed file carries the broad DACL, because NTFS security travels with the file object, not with the name. The repair succeeds, the bytes are right, and the access control the operator configured is silently gone. Nothing in the return value hints at it

PDF Library for Delphi therefore reads the destination's DACL before creating the temporary file and passes it in as the lpSecurityAttributes argument to CreateFileW, so the new file is born with the old file's permissions and the rename changes nothing the operator would notice. The read uses GetFileSecurityW with DACL_SECURITY_INFORMATION, sizing the buffer from the first call's ERROR_INSUFFICIENT_BUFFER result. Three conditions make the writer fail closed rather than guess. If the DACL cannot be read, publication stops with an EWriteError, which the public API maps to 305. If the descriptor comes back without SE_DACL_PRESENT set, publication also stops, because passing such a descriptor to CreateFileW would let the kernel fall back to the process default DACL and change access semantics without anyone asking for it. And if the target carries FILE_ATTRIBUTE_ENCRYPTED, the writer refuses outright: the temporary file would be plaintext, and renaming a plaintext file over an EFS-protected one publishes an unencrypted replacement of something the user chose to encrypt at the filesystem level. EFS is unrelated to PDF standard security handlers, which are the subject of the article on encrypted document loading, but the failure mode is the same kind of quiet downgrade

Why the QDF publication writer copies the destination DACL before creating its temporary file: a nil descriptor would inherit the directory's broader permissions and the rename would silently widen access, so GetFileSecurityW reads the DACL, a missing SE_DACL_PRESENT bit or an EFS attribute stops publication with 305, and CreateFileW is born with the old permissions
NTFS security travels with the file object, not with the name: passing the read descriptor as lpSecurityAttributes makes the rename change nothing the operator configured, and every gate fails closed rather than guessing
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');
  // size the descriptor, then read only the DACL portion of it
  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;   // handed to CreateFileW / CREATE_NEW
end;

One detail from the regression is worth keeping in mind if you write a similar test yourself. To build the restricted fixture, the test applies an owner-only DACL and must set SE_DACL_PROTECTED in the descriptor control explicitly; merely passing the protected flag in the SecurityInformation argument of SetFileSecurityW does not turn an unprotected descriptor into a protected one. The assertion afterwards is that the published file still reports the protected bit and an explicit, non-null DACL, both for a separate output path and for repair over the source file itself

Which LastErrorCode tells you what failed?

RepairQDFFile returns 1 on success and 0 on any failure, and LastErrorCode says which stage refused. A source that cannot be read, including one another process holds with an exclusive lock, reports 401; the read is now wrapped so that an exception during input maps to 401 rather than leaking into the write error. Invalid or ambiguous QDF structure, such as a duplicated stream marker for the same object, reports PDFLIB_ERROR_QDF_REPAIR, which is 107, and the destination has not been touched because the writer was never constructed. Everything after the repair, from temporary file creation through flush and rename, reports PDFLIB_ERROR_QDF_WRITE, which is 305. The regression exercises the realistic ones: a destination opened by another handle without delete sharing, a read-only destination, a missing destination directory, and each of the three writer stages failing through injection. In all of them the return is 0, the code is 305, and no new or partial target exists afterwards. The general habit of reading the code instead of just the return value is the same one described in the article on diagnosing silent failures in the library

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // In-place repair: the same path is input and output
    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;

Where the guarantee stops

The writer promises consistency against failures the process can see, and it is honest about the ones it cannot. If the process is killed between creating the temporary file and the rename, the finally block never runs and a .pdflib-qdf-<GUID>.tmp file is left in the directory; the destination is still intact, which is the property that matters, but the debris is yours to sweep. Power loss is outside the promise as well: the data is flushed and the rename is write-through, which is the best a user-mode library can ask for, but the writer does not fsync the directory entry and makes no durability claim on top of what the filesystem provides. A second writer that modifies the destination concurrently is not detected, because the DACL and attributes are read before the temporary file is created and nothing re-checks them at rename time. And a successful rename creates a new file identity, so alternate data streams and ordinary attributes such as the archive or hidden bit on the old file do not survive; only the DACL is carried across deliberately

The narrower boundary is which API even uses this path. Only RepairQDFFile goes through TPDFQDFFileWriter. SaveQDFToFile and ConvertFileToQDF still open their output with PLCreateFileStream(FileName, fmCreate) and stream the QDF conversion straight into it, the same way the incremental path described in the article on appending updates to a stream writes to whatever stream you hand it. Those two calls are producing a new debugging artefact from a document that has already been loaded and validated, so the parse-failure hole never applied to them, but they do not inherit the rename-based publication either. Do not read this article as "every QDF export is atomic". It is one exit, the one whose input is an untrusted, hand-edited file and whose output is routinely the same path, and that combination is what earned it the extra machinery. The fault injection that proves all of this is cheap because the writer's three stages, WriteData, Flush and Publish, are virtual. The test subclass overrides one of them to raise after the real work has started, calls Save on a repaired stream, and asserts that the exception propagates, that the source and destination bytes are unchanged, and that no temporary file remains. No global file API is hooked, no real user file is touched, and the three stages map one-to-one onto the three ways a publication can fail in production: the disk fills, the flush is rejected, or the rename is refused because someone else holds the target

The RepairQDFFile API, its atomic publication writer and the rest of the QDF debugging workflow are part of the PDF Library for Delphi, alongside the cross-reference recovery, incremental update and encryption features covered elsewhere on this blog