PDF Library for Delphi는 RepairQDFFile의 출력을 내부 라이터 TPDFQDFFileWriter를 통해 게시하는데, 이 라이터는 대상 파일을 쓰기용으로 열지 않습니다. 복구한 바이트는 같은 디렉터리에 배타적으로 생성한 임시 파일에 들어가고, 파일을 flush하고 닫은 다음에야 Windows에서는 MoveFileExW로, POSIX에서는 rename(2)로 대상 위에 이름을 바꿔 겁니다. rename 전에 무엇이든 실패하면 대상 파일은 갖고 있던 바이트를 전부 유지하고, 호출자는 LastErrorCode 305를 봅니다. 문서를 메모리에서 복구하는 것은 복구 기능의 쉬운 절반입니다. 사용자에게 길이 0의 파일이나 절반만 쓰인 파일을 남기지 않고 결과를 디스크에 올리는 것이 이 글에서 다루는 나머지 절반입니다
실패한 복구가 대상 파일을 파괴할 수 있는 이유는?
작업 순서가 잘못되어 있었기 때문입니다. v3.539.13 이전에 RepairQDFFile은 PLCreateFileStream(OutputFileName, fmCreate)로 출력을 열고 그 스트림을 파서에 넘겼습니다. fmCreate는 열 때 잘라내므로, QDF 스캔이 입력을 복구할 수 없다고 판단할 무렵에는 대상이 이미 비워진 뒤였습니다. InputFileName과 OutputFileName이 같은 경로인 제자리 복구에서는 거부된 입력이 곧 잃어버린 파일이 되었습니다. 파서 자체는 얌전했습니다. 저수준 PDFQDFRepair 함수는 모호한 마커를 거부할 때 대상 스트림을 건드리지 않고 그대로 둡니다. 그 보호는 그냥 무의미했는데, 공개 API가 한 호출 먼저 파일을 잘라내 버렸기 때문입니다
v3.539.13의 수정은 복구를 TMemoryStream으로 옮기고 PDFQDFRepair가 성공한 뒤에만 출력을 열었습니다. 이는 파싱 실패로 인한 구멍을 막을 뿐, 그 이상은 아닙니다. 쓰기 단계는 여전히 fmCreate 다음에 CopyFrom이었으므로, 디스크 가득 참 상황이나 도중의 공유 위반, 잘라내기와 마지막 WriteBuffer 사이의 예외는 여전히 대상을 손상된 채로 남겼습니다. 메모리 우선 복구는 잘못된 입력을 막아 줍니다. 디스크 게시에는 그 나름의 경계가 필요했고, v3.539.14와 v3.539.15가 그것을 세웠습니다
// v3.539.12: 입력을 검증하기 전에 대상이 잘립니다
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // 거부하기엔 너무 늦습니다
Result := 1;
finally
Output.Free;
end;
// v3.539.15: 메모리에서 복구한 뒤 바이트를 게시 라이터에 넘깁니다
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // 대상은 열리지 않습니다
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
원자적 게시가 실제로 보장하는 것은?
TPDFQDFFileWriter.Save는 라이브러리 자체가 관찰할 수 있는 모든 실패에 대해 대상 경로가 완전한 옛 파일이거나 완전한 새 파일이고, 결코 뒤섞이지 않는다고 보장합니다. 라이터는 이를 네 단계로 수행하며, 각 단계는 앞 단계가 끝나지 않으면 진행을 거부합니다. 먼저 GetFullPathNameW로 대상을 해석하는데, 두 번 호출하고 MAX_PATH를 가정하는 대신 반환된 길이로 버퍼를 할당해서 긴 경로가 조용히 잘리지 않게 합니다. 둘째, 대상 디렉터리에 .pdflib-qdf-에 GUID와 .tmp를 붙인 이름의 임시 파일을 만듭니다. Windows에서는 CREATE_NEW로 CreateFileW를, POSIX에서는 O_CREAT or O_EXCL과 모드 0600으로 open(2)를 씁니다. 두 플래그 모두 이름이 이미 있으면 생성을 실패시키므로, 같은 GUID를 두고 경쟁하는 두 프로세스가 핸들을 공유할 수 없습니다. 셋째, 복구한 스트림을 WriteBuffer로 64 KiB 청크씩 복사합니다. 이 함수는 아무도 확인하지 않는 반환값을 주는 대신 짧은 쓰기에서 예외를 냅니다. 그다음 FlushFileBuffers나 fsync(2)를 호출하고 핸들을 닫습니다. 넷째, 이름을 바꿉니다
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
// 볼륨 간 복사도, 대상을 먼저 지우는 것도 허용하지 않습니다
if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
raise EWriteError.Create('Unable to publish QDF output');
end;
rename 단계는 직접 만든 "안전 저장" 루틴 대부분이 조용히 무너지는 자리입니다. MOVEFILE_REPLACE_EXISTING을 붙인 MoveFileExW는 같은 볼륨에서 파일 시스템 연산 한 번으로 대상을 교체합니다. 라이터는 MOVEFILE_COPY_ALLOWED를 의도적으로 뺐습니다. 볼륨 간 이동은 복사 후 삭제로 전락하고, 그것이 바로 이 설계 전체가 피하려는 비원자적 시퀀스이기 때문입니다. 임시 파일이 대상 디렉터리에 있으므로 구조적으로 대상 볼륨에 있습니다. 라이터는 옛 파일을 먼저 지우지도 않습니다. 삭제 후 rename 조합에는 경로가 아예 존재하지 않는 창이 생기고, 그 창 안에서 크래시가 나면 문서를 잃습니다. MOVEFILE_WRITE_THROUGH는 rename이 디스크에 도달할 때까지 호출이 반환하지 않기를 요청하며, 데이터의 명시적 flush와 짝을 이룹니다. POSIX에서는 rename(2)가 이미 새 이름이 기존 파일을 원자적으로 대체한다고 보장하고, 같은 디렉터리에 두는 배치 덕분에 EXDEV로 실패하지도 않습니다. 정리는 대칭적입니다. 임시 이름은 모든 경로에서 finally 블록으로 제거되는데, 성공 시에는 rename이 이미 소비했으므로 no-op이고, 실패 시에는 부분 파일을 지워 디렉터리에 .tmp 찌꺼기가 쌓이지 않게 합니다. Tests\QDFFileRegression.inc의 회귀 테스트는 정확히 이것을 검사합니다. 주입한 모든 실패 후에 대상 바이트가 원본과 같고, 소스 바이트가 원본과 같고, 디렉터리에 두 픽스처 외에는 아무것도 없어야 합니다
Windows에서 임시 파일이 권한을 느슨하게 만드는 이유는?
보안 서술자를 nil로 두고 만든 파일은 대체하려는 파일이 아니라 부모 디렉터리에서 DACL을 상속합니다. 새 문서에는 맞는 기본값이지만 제자리 복구에는 틀린 값입니다. 운영자가 contract.pdf를 상속되지 않는 보호된 DACL로 특정 계정 하나에만 잠가 두었다고 합시다. 그 옆에 만든 임시 파일은 디렉터리의 더 넓은 권한을 상속하고, 이것이 contract.pdf 위로 rename되면 이름이 바뀐 파일이 넓은 DACL을 달고 갑니다. NTFS 보안은 이름이 아니라 파일 객체를 따라가기 때문입니다. 복구는 성공하고, 바이트도 맞고, 운영자가 설정한 접근 제어는 조용히 사라집니다. 반환값 어디에도 그런 암시는 없습니다
그래서 PDF Library for Delphi는 임시 파일을 만들기 전에 대상의 DACL을 읽어 CreateFileW의 lpSecurityAttributes 인자로 넘깁니다. 새 파일이 옛 파일의 권한을 갖고 태어나므로, rename이 운영자가 알아챌 만한 것을 바꾸지 않습니다. 읽기는 DACL_SECURITY_INFORMATION으로 GetFileSecurityW를 쓰고, 첫 호출의 ERROR_INSUFFICIENT_BUFFER 결과로 버퍼 크기를 잡습니다. 세 가지 조건에서 라이터는 추측하는 대신 안전하게 실패합니다. DACL을 읽을 수 없으면 게시는 EWriteError와 함께 멈추고, 공개 API가 이를 305로 매핑합니다. 서술자가 SE_DACL_PRESENT 없이 돌아와도 게시는 멈춥니다. 그런 서술자를 CreateFileW에 넘기면 커널이 프로세스 기본 DACL로 되돌아가서 아무도 요청하지 않은 채 접근 의미가 바뀌기 때문입니다. 그리고 대상이 FILE_ATTRIBUTE_ENCRYPTED를 달고 있으면 라이터는 곧바로 거부합니다. 임시 파일은 평문이 될 테고, 평문 파일을 EFS로 보호된 파일 위로 rename하면 사용자가 파일 시스템 수준에서 암호화하기로 선택한 것의 암호화되지 않은 대체본을 게시하는 셈이기 때문입니다. EFS는 암호화된 문서 로딩 글에서 다루는 PDF 표준 보안 핸들러와는 무관하지만, 실패 양상은 같은 종류의 조용한 다운그레이드입니다
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');
// 서술자 크기를 잡고, 그중 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; // CreateFileW / CREATE_NEW로 넘깁니다
end;
비슷한 테스트를 직접 작성한다면 회귀 테스트의 세부 사항 하나를 기억해 둘 만합니다. 제한된 픽스처를 만들기 위해 테스트는 소유자 전용 DACL을 적용하면서 서술자 컨트롤에 SE_DACL_PROTECTED를 명시적으로 설정해야 합니다. SetFileSecurityW의 SecurityInformation 인자에 보호 플래그를 넘기는 것만으로는 보호되지 않은 서술자가 보호된 것으로 바뀌지 않습니다. 그 뒤의 단언은 게시된 파일이 여전히 보호 비트와 명시적이고 null이 아닌 DACL을 보고한다는 것이며, 별도 출력 경로와 소스 파일 자체에 대한 복구 양쪽 모두에서 확인합니다
어떤 LastErrorCode가 무엇이 실패했는지 알려 줄까요?
RepairQDFFile은 성공하면 1을, 어떤 실패든 0을 반환하고, LastErrorCode가 어느 단계가 거부했는지 알려 줍니다. 다른 프로세스가 배타적 잠금으로 쥐고 있는 경우를 포함해 소스를 읽을 수 없으면 401을 보고합니다. 이제 읽기가 감싸져 있어서 입력 중의 예외가 쓰기 오류로 새지 않고 401로 매핑됩니다. 같은 객체에 대한 중복 스트림 마커처럼 잘못되었거나 모호한 QDF 구조는 107인 PDFLIB_ERROR_QDF_REPAIR를 보고하고, 라이터가 아예 생성되지 않았으므로 대상은 건드려지지도 않았습니다. 복구 이후의 모든 것, 즉 임시 파일 생성부터 flush와 rename까지는 305인 PDFLIB_ERROR_QDF_WRITE를 보고합니다. 회귀 테스트는 현실적인 것들을 훈련합니다. 삭제 공유 없이 다른 핸들이 열어 둔 대상, 읽기 전용 대상, 없는 대상 디렉터리, 그리고 주입으로 실패하는 라이터의 세 단계 각각입니다. 모두에서 반환값은 0, 코드는 305이고, 이후에 새 대상이나 부분 대상이 존재하지 않습니다. 반환값만 보지 않고 코드를 읽는 일반적인 습관은 라이브러리의 조용한 실패를 진단하는 글에서 설명한 것과 같습니다
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// 제자리 복구: 같은 경로가 입력이자 출력입니다
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;
보장이 멈추는 지점
라이터는 프로세스가 볼 수 있는 실패에 대해 일관성을 약속하고, 볼 수 없는 실패에 대해서는 솔직합니다. 임시 파일을 만든 뒤 rename 사이에 프로세스가 죽으면 finally 블록이 실행되지 않고 .pdflib-qdf-<GUID>.tmp 파일이 디렉터리에 남습니다. 대상은 여전히 온전하고 그것이 중요한 속성이지만, 찌꺼기는 직접 치워야 합니다. 전원 손실도 약속 밖입니다. 데이터는 flush되고 rename은 write-through이므로 유저 모드 라이브러리가 요구할 수 있는 최선이지만, 라이터는 디렉터리 엔트리를 fsync하지 않고 파일 시스템이 제공하는 것 위에 어떤 내구성 주장도 하지 않습니다. 대상을 동시에 수정하는 두 번째 라이터는 감지되지 않습니다. DACL과 속성을 임시 파일을 만들기 전에 읽고, rename 시점에는 아무것도 다시 확인하지 않기 때문입니다. 그리고 성공한 rename은 새 파일 아이덴티티를 만들므로, 대체 데이터 스트림과 옛 파일의 아카이브 비트나 숨김 비트 같은 일반 속성은 살아남지 않습니다. 의도적으로 넘어가는 것은 DACL뿐입니다
더 좁은 경계는 어떤 API가 이 경로를 쓰는가입니다. TPDFQDFFileWriter를 거치는 것은 RepairQDFFile뿐입니다. SaveQDFToFile과 ConvertFileToQDF는 여전히 PLCreateFileStream(FileName, fmCreate)로 출력을 열고 QDF 변환을 그대로 흘려 넣습니다. 스트림에 업데이트를 덧붙이는 글에 나오는 증분 경로가 넘겨받은 스트림에 쓰는 방식과 같습니다. 이 두 호출은 이미 로드되고 검증된 문서에서 새 디버깅 산출물을 만드는 것이므로 파싱 실패 구멍이 적용된 적이 없지만, rename 기반 게시를 물려받지도 않습니다. 이 글을 "모든 QDF 내보내기가 원자적이다"로 읽으면 안 됩니다. 여기서 다루는 것은 하나의 출구이고, 그 출구는 입력이 신뢰할 수 없는 손편집 파일이며 출력이 일상적으로 같은 경로인 곳입니다. 그 조합이 이 출구에 추가 장치를 얻게 해 줬습니다. 이 모든 것을 입증하는 결함 주입이 저렴한 이유는 라이터의 세 단계 WriteData, Flush, Publish가 virtual이기 때문입니다. 테스트 하위 클래스가 그중 하나를 재정의해 실제 작업이 시작된 뒤 예외를 내게 하고, 복구된 스트림에 Save를 호출한 다음, 예외가 전파되는지, 소스와 대상 바이트가 그대로인지, 임시 파일이 남지 않았는지 단언합니다. 전역 파일 API를 갈고리로 걸지도, 실제 사용자 파일을 건드리지도 않으며, 세 단계가 프로덕션에서 게시가 실패할 수 있는 세 가지 방식과 일대일로 대응합니다. 디스크가 가득 차거나, flush가 거부되거나, 다른 누군가가 대상을 쥐고 있어서 rename이 거부되는 경우입니다
RepairQDFFile API와 그 원자적 게시 라이터, 그리고 나머지 QDF 디버깅 워크플로는 PDF Library for Delphi의 일부이며, 이 블로그 다른 곳에서 다루는 상호 참조 복구, 증분 업데이트, 암호화 기능과 함께 제공됩니다