인수인계 폴더의 필수 파일과 예상 밖 파일을 계약표로 확인하기

인수인계 폴더를 받았을 때는 합의한 필수 파일이 모두 있는지, 다른 파일이 섞였는지, 파일이어야 할 경로가 폴더로 바뀌지는 않았는지를 먼저 확인하세요. 이 예제는 납품 계약표에 상대경로와 종류를 적고, 실제 폴더의 관찰 결과와 대조해 새 JSON 보고서를 만듭니다.

가상 계약에는 docs/guide.pdf, data/input.csv, code/run.py 세 파일이 있습니다. 실제 연습 폴더에는 앞의 두 파일만 있고 tmp/extra.txt가 추가로 들어 있습니다. 결과는 필수 파일 누락 1개, 예상 밖 디렉터리 1개와 파일 1개입니다. 원본 파일을 고치거나 지우지 않고, 검사 프로그램이 압축파일을 만들지도 않습니다.

이 실습의 PDF는 이름 존재 여부를 시험하는 가짜 텍스트 파일입니다. 유효한 PDF 문서가 아니며 PDF의 내용·진위·서명이나 CSV 데이터, Python 코드의 기능을 검증하지 않습니다. 실제 업무 인수인계를 통과했다는 의미로 사용하지 마세요.

필수 파일의 경로와 종류부터 계약표에 적기

contract.json은 확인할 기준입니다. 경로는 인수인계 폴더를 기준으로 한 상대경로이고, kind는 일반 파일 file 또는 디렉터리 dir입니다. 이 예제는 파일 세 개를 요구합니다.

{
  "contract_version": "handover-2026-10-11-v1",
  "required": [
    {
      "path": "docs/guide.pdf",
      "kind": "file"
    },
    {
      "path": "data/input.csv",
      "kind": "file"
    },
    {
      "path": "code/run.py",
      "kind": "file"
    }
  ]
}

docs, data, code는 위 경로를 담기 위해 필요한 상위 디렉터리로 계산합니다. 이를 필수 파일처럼 추가해 누락 수를 두 번 세지 않습니다. 별도 빈 디렉터리도 납품 요건이라면 {"path": "output", "kind": "dir"}처럼 계약에 명시할 수 있습니다.

계약 버전 handover-2026-10-11-v1과 SHA-256을 함께 고정합니다. 버전 이름이 같더라도 JSON 내용이 바뀌면 다른 기준이 될 수 있습니다. 이 첨부의 contract.json은 UTF-8, LF 줄바꿈과 마지막 줄바꿈까지 포함해 아래 해시를 사용합니다.

34171e5bd6020a02ac984e7a5642133f2dbc432cb588c745cd48f91fbecbe672

프로그램은 전달한 승인본의 해시와 실제 읽은 바이트를 비교한 후에만 대조를 시작합니다. 공백이나 줄바꿈을 바꾸어도 중단합니다. 자기 계약표를 새로 만들 때는 먼저 내용을 검토하고 승인본의 해시를 별도로 보관하세요. 파일이 바뀔 때마다 새 해시를 자동으로 넘기면 기준 변경을 막는 효과가 사라집니다.

가상 입력에서 무엇이 빠지고 무엇이 더 있는지

실습 ZIP을 새 빈 연습 폴더에 직접 풀고 README.md가 있는 곳에서 시작하세요. 자기 소유의 안정된 로컬 연습 사본만 사용합니다. 실제 업무 폴더, 동기화 폴더, 외부 마운트 또는 다른 사람이 수정하는 폴더를 지정하지 마세요.

fixture_handover/
  docs/
    guide.pdf       이름 확인용 가짜 텍스트
  data/
    input.csv       가상 CSV
  tmp/
    extra.txt       계약에 없는 파일
contract.json
contract.sha256
check_handover.py
expected_report.json

