QDF 파일을 손으로 편집해 같은 객체에 스트림 마커를 하나 더 밀어 넣은 뒤, 가장 자연스럽게 쓸 수 있는 Delphi 코드로 제자리 수리를 돌려 보세요. 수리 단계는 손상을 알아차리고 거부합니다. 그런데 나중에 파일을 보면 0바이트입니다. TFileStream.Create(OutputFileName, fmCreate)가 검증기가 입력을 보기도 전에, 바로 한 줄 앞에서 그 경로를 잘라 버렸기 때문입니다. 바이트를 메모리에서 고치는 것은 작업의 쉬운 절반이고, 길이 0, 반쯤 쓰인, 권한이 틀린 파일을 단 한 번도 남기지 않고 디스크에 되돌려 놓는 것이 나머지 절반입니다. 이쪽에는 자체 실패 모드가 있습니다
제자리 수리가 파일을 비우는 이유
제자리 수리가 파일을 비우는 이유는 연산 순서가 틀렸기 때문입니다. fmCreate는 열 때 잘라 버리므로, 열기 이후의 거부는 언제나 너무 늦습니다. InputFileName과 OutputFileName이 같은 경로일 때 거부된 입력은 잃어버린 문서가 됩니다. 아무리 신중한 파서도 여기서는 구해 주지 못합니다. 모호한 마커를 거부할 때 출력 스트림을 건드리지 않을 수는 있지만, 호출자가 이미 한 호출 전에 대상을 파괴했습니다
// 가장 자연스러운 버전: 무엇이든 검증되기 전에 대상이 잘림
Input := TMemoryStream.Create;
try
Input.LoadFromFile(InputFileName); // OutputFileName과 같은 경로
Output := TFileStream.Create(OutputFileName, fmCreate); // 이 시점에서 파일은 0바이트
try
if not MyRepairQDF(Input, Output) then // 여러분의 검증기: 거부하기엔 이미 늦음
raise Exception.Create('QDF structure rejected');
finally
Output.Free;
end;
finally
Input.Free;
end;
보통 다음 시도는 TMemoryStream으로 수리하고 수리가 성공한 뒤에만 출력을 여는 것입니다. 이것은 나쁜 입력 구멍만 막고 그 이상은 막지 못합니다. 쓰기 단계는 여전히 자르고 복사하기이므로, 디스크가 가득 차거나 도중에 공유 위반이 나거나 잘라내기와 마지막 WriteBuffer 사이에서 예외가 나면 대상은 여전히 망가집니다. 파싱과 게시는 별개의 두 경계이고 각자 자기 보호가 필요합니다
PDF Library for Delphi에서 TPDFlib.RepairQDFFile(InputFileName, OutputFileName)은 둘 다 처리합니다. 입력을 읽고 전부 메모리에서 수리하며, QDF 구조가 거부되면 대상에 아무것도 열지 않은 채 일찍 돌아옵니다. 성공한 수리만 게시로 넘어가고, 게시는 대상을 쓰기용으로 아예 열지 않습니다
수리된 바이트와 대상 사이의 네 단계
RepairQDFFile은 프로세스가 관찰할 수 있는 모든 실패에 대해, 대상 경로에 완전한 옛 파일이나 완전한 새 파일 둘 중 하나만 놓이도록 보장합니다. 네 단계로 그곳에 도달하며 어느 단계도 이전 단계가 끝나지 않으면 시작하지 않습니다. 대상은 GetFullPathNameW로 전체 경로로 해석됩니다. 필요한 길이를 알기 위해 한 번, 정확히 그 크기의 버퍼로 다시 호출하므로 긴 경로가 MAX_PATH에서 잘리지 않습니다. 다음으로 대상 디렉터리에 .pdflib-qdf- 더하기 GUID 더하기 .tmp 이름의 임시 파일을 Windows에서는 CreateFileW와 CREATE_NEW로, POSIX에서는 open(2)와 O_CREAT or O_EXCL 및 0600 모드로 만듭니다. 두 플래그 모두 이름이 이미 있으면 만들기가 실패하게 합니다. 우연히 같은 GUID를 뽑은 두 프로세스가 하나의 핸들을 공유하는 일은 없습니다
수리된 스트림은 64 KiB 청크로 WriteBuffer를 통해 복사됩니다. 이 메서드는 아무도 확인하지 않는 개수를 돌려주는 대신 짧은 쓰기에서 예외를 던집니다. FlushFileBuffers나 fsync(2)가 데이터를 아래로 밀어 넣고 핸들이 닫힙니다. 닫기는 의도적으로 예외를 던지지 않습니다. flush가 성공한 뒤에는 실패한 닫기가 데이터에 위협이 되지 않고, 거기서 예외가 나면 진짜 쓰기나 flush 오류를 진단할 사람에게 그것이 가려지기 때문입니다. 마지막 단계는 rename입니다
직접 만든 안전 저장 루틴이 대개 부서지는 곳이 rename입니다. Windows에서 임시 파일은 MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH를 단 MoveFileExW로 대상을 교체하는데, 같은 볼륨에서는 이것이 하나의 파일시스템 연산입니다. MOVEFILE_COPY_ALLOWED는 의도적으로 빼 둡니다. 볼륨을 넘는 이동은 조용히 복사 후 삭제로 저하되는데, 이 설계 전체가 피하려던 바로 그 비원자적 순서입니다. 기계가 죽었을 때 절반이 된 "성공적인" 복사보다는 거부된 rename을 보는 편이 훨씬 낫습니다. 임시 파일이 대상 디렉터리에 있으므로 어차피 대상 볼륨에 있습니다. 옛 파일을 먼저 지우지도 않습니다. 지우고 rename하는 쌍은 경로가 존재하지 않는 창을 남기고, 그 안의 충돌은 문서를 잃습니다. MOVEFILE_WRITE_THROUGH는 rename이 디스크에 도달하기 전에 호출이 돌아오지 않게 합니다. POSIX에서 rename(2)는 이미 기존 이름을 원자적으로 교체하고, 같은 디렉터리 배치가 EXDEV를 피하게 해 줍니다
정리는 모든 경로에서 finally 블록으로 돕니다. 성공한 rename 뒤에는 임시 이름이 더 이상 없어 삭제가 아무것도 하지 않고, 실패 뒤에는 부분 파일을 제거합니다. 디렉터리는 호출 전과 정확히 같은 파일들로 끝납니다
Windows에서 임시 파일이 권한을 느슨하게 하는 이유
nil 보안 디스크립터로 만든 파일은 부모 디렉터리에서 DACL을 상속합니다. 곧 교체될 파일은 발언권이 없습니다. 완전히 새 문서라면 이 기본값이 맞습니다. 제자리 수리에서는 틀립니다. contract.pdf를 보호된 비상속 DACL로 계정 하나에 잠가 둔 운영자를 상상해 보세요. 옆에서 만든 평범한 임시 파일은 디렉터리의 더 넓은 권한을 물려받고, rename 뒤 게시된 contract.pdf는 그 넓은 DACL을 달고 나옵니다. NTFS 보안은 이름이 아니라 파일 객체에 속하기 때문입니다. 바이트는 올바르고 반환값은 1입니다. 운영자가 설정한 접근 제한은 그냥 사라졌습니다
그래서 RepairQDFFile은 임시 파일을 만들기 전에 대상의 DACL을 읽어 lpSecurityAttributes로 CreateFileW에 넘깁니다. 새 파일은 옛 파일의 권한으로 태어나고 rename은 운영자가 알아차릴 무엇도 바꾸지 않습니다. 읽기는 DACL_SECURITY_INFORMATION을 단 GetFileSecurityW이며 버퍼 크기는 첫 호출의 ERROR_INSUFFICIENT_BUFFER 답에서 정합니다. 대상이 아직 없는 경우, 즉 별도 출력 경로의 평범한 경우에는 디스크립터를 넘기지 않고 새 파일은 다른 새 파일처럼 디렉터리에서 상속합니다
코드가 추측하게 두는 대신 게시를 멈추는 조건이 셋이고, 각각 LastErrorCode 305를 보고합니다:
- DACL을 읽을 수 없는 경우. 예컨대 호출 계정이 대상에 대한
READ_CONTROL가 없을 때입니다 - 돌려준 디스크립터에
SE_DACL_PRESENT가 설정되어 있지 않은 경우. 그런 디스크립터를CreateFileW에 넘기면 Windows가 프로세스 기본 DACL로 폴백해 아무도 바꾸라고 하지 않은 접근 의미론을 바꿉니다 - 대상이
FILE_ATTRIBUTE_ENCRYPTED를 갖고 있는 경우. 임시 파일은 평문이고, 그것을 EFS로 보호된 파일 위로 rename하면 사용자가 암호화하기로 한 것의 암호화되지 않은 대체물을 게시하게 됩니다
EFS는 파일시스템 기능이며 PDF 보안 핸들러와는 아무 상관이 없습니다. 보안 핸들러는 암호화 문서 로딩 글에서 다룹니다. 다만 실패의 형태는 같은 종류의 조용한 강등이고, 거부하는 것이 올바른 기본값입니다. EFS 공유에서 가끔 거부하는 수리는 지원 티켓이지만, 사용자가 암호화한 파일을 복호화하는 수리는 사고입니다
DACL 보존 테스트를 직접 작성한다면 한 가지 Windows 세부사항이 틀리기 쉽습니다. 제한된 테스트 파일을 만들 때 owner-only DACL을 적용하고 디스크립터 컨트롤에 SE_DACL_PROTECTED를 명시적으로 설정하세요. SetFileSecurityW에 PROTECTED_DACL_SECURITY_INFORMATION을 넘기는 것은 보호되지 않은 디스크립터를 보호된 것으로 바꾸지 않습니다. 그 뒤 게시된 파일이 보호 비트와 명시적 non-null DACL을 여전히 보고하는지, 별도 출력 경로에 대해 한 번, 소스 자체 위의 수리에 대해 한 번 확인하세요
어느 LastErrorCode가 무엇이 실패했는지 말해 주나
RepairQDFFile은 성공 시 1, 실패 시 0을 돌려주고 TPDFlib.LastErrorCode가 거부한 단계를 이름 지어 줍니다. 존재하지 않거나 열 수 없거나 2 GB보다 큰 입력은 401을 보고하고, 읽기 중의 예외도 쓰기 코드로 새는 대신 401로 매핑됩니다. 그 중복 스트림 마커 같은 잘못되거나 모호한 QDF 구조는 PDFLIB_ERROR_QDF_REPAIR(107)을 보고합니다. 대상은 한 번도 열리지 않았습니다. 경로 해석부터 flush와 rename까지 수리 이후의 모든 것은 PDFLIB_ERROR_QDF_WRITE(305)를 보고합니다. 현실적인 쓰기 실패는 모두 305에 떨어집니다. delete 공유 없이 다른 핸들이 붙들고 있는 대상, 읽기 전용 대상, 없는 대상 디렉터리, 그리고 위의 DACL과 EFS 거부들입니다. 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');
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;
305 경로는 파일 API를 훅하지 않고도 여러분 테스트 프로젝트에서 재현하기 쉽습니다. Delphi의 fmShareDenyNone은 읽기·쓰기 공유는 하지만 FILE_SHARE_DELETE 없이 파일을 열고, Windows는 그렇게 붙들린 파일 위로 무엇을 rename하는 것도 거부합니다. 그런 핸들을 호출 동안 열어 두면 rename은 데이터가 기록되고 flush된 뒤의 마지막 단계에서 실패하는데, 바로 처리되는 걸 보고 싶은 그 사례입니다
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;
// 이 검사들은 여러분 테스트를 위한 제안이지 라이브러리 호출이 아님
Assert(BytesEqual(Before, TFile.ReadAllBytes(Target))); // BytesEqual: 여러분의 헬퍼
// 임시 파일이 남으면 안 됨
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;
0만 믿지 말고 코드를 읽는 습관에 대해서는 조용한 실패 진단 글이 API의 나머지 부분에서 같은 패턴을 짚어 줍니다
보장이 멈추는 곳
이 약속은 프로세스가 볼 수 있는 실패를 커버합니다. 임시 파일을 만든 뒤 rename 전에 프로세스가 죽으면 finally 블록은 실행되지 않고 .pdflib-qdf-<GUID>.tmp 파일이 디렉터리에 남습니다. 대상은 여전히 온전한데 그것이 중요한 것이고, 그 잔해를 쓸어 내리는 일은 여러분 몫입니다. 정전은 파일시스템 질문입니다. 데이터는 flush되고 rename은 write-through지만, POSIX에서 디렉터리 엔트리는 별도로 sync되지 않으므로 내구성은 그 위에 파일시스템이 제공하는 만큼입니다
성공한 rename은 새 파일 객체를 만들고, 옛 객체의 몇 가지 성질은 함께 가지 않습니다. DACL은 의도적으로 실어 나릅니다. 소유권은 그렇지 않습니다. DACL_SECURITY_INFORMATION만 읽으므로 게시된 파일은 수리를 실행한 계정의 소유입니다. 임시 파일은 FILE_ATTRIBUTE_NORMAL로 만들어지므로 숨김, 아카이브 등의 속성 비트는 새로 시작하고, 옛 파일의 alternate data stream은 사라집니다. POSIX에서 게시된 파일은 임시 파일의 0600 모드를 유지하므로, 그룹이 읽어야 한다면 뒤에 chmod를 호출하세요. 대상의 속성과 DACL은 임시 파일이 존재하기 전에 읽히므로, 같은 경로에 두 번째 writer가 있다면 조율은 여러분 몫입니다
SaveQDFToFile과 ConvertFileToQDF는 곧장 씁니다
RepairQDFFile은 임시 파일을 통해 게시하는 유일한 QDF 진입점입니다. SaveQDFToFile과 ConvertFileToQDF는 출력을 fmCreate로 열어 변환을 곧장 그 안으로 흘려 보냅니다. 스트림에 업데이트를 append하는 글의 인크리멘털 경로가 여러분이 공급하는 스트림에 쓰는 것과 비슷합니다. 이 분할은 의도적입니다. 그 두 호출은 이미 로드되고 검증된 문서를 새 디버깅 아티팩트로 바꾸는 것이므로 도중에 거부될 신뢰할 수 없는 입력이 없고, 출력 경로는 보통 새 파일입니다. RepairQDFFile은 손으로 편집된 신뢰할 수 없는 파일을 받고 흔히 같은 경로로 되돌려지는데, 바로 그 조합이 추가 기계장치를 정당화합니다
QDF 덤프에도 원자적 교체를 원한다면 짧은 래퍼 하나면 충분합니다:
// 직접 작성하는 헬퍼: QDF를 대상 옆에 쓴 뒤 한 번의 rename으로 교체
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); // 무언가 실패했을 때만 존재
end;
end;
이 버전은 RepairQDFFile이 수행하는 DACL 복사와 명시적 데이터 flush를 건너뜁니다. scratch나 디버깅 폴더로 떨어지는 QDF 덤프라면 그 정도의 기계장치가 맞다고 생각합니다. 덤프가 잠긴 권한의 파일을 교체한다면 수리 경로로 돌리거나 보안 디스크립터를 직접 복사하세요
원자적 수리 출력, 위의 QDF 변환 호출과 오류 코드는 모두 PDF Library for Delphi에 들어 있으며, 다른 글에서 다룬 교차 참조 복구, 인크리멘털 업데이트, 암호화 지원이 그 옆에 있습니다