"""tests/regression/test_auto_remediation_planner.py — task-2612+1 회귀.

spec: memory/tasks/task-2612+1.md
(sha256 826605c85d8644c7ea5fc1624cb8ff147a094a310b75e44f7abb489f3a26d241)

task-2612 Codex 재audit 잔여 non-Critical HIGH×2 의 AUTO_REMEDIATION:

  - **H1**: ``_read_json()`` 절대경로 무검 허용·상대 ``..`` traversal 미검
    → resolve 후 CANONICAL_WS_ROOT 하위 strict containment·traversal·
    임의 절대경로·symlink 이탈 fail-closed.
  - **H2**: ``_planner_detail`` 의 module_rel/test_rel 정규화 없이
    ``expected_files`` 유입 → 정규화 + containment, 이탈 fail-closed
    (downstream out-of-scope 유도 차단).

불변 검증: planner **산출 성격**(plan 골격 생성만·실 dispatch 0·실 코드수정
0) 은 본 hardening(입력검증 강화)으로 변경되지 않으며, 2604/2605/2609 plan
변환은 byte-0 무회귀(placeholder 보존)다. schema 무변.

100% offline — network/git/subprocess 호출 0건.
"""
from __future__ import annotations

import json
import os
from pathlib import Path

import pytest

import anu_v3.auto_remediation_planner as arp
from anu_v3.auto_remediation_planner import (
    CANONICAL_WS_ROOT,
    PathContainmentError,
    TYPE_COVERAGE_GAP,
    TYPE_GLOBAL_LEDGER_SHA_FALSE_POSITIVE,
    TYPE_STAGE_CLAIM_TEST_MISMATCH,
    _assert_fd_inode_contained,
    _contained_resolved,
    _normalize_expected_rel,
    _read_json,
    assert_plan_only,
    build_plan_from_adjudication,
    run_self_check,
    validate_plan,
)

_2604 = "memory/events/task-2604+1.independent-collector-adjudication.json"
_2605 = "memory/events/task-2605+2.independent-anu-collector.adjudication.json"
_2609 = "memory/events/task-2609.independent-collector-adjudication.json"


# ── H1: _read_json read-path containment (fail-closed) ──────────────────
@pytest.mark.parametrize(
    "bad",
    [
        "../../etc/passwd",
        "../outside.json",
        "memory/../../../etc/passwd",
        "/etc/passwd",
        "/home/jay/.bashrc",
        "..",
    ],
)
def test_h1_read_json_traversal_and_abs_escape_fail_closed(bad: str) -> None:
    with pytest.raises(PathContainmentError):
        _read_json(bad)


def test_h1_read_json_symlink_escape_fail_closed(tmp_path: Path) -> None:
    # 입력은 normpath 가 아니라 ``Path.resolve()`` 로 symlink 까지 실체화한
    # 뒤 containment 검증된다 — ws 밖을 가리키는 symlink 는 fail-closed.
    link = tmp_path / "escape.json"
    link.symlink_to("/etc/passwd")
    with pytest.raises(PathContainmentError):
        _read_json(str(link))


def test_h1_read_json_resolve_is_applied_symlink_into_ws_allowed(
    tmp_path: Path,
) -> None:
    # resolve() 가 실제로 적용됨을 증명: symlink 의 실체가 ws 하위면
    # (normpath 였다면 tmp 절대경로로 거부됐을 것이) 허용된다 —
    # 보안 성격은 "탈출 차단"이지 "symlink 존재 자체 차단"이 아니다.
    link = tmp_path / "into_ws.json"
    link.symlink_to(CANONICAL_WS_ROOT / "schemas/auto_remediation_plan.schema.json")
    assert _read_json(str(link)).get("required")


def test_h1_read_json_legit_relative_still_reads() -> None:
    schema = _read_json("schemas/auto_remediation_plan.schema.json")
    assert schema.get("required")  # 정상 read 유지(무회귀)


def test_h1_read_json_legit_absolute_in_ws_allowed() -> None:
    abs_in_ws = str(CANONICAL_WS_ROOT / _2604)
    data = _read_json(abs_in_ws)
    assert isinstance(data, dict) and data  # ws 하위 절대경로는 허용


def test_h1_contained_resolved_rejects_ws_root_itself() -> None:
    with pytest.raises(PathContainmentError):
        _contained_resolved(str(CANONICAL_WS_ROOT))