guide.pdf의 내용은 “Synthetic name-only fixture. This is not a valid PDF document.”라는 시험 문장입니다. input.csv는 다음 헤더와 두 행입니다. 이름·종류 대조 프로그램은 수량을 합산하거나 CSV 구조를 해석하지 않습니다.

item,qty
A100,3
B200,2
필수 파일 세 개 중 code/run.py가 빠지고 tmp 디렉터리와 tmp/extra.txt가 예상 밖 항목으로 남는 가상 입력 설명 도해
AI로 만든 가상 입력의 설명 도해입니다. 실제 프로그램 화면이나 실제 인수인계 성공 증빙이 아닙니다. 파일의 내용·형식 검증도 표시하지 않습니다.

tmp와 tmp/extra.txt는 각각 디렉터리와 일반 파일이므로 unexpected에 두 항목으로 남습니다. 예상 밖 항목을 발견했다고 자동 삭제하지 않습니다. 담당자가 납품 범위를 다시 확인해야 할 목록입니다.

누락과 관찰 실패를 다르게 기록하기

이 코드의 관찰 범위에는 숨김 파일·디렉터리도 포함됩니다. 루트 자체는 항목 수에서 제외하고 그 아래를 최대 2,000항목, 루트 깊이 0에서 항목 깊이 최대 16단계까지 읽습니다. 순서가 일정하지 않은 디렉터리 관찰 결과를 보고서에서 경로순으로 정렬합니다.

링크를 다른 폴더로 따라가지 않도록 심볼릭 링크와 특수 파일은 별도로 기록하고 INCOMPLETE로 남깁니다. 디렉터리는 파일 디스크립터로 열어 목록을 읽고, 각 일반 파일은 최대 1바이트만 읽어 접근 가능성을 시험합니다. 파일 전체 가독성, 문서 내용 또는 정상 형식을 보증하는 검사는 아닙니다.

code를 읽을 권한이 없으면 그 안의 code/run.py가 없는지 판단할 수 없습니다. 이런 경로는 unverified_required에 두고 확정 missing으로 단정하지 않습니다. 목록 읽기·메타데이터·1바이트 읽기 오류, 경로 종류 변경, 관찰 한계도 완전 통과로 바꾸지 않습니다.

Python 문서는 os.scandir()가 디렉터리 항목을 반복해서 읽으며 관찰 중 추가·삭제된 이름의 포함 여부는 정해져 있지 않다고 설명합니다. 따라서 이 보고서는 특정 시점 전체를 잠근 원자적 스냅샷이 아닙니다. 실행 전후에 원본과 계약표, 출력 상위 폴더를 안정된 상태로 유지해야 합니다. scandir 공식 문서

경로 이름과 새 보고서의 안전 경계

계약에는 절대경로, .., ., 빈 경로 구성요소, 역슬래시 또는 드라이브 표기를 허용하지 않습니다. 같은 경로의 중복, Data/a.csv와 data/b.csv처럼 상위 이름의 대소문자 접기 충돌도 거부합니다. a를 파일로 요구하면서 a/b.txt를 요구하는 상위 종류 충돌 역시 중단합니다.

JSON 객체의 중복 키와 UTF-8로 표현할 수 없는 계약 경로도 거부합니다. 실제 관찰 이름이 UTF-8로 표현되지 않으면 non_utf8_name 문제와 INCOMPLETE를 기록하고, 보고서에는 JSON 이스케이프로 이름을 보존합니다.

실제 폴더에서 Data와 data가 함께 관찰되면 INCOMPLETE입니다. 파일시스템의 대소문자 구별 방식은 환경에 따라 다르므로 이 예제는 경로 표기를 정확히 맞추고 모호한 조합을 피하도록 보수적인 규칙을 둡니다. 모든 운영체제의 파일명 규칙을 재현하는 기능은 아닙니다.

입력 루트·계약표·출력 상위 폴더의 경로 구성요소도 심볼릭 링크가 아닌지 확인합니다. Path.lstat()는 링크 대상 대신 링크 자체의 정보를 읽습니다. 실제 관찰에서는 링크를 따르지 않는 메타데이터 검사와 Linux의 O_NOFOLLOW를 사용합니다. lstat 공식 문서, os.open 공식 문서

