201 lines
8.3 KiB
Python
201 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Reassemble split-document chunks after verifying the immutable inputs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from harness_common import (
|
|
InputError,
|
|
atomic_write_text,
|
|
load_json,
|
|
sha256_bytes,
|
|
sha256_text,
|
|
validate_with_schema,
|
|
)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="검증된 Markdown 청크를 순서대로 재조립합니다.")
|
|
parser.add_argument("--manifest", required=True, type=Path)
|
|
parser.add_argument("--output", required=True, type=Path)
|
|
parser.add_argument(
|
|
"--source",
|
|
choices=("input", "rewritten"),
|
|
default="input",
|
|
help="input은 무손실 round-trip, rewritten은 에이전트가 고친 청크를 조립합니다.",
|
|
)
|
|
parser.add_argument("--force", action="store_true")
|
|
return parser.parse_args()
|
|
|
|
|
|
def require_relative_file(base: Path, value: Any, label: str) -> Path:
|
|
if not isinstance(value, str) or not value or Path(value).is_absolute():
|
|
raise InputError(f"{label}은 manifest 기준 상대 파일 경로여야 합니다.")
|
|
candidate = (base / value).resolve()
|
|
try:
|
|
candidate.relative_to(base.resolve())
|
|
except ValueError as exc:
|
|
raise InputError(f"{label}이 manifest 디렉터리를 벗어납니다: {value}") from exc
|
|
if not candidate.is_file():
|
|
raise InputError(f"{label} 파일이 없습니다: {candidate}")
|
|
return candidate
|
|
|
|
|
|
def read_exact_utf8(path: Path) -> tuple[bytes, str]:
|
|
try:
|
|
data = path.read_bytes()
|
|
return data, data.decode("utf-8")
|
|
except (OSError, UnicodeError) as exc:
|
|
raise InputError(f"UTF-8 청크를 읽을 수 없습니다: {path}: {exc}") from exc
|
|
|
|
|
|
def validate_manifest_shape(manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
|
validate_with_schema(manifest, "chunk-manifest.schema.json", "chunk manifest")
|
|
if manifest.get("schema_version") != "1.0" or manifest.get("tool") != "split_document":
|
|
raise InputError("지원하지 않는 chunk manifest입니다.")
|
|
if manifest.get("offset_unit") != "unicode_codepoint" or manifest.get("self_check") is not True:
|
|
raise InputError("chunk manifest self-check 계약이 유효하지 않습니다.")
|
|
chunks = manifest.get("chunks")
|
|
if not isinstance(chunks, list) or not chunks:
|
|
raise InputError("chunk manifest의 chunks가 비어 있습니다.")
|
|
if not isinstance(manifest.get("source"), dict):
|
|
raise InputError("chunk manifest에 source 객체가 필요합니다.")
|
|
return chunks
|
|
|
|
|
|
def validate_input_chunks(base: Path, manifest: dict[str, Any]) -> list[str]:
|
|
chunks = validate_manifest_shape(manifest)
|
|
contents: list[str] = []
|
|
expected_start = 0
|
|
for expected_index, chunk in enumerate(chunks, start=1):
|
|
if not isinstance(chunk, dict) or chunk.get("index") != expected_index:
|
|
raise InputError(f"청크 index가 연속적이지 않습니다: expected={expected_index}")
|
|
start = chunk.get("start_offset")
|
|
end = chunk.get("end_offset")
|
|
count = chunk.get("char_count")
|
|
if not all(isinstance(value, int) and not isinstance(value, bool) for value in (start, end, count)):
|
|
raise InputError(f"청크 {expected_index}의 offset/count 형식이 잘못되었습니다.")
|
|
if start != expected_start or end < start or count != end - start:
|
|
raise InputError(f"청크 {expected_index}의 offset이 연속적이지 않습니다.")
|
|
path = require_relative_file(base, chunk.get("input_file"), f"chunk {expected_index} input_file")
|
|
_, text = read_exact_utf8(path)
|
|
if len(text) != count or sha256_text(text) != chunk.get("sha256"):
|
|
raise InputError(f"청크 {expected_index} 입력 hash 또는 길이가 달라졌습니다.")
|
|
contents.append(text)
|
|
expected_start = end
|
|
|
|
joined = "".join(contents)
|
|
source = manifest["source"]
|
|
if expected_start != source.get("char_count"):
|
|
raise InputError("마지막 청크 offset이 source char_count와 다릅니다.")
|
|
expected_hash = source.get("sha256")
|
|
if sha256_text(joined) != expected_hash or manifest.get("round_trip_sha256") != expected_hash:
|
|
raise InputError("입력 청크의 round-trip hash가 원문과 다릅니다.")
|
|
if len(joined.encode("utf-8")) != source.get("size_bytes"):
|
|
raise InputError("입력 청크의 byte 길이가 원문과 다릅니다.")
|
|
return contents
|
|
|
|
|
|
def rewritten_chunks(base: Path, chunks: list[dict[str, Any]]) -> list[str]:
|
|
result: list[str] = []
|
|
for index, chunk in enumerate(chunks, start=1):
|
|
path = require_relative_file(
|
|
base, chunk.get("rewritten_file"), f"chunk {index} rewritten_file"
|
|
)
|
|
_, text = read_exact_utf8(path)
|
|
result.append(text)
|
|
return result
|
|
|
|
|
|
def write_text_no_clobber(path: Path, value: str) -> None:
|
|
"""Publish a regular file atomically only when the target name is absent."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
temporary = Path(temporary_name)
|
|
try:
|
|
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as stream:
|
|
stream.write(value)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
try:
|
|
os.link(temporary, path)
|
|
except FileExistsError as exc:
|
|
raise InputError(f"출력 파일이 이미 존재합니다(--force로 교체): {path}") from exc
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def manifest_member_path(base: Path, value: Any, label: str) -> Path:
|
|
if not isinstance(value, str) or not value or Path(value).is_absolute():
|
|
raise InputError(f"{label}은 manifest 기준 상대 파일 경로여야 합니다.")
|
|
candidate = (base / value).resolve(strict=False)
|
|
try:
|
|
candidate.relative_to(base.resolve())
|
|
except ValueError as exc:
|
|
raise InputError(f"{label}이 manifest 디렉터리를 벗어납니다: {value}") from exc
|
|
return candidate
|
|
|
|
|
|
def reject_output_alias(
|
|
output: Path,
|
|
manifest_path: Path,
|
|
base: Path,
|
|
manifest: dict[str, Any],
|
|
) -> None:
|
|
protected = [manifest_path.resolve()]
|
|
source_path = manifest.get("source", {}).get("resolved_path")
|
|
if isinstance(source_path, str) and source_path:
|
|
protected.append(Path(source_path).expanduser().resolve(strict=False))
|
|
for index, chunk in enumerate(manifest["chunks"], start=1):
|
|
for field in ("input_file", "rewritten_file"):
|
|
protected.append(
|
|
manifest_member_path(base, chunk.get(field), f"chunk {index} {field}")
|
|
)
|
|
resolved_output = output.resolve(strict=False)
|
|
for path in protected:
|
|
same_name = resolved_output == path.resolve(strict=False)
|
|
same_inode = False
|
|
if output.exists() and path.exists():
|
|
try:
|
|
same_inode = os.path.samefile(output, path)
|
|
except OSError:
|
|
same_inode = False
|
|
if same_name or same_inode:
|
|
raise InputError(f"출력은 manifest/source/chunk 파일을 덮을 수 없습니다: {path}")
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
try:
|
|
manifest_path = args.manifest.expanduser().resolve(strict=True)
|
|
manifest = load_json(manifest_path)
|
|
base = manifest_path.parent
|
|
original = validate_input_chunks(base, manifest)
|
|
selected = original if args.source == "input" else rewritten_chunks(base, manifest["chunks"])
|
|
raw_output = args.output.expanduser()
|
|
output = Path(os.path.abspath(raw_output))
|
|
reject_output_alias(output, manifest_path, base, manifest)
|
|
if args.force:
|
|
atomic_write_text(output, "".join(selected))
|
|
else:
|
|
write_text_no_clobber(output, "".join(selected))
|
|
if args.source == "input":
|
|
written = output.read_bytes()
|
|
if sha256_bytes(written) != manifest["source"]["sha256"]:
|
|
raise InputError("재조립 파일의 최종 hash self-check가 실패했습니다.")
|
|
print(str(output))
|
|
return 0
|
|
except (InputError, KeyError, TypeError, OSError) as exc:
|
|
print(f"input error: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|