#!/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())