계약표와 보고서는 관찰 루트 밖에 둡니다. 보고서의 부모 폴더는 이미 있어야 하며 새 이름만 사용할 수 있습니다. open("x")는 기존 파일이 있으면 실패하는 새 파일 전용 모드입니다. 입력 검증을 마친 뒤 보고서 내용을 메모리에 구성하고 이 모드로 씁니다. 쓰기 오류 때는 일부 새 내용이 남을 수 있어 정상 결과로 사용하지 않으며 자동 삭제도 하지 않습니다. 파일 열기 모드 공식 문서

첨부와 같은 전체 검사 코드

다음은 첨부의 check_handover.py 전체입니다. Linux 전용 표준 라이브러리 예제이며 별도 패키지나 API 키가 필요 없습니다. Windows·macOS에서는 검증되지 않은 흐름을 실행하지 않고 중단하도록 했습니다. stat.S_ISREG()와 S_ISDIR()는 경로의 종류를 구분하며 파일 내용을 해석하지 않습니다. stat 종류 검사 공식 문서

전체 검사 코드 펼치기 (check_handover.py)
"""Check a stable, owned Linux practice tree; write only a new JSON report."""
import argparse
import hashlib
import json
import os
from pathlib import Path, PureWindowsPath
import stat
import sys

MAX_ENTRIES = 2000
MAX_DEPTH = 16
MAX_CONTRACT_BYTES = 32768
MAX_REQUIRED = 64


def plain_path(path):
    path = Path(os.path.abspath(path))
    for part in [*reversed(path.parents), path]:
        if stat.S_ISLNK(part.lstat().st_mode):
            raise ValueError(f"symlink path component: {part}")
    return path


def validate_contract(obj):
    if not isinstance(obj, dict) or set(obj) != {"contract_version", "required"}:
        raise ValueError("contract needs only contract_version and required")
    version, rows = obj["contract_version"], obj["required"]
    if not isinstance(version, str) or not version.strip() or len(version) > 80:
        raise ValueError("contract_version must be a nonempty string <= 80 chars")
    if not isinstance(rows, list) or not 1 <= len(rows) <= MAX_REQUIRED:
        raise ValueError("required must contain 1..64 entries")
    required, prefixes = {}, {}
    for row in rows:
        if not isinstance(row, dict) or set(row) != {"path", "kind"}:
            raise ValueError("each required entry needs path and kind")
        name, kind = row["path"], row["kind"]
        if (not isinstance(name, str) or not name or len(name) > 240
                or name.startswith("/") or "\\" in name or ":" in name
                or PureWindowsPath(name).drive
                or any(ord(c) < 32 or 0xD800 <= ord(c) <= 0xDFFF for c in name)):
            raise ValueError("use a plain relative path with forward slashes")
        parts = name.split("/")
        if any(not p or p in (".", "..") for p in parts):
            raise ValueError("empty, . and .. path components are forbidden")
        if kind not in ("file", "dir") or name in required:
            raise ValueError("invalid kind or duplicate path")
        required[name] = kind
        for n in range(1, len(parts) + 1):
            prefix = "/".join(parts[:n])
            folded = prefix.casefold()
            if folded in prefixes and prefixes[folded] != prefix:
                raise ValueError("casefold collision in contract paths")
            prefixes[folded] = prefix
    for name in required:
        for parent in Path(name).parents:
            if str(parent) != "." and required.get(parent.as_posix()) == "file":
                raise ValueError("a required file cannot be another path's parent")
    return version, required


def unique_object(pairs):
    obj = {}
    for key, value in pairs:
        if key in obj:
            raise ValueError("duplicate JSON key in contract")
        obj[key] = value
    return obj