# ── H2: expected_files module_rel/test_rel 정규화 + containment ─────────
def test_h2_placeholder_passthrough_byte0() -> None:
    # 골격 placeholder 는 실경로가 아님 — 그대로 통과(기존 동작 보존).
    for ph in ("<entrypoint module>", "<regression test>", "<HOLD test/fixture>"):
        assert _normalize_expected_rel(ph) == ph


def test_h2_real_path_normalized_to_ws_relative() -> None:
    assert (
        _normalize_expected_rel("anu_v3/./auto_remediation_planner.py")
        == "anu_v3/auto_remediation_planner.py"
    )


@pytest.mark.parametrize(
    "bad",
    ["../../../tmp/evil.py", "/etc/cron.d/x", "memory/../../outside.py", ".."],
)
def test_h2_out_of_scope_module_rel_fail_closed(bad: str) -> None:
    with pytest.raises(PathContainmentError):
        _normalize_expected_rel(bad)


# ── regression: 2604/2605/2609 plan 변환 byte-0 무회귀 ──────────────────
@pytest.mark.parametrize(
    "path,itype,plan_id,ph0",
    [
        (_2604, TYPE_GLOBAL_LEDGER_SHA_FALSE_POSITIVE, "task-2604-AR1", "<HOLD test/fixture>"),
        (_2605, TYPE_STAGE_CLAIM_TEST_MISMATCH, "task-2605-AR1", "<entrypoint module>"),
        (_2609, TYPE_COVERAGE_GAP, "task-2609-AR1", "<verdict module>"),
    ],
)
def test_regression_plan_conversion_byte0(
    path: str, itype: str, plan_id: str, ph0: str
) -> None:
    plan = build_plan_from_adjudication(path, itype).to_dict()
    assert plan["plan_id"] == plan_id
    assert plan["disposition"] == "AUTO_REMEDIATION_HOLD"
    ef = plan["spec_skeleton"]["expected_files"]
    # placeholder(_planner_detail={}) 보존 + tail 3 경로 불변
    assert ef[0] == ph0
    assert ef[2:] == [
        f"memory/events/{plan_id}.decision.json",
        f"memory/events/{plan_id}.result.json",
        f"memory/reports/{plan_id}.md",
    ]
    assert validate_plan(plan) == []  # schema 무변·무오류


def test_regression_self_check_all_passed_mock0() -> None:
    sc = run_self_check()
    assert sc["all_passed"] is True
    assert sc["plan_only"] is True
    assert sc["dispatch_performed"] is False
    assert len(sc["cases"]) == 3
    assert all(c["passed"] for c in sc["cases"])


# ── 산출 성격 불변 (plan-only invariant 무변경) ─────────────────────────
def test_plan_only_invariant_preserved() -> None:
    # 입력검증 hardening 은 산출 성격(plan 골격 생성만·dispatch 0)을
    # 바꾸지 않는다 — AST 정적 가드 여전히 PASS.
    assert_plan_only()