def load_contract(path, expected_sha256):
    path = plain_path(path)
    if not stat.S_ISREG(path.lstat().st_mode):
        raise ValueError("contract must be a regular file")
    if len(expected_sha256) != 64 or any(c not in "0123456789abcdef" for c in expected_sha256):
        raise ValueError("expected SHA-256 must be 64 lowercase hex chars")
    with path.open("rb") as f:
        raw = f.read(MAX_CONTRACT_BYTES + 1)
    if len(raw) > MAX_CONTRACT_BYTES:
        raise ValueError("contract exceeds 32768 bytes")
    actual = hashlib.sha256(raw).hexdigest()
    if actual != expected_sha256:
        raise ValueError("contract SHA-256 differs; confirm the approved contract")
    version, required = validate_contract(json.loads(raw.decode("utf-8"), object_pairs_hook=unique_object))
    return version, required, actual


def scan_tree(root):
    observed, issues = {}, []
    folded_names = {}
    attempted_entries = 0

    def issue(name, operation, detail):
        issues.append({"path": name, "operation": operation, "detail": detail})

    def visit(fd, prefix, depth):
        nonlocal attempted_entries
        if depth >= MAX_DEPTH:
            issue(prefix, "depth_limit", "children beyond maximum entry depth 16 not observed")
            return
        try:
            with os.scandir(fd) as entries:
                for entry in entries:
                    if attempted_entries >= MAX_ENTRIES:
                        issue(prefix, "entry_limit", "maximum attempted entries 2000 reached")
                        return
                    attempted_entries += 1
                    name = f"{prefix}/{entry.name}" if prefix else entry.name
                    try:
                        info = os.stat(entry.name, dir_fd=fd, follow_symlinks=False)
                    except OSError as exc:
                        issue(name, "stat", str(exc))
                        continue
                    if any(0xD800 <= ord(c) <= 0xDFFF for c in name):
                        issue(name, "non_utf8_name", "filename is not valid UTF-8")
                    mode = info.st_mode
                    kind = ("file" if stat.S_ISREG(mode) else "dir" if stat.S_ISDIR(mode)
                            else "symlink" if stat.S_ISLNK(mode) else "special")
                    observed[name] = kind
                    key = name.casefold()
                    if key in folded_names and folded_names[key] != name:
                        issue(name, "case_collision", f"also observed: {folded_names[key]}")
                    folded_names[key] = name
                    if kind not in ("file", "dir"):
                        issue(name, "unsupported_kind", kind)
                        continue
                    flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
                    if kind == "dir":
                        flags |= os.O_DIRECTORY
                    child = None
                    try:
                        child = os.open(entry.name, flags, dir_fd=fd)
                        opened = os.fstat(child)
                        if (opened.st_dev, opened.st_ino, stat.S_IFMT(opened.st_mode)) != (
                                info.st_dev, info.st_ino, stat.S_IFMT(info.st_mode)):
                            issue(name, "changed_entry", "entry changed while opening")
                        elif kind == "dir":
                            visit(child, name, depth + 1)
                        else:
                            os.read(child, 1)  # Readability probe only; no format/content validation.
                    except OSError as exc:
                        issue(name, "scandir" if kind == "dir" else "read_probe", str(exc))
                    finally:
                        if child is not None:
                            os.close(child)
        except OSError as exc:
            issue(prefix, "scandir", str(exc))

    fd = None
    try:
        fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
        visit(fd, "", 0)
    except OSError as exc:
        issue("", "open_root", str(exc))
    finally:
        if fd is not None:
            os.close(fd)
    return observed, issues


def compare(root, version, required, contract_sha256):
    expected = dict(required)
    for name in required:
        for parent in Path(name).parents:
            if str(parent) != ".":
                expected.setdefault(parent.as_posix(), "dir")
    observed, issues = scan_tree(root)
    def uncertain(name):
        return any(not x["path"] or name == x["path"] or name.startswith(x["path"] + "/") for x in issues)
    missing = sorted(p for p in required if p not in observed and not uncertain(p))
    unverified = sorted(p for p in required if uncertain(p))
    unexpected = [{"path": p, "kind": observed[p]} for p in sorted(observed) if p not in expected]
    mismatch = [{"path": p, "expected": expected[p], "observed": observed[p], "explicit": p in required}
                for p in sorted(expected.keys() & observed.keys()) if expected[p] != observed[p]]
    status = "INCOMPLETE" if issues else "REVIEW" if missing or unexpected or mismatch else "PASS"
    return {"status": status, "contract_version": version, "contract_sha256": contract_sha256,
            "required_count": len(required), "observed_count": len(observed),
            "scope": {"hidden_entries": "included", "symlinks": "recorded_not_followed",
                      "maximum_depth": MAX_DEPTH, "maximum_entries": MAX_ENTRIES,
                      "content_validation": False, "readability_probe_bytes": 1,
                      "atomic_snapshot": False},
            "missing": missing, "unexpected": unexpected, "type_mismatches": mismatch,
            "unverified_required": unverified,
            "scan_issues": sorted(issues, key=lambda x: (x["path"], x["operation"])),
            "observed": [{"path": p, "kind": observed[p]} for p in sorted(observed)]}