# ── H1r (task-2612+2): validate→open TOCTOU symlink-swap 봉합 (additive) ─
# 정적 검증(_contained_resolved) 통과 후 open 직전에 경로 구성요소가 ws-밖
# symlink 로 교체되는 race 를 재현한다. monkeypatch 로 _contained_resolved
# 가 "정적 검증을 통과한(swap 직전) 경로"를 돌려주게 하여, _read_json 의
# 2차 방어(O_NOFOLLOW + 열린 fd /proc/self/fd 재검증 + fstat)만으로
# 이탈을 fail-closed 차단함을 실증한다.
def test_h1r_final_component_symlink_swap_after_check_blocked(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    # swap 결과: 최종 구성요소가 ws-밖을 가리키는 symlink → O_NOFOLLOW
    # 가 추종을 거부(ELOOP) → PathContainmentError fail-closed.
    swapped = tmp_path / "swapped.json"
    swapped.symlink_to("/etc/passwd")
    monkeypatch.setattr(arp, "_contained_resolved", lambda _p: swapped)
    with pytest.raises(PathContainmentError):
        _read_json("memory/events/task-2604+1.independent-collector-adjudication.json")


def test_h1r_intermediate_dir_symlink_swap_after_check_blocked(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    # swap 결과: 중간 디렉터리 구성요소가 ws-밖 디렉터리 symlink (O_NOFOLLOW
    # 은 최종 구성요소만 보호 → open 은 성공). 열린 fd 의 실경로(/proc/self/
    # fd)가 ws 밖 → 2차 fd-재검증이 fail-closed 로 차단.
    outdir = tmp_path / "outdir"
    outdir.mkdir()
    secret = outdir / "secret.json"
    secret.write_text(json.dumps({"leaked": True}), encoding="utf-8")
    evil_link = tmp_path / "evil_link"
    evil_link.symlink_to(outdir, target_is_directory=True)
    crafted = evil_link / "secret.json"  # 최종 구성요소는 symlink 아님
    monkeypatch.setattr(arp, "_contained_resolved", lambda _p: crafted)
    with pytest.raises(PathContainmentError):
        _read_json("schemas/auto_remediation_plan.schema.json")


def test_h1r_in_ws_read_no_regression_under_fd_recheck(
    monkeypatch: pytest.MonkeyPatch
) -> None:
    # 2차 fd-재검증 경로에서도 ws-하위 정규파일 read 는 무회귀로 성공.
    real = CANONICAL_WS_ROOT / "schemas/auto_remediation_plan.schema.json"
    monkeypatch.setattr(arp, "_contained_resolved", lambda _p: real)
    assert _read_json("schemas/auto_remediation_plan.schema.json").get("required")


def test_h1r_legit_relative_and_absolute_in_ws_still_read() -> None:
    # end-to-end 무회귀: 1차 정적 + 2차 fd-재검증 모두 거쳐 정상 read.
    assert _read_json("schemas/auto_remediation_plan.schema.json").get("required")
    abs_in_ws = str(CANONICAL_WS_ROOT / _2604)
    assert isinstance(_read_json(abs_in_ws), dict)


@pytest.mark.parametrize(
    "path,itype,plan_id",
    [
        (_2604, TYPE_GLOBAL_LEDGER_SHA_FALSE_POSITIVE, "task-2604-AR1"),
        (_2605, TYPE_STAGE_CLAIM_TEST_MISMATCH, "task-2605-AR1"),
        (_2609, TYPE_COVERAGE_GAP, "task-2609-AR1"),
    ],
)
def test_h1r_plan_conversion_no_regression_after_hardening(
    path: str, itype: str, plan_id: str
) -> None:
    # H1r read-path 원자성 hardening 후에도 2604/2605/2609 plan 변환 byte-0.
    plan = build_plan_from_adjudication(path, itype).to_dict()
    assert plan["plan_id"] == plan_id
    assert plan["disposition"] == "AUTO_REMEDIATION_HOLD"
    assert plan["plan_only"] is True
    assert plan["dispatch_performed"] is False
    assert validate_plan(plan) == []


def test_h1r_plan_only_ast_guard_passes_after_hardening() -> None:
    # read-path 원자성 hardening(os.open/fdopen 추가)이 plan-only AST 가드를
    # 위반하지 않음 — dispatch/side-effect 실호출 0 (산출 성격 불변).
    assert_plan_only()


# ── H1h (task-2612+3): in-ws hard-link → ws-밖 동일-inode 우회 봉합 ──────
# 정적 1차(_contained_resolved)·H1r 2차(O_NOFOLLOW+/proc/self/fd realpath+
# fstat)를 모두 통과하는 in-root pathname 이 실제로는 ws-밖 inode 에 대한
# hard link 인 경우(race 없는 out-of-scope read). hard link 은 별도 realpath
# 가 없으므로 inode 메타데이터(samestat / st_nlink)로만 탐지 가능 — 3차
# additive defense-in-depth 가 fail-closed 차단함을 실증한다.
def test_h1h_in_ws_hardlink_to_out_of_scope_inode_blocked(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    ws = tmp_path / "ws"
    ws.mkdir()
    outside = tmp_path / "outside"  # ws 형제 = out-of-scope, 동일 fs
    outside.mkdir()
    secret = outside / "secret.json"
    secret.write_text(json.dumps({"leaked": True}), encoding="utf-8")
    hlink = ws / "innocent.json"  # in-root pathname …
    os.link(secret, hlink)  # … 이지만 ws-밖 inode 로의 hard link
    # 정적·H1r 검증은 모두 통과(hlink 는 진짜로 ws 하위·symlink 아님·
    # /proc/self/fd realpath 도 in-root) → H1h 3차만이 차단해야 한다.
    monkeypatch.setattr(arp, "CANONICAL_WS_ROOT", ws)
    monkeypatch.setattr(arp, "_contained_resolved", lambda _p: hlink)
    with pytest.raises(PathContainmentError):
        arp._read_json("innocent.json")


def test_h1h_single_link_regular_file_reads_no_regression(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    # nlink==1 정규파일(정상 정본)은 3차 검증을 무회귀로 통과.
    ws = tmp_path / "ws"
    ws.mkdir()
    f = ws / "ok.json"
    f.write_text(json.dumps({"ok": 1}), encoding="utf-8")
    monkeypatch.setattr(arp, "CANONICAL_WS_ROOT", ws)
    monkeypatch.setattr(arp, "_contained_resolved", lambda _p: f)
    assert arp._read_json("ok.json") == {"ok": 1}


def test_h1h_real_schema_single_link_end_to_end_no_regression() -> None:
    # 1차+2차+3차 전 경로를 거쳐 실제 정본 schema(nlink==1) 정상 read.
    assert _read_json("schemas/auto_remediation_plan.schema.json").get("required")


def test_h1h_helper_fd_inode_mismatch_fail_closed(tmp_path: Path) -> None:
    # _assert_fd_inode_contained 의 samestat 분기 직접 검증: 열린 fd 가
    # 가리키는 inode 와 정적 경로 inode 가 다르면(check→open swap 등)
    # fail-closed.
    a = tmp_path / "a.json"
    a.write_text("{}", encoding="utf-8")
    b = tmp_path / "b.json"
    b.write_text("{}", encoding="utf-8")
    fd = os.open(a, os.O_RDONLY)
    try:
        with pytest.raises(PathContainmentError):
            _assert_fd_inode_contained(fd, b)  # 서로 다른 inode
    finally:
        os.close(fd)


def test_h1h_helper_single_link_same_inode_passes(tmp_path: Path) -> None:
    # 동일 inode·단일 link 정규파일은 helper 가 통과(무회귀).
    f = tmp_path / "ok.json"
    f.write_text("{}", encoding="utf-8")
    fd = os.open(f, os.O_RDONLY)
    try:
        _assert_fd_inode_contained(fd, f)  # 예외 없음 = PASS
    finally:
        os.close(fd)


@pytest.mark.parametrize(
    "path,itype,plan_id",
    [
        (_2604, TYPE_GLOBAL_LEDGER_SHA_FALSE_POSITIVE, "task-2604-AR1"),
        (_2605, TYPE_STAGE_CLAIM_TEST_MISMATCH, "task-2605-AR1"),
        (_2609, TYPE_COVERAGE_GAP, "task-2609-AR1"),
    ],
)
def test_h1h_plan_conversion_no_regression_after_hardening(
    path: str, itype: str, plan_id: str
) -> None:
    # H1h 3차 inode-containment 추가 후에도 2604/2605/2609 plan 변환 byte-0.
    plan = build_plan_from_adjudication(path, itype).to_dict()
    assert plan["plan_id"] == plan_id
    assert plan["disposition"] == "AUTO_REMEDIATION_HOLD"
    assert plan["plan_only"] is True
    assert plan["dispatch_performed"] is False
    assert validate_plan(plan) == []


def test_h1h_plan_only_ast_guard_passes_after_hardening() -> None:
    # 3차 inode-containment(os.fstat/os.lstat/samestat read-only)는 dispatch
    # surface 호출 0 — plan-only AST 가드 무위반(산출 성격 불변).
    assert_plan_only()


def test_h1h_self_check_all_passed_plan_only_dispatch_false() -> None:
    # spec §3 재현: --self-check 등가 호출이 all_passed=True·plan_only=True·
    # dispatch_performed=False 를 H1h hardening 후에도 유지.
    sc = run_self_check()
    assert sc["all_passed"] is True
    assert sc["plan_only"] is True
    assert sc["dispatch_performed"] is False


def test_schema_byte0() -> None:
    # schemas/auto_remediation_plan.schema.json 무변(required 키 안정).
    schema = json.loads(
        (CANONICAL_WS_ROOT / "schemas/auto_remediation_plan.schema.json").read_text()
    )
    assert schema["required"] == [
        "schema",
        "plan_id",
        "remediation_of",
        "issue_type",
        "disposition",
        "severity",
        "plan_only",
        "dispatch_performed",
        "spec_skeleton",
        "shared_invariant_preserved",
        "hold_for_chair",
        "reasons",
    ]