def validate_output(root, output):
    root = plain_path(root)
    output = Path(os.path.abspath(output))
    parent = plain_path(output.parent)
    if not parent.is_dir():
        raise ValueError("output parent must already exist")
    if output == root or root in output.parents:
        raise ValueError("output must be outside the observed root")
    try:
        output.lstat()
    except FileNotFoundError:
        return output
    raise ValueError("output already exists; choose a new report name")


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("root", type=Path)
    parser.add_argument("contract", type=Path)
    parser.add_argument("output", type=Path)
    parser.add_argument("--expected-sha256", required=True)
    args = parser.parse_args()
    try:
        if not sys.platform.startswith("linux"):
            raise ValueError("this practice checker is Linux-only; other OSes were not validated")
        root = plain_path(args.root)
        if not stat.S_ISDIR(root.lstat().st_mode):
            raise ValueError("root must be a regular directory")
        output = validate_output(root, args.output)
        contract = plain_path(args.contract)
        if contract == root or root in contract.parents:
            raise ValueError("contract must be outside the observed root")
        version, required, sha = load_contract(contract, args.expected_sha256)
        report = compare(root, version, required, sha)
        payload = json.dumps(report, ensure_ascii=True, indent=2) + "\n"
        with output.open("x", encoding="utf-8", newline="\n") as f:
            f.write(payload)
        print(f'{report["status"]}: {output.name}')
        return {"PASS": 0, "REVIEW": 1, "INCOMPLETE": 3}[report["status"]]
    except (OSError, ValueError, UnicodeError) as exc:
        print(f"STOP: {exc}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())

실행 결과에서 확인할 다섯 가지

README.md가 있는 연습 폴더에서 아래 명령을 실행합니다. report-01.json은 새 이름이고 fixture_handover 밖에 생성됩니다. 이미 같은 보고서가 있다면 report-02.json처럼 이름만 바꾸세요.

python3 check_handover.py fixture_handover contract.json report-01.json --expected-sha256 34171e5bd6020a02ac984e7a5642133f2dbc432cb588c745cd48f91fbecbe672

검수한 합성 입력의 콘솔 출력은 REVIEW: report-01.json, 종료 코드는 1입니다. 검사 실행 오류가 아니라 발견된 차이가 있어 검토가 필요하다는 뜻입니다. 전체 보고서에서 아래 다섯 필드를 먼저 보세요. 다음 JSON은 읽기 쉽게 발췌한 결과이며 전체 보고서는 첨부의 expected_report.json에 있습니다.

{
  "status": "REVIEW",
  "missing": ["code/run.py"],
  "unexpected": [
    {"path": "tmp", "kind": "dir"},
    {"path": "tmp/extra.txt", "kind": "file"}
  ],
  "type_mismatches": [],
  "unverified_required": [],
  "scan_issues": []
}
  • missing: 관찰을 완료한 범위에서 찾지 못한 필수 경로입니다. 이 입력에서는 code/run.py 한 개입니다.
  • unexpected: 계약 경로와 필요한 상위 디렉터리 목록에 없는 실제 항목입니다. 파일과 디렉터리를 구분해 읽습니다.
  • type_mismatches: 이름은 있지만 종류가 다른 경로입니다. docs/guide.pdf가 디렉터리면 file → dir로 남습니다. explicit는 계약에 직접 적은 경로인지 나타냅니다.
  • unverified_required: 오류나 불완전 관찰 때문에 존재·접근을 확정할 수 없는 필수 경로입니다.
  • scan_issues: 읽지 못한 위치와 작업, 관찰 한계의 이유입니다. 하나라도 있으면 PASS를 내지 않습니다.

PASS(종료 0)는 정한 범위에서 경로·종류가 맞고 예상 밖 항목과 관찰 문제가 없다는 뜻입니다. REVIEW(1)는 차이가 있는 완료 관찰, STOP(2)는 계약·입력·출력 검증 또는 쓰기 실패, INCOMPLETE(3)는 관찰 문제가 남은 상태입니다. PASS도 납품 내용의 정확성이나 공유 권한을 승인하는 결과는 아닙니다.

오류가 나면 기준과 관찰 범위를 먼저 확인하기

SHA 불일치가 나오면 승인된 contract.json과 해시를 다시 가져오세요. 이름을 바꾸거나 해시 확인을 제거해 통과시키지 않습니다. output already exists라면 기존 결과를 그대로 두고 새 보고서 이름을 사용합니다. 권한 오류를 만나면 scan_issues의 위치를 확인해 소유한 연습 사본을 준비한 다음 새 이름으로 다시 관찰합니다. 이 프로그램은 권한 설정을 바꾸지 않습니다.

누락 파일을 받거나 예상 밖 항목의 납품 여부를 결정한 뒤에는 별도로 준비한 새 연습 사본에서 다시 확인할 수 있습니다. 검사기가 원본을 자동 정리하지 않으므로 담당자의 결정과 확인 보고서를 구분해 보관하세요.

실습의 자체 시험은 아래 명령으로 실행합니다. 임시 합성 폴더에서 이름·종류 불일치, 숨김 항목, 링크, 실제 권한 차단, 관찰 오류, 해시 불일치와 기존 출력 보존 등을 시험합니다. 권한 시험은 일반 사용자로 실행해야 하며 관리자(root) 권한으로 실행하지 마세요.

python3 -m unittest discover -s . -v

2026년 10월 11일 Linux Python 3.12.14에서 합성 입력을 실제 실행하고 자체 시험 20개를 통과했습니다. 별도의 독립 시험 40개 중 39개를 통과했고, AF_UNIX 소켓 생성 시험 1개는 실행 환경의 EPERM 차단으로 생략했습니다. 생략한 시험은 통과로 세지 않으며 FIFO 특수 파일 처리는 따로 시험했습니다. 원본 파일의 내용 SHA-256과 기존 보고서 보존을 비교했습니다. 읽기는 운영체제의 접근시간을 바꿀 수 있으므로 모든 메타데이터가 그대로라는 뜻은 아닙니다. Windows·macOS·Office 앱과 실제 인수인계 완료는 시험하지 않았습니다. Python 3.12 공식 문서는 같은 날 확인했으며 당시 문서 페이지의 표시 버전은 3.12.15였습니다.

실습 자료와 이어서 읽을 글

인수인계 계약표 대조 실습 ZIP 다운로드. 전체 코드, 고정 계약표·해시, 가상 입력, 기준 보고서, 자체 시험과 검수 설명을 포함합니다. 문서 형식 검증용 PDF는 제공하지 않습니다. 새 빈 연습 폴더에 직접 풀어 사용하세요.

작성자 소개