From 6966237855c31768090ca607b8066e68a87dba76 Mon Sep 17 00:00:00 2001 From: Shamim Rehman Date: Mon, 13 Jul 2026 19:24:21 -0400 Subject: [PATCH] feat: harden Forge incident storage --- README.md | 101 +- forge_cli/cli.py | 306 ++++- forge_cli/display.py | 321 +++-- forge_cli/incident_store.py | 1413 ++++++++++++++++++-- forge_cli/mcp_server.py | 108 +- tests/test_cli.py | 2385 +++++++++++++++++++++++++++++++++- tests/test_display.py | 189 +++ tests/test_incident_store.py | 1024 +++++++++++++++ tests/test_mcp_http.py | 495 ++++++- 9 files changed, 6042 insertions(+), 300 deletions(-) create mode 100644 tests/test_display.py diff --git a/README.md b/README.md index 2663c05..95c0591 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,18 @@ forge ref # Review aggregate patterns forge stats +# Validate the corpus; fail automation if corruption exists +forge validate --strict + +# Preview repair candidates without changing malformed files +forge repair --dry-run + +# Preview quarantine candidates without moving malformed files +forge quarantine --dry-run + +# Explicitly move freshly verified corrupt files into quarantine +forge quarantine --apply + # Prepare a rendered analysis prompt without calling an LLM forge analyze --prepare-only ``` @@ -89,17 +101,89 @@ You can also set an `organization_name` in `config.yaml` or `config.local.yaml` | Command | Description | |---------|-------------| | `forge log` | Log a new incident with interactive prompts | -| `forge list` | List incidents with `--project`, `--severity`, `--since`, `--tag`, structured-axis, and `--limit` filters | +| `forge list` | List incidents with filters and report valid-corpus, corrupt-corpus, matched, and returned counts | | `forge show ` | Show full details of one incident; suffix matches like `forge show 001` work | | `forge ref ` | Print a Proofhouse `IncidentRef` compatibility projection as JSON | | `forge edit ` | Open an incident in your editor | -| `forge stats` | Show aggregate counts by severity, type, project, platform, and tags | +| `forge stats` | Show aggregate counts with explicit corpus and matched-count diagnostics | +| `forge validate [--strict]` | Report safe relative-path corruption diagnostics; `--strict` exits nonzero for corrupt files | +| `forge repair [--dry-run]` | Preview corrupt-file repair candidates; always read-only and never rewrites files | +| `forge quarantine [--dry-run\|--apply]` | Preview corrupt-file candidates by default; `--apply` moves freshly revalidated corrupt regular files into quarantine | | `forge playbook` | List playbook entries | | `forge playbook show ` | Show one playbook entry | | `forge analyze` | Run LLM-backed pattern analysis | | `forge analyze --prepare-only` | Save the fully rendered analysis input without calling an LLM | | `forge mcp serve` | Run Forge as a Streamable HTTP MCP server with local-only defaults | +Read commands treat a missing `incidents/` directory as a valid, empty corpus and +do not create it. `forge validate --strict` therefore succeeds for a missing +directory; it fails only for corrupt files or operational scan errors. Corpus +reads reject file and directory symlinks. Incident ordering is deterministic: +canonical `YYYY-MM-DD-NNN` IDs use their date and numeric sequence, while legacy +IDs use timestamp and relative-path fallback ordering. Lists are newest-first; +analysis loads are oldest-first. + +`forge repair` remains preview-only. `forge quarantine` is also read-only by +default, and `forge quarantine --dry-run` performs the same zero-write scan. +`forge quarantine --apply` is the sole mutating path; it is mutually exclusive +with `--dry-run`. Apply aborts before creating the quarantine area when the +initial scan has operational errors, then reopens every candidate without +following symlinks and moves only files that are still corrupt regular files. +Destinations mirror incidents-root-relative paths under the data root's +`quarantine/` directory. Forge durably syncs each newly inserted quarantine +directory before descending and verifies that each opened directory descriptor +still names the created or revalidated entry. + +Apply uses one descriptor-relative, atomic no-replace rename from the incident +pathname to its visible quarantine destination: `renameatx_np` with +`RENAME_EXCL` on Darwin and `renameat2` with `RENAME_NOREPLACE` on Linux. +Unsupported platforms, unavailable native symbols, and filesystems that reject +the required flag fail closed; Forge does not fall back to replacement, +link-and-unlink, descriptor pseudo-paths, or path-based moves. A destination +collision leaves both entries untouched. Forge verifies the moved inode and +content, then syncs the destination parent before the source parent. Successful +moves leave exactly the visible destination and no private recovery namespace. + +If the source changes in the final pre-syscall interval, Forge detects the +replacement after the move and restores it to the source pathname with the same +no-replace primitive. A successful durable restore reports +`SourceChangedError`. A restore collision or post-move verification or +durability failure may leave the recoverable candidate at either its original +source pathname or the visible quarantine destination, depending on the failure +sequence, while a concurrent entry may occupy the other path. Operators must +inspect both sanitized reported paths; Forge's no-replace recovery never +overwrites either name. If a later candidate fails +after earlier moves, Forge reports both the moved and failed relative paths. + +`forge edit` copies the selected incident into a private regular-file stage and +never passes a corpus descriptor to the editor. Forge validates the completed +stage, then reopens the target through its verified parent-directory descriptor +and compares device/inode identity, metadata, and bytes with the original +snapshot before descriptor-relative atomic replacement. Any observed conflict +fails closed and leaves the product file untouched; replacement publishes a new +inode, so pre-existing hard links are not modified. Conflict detection is +bounded by POSIX semantics: POSIX has no compare-and-swap rename, so a concurrent +replacement in the final recheck-to-rename interval cannot be detected +atomically. +Operational storage failures during reopening, staging, publication, fsync, +replacement, rollback, or cleanup exit nonzero with a stable error class and, +when available, only the sanitized incidents-root-relative target. Raw storage +exception messages and configured roots are never printed. When cleanup or +rollback also fails, Forge reports the primary storage error class separately +from the `RecoverablePartialStateError` marker, so cleanup never masks the +publication failure. + +Publication keeps a descriptor-relative rollback hard link until the +replacement directory is durably synced. A post-replacement directory-fsync +failure restores the original inode when rollback succeeds. If rollback fails, +Forge preserves the backup link and reports the primary publication error plus +the recoverable-partial-state marker. If publication itself fails and removing +or syncing the rollback link also fails, Forge likewise preserves or recreates +the recovery link while retaining the primary publication classification. +After successful publication, Forge reports success only after removing the +backup and syncing its parent directory. Backup unlink or post-unlink +directory-fsync failure exits nonzero because cleanup durability is unknown. + ## MCP Server Forge exposes tools over the [Model Context Protocol](https://modelcontextprotocol.io) so agent tools can log and query incidents directly. @@ -115,6 +199,12 @@ Available tools: - `forge_playbook_show` - `forge_schema` +`forge_list` and `forge_stats` retain safe partial results when files are merely +corrupt, including skipped-corrupt diagnostics. Operational scan errors instead +return an explicit incomplete response containing sanitized corruption and scan +diagnostics only; incident rows, total markers, and aggregate sections are +withheld. + ### Local stdio MCP Use stdio when the MCP client runs on the same machine as Forge. @@ -241,7 +331,12 @@ If you want to inspect or paste the analysis input into another tool yourself: forge analyze --prepare-only ``` -This writes a dated `analysis-input` file into the active Forge data root. +Both analysis modes require a complete incident corpus. Corrupt files or +operational scan errors are reported with safe relative-path diagnostics, and +analysis exits before creating an artifact or invoking a provider. A genuinely +missing corpus remains a successful empty, read-only result. For a complete +non-empty corpus, prepare-only writes a dated `analysis-input` file into the +active Forge data root. ## Development diff --git a/forge_cli/cli.py b/forge_cli/cli.py index c325af7..dcf97d0 100644 --- a/forge_cli/cli.py +++ b/forge_cli/cli.py @@ -4,6 +4,7 @@ import os import subprocess import tempfile +from pathlib import Path from datetime import datetime, timezone from typing import Optional @@ -26,13 +27,21 @@ from forge_cli.incident_store import ( AmbiguousIncidentLookupError, DuplicateIncidentError, + IncidentEditConflictError, + IncidentScanResult, + IncidentLookupCorruptError, + IncidentLookupIncompleteError, + InvalidIncidentEditError, + UnsafeIncidentPathError, find_incident, - find_incident_path, - generate_id, - get_all_incidents, - list_incidents, - load_incident, - save_incident, + list_incidents_result, + quarantine_corrupt_incidents, + save_generated_incident, + safe_incident_relative_path, + safe_storage_error_type, + storage_error_has_recoverable_partial_state, + scan_incidents, + stage_incident_for_edit, ) from forge_cli.models import ( CAPABILITY_AREA_VALUES, @@ -141,6 +150,23 @@ def _parse_optional_observed_state(value: str | None) -> dict | None: raise typer.Exit(1) +def _print_scan_diagnostics(result: IncidentScanResult) -> None: + print_info(f"Valid corpus incidents: {result.valid_corpus_count}") + print_info(f"Corrupt corpus files: {result.corrupt_corpus_count}") + print_info(f"Matched incidents: {result.matched_count}") + print_info(f"Returned incidents: {result.returned_count}") + print_info(f"Scan operational errors: {len(result.scan_errors)}") + for error in result.errors: + print_error(f"{error.path}: {error.error_type}") + for error in result.scan_errors: + print_error(f"Scan error - {error.path}: {error.error_type}") + + +def _fail_on_scan_errors(result: IncidentScanResult) -> None: + if result.scan_errors: + raise typer.Exit(1) + + @app.command() def log( project: Optional[str] = typer.Option(None, "--project", "-p", help="Project name"), @@ -330,7 +356,7 @@ def log( # Build incident now = datetime.now(timezone.utc) - incident_id = generate_id(cfg.incidents_dir, now.date()) + incident_id = "" try: incident = Incident( @@ -373,11 +399,16 @@ def log( raise typer.Exit(0) try: - filepath = save_incident(incident, cfg.incidents_dir) - except DuplicateIncidentError as e: - print_error(str(e)) + filepath = save_generated_incident(incident, cfg.incidents_dir) + except DuplicateIncidentError: + print_error("Duplicate incident id") + raise typer.Exit(1) + except OSError as exc: + print_error(f"Storage error: {safe_storage_error_type(exc)}") raise typer.Exit(1) - print_success(f"Saved: {filepath}") + relative_path = safe_incident_relative_path(filepath, cfg.incidents_dir) + print_success(f"Saved incident {incident.id}") + print_success(f"Saved: {relative_path}") @app.command("list") @@ -408,21 +439,27 @@ def list_cmd( print_error(str(e)) raise typer.Exit(1) - incidents = list_incidents( - cfg.incidents_dir, - project=project, - severity=severity, - since=since, - tag=tag, - issue_class=issue_class, - capability_area=capability_area, - lifecycle_stage=lifecycle_stage, - workflow_archetype=workflow_archetype, - blocked_use_class=blocked_use_class, - limit=limit, - ) + try: + result = list_incidents_result( + cfg.incidents_dir, + project=project, + severity=severity, + since=since, + tag=tag, + issue_class=issue_class, + capability_area=capability_area, + lifecycle_stage=lifecycle_stage, + workflow_archetype=workflow_archetype, + blocked_use_class=blocked_use_class, + limit=limit, + ) + except ValueError as e: + print_error(str(e)) + raise typer.Exit(1) - display_incident_table(incidents) + _print_scan_diagnostics(result) + _fail_on_scan_errors(result) + display_incident_table(list(result.incidents)) @app.command() @@ -438,7 +475,11 @@ def show( try: incident = find_incident(cfg.incidents_dir, incident_id) - except AmbiguousIncidentLookupError as e: + except ( + AmbiguousIncidentLookupError, + IncidentLookupCorruptError, + IncidentLookupIncompleteError, + ) as e: print_error(str(e)) raise typer.Exit(1) if incident is None: @@ -462,7 +503,11 @@ def ref_cmd( try: incident = find_incident(cfg.incidents_dir, incident_id) - except AmbiguousIncidentLookupError as e: + except ( + AmbiguousIncidentLookupError, + IncidentLookupCorruptError, + IncidentLookupIncompleteError, + ) as e: print_error(str(e)) raise typer.Exit(1) if incident is None: @@ -477,41 +522,66 @@ def ref_cmd( def edit( incident_id: str = typer.Argument(help="Incident ID (e.g., '2026-03-04-001' or '001')"), ) -> None: - """Open an incident in $EDITOR for modification.""" + """Edit an isolated copy and atomically publish after validation.""" try: cfg = load_config() except FileNotFoundError as e: print_error(str(e)) raise typer.Exit(1) + editor = os.environ.get("EDITOR", "vi") + relative_path: str | None = None try: - path = find_incident_path(cfg.incidents_dir, incident_id) - except AmbiguousIncidentLookupError as e: + with stage_incident_for_edit(cfg.incidents_dir, incident_id) as session: + if session is None: + print_error(f"No incident found matching '{incident_id}'.") + raise typer.Exit(1) + relative_path = session.relative_path + print_info( + f"Opening {Path(editor).name or 'editor'} for " + f"{session.relative_path}." + ) + try: + subprocess.run( + [editor, str(session.stage_path)], + check=True, + close_fds=True, + ) + except subprocess.CalledProcessError: + print_error("Editor exited with an error.") + raise typer.Exit(1) + except OSError: + print_error("Editor could not be started.") + raise typer.Exit(1) + try: + updated = session.publish() + except InvalidIncidentEditError: + print_error("Edited file has invalid YAML.") + raise typer.Exit(1) + except ( + AmbiguousIncidentLookupError, + IncidentLookupCorruptError, + IncidentLookupIncompleteError, + ) as e: print_error(str(e)) raise typer.Exit(1) - if path is None: - print_error(f"No incident found matching '{incident_id}'.") + except IncidentEditConflictError: + print_error("Incident changed while the editor was open; edit not applied.") raise typer.Exit(1) - - editor = os.environ.get("EDITOR", "vi") - console.print(f"[bold]Opening {path.name} in {editor}...[/bold]") - - try: - subprocess.run([editor, str(path)], check=True) - except subprocess.CalledProcessError: - print_error("Editor exited with an error.") + except UnsafeIncidentPathError as e: + print_error(f"Cannot safely edit incident: {e}") raise typer.Exit(1) - - # Validate the edited file still parses - try: - updated = load_incident(path) - except Exception as e: - print_error(f"Edited file has invalid YAML: {e}") - print_info(f"File is at: {path}") + except OSError as exc: + target = f" (target: {relative_path})" if relative_path is not None else "" + print_error(f"Storage error: {safe_storage_error_type(exc)}{target}") + if storage_error_has_recoverable_partial_state(exc): + print_error( + "Recoverable partial state: RecoverablePartialStateError" + ) raise typer.Exit(1) display_incident_detail(updated) - print_success(f"Updated: {path}") + print_success(f"Updated: {relative_path}") @app.command() @@ -534,8 +604,12 @@ def analyze( from forge_cli.providers import get_provider - # Load incidents - all_incidents = get_all_incidents(cfg.incidents_dir) + scan = scan_incidents(cfg.incidents_dir) + if scan.errors or scan.scan_errors: + _print_scan_diagnostics(scan) + print_error("Incident corpus is incomplete; analysis was not started.") + raise typer.Exit(1) + all_incidents = list(scan.incidents) if not full and since: all_incidents = [i for i in all_incidents if i.timestamp >= since] @@ -609,6 +683,102 @@ def analyze( print_info(f" ... ({len(report.split(chr(10)))} lines total)") +def _corrupt_file_preview(label: str, dry_run: bool) -> None: + try: + cfg = load_config() + except FileNotFoundError as e: + print_error(str(e)) + raise typer.Exit(1) + + result = scan_incidents(cfg.incidents_dir) + _print_scan_diagnostics(result) + mode = "dry-run" if dry_run else "preview" + print_info(f"{label} {mode}: {result.corrupt_corpus_count} corrupt file(s)") + _fail_on_scan_errors(result) + + +@app.command() +def validate( + strict: bool = typer.Option( + False, + "--strict", + help="Exit nonzero when any corrupt incident file is found", + ), +) -> None: + """Validate the incident corpus without modifying files.""" + try: + cfg = load_config() + except FileNotFoundError as e: + print_error(str(e)) + raise typer.Exit(1) + + result = scan_incidents(cfg.incidents_dir) + _print_scan_diagnostics(result) + _fail_on_scan_errors(result) + if strict and result.errors: + raise typer.Exit(1) + + +@app.command() +def repair( + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Explicitly preview repair candidates without writing", + ), +) -> None: + """Preview corrupt-file repair candidates without modifying files.""" + _corrupt_file_preview("Repair", dry_run) + + +@app.command() +def quarantine( + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Explicitly preview quarantine candidates without writing", + ), + apply: bool = typer.Option( + False, + "--apply", + help="Move freshly verified corrupt files into the data-root quarantine", + ), +) -> None: + """Preview quarantine candidates, or explicitly move them with --apply.""" + if apply and dry_run: + print_error("--apply and --dry-run cannot be combined.") + raise typer.Exit(2) + if not apply: + _corrupt_file_preview("Quarantine", dry_run) + return + + try: + cfg = load_config() + except FileNotFoundError as e: + print_error(str(e)) + raise typer.Exit(1) + + result = quarantine_corrupt_incidents(cfg.incidents_dir) + _print_scan_diagnostics(result.scan) + if result.scan.scan_errors: + print_error("Quarantine was not started because the scan was incomplete.") + raise typer.Exit(1) + + print_info(f"Quarantine apply: {len(result.moved)} file(s) moved") + for relative_path in result.moved: + print_success(f"Moved: {relative_path}") + partial_paths = set(result.partial) + for relative_path in result.partial: + print_error( + f"Partial move: {relative_path} (RecoverablePartialStateError)" + ) + for failure in result.failures: + if failure.path not in partial_paths: + print_error(f"Failed: {failure.path} ({failure.error_type})") + if result.failures: + raise typer.Exit(1) + + @app.command() def stats( project: Optional[str] = typer.Option(None, "--project", "-p", help="Filter by project"), @@ -635,26 +805,22 @@ def stats( print_error(str(e)) raise typer.Exit(1) - incidents = get_all_incidents(cfg.incidents_dir) - - if project: - incidents = [i for i in incidents if i.project == project] - if severity: - incidents = [i for i in incidents if i.severity == severity] - if since: - incidents = [i for i in incidents if i.timestamp >= since] - if issue_class: - incidents = [i for i in incidents if i.issue_class == issue_class] - if capability_area: - incidents = [i for i in incidents if i.capability_area == capability_area] - if lifecycle_stage: - incidents = [i for i in incidents if i.lifecycle_stage == lifecycle_stage] - if workflow_archetype: - incidents = [i for i in incidents if i.workflow_archetype == workflow_archetype] - if blocked_use_class: - incidents = [i for i in incidents if i.blocked_use_class == blocked_use_class] - - display_stats(incidents) + result = list_incidents_result( + cfg.incidents_dir, + project=project, + severity=severity, + since=since, + issue_class=issue_class, + capability_area=capability_area, + lifecycle_stage=lifecycle_stage, + workflow_archetype=workflow_archetype, + blocked_use_class=blocked_use_class, + limit=None, + ) + + _print_scan_diagnostics(result) + _fail_on_scan_errors(result) + display_stats(list(result.incidents)) # --- Playbook subcommand --- diff --git a/forge_cli/display.py b/forge_cli/display.py index 53b4fa9..b7ac065 100644 --- a/forge_cli/display.py +++ b/forge_cli/display.py @@ -21,55 +21,119 @@ def severity_color(severity: str) -> str: }.get(severity, "white") +def _safe_string(value: object) -> str: + return "".join( + character if character.isprintable() else "?" for character in str(value) + ) + + +def _literal_text(value: object, style: str | None = None) -> Text: + return Text(_safe_string(value), style=style) + + +def _literal_multiline_text(value: object, style: str | None = None) -> Text: + safe = "\n".join(_safe_string(line) for line in str(value).split("\n")) + return Text(safe, style=style) + + +def _append_field( + content: Text, + label: str, + value: object, + *, + width: int, + label_style: str = "cyan bold", + value_style: str | None = None, +) -> None: + if content: + content.append("\n") + content.append(Text(f"{label + ':':<{width}}", style=label_style)) + content.append(_literal_multiline_text(value, value_style)) + + def display_incident_table(incidents: list[Incident], total: int | None = None) -> None: """Display a Rich table of incidents.""" if not incidents: - console.print("[dim]No incidents found.[/dim]") + console.print(Text("No incidents found.", style="dim")) return - table = Table(title="Forge Incidents", show_lines=False, padding=(0, 1)) - table.add_column("ID", style="cyan", no_wrap=True) - table.add_column("Project", style="blue") - table.add_column("Platform", style="dim cyan") - table.add_column("Severity", no_wrap=True) - table.add_column("Type", style="magenta") - table.add_column("Summary") + table = Table( + title=Text("Forge Incidents"), + show_lines=False, + padding=(0, 1), + ) + table.add_column(Text("ID"), style="cyan", no_wrap=True) + table.add_column(Text("Project"), style="blue") + table.add_column(Text("Platform"), style="dim cyan") + table.add_column(Text("Severity"), no_wrap=True) + table.add_column(Text("Type"), style="magenta") + table.add_column(Text("Summary")) for inc in incidents: sev_style = severity_color(inc.severity) summary = (inc.actual_behavior or "").strip().split("\n")[0][:60] - table.add_row( - inc.id, - inc.project, - inc.platform or "", - Text(inc.severity, style=sev_style), - inc.failure_type, - summary, + _literal_text(inc.id), + _literal_text(inc.project), + _literal_text(inc.platform or ""), + _literal_text(inc.severity, sev_style), + _literal_text(inc.failure_type), + _literal_text(summary), ) console.print(table) shown = len(incidents) if total and total > shown: - console.print(f"[dim]Showing {shown} of {total} incidents[/dim]") + console.print( + Text(f"Showing {shown} of {total} incidents", style="dim") + ) def display_incident_panel(incident: Incident) -> None: """Display a Rich panel summarizing a logged incident.""" - lines = [ - f"[cyan]ID:[/cyan] {incident.id}", - f"[cyan]Project:[/cyan] {incident.project}", - f"[cyan]Agent:[/cyan] {incident.agent}", - f"[cyan]Platform:[/cyan] {incident.platform}", - f"[cyan]Severity:[/cyan] [{severity_color(incident.severity)}]{incident.severity}[/]", - f"[cyan]Type:[/cyan] {incident.failure_type}", - "", - f"[cyan]Expected:[/cyan] {incident.expected_behavior.strip()[:80]}", - f"[cyan]Actual:[/cyan] {incident.actual_behavior.strip()[:80]}", - ] + shown_id = incident.id or "[allocated at save]" + content = Text() + for label, value, value_style in [ + ("ID", shown_id, None), + ("Project", incident.project, None), + ("Agent", incident.agent, None), + ("Platform", incident.platform or "", None), + ("Severity", incident.severity, severity_color(incident.severity)), + ("Type", incident.failure_type, None), + ]: + _append_field( + content, + label, + value, + width=12, + label_style="cyan", + value_style=value_style, + ) + + content.append("\n") + _append_field( + content, + "Expected", + incident.expected_behavior.strip()[:80], + width=12, + label_style="cyan", + ) + _append_field( + content, + "Actual", + incident.actual_behavior.strip()[:80], + width=12, + label_style="cyan", + ) if incident.tags: - lines.append(f"[cyan]Tags:[/cyan] {', '.join(incident.tags)}") + _append_field( + content, + "Tags", + ", ".join(incident.tags), + width=12, + label_style="cyan", + ) axes = [ value for value in [ @@ -81,27 +145,43 @@ def display_incident_panel(incident: Incident) -> None: if value ] if axes: - lines.append(f"[cyan]Axes:[/cyan] {', '.join(axes)}") + _append_field( + content, + "Axes", + ", ".join(axes), + width=12, + label_style="cyan", + ) - content = "\n".join(lines) - console.print(Panel(content, title="Incident Captured", border_style="green")) + console.print( + Panel( + content, + title=Text("Incident Captured"), + border_style="green", + ) + ) def display_incident_detail(incident: Incident) -> None: """Display full incident details in a Rich panel.""" - sev_style = severity_color(incident.severity) - - lines = [ - f"[cyan bold]ID:[/] {incident.id}", - f"[cyan bold]Timestamp:[/] {incident.timestamp}", - f"[cyan bold]Reported by:[/] {incident.reported_by}", - "", - f"[cyan bold]Project:[/] {incident.project}", - f"[cyan bold]Agent:[/] {incident.agent}", - f"[cyan bold]Platform:[/] {incident.platform}", - f"[cyan bold]Severity:[/] [{sev_style}]{incident.severity}[/]", - f"[cyan bold]Failure type:[/] {incident.failure_type}", - ] + content = Text() + for label, value, value_style in [ + ("ID", incident.id, None), + ("Timestamp", incident.timestamp, None), + ("Reported by", incident.reported_by, None), + ("Project", incident.project, None), + ("Agent", incident.agent, None), + ("Platform", incident.platform, None), + ("Severity", incident.severity, severity_color(incident.severity)), + ("Failure type", incident.failure_type, None), + ]: + _append_field( + content, + label, + value, + width=16, + value_style=value_style, + ) for label, value in [ ("Expected", incident.expected_behavior), @@ -113,14 +193,17 @@ def display_incident_detail(incident: Incident) -> None: ]: text = (value or "").strip() if text: - lines.append("") - lines.append(f"[cyan bold]{label}:[/]") + content.append("\n\n") + content.append(Text(f"{label}:", style="cyan bold")) for line in text.split("\n"): - lines.append(f" {line}") + content.append("\n ") + content.append(_literal_text(line)) if incident.tags: - lines.append("") - lines.append(f"[cyan bold]Tags:[/] {', '.join(incident.tags)}") + content.append("\n\n") + content.append(Text(f"{'Tags:':<16}", style="cyan bold")) + content.append(_literal_text(", ".join(incident.tags))) + axes = [ ("Capability area", incident.capability_area), ("Lifecycle stage", incident.lifecycle_stage), @@ -131,54 +214,85 @@ def display_incident_detail(incident: Incident) -> None: ] shown_axes = [(label, value) for label, value in axes if value] if shown_axes: - lines.append("") - lines.append("[cyan bold]Structured axes:[/]") + content.append("\n\n") + content.append(Text("Structured axes:", style="cyan bold")) for label, value in shown_axes: - lines.append(f" {label}: {value}") + content.append(f"\n {label}: ") + content.append(_literal_text(value)) + if incident.observed_state: - lines.append("") - lines.append("[cyan bold]Observed state:[/]") + content.append("\n\n") + content.append(Text("Observed state:", style="cyan bold")) for key, value in incident.observed_state.items(): - lines.append(f" {key}: {value}") - present_refs = [field_name for field_name in PROOFHOUSE_REF_FIELDS if getattr(incident, field_name)] + content.append("\n ") + content.append(_literal_text(key)) + content.append(": ") + content.append(_literal_text(value)) + + present_refs = [ + field_name + for field_name in PROOFHOUSE_REF_FIELDS + if getattr(incident, field_name) + ] if present_refs: - lines.append("") - lines.append(f"[cyan bold]Pointer refs:[/] {', '.join(present_refs)}") + content.append("\n\n") + content.append(Text(f"{'Pointer refs:':<16}", style="cyan bold")) + content.append(_literal_text(", ".join(present_refs))) if incident.related_incidents: - lines.append(f"[cyan bold]Related:[/] {', '.join(incident.related_incidents)}") + content.append("\n") + content.append(Text(f"{'Related:':<16}", style="cyan bold")) + content.append(_literal_text(", ".join(incident.related_incidents))) if incident.playbook_entry: - lines.append(f"[cyan bold]Playbook:[/] {incident.playbook_entry}") + content.append("\n") + content.append(Text(f"{'Playbook:':<16}", style="cyan bold")) + content.append(_literal_text(incident.playbook_entry)) - content = "\n".join(lines) - console.print(Panel(content, title=f"Incident {incident.id}", border_style="cyan")) + title = Text("Incident ") + title.append(_literal_text(incident.id)) + console.print(Panel(content, title=title, border_style="cyan")) def print_success(msg: str) -> None: - console.print(f"[green]{msg}[/green]") + console.print(_literal_text(msg, "green")) + + +def print_warning(msg: str) -> None: + console.print(_literal_text(msg, "yellow")) def print_error(msg: str) -> None: - console.print(f"[bold red]Error:[/bold red] {msg}") + text = Text("Error:", style="bold red") + text.append(" ") + text.append(_literal_text(msg, "red")) + console.print(text) def print_info(msg: str) -> None: - console.print(f"[dim]{msg}[/dim]") + console.print(_literal_text(msg, "dim")) def _counter_table(title: str, counter: Counter, color: str = "cyan") -> Table: """Build a small Rich table from a Counter.""" - table = Table(title=title, show_lines=False, padding=(0, 1), expand=True) - table.add_column("Value", style=color) - table.add_column("Count", justify="right", style="bold") + table = Table( + title=Text(title), + show_lines=False, + padding=(0, 1), + expand=True, + ) + table.add_column(Text("Value"), style=color) + table.add_column(Text("Count"), justify="right", style="bold") for value, count in counter.most_common(): - table.add_row(value or "(empty)", str(count)) + table.add_row( + _literal_text(value or "(empty)"), + _literal_text(count), + ) return table def display_stats(incidents: list[Incident]) -> None: """Display aggregate statistics for a list of incidents.""" if not incidents: - console.print("[dim]No incidents to summarize.[/dim]") + console.print(Text("No incidents to summarize.", style="dim")) return total = len(incidents) @@ -188,9 +302,10 @@ def display_stats(incidents: list[Incident]) -> None: by_platform = Counter(i.platform for i in incidents if i.platform) all_tags = Counter(tag for i in incidents for tag in i.tags) by_issue_class = Counter(i.issue_class for i in incidents if i.issue_class) - by_capability_area = Counter(i.capability_area for i in incidents if i.capability_area) + by_capability_area = Counter( + i.capability_area for i in incidents if i.capability_area + ) - # Date range timestamps = sorted(i.timestamp for i in incidents if i.timestamp) date_range = "" if timestamps: @@ -198,43 +313,67 @@ def display_stats(incidents: list[Incident]) -> None: last = timestamps[-1][:10] date_range = f"{first} to {last}" if first != last else first - # Header - header_lines = [f"[bold]Total incidents:[/bold] {total}"] + header = Text("Total incidents:", style="bold") + header.append(f" {total}") if date_range: - header_lines.append(f"[bold]Date range:[/bold] {date_range}") - console.print(Panel("\n".join(header_lines), title="Forge Stats", border_style="cyan")) + header.append("\n") + header.append(Text("Date range:", style="bold")) + header.append(" ") + header.append(_literal_text(date_range)) + console.print( + Panel( + header, + title=Text("Forge Stats"), + border_style="cyan", + ) + ) console.print() - # Severity & project side by side - sev_table = Table(title="By Severity", show_lines=False, padding=(0, 1), expand=True) - sev_table.add_column("Severity") - sev_table.add_column("Count", justify="right", style="bold") + sev_table = Table( + title=Text("By Severity"), + show_lines=False, + padding=(0, 1), + expand=True, + ) + sev_table.add_column(Text("Severity")) + sev_table.add_column(Text("Count"), justify="right", style="bold") for sev, count in by_severity.most_common(): - sev_table.add_row(Text(sev, style=severity_color(sev)), str(count)) + sev_table.add_row( + _literal_text(sev, severity_color(sev)), + _literal_text(count), + ) proj_table = _counter_table("By Project", by_project, color="blue") - console.print(Columns([sev_table, proj_table], equal=True)) console.print() - # Type & platform side by side type_table = _counter_table("By Failure Type", by_type, color="magenta") plat_table = _counter_table("By Platform", by_platform, color="dim cyan") - console.print(Columns([type_table, plat_table], equal=True)) if by_issue_class or by_capability_area: console.print() - issue_table = _counter_table("By Issue Class", by_issue_class, color="yellow") - capability_table = _counter_table("By Capability Area", by_capability_area, color="cyan") + issue_table = _counter_table( + "By Issue Class", + by_issue_class, + color="yellow", + ) + capability_table = _counter_table( + "By Capability Area", + by_capability_area, + color="cyan", + ) console.print(Columns([issue_table, capability_table], equal=True)) - # Top tags if all_tags: console.print() - tag_table = Table(title="Top Tags", show_lines=False, padding=(0, 1)) - tag_table.add_column("Tag", style="green") - tag_table.add_column("Count", justify="right", style="bold") + tag_table = Table( + title=Text("Top Tags"), + show_lines=False, + padding=(0, 1), + ) + tag_table.add_column(Text("Tag"), style="green") + tag_table.add_column(Text("Count"), justify="right", style="bold") for tag, count in all_tags.most_common(10): - tag_table.add_row(tag, str(count)) + tag_table.add_row(_literal_text(tag), _literal_text(count)) console.print(tag_table) diff --git a/forge_cli/incident_store.py b/forge_cli/incident_store.py index 355f738..ae4eb10 100644 --- a/forge_cli/incident_store.py +++ b/forge_cli/incident_store.py @@ -1,10 +1,17 @@ from __future__ import annotations +from contextlib import contextmanager +import ctypes +from dataclasses import dataclass, field +import errno from datetime import date, datetime import os from pathlib import Path +import re +import secrets +import stat +import sys import tempfile - import yaml from forge_cli.models import Incident @@ -43,68 +50,857 @@ class AmbiguousIncidentLookupError(LookupError): """Raised when a suffix lookup matches multiple incidents.""" +class UnsafeIncidentPathError(OSError): + """Raised when no-follow access cannot safely open an incident path.""" + + +class IncidentLookupCorruptError(LookupError): + """Raised when the requested exact or suffix candidate is corrupt.""" + + +class IncidentLookupIncompleteError(LookupError): + """Raised when operational scan errors prevent a reliable lookup.""" + + +class IncidentEditConflictError(OSError): + """Raised when the corpus target changed during a staged edit.""" + + +class InvalidIncidentEditError(ValueError): + """Raised when an editor stage is unsafe or does not contain a valid incident.""" + + +class AtomicRenameUnsupportedError(OSError): + """Raised when the platform lacks a safe atomic no-replace rename.""" + + +class QuarantineSourceChangedError(OSError): + """Raised when a quarantine candidate is no longer freshly corrupt.""" + +class RecoverablePartialStateError(OSError): + """Indicates recovery state remains after an incomplete operation.""" + + def __init__( + self, + *, + primary_error: OSError | None = None, + moved: bool = False, + ) -> None: + super().__init__() + if ( + isinstance(primary_error, RecoverablePartialStateError) + and primary_error.primary_error is not None + ): + primary_error = primary_error.primary_error + self.primary_error = primary_error + self.moved = moved + + +@dataclass(frozen=True) +class IncidentLoadError: + """Safe diagnostic for an incident file that could not be loaded.""" + + path: str + error_type: str + _relative_path: str | None = field(default=None, repr=False, compare=False) + + +@dataclass(frozen=True) +class IncidentScanResult: + """Incident results with explicit corpus, match, return, and error scopes.""" + + incidents: tuple[Incident, ...] + errors: tuple[IncidentLoadError, ...] + scan_errors: tuple[IncidentLoadError, ...] + valid_corpus_count: int + matched_count: int + + @property + def corrupt_corpus_count(self) -> int: + return len(self.errors) + + @property + def returned_count(self) -> int: + return len(self.incidents) + + +@dataclass(frozen=True) +class QuarantineResult: + """Sanitized result of an explicit corrupt-file quarantine operation.""" + + scan: IncidentScanResult + moved: tuple[str, ...] = () + partial: tuple[str, ...] = () + failures: tuple[IncidentLoadError, ...] = () + + def generate_id(incidents_dir: Path, incident_date: date | None = None) -> str: - """Generate the next incident ID for the given date (YYYY-MM-DD-NNN).""" + """Read the next dated ID without creating corpus directories.""" if incident_date is None: incident_date = date.today() - - month_dir = incidents_dir / incident_date.strftime("%Y-%m") + month_name = incident_date.strftime("%Y-%m") prefix = incident_date.strftime("%Y-%m-%d") - - if month_dir.exists(): - existing = sorted(month_dir.glob(f"{prefix}-*.yml")) - if existing: - last_seq = int(existing[-1].stem.split("-")[-1]) - return f"{prefix}-{last_seq + 1:03d}" - - return f"{prefix}-001" + pattern = re.compile(rf"{re.escape(prefix)}-(\d{{3,}})\.yml") + maximum = 0 + try: + root_fd = _open_nofollow(incidents_dir, directory=True) + except FileNotFoundError: + return f"{prefix}-001" + try: + try: + month_fd = _open_nofollow(month_name, directory=True, dir_fd=root_fd) + except FileNotFoundError: + return f"{prefix}-001" + try: + for name in os.listdir(month_fd): + match = pattern.fullmatch(name) + if match: + maximum = max(maximum, int(match.group(1))) + finally: + os.close(month_fd) + finally: + os.close(root_fd) + return f"{prefix}-{maximum + 1:03d}" # --- File I/O --- def save_incident(incident: Incident, incidents_dir: Path) -> Path: - """Write an incident as a YAML file. Returns the file path.""" + """Atomically publish an incident through verified directory descriptors.""" incident_date = datetime.fromisoformat(incident.timestamp).date() - month_dir = incidents_dir / incident_date.strftime("%Y-%m") - month_dir.mkdir(parents=True, exist_ok=True) + month_name = incident_date.strftime("%Y-%m") + target_name = f"{incident.id}.yml" + if Path(target_name).name != target_name or target_name in {".", ".."}: + raise ValueError("Incident id is not safe for a corpus filename") + incidents_dir.mkdir(parents=True, exist_ok=True) + root_fd = _open_nofollow(incidents_dir, directory=True) + month_fd: int | None = None + temporary_name: str | None = None + temporary_fd: int | None = None + try: + try: + month_fd = _open_nofollow(month_name, directory=True, dir_fd=root_fd) + except FileNotFoundError: + try: + os.mkdir(month_name, 0o755, dir_fd=root_fd) + except FileExistsError: + pass + month_fd = _open_nofollow(month_name, directory=True, dir_fd=root_fd) + + payload = yaml.dump( + incident.to_dict(), + Dumper=_BlockDumper, + default_flow_style=False, + allow_unicode=True, + ).encode() + for _ in range(100): + candidate = f".{incident.id}.{secrets.token_hex(8)}.tmp" + try: + temporary_fd = os.open( + candidate, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=month_fd, + ) + except FileExistsError: + continue + temporary_name = candidate + break + if temporary_fd is None or temporary_name is None: + raise FileExistsError("Could not create an incident publication file") + _write_all(temporary_fd, payload) + os.fsync(temporary_fd) + os.close(temporary_fd) + temporary_fd = None + try: + os.link( + temporary_name, + target_name, + src_dir_fd=month_fd, + dst_dir_fd=month_fd, + follow_symlinks=False, + ) + except FileExistsError as exc: + raise DuplicateIncidentError( + f"Incident id already exists: {incident.id}" + ) from exc + os.fsync(month_fd) + finally: + if temporary_fd is not None: + os.close(temporary_fd) + if temporary_name is not None and month_fd is not None: + try: + os.unlink(temporary_name, dir_fd=month_fd) + except FileNotFoundError: + pass + if month_fd is not None: + os.close(month_fd) + os.close(root_fd) + return incidents_dir / month_name / target_name + + +def save_generated_incident( + incident: Incident, + incidents_dir: Path, + *, + max_attempts: int = 100, +) -> Path: + """Assign a human-readable dated ID and save with bounded collision retries.""" + if max_attempts <= 0: + raise ValueError("max_attempts must be positive") + incident_date = datetime.fromisoformat(incident.timestamp.replace("Z", "+00:00")).date() + for _ in range(max_attempts): + incident.id = generate_id(incidents_dir, incident_date) + try: + return save_incident(incident, incidents_dir) + except DuplicateIncidentError: + continue + raise DuplicateIncidentError( + f"Could not allocate a unique incident id for {incident_date.isoformat()} " + f"after {max_attempts} attempts" + ) + + +def _require_nofollow_flags(*, directory: bool = False) -> int: + nofollow = getattr(os, "O_NOFOLLOW", 0) + if not nofollow: + raise UnsafeIncidentPathError( + "Safe incident access requires POSIX O_NOFOLLOW support" + ) + flags = os.O_RDONLY | nofollow + if directory: + directory_flag = getattr(os, "O_DIRECTORY", 0) + if not directory_flag: + raise UnsafeIncidentPathError( + "Safe incident access requires POSIX O_DIRECTORY support" + ) + flags |= directory_flag + else: + flags |= getattr(os, "O_NONBLOCK", 0) + return flags + + +def _open_nofollow( + path: str | os.PathLike[str], + *, + directory: bool = False, + dir_fd: int | None = None, +) -> int: + flags = _require_nofollow_flags(directory=directory) + try: + fd = os.open(path, flags, dir_fd=dir_fd) + except OSError as exc: + if exc.errno in {errno.ELOOP, errno.EMLINK} or ( + directory and exc.errno == errno.ENOTDIR + ): + raise UnsafeIncidentPathError( + "Incident directory path is a symlink or non-directory" + ) from exc + raise + mode = os.fstat(fd).st_mode + expected = stat.S_ISDIR(mode) if directory else stat.S_ISREG(mode) + if not expected: + os.close(fd) + kind = "directory" if directory else "regular file" + raise UnsafeIncidentPathError(f"Incident path is not a {kind}") + return fd + + +def _load_incident_fd(fd: int) -> Incident: + with os.fdopen(os.dup(fd)) as file: + data = yaml.safe_load(file) + return Incident.from_dict(data) + + +def load_open_incident(fd: int) -> Incident: + """Load an already-open incident descriptor from its current contents.""" + os.lseek(fd, 0, os.SEEK_SET) + return _load_incident_fd(fd) + + +def load_incident(path: Path) -> Incident: + """Load one regular incident file without following its final symlink.""" + fd = _open_nofollow(path) + try: + return _load_incident_fd(fd) + finally: + os.close(fd) - filepath = month_dir / f"{incident.id}.yml" - data = incident.to_dict() +def _safe_text(value: str) -> str: + return "".join(character if character.isprintable() else "?" for character in value) - if filepath.exists(): - raise DuplicateIncidentError(f"Incident id already exists: {incident.id}") - tmp_path: Path | None = None +def _safe_relative_path(path: str | os.PathLike[str], incidents_dir: Path) -> str: + candidate = Path(path) try: - with tempfile.NamedTemporaryFile( - "w", - dir=month_dir, - prefix=f".{incident.id}.", - suffix=".tmp", - delete=False, - ) as f: - tmp_path = Path(f.name) - yaml.dump(data, f, Dumper=_BlockDumper, default_flow_style=False, allow_unicode=True) - f.flush() - os.fsync(f.fileno()) + relative = candidate.relative_to(incidents_dir) + except ValueError: + relative = Path(candidate.name) + return _safe_text(relative.as_posix() or ".") + + +def safe_incident_relative_path( + path: str | os.PathLike[str], + incidents_dir: Path, +) -> str: + """Return a printable incidents-root-relative path without exposing the root.""" + return _safe_relative_path(path, incidents_dir) + + +def _safe_error_type(exc: BaseException) -> str: + if isinstance(exc, AtomicRenameUnsupportedError): + return "UnsupportedOperation" + if isinstance(exc, UnsafeIncidentPathError): + return "UnsafeIncidentPathError" + if isinstance(exc, QuarantineSourceChangedError): + return "SourceChangedError" + if isinstance(exc, RecoverablePartialStateError): + return "RecoverablePartialStateError" + if isinstance(exc, PermissionError): + return "PermissionError" + if isinstance(exc, FileNotFoundError): + return "FileNotFoundError" + if isinstance(exc, FileExistsError): + return "FileExistsError" + if isinstance(exc, IsADirectoryError): + return "IsADirectoryError" + if isinstance(exc, NotADirectoryError): + return "NotADirectoryError" + if isinstance(exc, yaml.YAMLError): + return "YAMLError" + if isinstance(exc, OSError): + return "OSError" + return "InvalidIncidentError" + + +def safe_storage_error_type(exc: OSError) -> str: + """Classify a storage error without exposing its message or filename.""" + if ( + isinstance(exc, RecoverablePartialStateError) + and exc.primary_error is not None + ): + return _safe_error_type(exc.primary_error) + return _safe_error_type(exc) + + +def storage_error_has_recoverable_partial_state(exc: OSError) -> bool: + """Return whether an operation left recoverable partial storage state.""" + return isinstance(exc, RecoverablePartialStateError) + + +@dataclass(frozen=True) +class _IncidentEntry: + incident: Incident + relative_path: str + order_key: tuple + + +@dataclass(frozen=True) +class _IncidentLookupResult: + entry: _IncidentEntry | None + errors: tuple[IncidentLoadError, ...] + scan_errors: tuple[IncidentLoadError, ...] + + +_CANONICAL_ID = re.compile( + r"^(?P\d{4})-(?P\d{2})-(?P\d{2})-(?P\d{3,})$" +) + + +def _incident_order_key(incident: Incident, relative_path: str) -> tuple: + """Validate and order canonical IDs or legacy timestamps deterministically.""" + match = _CANONICAL_ID.fullmatch(incident.id) + if match: + canonical_date = date( + int(match["year"]), + int(match["month"]), + int(match["day"]), + ) + return ( + canonical_date, + 0, + int(match["sequence"]), + relative_path, + ) + timestamp = datetime.fromisoformat(incident.timestamp.replace("Z", "+00:00")) + return ( + timestamp.date(), + 1, + timestamp.isoformat(), + relative_path, + ) + + +def _symlink_error(relative_path: Path) -> IncidentLoadError: + return IncidentLoadError( + path=_safe_text(relative_path.as_posix()), + error_type="SymlinkRejectedError", + ) + + +def _scan_incident_entries( + incidents_dir: Path, +) -> tuple[list[_IncidentEntry], list[IncidentLoadError], list[IncidentLoadError]]: + entries: list[_IncidentEntry] = [] + errors: list[IncidentLoadError] = [] + scan_errors: list[IncidentLoadError] = [] + + try: + root_fd = _open_nofollow(incidents_dir, directory=True) + except FileNotFoundError: + return entries, errors, scan_errors + except Exception as exc: + scan_errors.append( + IncidentLoadError( + path=".", + error_type=_safe_error_type(exc), + ) + ) + return entries, errors, scan_errors + + def visit(directory_fd: int, relative_root: Path) -> None: try: - os.link(tmp_path, filepath) - except FileExistsError as exc: - raise DuplicateIncidentError(f"Incident id already exists: {incident.id}") from exc + names = sorted(os.listdir(directory_fd)) + except OSError as exc: + scan_errors.append( + IncidentLoadError( + path=_safe_text(relative_root.as_posix() or "."), + error_type=_safe_error_type(exc), + ) + ) + return + + for name in names: + relative_path = relative_root / name + try: + metadata = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + except OSError as exc: + scan_errors.append( + IncidentLoadError( + path=_safe_text(relative_path.as_posix()), + error_type=_safe_error_type(exc), + ) + ) + continue + if stat.S_ISLNK(metadata.st_mode): + scan_errors.append(_symlink_error(relative_path)) + continue + if stat.S_ISDIR(metadata.st_mode): + try: + child_fd = _open_nofollow( + name, + directory=True, + dir_fd=directory_fd, + ) + except UnsafeIncidentPathError: + scan_errors.append(_symlink_error(relative_path)) + continue + except OSError as exc: + scan_errors.append( + IncidentLoadError( + path=_safe_text(relative_path.as_posix()), + error_type=_safe_error_type(exc), + ) + ) + continue + try: + visit(child_fd, relative_path) + finally: + os.close(child_fd) + continue + if Path(name).suffix != ".yml": + continue + if not stat.S_ISREG(metadata.st_mode): + scan_errors.append( + IncidentLoadError( + path=_safe_text(relative_path.as_posix()), + error_type="UnsafeFileTypeError", + ) + ) + continue + try: + file_fd = _open_nofollow(name, dir_fd=directory_fd) + except UnsafeIncidentPathError: + scan_errors.append(_symlink_error(relative_path)) + continue + except OSError as exc: + scan_errors.append( + IncidentLoadError( + path=_safe_text(relative_path.as_posix()), + error_type=_safe_error_type(exc), + ) + ) + continue + try: + try: + content = _read_fd_bytes(file_fd) + except OSError as exc: + scan_errors.append( + IncidentLoadError( + path=_safe_text(relative_path.as_posix()), + error_type=_safe_error_type(exc), + ) + ) + continue + try: + data = yaml.safe_load(content.decode("utf-8")) + incident = Incident.from_dict(data) + relative_text = relative_path.as_posix() + order_key = _incident_order_key(incident, relative_text) + except Exception as exc: + errors.append( + IncidentLoadError( + path=_safe_text(relative_path.as_posix()), + error_type=_safe_error_type(exc), + _relative_path=relative_path.as_posix(), + ) + ) + else: + entries.append( + _IncidentEntry( + incident=incident, + relative_path=relative_text, + order_key=order_key, + ) + ) + finally: + os.close(file_fd) + + try: + visit(root_fd, Path()) finally: - if tmp_path is not None and tmp_path.exists(): - tmp_path.unlink() + os.close(root_fd) + entries.sort(key=lambda entry: entry.order_key) + return entries, errors, scan_errors - return filepath +def scan_incidents(incidents_dir: Path) -> IncidentScanResult: + """Load an oldest-first corpus without writes or symlink traversal.""" + entries, errors, scan_errors = _scan_incident_entries(incidents_dir) + loaded = tuple(entry.incident for entry in entries) + return IncidentScanResult( + incidents=loaded, + errors=tuple(errors), + scan_errors=tuple(scan_errors), + valid_corpus_count=len(loaded), + matched_count=len(loaded), + ) -def load_incident(path: Path) -> Incident: - """Load a single incident from a YAML file.""" - with open(path) as f: - data = yaml.safe_load(f) - return Incident.from_dict(data) + +def _close_quietly(fd: int | None) -> None: + if fd is None: + return + try: + os.close(fd) + except OSError: + pass + + +def _open_or_create_directory(name: str, parent_fd: int) -> int: + if name in {"", ".", ".."} or "/" in name: + raise UnsafeIncidentPathError("Unsafe quarantine directory component") + created_metadata: os.stat_result | None = None + try: + os.mkdir(name, 0o700, dir_fd=parent_fd) + created_metadata = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + os.fsync(parent_fd) + except FileExistsError: + pass + + directory_fd = _open_nofollow(name, directory=True, dir_fd=parent_fd) + try: + opened_metadata = os.fstat(directory_fd) + current_metadata = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + opened_identity = (opened_metadata.st_dev, opened_metadata.st_ino) + if ( + opened_identity != (current_metadata.st_dev, current_metadata.st_ino) + or ( + created_metadata is not None + and opened_identity + != (created_metadata.st_dev, created_metadata.st_ino) + ) + ): + raise UnsafeIncidentPathError( + "Quarantine directory changed while it was opened" + ) + return directory_fd + except BaseException: + _close_quietly(directory_fd) + raise + + +def _open_quarantine_parent(incidents_dir: Path, relative_path: str) -> tuple[int, str]: + relative = Path(relative_path) + parts = relative.parts + if ( + relative.is_absolute() + or not parts + or any(part in {"", ".", ".."} for part in parts) + ): + raise UnsafeIncidentPathError("Unsafe quarantine relative path") + root_fd = _open_nofollow(incidents_dir.parent, directory=True) + quarantine_fd: int | None = None + try: + quarantine_fd = _open_or_create_directory("quarantine", root_fd) + for part in parts[:-1]: + child_fd = _open_or_create_directory(part, quarantine_fd) + _close_quietly(quarantine_fd) + quarantine_fd = child_fd + return quarantine_fd, parts[-1] + except BaseException: + _close_quietly(quarantine_fd) + raise + finally: + _close_quietly(root_fd) + + +def _require_fresh_corrupt_file( + source_name: str, + source_parent_fd: int, +) -> tuple[int, os.stat_result, bytes]: + source_fd = _open_nofollow(source_name, dir_fd=source_parent_fd) + try: + before = os.fstat(source_fd) + content = _read_fd_bytes(source_fd) + after = os.fstat(source_fd) + if _stable_metadata(before) != _stable_metadata(after): + raise QuarantineSourceChangedError() + try: + data = yaml.safe_load(content.decode("utf-8")) + incident = Incident.from_dict(data) + _incident_order_key(incident, source_name) + except Exception: + return source_fd, after, content + except BaseException: + _close_quietly(source_fd) + raise + _close_quietly(source_fd) + raise QuarantineSourceChangedError() + + +def _load_native_rename_noreplace(): + try: + libc = ctypes.CDLL(None, use_errno=True) + except OSError: + return None + + if sys.platform == "darwin": + function = getattr(libc, "renameatx_np", None) + flag = 0x00000004 # RENAME_EXCL + elif sys.platform.startswith("linux"): + function = getattr(libc, "renameat2", None) + flag = 0x00000001 # RENAME_NOREPLACE + else: + return None + if function is None: + return None + + function.argtypes = [ + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_uint, + ] + function.restype = ctypes.c_int + + def rename_noreplace( + source_parent_fd: int, + source_name: str, + destination_parent_fd: int, + destination_name: str, + ) -> None: + ctypes.set_errno(0) + result = function( + source_parent_fd, + os.fsencode(source_name), + destination_parent_fd, + os.fsencode(destination_name), + flag, + ) + if result == 0: + return + error_number = ctypes.get_errno() + if error_number in { + errno.ENOSYS, + errno.EINVAL, + errno.ENOTSUP, + errno.EOPNOTSUPP, + }: + raise AtomicRenameUnsupportedError() + raise OSError(error_number, os.strerror(error_number)) + + return rename_noreplace + + +_NATIVE_RENAME_NOREPLACE = _load_native_rename_noreplace() + + +def _rename_noreplace( + source_parent_fd: int, + source_name: str, + destination_parent_fd: int, + destination_name: str, +) -> None: + native_rename = _NATIVE_RENAME_NOREPLACE + if native_rename is None: + raise AtomicRenameUnsupportedError() + native_rename( + source_parent_fd, + source_name, + destination_parent_fd, + destination_name, + ) + + +def _restore_changed_quarantine_source( + source_parent_fd: int, + source_name: str, + destination_parent_fd: int, + destination_name: str, +) -> None: + source_changed = QuarantineSourceChangedError() + try: + _rename_noreplace( + destination_parent_fd, + destination_name, + source_parent_fd, + source_name, + ) + except OSError as restore_error: + raise RecoverablePartialStateError( + primary_error=source_changed, + moved=True, + ) from restore_error + try: + os.fsync(source_parent_fd) + os.fsync(destination_parent_fd) + except OSError as durability_error: + try: + _rename_noreplace( + source_parent_fd, + source_name, + destination_parent_fd, + destination_name, + ) + except OSError: + pass + raise RecoverablePartialStateError( + primary_error=source_changed, + moved=True, + ) from durability_error + raise source_changed + + +def _quarantine_one(incidents_dir: Path, relative_path: str) -> None: + if _NATIVE_RENAME_NOREPLACE is None: + raise AtomicRenameUnsupportedError() + + source_parent_fd: int | None = None + destination_parent_fd: int | None = None + source_fd: int | None = None + moved = False + try: + source_parent_fd, source_name = _open_incident_parent( + incidents_dir, relative_path + ) + source_fd, source_metadata, source_content = _require_fresh_corrupt_file( + source_name, source_parent_fd + ) + expected_metadata = _quarantine_metadata(source_metadata) + destination_parent_fd, destination_name = _open_quarantine_parent( + incidents_dir, relative_path + ) + _rename_noreplace( + source_parent_fd, + source_name, + destination_parent_fd, + destination_name, + ) + moved = True + try: + destination_metadata = os.stat( + destination_name, + dir_fd=destination_parent_fd, + follow_symlinks=False, + ) + if ( + _quarantine_metadata(destination_metadata) != expected_metadata + or _read_fd_bytes(source_fd) != source_content + ): + _restore_changed_quarantine_source( + source_parent_fd, + source_name, + destination_parent_fd, + destination_name, + ) + os.fsync(destination_parent_fd) + os.fsync(source_parent_fd) + except QuarantineSourceChangedError: + raise + except RecoverablePartialStateError: + raise + except OSError as exc: + raise RecoverablePartialStateError( + primary_error=exc, + moved=True, + ) from exc + except QuarantineSourceChangedError: + raise + except AtomicRenameUnsupportedError: + raise + except RecoverablePartialStateError: + raise + except OSError as exc: + if moved: + raise RecoverablePartialStateError( + primary_error=exc, + moved=True, + ) from exc + raise + finally: + _close_quietly(source_fd) + _close_quietly(destination_parent_fd) + _close_quietly(source_parent_fd) + + +def quarantine_corrupt_incidents(incidents_dir: Path) -> QuarantineResult: + """Move freshly revalidated corrupt regular files into a sibling quarantine.""" + scan = scan_incidents(incidents_dir) + if scan.scan_errors: + return QuarantineResult(scan=scan) + + moved: list[str] = [] + partial: list[str] = [] + failures: list[IncidentLoadError] = [] + for candidate in scan.errors: + relative_path = candidate._relative_path + if relative_path is None: + failures.append( + IncidentLoadError(candidate.path, "UnsafeIncidentPathError") + ) + continue + try: + _quarantine_one(incidents_dir, relative_path) + except OSError as exc: + if isinstance(exc, RecoverablePartialStateError) and exc.moved: + partial.append(candidate.path) + failures.append( + IncidentLoadError(candidate.path, _safe_error_type(exc)) + ) + else: + moved.append(candidate.path) + return QuarantineResult( + scan=scan, + moved=tuple(moved), + partial=tuple(partial), + failures=tuple(failures), + ) def list_incidents( @@ -121,79 +917,488 @@ def list_incidents( limit: int = 10, ) -> list[Incident]: """List incidents with optional filtering, most recent first.""" - all_files = sorted(incidents_dir.rglob("*.yml"), reverse=True) + return list( + list_incidents_result( + incidents_dir, + project=project, + severity=severity, + since=since, + tag=tag, + issue_class=issue_class, + capability_area=capability_area, + lifecycle_stage=lifecycle_stage, + workflow_archetype=workflow_archetype, + blocked_use_class=blocked_use_class, + limit=limit, + ).incidents + ) - # Exclude template-like files (e.g., .gitkeep won't match *.yml) - incidents: list[Incident] = [] - for filepath in all_files: - try: - incident = load_incident(filepath) - except Exception: - continue - if project and incident.project != project: - continue - if severity and incident.severity != severity: - continue - if since and incident.timestamp < since: - continue - if tag and tag not in incident.tags: - continue - if issue_class and incident.issue_class != issue_class: - continue - if capability_area and incident.capability_area != capability_area: - continue - if lifecycle_stage and incident.lifecycle_stage != lifecycle_stage: - continue - if workflow_archetype and incident.workflow_archetype != workflow_archetype: - continue - if blocked_use_class and incident.blocked_use_class != blocked_use_class: - continue - - incidents.append(incident) - if len(incidents) >= limit: - break - - return incidents +def list_incidents_result( + incidents_dir: Path, + project: str | None = None, + severity: str | None = None, + since: str | None = None, + tag: str | None = None, + issue_class: str | None = None, + capability_area: str | None = None, + lifecycle_stage: str | None = None, + workflow_archetype: str | None = None, + blocked_use_class: str | None = None, + limit: int | None = 10, +) -> IncidentScanResult: + """List newest-first incidents with diagnostics for every skipped file.""" + if limit is not None and limit < 0: + raise ValueError("limit must be non-negative") + scan = scan_incidents(incidents_dir) + incidents = [ + incident + for incident in reversed(scan.incidents) + if (not project or incident.project == project) + and (not severity or incident.severity == severity) + and (not since or incident.timestamp >= since) + and (not tag or tag in incident.tags) + and (not issue_class or incident.issue_class == issue_class) + and (not capability_area or incident.capability_area == capability_area) + and (not lifecycle_stage or incident.lifecycle_stage == lifecycle_stage) + and (not workflow_archetype or incident.workflow_archetype == workflow_archetype) + and (not blocked_use_class or incident.blocked_use_class == blocked_use_class) + ] + matched_count = len(incidents) + if limit is not None: + incidents = incidents[:limit] + return IncidentScanResult( + incidents=tuple(incidents), + errors=scan.errors, + scan_errors=scan.scan_errors, + valid_corpus_count=scan.valid_corpus_count, + matched_count=matched_count, + ) -def find_incident_path(incidents_dir: Path, incident_id: str) -> Path | None: - """Find the file path for an incident by ID (exact or suffix match).""" - parts = incident_id.split("-") - if len(parts) >= 3: - month_prefix = f"{parts[0]}-{parts[1]}" - exact_path = incidents_dir / month_prefix / f"{incident_id}.yml" - if exact_path.exists(): - return exact_path - - matches = sorted(filepath for filepath in incidents_dir.rglob("*.yml") if filepath.stem.endswith(incident_id)) +def _lookup_result( + incidents_dir: Path, + incident_id: str, +) -> _IncidentLookupResult: + entries, errors, scan_errors = _scan_incident_entries(incidents_dir) + if scan_errors: + details = ", ".join( + f"{error.path}: {error.error_type}" for error in scan_errors + ) + raise IncidentLookupIncompleteError( + f"Incident lookup is operationally incomplete: {details}" + ) + exact_entries = [ + entry + for entry in entries + if Path(entry.relative_path).stem == incident_id + ] + exact_errors = tuple( + error for error in errors if Path(error.path).stem == incident_id + ) + if exact_entries or exact_errors: + matches = exact_entries + requested_errors = exact_errors + else: + matches = [ + entry + for entry in entries + if Path(entry.relative_path).stem.endswith(incident_id) + ] + requested_errors = tuple( + error + for error in errors + if Path(error.path).stem.endswith(incident_id) + ) + if requested_errors: + details = ", ".join( + f"{error.path}: {error.error_type}" for error in requested_errors + ) + raise IncidentLookupCorruptError( + f"Requested incident candidate is corrupt: {details}" + ) if len(matches) == 1: - return matches[0] + return _IncidentLookupResult(matches[0], tuple(errors), tuple(scan_errors)) if len(matches) > 1: - ids = ", ".join(match.stem for match in matches) - raise AmbiguousIncidentLookupError(f"Ambiguous incident id '{incident_id}'. Matches: {ids}") + ids = ", ".join( + _safe_text(Path(match.relative_path).stem) + for match in matches + ) + safe_id = _safe_text(incident_id) + raise AmbiguousIncidentLookupError( + f"Ambiguous incident id '{safe_id}'. Matches: {ids}" + ) + return _IncidentLookupResult(None, tuple(errors), tuple(scan_errors)) - return None +def _lookup_entry( + incidents_dir: Path, + incident_id: str, +) -> _IncidentEntry | None: + return _lookup_result(incidents_dir, incident_id).entry -def find_incident(incidents_dir: Path, incident_id: str) -> Incident | None: - """Find an incident by ID (exact or suffix match).""" - path = find_incident_path(incidents_dir, incident_id) - if path is None: + +def find_incident_path(incidents_dir: Path, incident_id: str) -> Path | None: + """Return an advisory path; callers needing guarantees must reopen no-follow.""" + entry = _lookup_entry(incidents_dir, incident_id) + if entry is None: return None + return incidents_dir / entry.relative_path + + +def find_incident(incidents_dir: Path, incident_id: str) -> Incident | None: + """Find an incident from the no-follow scan snapshot.""" + entry = _lookup_entry(incidents_dir, incident_id) + return entry.incident if entry is not None else None + + +def _read_fd_bytes(fd: int) -> bytes: + os.lseek(fd, 0, os.SEEK_SET) + chunks: list[bytes] = [] + while True: + chunk = os.read(fd, 1024 * 1024) + if not chunk: + return b"".join(chunks) + chunks.append(chunk) + + +def _stable_metadata(metadata: os.stat_result) -> tuple[int, ...]: + return ( + metadata.st_dev, + metadata.st_ino, + metadata.st_mode, + metadata.st_size, + metadata.st_mtime_ns, + metadata.st_ctime_ns, + ) + + +def _quarantine_metadata(metadata: os.stat_result) -> tuple[int, ...]: + """Return identity and content metadata that rename does not update.""" + return ( + metadata.st_dev, + metadata.st_ino, + metadata.st_mode, + metadata.st_size, + metadata.st_mtime_ns, + ) + + +def _open_incident_parent(incidents_dir: Path, relative_path: str) -> tuple[int, str]: + parts = Path(relative_path).parts + if not parts or any(part in {"", ".", ".."} for part in parts): + raise UnsafeIncidentPathError("Incident path is not a safe relative path") + directory_fd = _open_nofollow(incidents_dir, directory=True) try: - return load_incident(path) - except Exception: - return None + for part in parts[:-1]: + child_fd = _open_nofollow(part, directory=True, dir_fd=directory_fd) + os.close(directory_fd) + directory_fd = child_fd + return directory_fd, parts[-1] + except BaseException: + os.close(directory_fd) + raise -def get_all_incidents(incidents_dir: Path) -> list[Incident]: - """Load all incidents, oldest first (for analysis).""" - all_files = sorted(incidents_dir.rglob("*.yml")) - incidents: list[Incident] = [] - for filepath in all_files: +def _read_stable_regular_file(name: str, directory_fd: int) -> tuple[bytes, os.stat_result]: + fd = _open_nofollow(name, dir_fd=directory_fd) + try: + before = os.fstat(fd) + content = _read_fd_bytes(fd) + after = os.fstat(fd) + if _stable_metadata(before) != _stable_metadata(after): + raise IncidentEditConflictError( + "Incident changed while it was being read" + ) + return content, after + finally: + os.close(fd) + + +def _create_stage(original: bytes) -> tuple[tempfile.TemporaryDirectory[str], Path]: + stage_dir = tempfile.TemporaryDirectory(prefix="forge-edit-") + stage_path = Path(stage_dir.name) / "incident.yml" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(stage_path, flags, 0o600) + try: + view = memoryview(original) + while view: + written = os.write(fd, view) + view = view[written:] + os.fsync(fd) + except BaseException: + _close_quietly(fd) try: - incidents.append(load_incident(filepath)) - except Exception: - continue - return incidents + stage_dir.cleanup() + except OSError: + pass + raise + try: + os.close(fd) + except OSError: + try: + stage_dir.cleanup() + except OSError: + pass + raise + return stage_dir, stage_path + + +def _validated_stage(stage_path: Path) -> tuple[bytes, Incident]: + try: + fd = _open_nofollow(stage_path) + except (UnsafeIncidentPathError, FileNotFoundError) as exc: + raise InvalidIncidentEditError("Edited file is not a safe regular file") from exc + try: + before = os.fstat(fd) + content = _read_fd_bytes(fd) + after = os.fstat(fd) + if _stable_metadata(before) != _stable_metadata(after): + raise InvalidIncidentEditError("Edited file changed while it was read") + finally: + os.close(fd) + try: + incident = Incident.from_dict(yaml.safe_load(content)) + except Exception as exc: + raise InvalidIncidentEditError("Edited file has invalid YAML") from exc + return content, incident + + +def _write_all(fd: int, content: bytes) -> None: + view = memoryview(content) + while view: + written = os.write(fd, view) + view = view[written:] + + +@dataclass +class IncidentEditSession: + """Isolated edit stage with bounded pre-publication conflict detection. + + Publication fails if the target's descriptor identity, metadata, or bytes differ + from the captured snapshot. POSIX has no compare-and-swap rename, so an external + replacement in the final recheck-to-rename interval cannot be detected atomically. + The replacement itself is descriptor-relative and atomic within the verified + parent directory; the old inode is never mutated. + """ + + relative_path: str + stage_path: Path + _directory_fd: int + _target_name: str + _original: bytes + _original_metadata: tuple[int, ...] + + def _verify_target_unchanged(self) -> None: + try: + content, metadata = _read_stable_regular_file( + self._target_name, self._directory_fd + ) + except UnsafeIncidentPathError: + raise + except OSError as exc: + raise IncidentEditConflictError( + "Incident changed while the editor was open" + ) from exc + if ( + _stable_metadata(metadata) != self._original_metadata + or content != self._original + ): + raise IncidentEditConflictError( + "Incident changed while the editor was open" + ) + + def publish(self) -> Incident: + content, incident = _validated_stage(self.stage_path) + self._verify_target_unchanged() + temporary_name: str | None = None + temporary_fd: int | None = None + backup_name: str | None = None + primary_error: OSError | None = None + try: + for _ in range(100): + candidate = f".{self._target_name}.{secrets.token_hex(8)}.tmp" + try: + temporary_fd = os.open( + candidate, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=self._directory_fd, + ) + except FileExistsError: + continue + temporary_name = candidate + break + if temporary_fd is None or temporary_name is None: + raise FileExistsError("Could not create an edit publication file") + _write_all(temporary_fd, content) + os.fsync(temporary_fd) + os.close(temporary_fd) + temporary_fd = None + self._verify_target_unchanged() + for _ in range(100): + candidate = f".{self._target_name}.{secrets.token_hex(8)}.bak" + try: + os.link( + self._target_name, + candidate, + src_dir_fd=self._directory_fd, + dst_dir_fd=self._directory_fd, + follow_symlinks=False, + ) + except FileExistsError: + continue + backup_name = candidate + break + if backup_name is None: + raise FileExistsError("Could not create an edit rollback link") + try: + os.fsync(self._directory_fd) + except OSError as exc: + raise RecoverablePartialStateError(primary_error=exc) from exc + + try: + os.replace( + temporary_name, + self._target_name, + src_dir_fd=self._directory_fd, + dst_dir_fd=self._directory_fd, + ) + except OSError as publication_error: + try: + os.unlink(backup_name, dir_fd=self._directory_fd) + except OSError as cleanup_error: + raise RecoverablePartialStateError( + primary_error=publication_error + ) from cleanup_error + try: + os.fsync(self._directory_fd) + except OSError as cleanup_error: + try: + os.link( + self._target_name, + backup_name, + src_dir_fd=self._directory_fd, + dst_dir_fd=self._directory_fd, + follow_symlinks=False, + ) + except OSError: + pass + raise RecoverablePartialStateError( + primary_error=publication_error + ) from cleanup_error + backup_name = None + raise publication_error + temporary_name = None + + try: + os.fsync(self._directory_fd) + except OSError as publication_error: + try: + os.replace( + backup_name, + self._target_name, + src_dir_fd=self._directory_fd, + dst_dir_fd=self._directory_fd, + ) + except OSError as rollback_error: + raise RecoverablePartialStateError( + primary_error=publication_error + ) from rollback_error + backup_name = None + try: + os.fsync(self._directory_fd) + except OSError as rollback_fsync_error: + raise RecoverablePartialStateError( + primary_error=publication_error + ) from rollback_fsync_error + raise publication_error + + try: + os.unlink(backup_name, dir_fd=self._directory_fd) + except OSError as cleanup_error: + raise RecoverablePartialStateError( + primary_error=cleanup_error + ) from cleanup_error + backup_name = None + try: + os.fsync(self._directory_fd) + except OSError as cleanup_fsync_error: + raise RecoverablePartialStateError( + primary_error=cleanup_fsync_error + ) from cleanup_fsync_error + return incident + except OSError as exc: + primary_error = exc + raise + finally: + cleanup_error: OSError | None = None + if temporary_fd is not None: + try: + os.close(temporary_fd) + except OSError as exc: + cleanup_error = exc + if temporary_name is not None: + try: + os.unlink(temporary_name, dir_fd=self._directory_fd) + except OSError as exc: + if cleanup_error is None: + cleanup_error = exc + if cleanup_error is not None: + raise RecoverablePartialStateError( + primary_error=primary_error or cleanup_error + ) from cleanup_error + + +@contextmanager +def stage_incident_for_edit(incidents_dir: Path, incident_id: str): + """Yield an isolated regular-file stage; never yield a product descriptor.""" + entry = _lookup_entry(incidents_dir, incident_id) + if entry is None: + yield None + return + directory_fd, target_name = _open_incident_parent( + incidents_dir, entry.relative_path + ) + stage_dir: tempfile.TemporaryDirectory[str] | None = None + primary_error: BaseException | None = None + try: + original, metadata = _read_stable_regular_file(target_name, directory_fd) + stage_dir, stage_path = _create_stage(original) + yield IncidentEditSession( + relative_path=entry.relative_path, + stage_path=stage_path, + _directory_fd=directory_fd, + _target_name=target_name, + _original=original, + _original_metadata=_stable_metadata(metadata), + ) + except BaseException as exc: + primary_error = exc + raise + finally: + cleanup_error: OSError | None = None + if stage_dir is not None: + try: + stage_dir.cleanup() + except OSError as exc: + cleanup_error = exc + try: + os.close(directory_fd) + except OSError as exc: + if cleanup_error is None: + cleanup_error = exc + if cleanup_error is not None and ( + primary_error is None or isinstance(primary_error, OSError) + ): + storage_error = ( + primary_error if isinstance(primary_error, OSError) else cleanup_error + ) + raise RecoverablePartialStateError( + primary_error=storage_error + ) from cleanup_error + + +def get_all_incidents(incidents_dir: Path) -> list[Incident]: + """Load all valid incidents, oldest first (for analysis).""" + return list(scan_incidents(incidents_dir).incidents) diff --git a/forge_cli/mcp_server.py b/forge_cli/mcp_server.py index da314cf..5a622c2 100644 --- a/forge_cli/mcp_server.py +++ b/forge_cli/mcp_server.py @@ -12,11 +12,14 @@ from forge_cli.incident_store import ( AmbiguousIncidentLookupError, DuplicateIncidentError, + IncidentScanResult, + IncidentLookupCorruptError, + IncidentLookupIncompleteError, find_incident, - generate_id, - get_all_incidents, - list_incidents, - save_incident, + list_incidents_result, + save_generated_incident, + safe_incident_relative_path, + safe_storage_error_type, ) from forge_cli.models import ( CAPABILITY_AREA_VALUES, @@ -85,6 +88,36 @@ def _incident_to_text(incident: Incident) -> str: return "\n".join(lines) +def _scan_report(result: IncidentScanResult) -> str: + lines = [ + f"Valid corpus incidents: {result.valid_corpus_count}", + f"Corrupt corpus files: {result.corrupt_corpus_count}", + f"Matched incidents: {result.matched_count}", + f"Returned incidents: {result.returned_count}", + f"Scan operational errors: {len(result.scan_errors)}", + ] + lines.extend(f"{error.path}: {error.error_type}" for error in result.errors) + lines.extend( + f"Scan error - {error.path}: {error.error_type}" for error in result.scan_errors + ) + return "\n".join(lines) + + +def _incomplete_scan_report(result: IncidentScanResult) -> str: + lines = [ + "Incident corpus scan incomplete.", + f"Scan operational errors: {len(result.scan_errors)}", + ] + lines.extend( + f"Corrupt file - {error.path}: {error.error_type}" for error in result.errors + ) + lines.extend( + f"Scan error - {error.path}: {error.error_type}" + for error in result.scan_errors + ) + return "\n".join(lines) + + @mcp.tool() def forge_log( project: str, @@ -201,7 +234,7 @@ def forge_log( return str(e) now = datetime.now(timezone.utc) - incident_id = generate_id(cfg.incidents_dir, now.date()) + incident_id = "" tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else [] related_list = [r.strip() for r in related_incidents.split(",") if r.strip()] if related_incidents else [] @@ -238,10 +271,13 @@ def forge_log( return str(e) try: - filepath = save_incident(incident, cfg.incidents_dir) - except DuplicateIncidentError as e: - return str(e) - return f"Incident logged: {incident_id}\nSaved to: {filepath}" + filepath = save_generated_incident(incident, cfg.incidents_dir) + except DuplicateIncidentError: + return "Duplicate incident id" + except OSError as exc: + return f"Storage error: {safe_storage_error_type(exc)}" + relative_path = safe_incident_relative_path(filepath, cfg.incidents_dir) + return f"Incident logged: {incident.id}\nSaved to: {relative_path}" @mcp.tool() @@ -271,8 +307,10 @@ def forge_list( blocked_use_class: Filter by blocked use class limit: Maximum number of incidents to return (default 10) """ + if limit < 0: + return "Invalid limit: must be non-negative" cfg = load_config() - incidents = list_incidents( + result = list_incidents_result( cfg.incidents_dir, project=project or None, severity=severity or None, @@ -285,19 +323,23 @@ def forge_list( blocked_use_class=blocked_use_class or None, limit=limit, ) + if result.scan_errors: + return _incomplete_scan_report(result) + report = _scan_report(result) - if not incidents: - return "No incidents found matching the given filters." + if not result.incidents: + return f"No incidents found matching the given filters.\n{report}" results = [] - for inc in incidents: + for inc in result.incidents: summary = (inc.actual_behavior or "").strip().split("\n")[0][:80] results.append( f"[{inc.id}] {inc.project}/{inc.platform} | {inc.severity} | {inc.failure_type}" f" | {inc.issue_class or '-'} | {summary}" ) - return f"Found {len(incidents)} incident(s):\n\n" + "\n".join(results) + marker = f"Found {len(result.incidents)} incident(s):" + return f"{marker}\n{report}\n\n" + "\n".join(results) @mcp.tool() @@ -310,7 +352,11 @@ def forge_show(incident_id: str) -> str: cfg = load_config() try: incident = find_incident(cfg.incidents_dir, incident_id) - except AmbiguousIncidentLookupError as e: + except ( + AmbiguousIncidentLookupError, + IncidentLookupCorruptError, + IncidentLookupIncompleteError, + ) as e: return str(e) if incident is None: @@ -329,7 +375,11 @@ def forge_incident_ref(incident_id: str) -> str: cfg = load_config() try: incident = find_incident(cfg.incidents_dir, incident_id) - except AmbiguousIncidentLookupError as e: + except ( + AmbiguousIncidentLookupError, + IncidentLookupCorruptError, + IncidentLookupIncompleteError, + ) as e: return str(e) if incident is None: @@ -356,19 +406,21 @@ def forge_stats( from collections import Counter cfg = load_config() - incidents = get_all_incidents(cfg.incidents_dir) - - if project: - incidents = [i for i in incidents if i.project == project] - if severity: - incidents = [i for i in incidents if i.severity == severity] - if issue_class: - incidents = [i for i in incidents if i.issue_class == issue_class] - if capability_area: - incidents = [i for i in incidents if i.capability_area == capability_area] + result = list_incidents_result( + cfg.incidents_dir, + project=project or None, + severity=severity or None, + issue_class=issue_class or None, + capability_area=capability_area or None, + limit=None, + ) + if result.scan_errors: + return _incomplete_scan_report(result) + report = _scan_report(result) + incidents = result.incidents if not incidents: - return "No incidents found." + return f"Total incidents: 0\n{report}\n\nNo incidents found." by_severity = Counter(i.severity for i in incidents) by_type = Counter(i.failure_type for i in incidents) @@ -378,7 +430,7 @@ def forge_stats( by_capability_area = Counter(i.capability_area for i in incidents if i.capability_area) all_tags = Counter(tag for i in incidents for tag in i.tags) - lines = [f"Total incidents: {len(incidents)}", ""] + lines = [f"Total incidents: {len(incidents)}", report, ""] lines.append("By Severity:") for sev, count in by_severity.most_common(): diff --git a/tests/test_cli.py b/tests/test_cli.py index 71e27a0..fdf0bbe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,15 +1,94 @@ from __future__ import annotations +import hashlib import json - +import os +from pathlib import Path +import sys +import time +import stat +import tempfile import yaml +import pytest from typer.testing import CliRunner +import forge_cli.incident_store as incident_store from forge_cli.cli import app -from forge_cli.incident_store import save_incident +from forge_cli.incident_store import DuplicateIncidentError, load_incident, save_incident from forge_cli.models import Incident +def _file_hashes(root: Path) -> dict[str, str]: + return { + path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in root.rglob("*") + if path.is_file() + } + + +def _invoke_minimal_log(data_root: Path): + return CliRunner().invoke( + app, + [ + "log", + "--project", + "project", + "--agent", + "agent", + "--platform", + "platform", + "--severity", + "functional", + "--type", + "other", + ], + input=( + "Expected behavior\n" + "Actual behavior\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "y\n" + ), + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + +def _prepare_incomplete_analysis_corpus( + data_root: Path, + sample_data: dict, + failure_kind: str, + monkeypatch, +) -> None: + incidents_dir = data_root / "incidents" + incidents_dir.mkdir(parents=True) + save_incident(Incident.from_dict(sample_data), incidents_dir) + candidate = incidents_dir / "2026-03" / "2026-03-04-002.yml" + if failure_kind == "corrupt": + candidate.write_text("incident: [unterminated") + return + + candidate.write_text( + yaml.safe_dump( + { + **sample_data, + "id": "2026-03-04-002", + "timestamp": "2026-03-04T15:30:00Z", + } + ) + ) + real_open_nofollow = incident_store._open_nofollow + + def fail_candidate_open(path, *, directory=False, dir_fd=None): + if not directory and Path(path).name == candidate.name: + raise PermissionError(f"secret root {data_root}") + return real_open_nofollow(path, directory=directory, dir_fd=dir_fd) + + monkeypatch.setattr(incident_store, "_open_nofollow", fail_candidate_open) + + def test_ref_command_prints_incident_ref(tmp_path, sample_data): data_root = tmp_path / "forge-data" incidents_dir = data_root / "incidents" @@ -86,6 +165,9 @@ def test_log_command_accepts_structured_axes_and_pointer_refs(tmp_path): assert result.exit_code == 0 saved = next((data_root / "incidents").rglob("*.yml")) + assert "[allocated at save]" in result.output + assert result.output.index("[allocated at save]") < result.output.index("Save this incident?") + assert saved.stem in result.output incident = Incident.from_dict(yaml.safe_load(saved.read_text())) assert incident.capability_area == "governance" assert incident.issue_class == "redaction_miss" @@ -97,6 +179,106 @@ def test_log_command_accepts_structured_axes_and_pointer_refs(tmp_path): assert incident.use_approval_ref["ref_id"] == "use-approval:document_ops_internal_eval:g2" +def test_log_success_reports_only_id_and_safe_relative_path(tmp_path): + data_root = tmp_path / "absolute-[hostile]\x1b-root" + + result = _invoke_minimal_log(data_root) + + saved = next((data_root / "incidents").rglob("*.yml")) + relative = saved.relative_to(data_root / "incidents").as_posix() + assert result.exit_code == 0 + assert f"Saved incident {saved.stem}" in result.output + assert f"Saved: {relative}" in result.output + assert str(data_root) not in result.output + assert "\x1b" not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize( + ("failure_scope", "exception_type", "classification"), + [ + ("root", PermissionError, "PermissionError"), + ("month", PermissionError, "PermissionError"), + ("root", OSError, "OSError"), + ], +) +def test_log_storage_failures_are_stable_and_do_not_leak_roots( + tmp_path, + monkeypatch, + failure_scope, + exception_type, + classification, +): + data_root = tmp_path / "absolute-[hostile]\x1b-root" + incidents_dir = data_root / "incidents" + real_open = __import__( + "forge_cli.incident_store", + fromlist=["_open_nofollow"], + )._open_nofollow + raw_message = f"raw storage failure at {data_root}\nsecret\x1b" + + def fail_open(path, *, directory=False, dir_fd=None): + is_root = dir_fd is None and Path(path) == incidents_dir + is_month = dir_fd is not None and directory + if (failure_scope == "root" and is_root) or ( + failure_scope == "month" and is_month + ): + raise exception_type(raw_message) + return real_open(path, directory=directory, dir_fd=dir_fd) + + monkeypatch.setattr("forge_cli.incident_store._open_nofollow", fail_open) + + result = _invoke_minimal_log(data_root) + + assert result.exit_code == 1 + assert f"Storage error: {classification}" in result.output + assert raw_message not in result.output + assert str(data_root) not in result.output + assert "\x1b" not in result.output + assert "Traceback" not in result.output + + +def test_log_rejects_symlinked_month_without_root_leakage(tmp_path): + data_root = tmp_path / "absolute-[hostile]\x1b-root" + incidents_dir = data_root / "incidents" + incidents_dir.mkdir(parents=True) + external = tmp_path / "external-month" + external.mkdir() + month = time.strftime("%Y-%m", time.gmtime()) + (incidents_dir / month).symlink_to(external, target_is_directory=True) + + result = _invoke_minimal_log(data_root) + + assert result.exit_code == 1 + assert "Storage error: UnsafeIncidentPathError" in result.output + assert str(data_root) not in result.output + assert "\x1b" not in result.output + assert "Traceback" not in result.output + assert list(external.iterdir()) == [] + + +def test_log_preserves_duplicate_classification_without_raw_error( + tmp_path, monkeypatch +): + data_root = tmp_path / "absolute-[hostile]\x1b-root" + + def fail_duplicate(*_args, **_kwargs): + raise DuplicateIncidentError(f"raw duplicate {data_root}\nsecret\x1b") + + monkeypatch.setattr( + "forge_cli.cli.save_generated_incident", + fail_duplicate, + ) + + result = _invoke_minimal_log(data_root) + + assert result.exit_code == 1 + assert "Duplicate incident id" in result.output + assert str(data_root) not in result.output + assert "\x1b" not in result.output + assert "Traceback" not in result.output + + def test_log_command_accepts_claims_issue_class(tmp_path): data_root = tmp_path / "forge-data" runner = CliRunner() @@ -308,3 +490,2202 @@ def test_log_command_rejects_sensitive_core_free_text(tmp_path): assert "actual_behavior" in result.output assert "source_payload" in result.output assert not list((data_root / "incidents").rglob("*.yml")) + + +def test_list_and_stats_report_loaded_and_skipped_corrupt_files(tmp_path, sample_data): + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + incidents_dir.mkdir(parents=True) + save_incident(Incident.from_dict(sample_data), incidents_dir) + corrupt_path = incidents_dir / "2026-03" / "2026-03-04-002.yml" + corrupt_payload = "incident: [unterminated" + corrupt_path.write_text(corrupt_payload) + runner = CliRunner() + env = {"FORGE_DATA_ROOT": str(data_root)} + + list_result = runner.invoke(app, ["list"], env=env) + stats_result = runner.invoke(app, ["stats"], env=env) + + for result in (list_result, stats_result): + assert result.exit_code == 0 + assert "Valid corpus incidents: 1" in result.output + assert "Corrupt corpus files: 1" in result.output + assert "Matched incidents: 1" in result.output + assert "Returned incidents: 1" in result.output + assert "2026-03/2026-03-04-002.yml" in result.output + assert "YAMLError" in result.output + assert str(data_root) not in result.output + assert corrupt_path.read_text() == corrupt_payload + + +def test_validate_strict_exits_nonzero_on_corruption(tmp_path): + data_root = tmp_path / "forge-data" + corrupt_path = data_root / "incidents" / "2026-03" / "2026-03-04-001.yml" + corrupt_path.parent.mkdir(parents=True) + corrupt_payload = "incident: [unterminated" + corrupt_path.write_text(corrupt_payload) + runner = CliRunner() + env = {"FORGE_DATA_ROOT": str(data_root)} + + lenient_result = runner.invoke(app, ["validate"], env=env) + strict_result = runner.invoke(app, ["validate", "--strict"], env=env) + + assert lenient_result.exit_code == 0 + assert strict_result.exit_code == 1 + assert "Corrupt corpus files: 1" in strict_result.output + assert "2026-03/2026-03-04-001.yml" in strict_result.output + assert str(data_root) not in strict_result.output + assert corrupt_path.read_text() == corrupt_payload + + +def test_quarantine_defaults_to_zero_write_dry_run(tmp_path): + data_root = tmp_path / "forge-data" + corrupt_path = data_root / "incidents" / "2026-03" / "2026-03-04-001.yml" + corrupt_path.parent.mkdir(parents=True) + corrupt_payload = "incident: [unterminated" + corrupt_path.write_text(corrupt_payload) + before = { + path.relative_to(data_root).as_posix(): path.read_bytes() + for path in data_root.rglob("*") + if path.is_file() + } + + result = CliRunner().invoke( + app, + ["quarantine"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + after = { + path.relative_to(data_root).as_posix(): path.read_bytes() + for path in data_root.rglob("*") + if path.is_file() + } + assert result.exit_code == 0 + assert "Quarantine preview: 1 corrupt file(s)" in result.output + assert "2026-03/2026-03-04-001.yml" in result.output + assert after == before + + +@pytest.mark.parametrize( + ("command", "marker"), + [ + ("repair", "Repair dry-run: 2 corrupt file(s)"), + ("quarantine", "Quarantine dry-run: 2 corrupt file(s)"), + ], +) +def test_maintenance_dry_run_reports_safe_candidates_without_writes( + tmp_path, command, marker +): + data_root = tmp_path / "absolute-[hostile]\x1b-root" + first = data_root / "incidents" / "2026-03" / "first-[candidate]\x1b.yml" + second = data_root / "incidents" / "nested" / "second.yml" + first.parent.mkdir(parents=True) + second.parent.mkdir(parents=True) + first.write_bytes(b"incident: [unterminated\x00") + second.write_bytes(b"\xff\xfe malformed") + before_hashes = _file_hashes(data_root) + before_count = len(before_hashes) + + result = CliRunner().invoke( + app, + [command, "--dry-run"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + after_hashes = _file_hashes(data_root) + assert result.exit_code == 0 + assert marker in result.output + assert "2026-03/first-[candidate]?.yml" in result.output + assert "nested/second.yml" in result.output + assert str(data_root) not in result.output + assert "\x1b" not in result.output + assert "Traceback" not in result.output + assert len(after_hashes) == before_count + assert after_hashes == before_hashes + + +@pytest.mark.parametrize("command", ["repair"]) +def test_maintenance_rejects_apply_and_performs_zero_writes(tmp_path, command): + data_root = tmp_path / "forge-data" + corrupt_path = data_root / "incidents" / "2026-03" / "2026-03-04-001.yml" + corrupt_path.parent.mkdir(parents=True) + corrupt_payload = b"incident: [unterminated" + corrupt_path.write_bytes(corrupt_payload) + + result = CliRunner().invoke( + app, + [command, "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code != 0 + assert corrupt_path.read_bytes() == corrupt_payload + assert not (data_root / "quarantine").exists() + + +def test_quarantine_rejects_apply_with_dry_run_without_writes(tmp_path): + data_root = tmp_path / "forge-data" + corrupt_path = data_root / "incidents" / "2026-03" / "corrupt.yml" + corrupt_path.parent.mkdir(parents=True) + corrupt_payload = b"incident: [unterminated" + corrupt_path.write_bytes(corrupt_payload) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply", "--dry-run"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code != 0 + assert corrupt_path.read_bytes() == corrupt_payload + assert not (data_root / "quarantine").exists() + + +def test_quarantine_apply_moves_only_freshly_corrupt_regular_files( + tmp_path, sample_data +): + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + incidents_dir.mkdir(parents=True) + valid = Incident.from_dict(sample_data) + valid_path = save_incident(valid, incidents_dir) + corrupt_path = incidents_dir / "nested" / "hostile-[name]\x1b.yml" + corrupt_path.parent.mkdir() + corrupt_payload = b"\xff\xfe malformed incident bytes\n" + corrupt_path.write_bytes(corrupt_payload) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + destination = data_root / "quarantine" / "nested" / corrupt_path.name + assert result.exit_code == 0, result.output + assert not corrupt_path.exists() + assert destination.read_bytes() == corrupt_payload + assert valid_path.exists() + assert load_incident(valid_path).id == valid.id + assert "Moved: nested/hostile-[name]?.yml" in result.output + assert str(data_root) not in result.output + assert "\x1b" not in result.output + assert "Traceback" not in result.output + + +def test_quarantine_apply_fails_closed_before_writes_on_scan_error( + tmp_path, monkeypatch +): + data_root = tmp_path / "absolute-[root]\x1b" + corrupt_path = data_root / "incidents" / "corrupt.yml" + blocked_path = data_root / "incidents" / "blocked.yml" + corrupt_path.parent.mkdir(parents=True) + corrupt_path.write_text("incident: [unterminated") + blocked_path.write_text("incident: [unterminated") + real_open = incident_store._open_nofollow + raw_message = f"blocked at {data_root} [bold]leak[/bold]" + + def fail_one(path, *, directory=False, dir_fd=None): + if not directory and Path(path).name == blocked_path.name: + raise PermissionError(raw_message) + return real_open(path, directory=directory, dir_fd=dir_fd) + + monkeypatch.setattr(incident_store, "_open_nofollow", fail_one) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code != 0 + assert corrupt_path.exists() + assert blocked_path.exists() + assert not (data_root / "quarantine").exists() + assert "PermissionError" in result.output + assert raw_message not in result.output + assert str(data_root) not in result.output + assert "[bold]leak[/bold]" not in result.output + assert "Traceback" not in result.output + + +def test_quarantine_apply_revalidates_source_after_scan( + tmp_path, monkeypatch, sample_data +): + data_root = tmp_path / "forge-data" + corrupt_path = data_root / "incidents" / "candidate.yml" + corrupt_path.parent.mkdir(parents=True) + corrupt_path.write_text("incident: [unterminated") + real_scan = incident_store.scan_incidents + + def mutate_after_scan(incidents_dir): + result = real_scan(incidents_dir) + corrupt_path.write_text(yaml.safe_dump(sample_data)) + return result + + monkeypatch.setattr(incident_store, "scan_incidents", mutate_after_scan) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code != 0 + assert load_incident(corrupt_path).id == sample_data["id"] + assert not (data_root / "quarantine" / "candidate.yml").exists() + assert "Failed: candidate.yml (SourceChangedError)" in result.output + + +def test_quarantine_no_replace_collision_at_syscall_preserves_both_files( + tmp_path, monkeypatch +): + data_root = tmp_path / "forge-data" + source = data_root / "incidents" / "nested" / "candidate.yml" + destination = data_root / "quarantine" / "nested" / "candidate.yml" + source.parent.mkdir(parents=True) + source_payload = b"incident: [unterminated" + destination_payload = b"concurrent quarantine payload" + source.write_bytes(source_payload) + real_rename_noreplace = incident_store._rename_noreplace + + def collide_at_syscall(source_fd, source_name, destination_fd, destination_name): + fd = os.open( + destination_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=destination_fd, + ) + try: + os.write(fd, destination_payload) + finally: + os.close(fd) + return real_rename_noreplace( + source_fd, source_name, destination_fd, destination_name + ) + + monkeypatch.setattr( + incident_store, "_rename_noreplace", collide_at_syscall + ) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code != 0 + assert source.read_bytes() == source_payload + assert destination.read_bytes() == destination_payload + assert "Failed: nested/candidate.yml (FileExistsError)" in result.output + + +def test_quarantine_apply_rejects_symlinked_destination_directory(tmp_path): + data_root = tmp_path / "forge-data" + corrupt_path = data_root / "incidents" / "nested" / "candidate.yml" + external = tmp_path / "external" + corrupt_path.parent.mkdir(parents=True) + external.mkdir() + corrupt_path.write_text("incident: [unterminated") + quarantine = data_root / "quarantine" + quarantine.mkdir() + (quarantine / "nested").symlink_to(external, target_is_directory=True) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code != 0 + assert corrupt_path.exists() + assert not (external / "candidate.yml").exists() + assert "UnsafeIncidentPathError" in result.output + + +def test_quarantine_apply_rejects_symlinked_source_without_writes(tmp_path): + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + incidents_dir.mkdir(parents=True) + external = tmp_path / "external-corrupt.yml" + payload = b"incident: [unterminated" + external.write_bytes(payload) + (incidents_dir / "candidate.yml").symlink_to(external) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code != 0 + assert external.read_bytes() == payload + assert (incidents_dir / "candidate.yml").is_symlink() + assert not (data_root / "quarantine").exists() + assert "SymlinkRejectedError" in result.output + assert str(external) not in result.output + assert "Traceback" not in result.output + + +def test_quarantine_internal_move_rejects_traversal_before_writes(tmp_path): + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + incidents_dir.mkdir(parents=True) + outside = data_root / "outside.yml" + payload = b"incident: [unterminated" + outside.write_bytes(payload) + + with pytest.raises(incident_store.UnsafeIncidentPathError): + incident_store._quarantine_one(incidents_dir, "../outside.yml") + + assert outside.read_bytes() == payload + assert not (data_root / "quarantine").exists() + + +def test_quarantine_apply_ancestor_fsync_failure_keeps_source( + tmp_path, monkeypatch +): + data_root = tmp_path / "absolute-[root]\x1b" + source = data_root / "incidents" / "candidate.yml" + source.parent.mkdir(parents=True) + payload = b"incident: [unterminated" + source.write_bytes(payload) + raw_message = f"fsync denied at {data_root} [bold]leak[/bold]" + real_fsync = incident_store.os.fsync + + def fail_directory_fsync(fd): + if stat.S_ISDIR(os.fstat(fd).st_mode): + raise PermissionError(raw_message) + return real_fsync(fd) + + monkeypatch.setattr(incident_store.os, "fsync", fail_directory_fsync) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code != 0 + assert source.read_bytes() == payload + assert not (data_root / "quarantine" / "candidate.yml").exists() + assert "Failed: candidate.yml (PermissionError)" in result.output + assert raw_message not in result.output + assert str(data_root) not in result.output + assert "[bold]leak[/bold]" not in result.output + assert "Traceback" not in result.output + + +def test_quarantine_apply_reports_safe_failure_after_an_earlier_move( + tmp_path, monkeypatch +): + data_root = tmp_path / "absolute-[root]\x1b" + first = data_root / "incidents" / "a.yml" + second = data_root / "incidents" / "b.yml" + first.parent.mkdir(parents=True) + first.write_bytes(b"first: [unterminated") + second.write_bytes(b"second: [unterminated") + real_rename_noreplace = incident_store._rename_noreplace + raw_message = f"denied {data_root} [bold]leak[/bold]" + + def fail_second(source_fd, source_name, destination_fd, destination_name): + if source_name == second.name: + raise PermissionError(raw_message) + return real_rename_noreplace( + source_fd, source_name, destination_fd, destination_name + ) + + monkeypatch.setattr(incident_store, "_rename_noreplace", fail_second) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code != 0 + assert not first.exists() + assert (data_root / "quarantine" / "a.yml").read_bytes() == b"first: [unterminated" + assert second.read_bytes() == b"second: [unterminated" + assert "Moved: a.yml" in result.output + assert "Failed: b.yml (PermissionError)" in result.output + assert raw_message not in result.output + assert str(data_root) not in result.output + assert "[bold]leak[/bold]" not in result.output + assert "Traceback" not in result.output + + +def test_quarantine_source_replacement_at_syscall_is_restored_without_overwrite( + tmp_path, monkeypatch +): + data_root = tmp_path / "absolute-[root]\x1b" + source = data_root / "incidents" / "candidate.yml" + source.parent.mkdir(parents=True) + corrupt_payload = b"incident: [unterminated" + replacement_payload = b"replacement restored to source\n" + source.write_bytes(corrupt_payload) + real_rename_noreplace = incident_store._rename_noreplace + calls = 0 + + def replace_at_syscall(source_fd, source_name, destination_fd, destination_name): + nonlocal calls + calls += 1 + if calls == 1: + os.unlink(source_name, dir_fd=source_fd) + fd = os.open( + source_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=source_fd, + ) + try: + os.write(fd, replacement_payload) + finally: + os.close(fd) + return real_rename_noreplace( + source_fd, source_name, destination_fd, destination_name + ) + + monkeypatch.setattr(incident_store, "_rename_noreplace", replace_at_syscall) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + destination = data_root / "quarantine" / source.name + assert result.exit_code != 0 + assert calls == 2 + assert source.read_bytes() == replacement_payload + assert not destination.exists() + assert "Failed: candidate.yml (SourceChangedError)" in result.output + assert str(data_root) not in result.output + assert "Traceback" not in result.output + + +def test_quarantine_source_metadata_change_at_syscall_is_restored( + tmp_path, monkeypatch +): + data_root = tmp_path / "forge-data" + source = data_root / "incidents" / "candidate.yml" + source.parent.mkdir(parents=True) + payload = b"incident: [unterminated" + source.write_bytes(payload) + real_rename_noreplace = incident_store._rename_noreplace + calls = 0 + + def mutate_metadata_at_syscall( + source_fd, source_name, destination_fd, destination_name + ): + nonlocal calls + calls += 1 + if calls == 1: + os.chmod(source_name, 0o640, dir_fd=source_fd) + return real_rename_noreplace( + source_fd, source_name, destination_fd, destination_name + ) + + monkeypatch.setattr( + incident_store, "_rename_noreplace", mutate_metadata_at_syscall + ) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + destination = data_root / "quarantine" / source.name + assert result.exit_code != 0 + assert calls == 2 + assert source.read_bytes() == payload + assert stat.S_IMODE(source.stat().st_mode) == 0o640 + assert not destination.exists() + assert "Failed: candidate.yml (SourceChangedError)" in result.output + + +def test_quarantine_restore_collision_leaves_visible_recoverable_entry( + tmp_path, monkeypatch +): + data_root = tmp_path / "absolute-[root]\x1b" + source = data_root / "incidents" / "candidate.yml" + source.parent.mkdir(parents=True) + source.write_bytes(b"incident: [unterminated") + replacement_payload = b"replacement moved at syscall\n" + concurrent_payload = b"concurrent source entry\n" + real_rename_noreplace = incident_store._rename_noreplace + calls = 0 + + def collide_during_restore( + source_fd, source_name, destination_fd, destination_name + ): + nonlocal calls + calls += 1 + if calls == 1: + os.unlink(source_name, dir_fd=source_fd) + fd = os.open( + source_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=source_fd, + ) + try: + os.write(fd, replacement_payload) + finally: + os.close(fd) + elif calls == 2: + fd = os.open( + destination_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=destination_fd, + ) + try: + os.write(fd, concurrent_payload) + finally: + os.close(fd) + return real_rename_noreplace( + source_fd, source_name, destination_fd, destination_name + ) + + monkeypatch.setattr(incident_store, "_rename_noreplace", collide_during_restore) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + destination = data_root / "quarantine" / source.name + assert result.exit_code != 0 + assert calls == 2 + assert source.read_bytes() == concurrent_payload + assert destination.read_bytes() == replacement_payload + assert ( + "Partial move: candidate.yml (RecoverablePartialStateError)" + in result.output + ) + assert str(data_root) not in result.output + assert "Traceback" not in result.output + + +def test_quarantine_without_native_noreplace_fails_closed_without_writes( + tmp_path, monkeypatch +): + data_root = tmp_path / "forge-data" + source = data_root / "incidents" / "candidate.yml" + source.parent.mkdir(parents=True) + payload = b"incident: [unterminated" + source.write_bytes(payload) + monkeypatch.setattr(incident_store, "_NATIVE_RENAME_NOREPLACE", None) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code != 0 + assert source.read_bytes() == payload + assert not (data_root / "quarantine").exists() + assert "Failed: candidate.yml (UnsupportedOperation)" in result.output + + +@pytest.mark.parametrize( + "failure_point", + ["destination-stat", "destination-parent-fsync", "source-parent-fsync"], +) +def test_quarantine_post_move_failures_report_safe_partial_move( + tmp_path, monkeypatch, failure_point +): + data_root = tmp_path / "absolute-[root]\x1b" + source = data_root / "incidents" / "candidate.yml" + destination = data_root / "quarantine" / source.name + source.parent.mkdir(parents=True) + payload = b"incident: [unterminated" + source.write_bytes(payload) + real_rename_noreplace = incident_store._rename_noreplace + real_stat = incident_store.os.stat + real_fsync = incident_store.os.fsync + moved = False + source_parent_fd = None + destination_parent_fd = None + raw_message = f"post-move failure at {data_root} [bold]leak[/bold]" + + def record_move(source_fd, source_name, destination_fd, destination_name): + nonlocal moved, source_parent_fd, destination_parent_fd + result = real_rename_noreplace( + source_fd, source_name, destination_fd, destination_name + ) + moved = True + source_parent_fd = source_fd + destination_parent_fd = destination_fd + return result + + def fail_destination_stat(path, *args, **kwargs): + if ( + failure_point == "destination-stat" + and moved + and path == destination.name + and kwargs.get("dir_fd") == destination_parent_fd + ): + raise PermissionError(raw_message) + return real_stat(path, *args, **kwargs) + + def fail_parent_fsync(fd): + if ( + moved + and failure_point == "destination-parent-fsync" + and fd == destination_parent_fd + ): + raise PermissionError(raw_message) + if ( + moved + and failure_point == "source-parent-fsync" + and fd == source_parent_fd + ): + raise PermissionError(raw_message) + return real_fsync(fd) + + monkeypatch.setattr(incident_store, "_rename_noreplace", record_move) + monkeypatch.setattr(incident_store.os, "stat", fail_destination_stat) + monkeypatch.setattr(incident_store.os, "fsync", fail_parent_fsync) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code != 0 + assert not source.exists() + assert destination.read_bytes() == payload + assert ( + "Partial move: candidate.yml (RecoverablePartialStateError)" + in result.output + ) + assert raw_message not in result.output + assert str(data_root) not in result.output + assert "[bold]leak[/bold]" not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize( + ("failed_restore_fsync", "move_back_collision"), + [ + ("source", False), + ("destination", False), + ("source", True), + ], +) +def test_quarantine_restore_durability_failure_leaves_safe_partial_state( + tmp_path, monkeypatch, failed_restore_fsync, move_back_collision +): + data_root = tmp_path / "absolute-[root]\x1b" + source = data_root / "incidents" / "candidate.yml" + destination = data_root / "quarantine" / source.name + source.parent.mkdir(parents=True) + source.write_bytes(b"incident: [unterminated") + replacement_payload = b"replacement moved at syscall\n" + concurrent_payload = b"concurrent quarantine entry\n" + real_rename_noreplace = incident_store._rename_noreplace + real_fsync = incident_store.os.fsync + calls = 0 + source_parent_fd = None + destination_parent_fd = None + restore_started = False + fsync_failed = False + + def inject_restore_failures( + source_fd, source_name, destination_fd, destination_name + ): + nonlocal calls, source_parent_fd, destination_parent_fd, restore_started + calls += 1 + if calls == 1: + source_parent_fd = source_fd + destination_parent_fd = destination_fd + os.unlink(source_name, dir_fd=source_fd) + fd = os.open( + source_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=source_fd, + ) + try: + os.write(fd, replacement_payload) + finally: + os.close(fd) + elif calls == 2: + restore_started = True + elif calls == 3 and move_back_collision: + fd = os.open( + destination_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=destination_fd, + ) + try: + os.write(fd, concurrent_payload) + finally: + os.close(fd) + return real_rename_noreplace( + source_fd, source_name, destination_fd, destination_name + ) + + def fail_restore_fsync(fd): + nonlocal fsync_failed + target_fd = ( + source_parent_fd + if failed_restore_fsync == "source" + else destination_parent_fd + ) + if restore_started and not fsync_failed and fd == target_fd: + fsync_failed = True + raise PermissionError("restore durability failure") + return real_fsync(fd) + + monkeypatch.setattr( + incident_store, "_rename_noreplace", inject_restore_failures + ) + monkeypatch.setattr(incident_store.os, "fsync", fail_restore_fsync) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code != 0 + assert calls == 3 + assert fsync_failed is True + assert ( + "Partial move: candidate.yml (RecoverablePartialStateError)" + in result.output + ) + if move_back_collision: + assert source.read_bytes() == replacement_payload + assert destination.read_bytes() == concurrent_payload + else: + assert not source.exists() + assert destination.read_bytes() == replacement_payload + assert str(data_root) not in result.output + assert "Traceback" not in result.output + + +def test_quarantine_directory_identity_mismatch_prevents_move( + tmp_path, monkeypatch +): + data_root = tmp_path / "forge-data" + source = data_root / "incidents" / "candidate.yml" + source.parent.mkdir(parents=True) + payload = b"incident: [unterminated" + source.write_bytes(payload) + real_open_nofollow = incident_store._open_nofollow + real_fstat = incident_store.os.fstat + quarantine_fd = None + move_called = False + + def record_quarantine_open(path, *, directory=False, dir_fd=None): + nonlocal quarantine_fd + fd = real_open_nofollow(path, directory=directory, dir_fd=dir_fd) + if path == "quarantine" and directory: + quarantine_fd = fd + return fd + + def mismatch_opened_directory(fd): + metadata = real_fstat(fd) + if fd == quarantine_fd: + return type( + "MismatchedDirectoryMetadata", + (), + {"st_dev": metadata.st_dev, "st_ino": metadata.st_ino + 1}, + )() + return metadata + + def reject_move(*_args, **_kwargs): + nonlocal move_called + move_called = True + raise AssertionError("move must not run") + + monkeypatch.setattr( + incident_store, "_open_nofollow", record_quarantine_open + ) + monkeypatch.setattr(incident_store.os, "fstat", mismatch_opened_directory) + monkeypatch.setattr(incident_store, "_rename_noreplace", reject_move) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code != 0 + assert source.read_bytes() == payload + assert move_called is False + assert not (data_root / "quarantine" / source.name).exists() + assert "Failed: candidate.yml (UnsafeIncidentPathError)" in result.output + + +def test_quarantine_success_leaves_only_visible_destination(tmp_path): + data_root = tmp_path / "forge-data" + source = data_root / "incidents" / "nested" / "candidate.yml" + destination = data_root / "quarantine" / "nested" / "candidate.yml" + source.parent.mkdir(parents=True) + payload = b"incident: [unterminated" + source.write_bytes(payload) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code == 0, result.output + assert not source.exists() + assert destination.read_bytes() == payload + quarantine_entries = sorted( + path.relative_to(data_root / "quarantine").as_posix() + for path in (data_root / "quarantine").rglob("*") + ) + assert quarantine_entries == ["nested", "nested/candidate.yml"] + assert not list((data_root / "quarantine").rglob(".forge-recovery-*")) + + +def test_quarantine_fsyncs_each_new_ancestor_before_descending( + tmp_path, monkeypatch +): + data_root = tmp_path / "forge-data" + source = data_root / "incidents" / "nested" / "candidate.yml" + source.parent.mkdir(parents=True) + source.write_bytes(b"incident: [unterminated") + real_mkdir = incident_store.os.mkdir + real_fsync = incident_store.os.fsync + pending_parent_fds: list[int] = [] + events: list[tuple[str, str | int]] = [] + + def record_mkdir(name, mode=0o777, *, dir_fd=None): + result = real_mkdir(name, mode, dir_fd=dir_fd) + if dir_fd is not None and name in {"quarantine", "nested"}: + pending_parent_fds.append(dir_fd) + events.append(("mkdir", name)) + return result + + def record_fsync(fd): + if pending_parent_fds and fd == pending_parent_fds[0]: + pending_parent_fds.pop(0) + events.append(("fsync-parent", fd)) + return real_fsync(fd) + + monkeypatch.setattr(incident_store.os, "mkdir", record_mkdir) + monkeypatch.setattr(incident_store.os, "fsync", record_fsync) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code == 0, result.output + assert [event[0] for event in events[:4]] == [ + "mkdir", + "fsync-parent", + "mkdir", + "fsync-parent", + ] + assert [event[1] for event in events[::2][:2]] == ["quarantine", "nested"] + assert pending_parent_fds == [] + + +def test_quarantine_nested_ancestry_fsync_failure_retains_source( + tmp_path, monkeypatch +): + data_root = tmp_path / "absolute-[root]\x1b" + source = data_root / "incidents" / "nested" / "candidate.yml" + destination = data_root / "quarantine" / "nested" / source.name + source.parent.mkdir(parents=True) + payload = b"incident: [unterminated" + source.write_bytes(payload) + real_mkdir = incident_store.os.mkdir + real_fsync = incident_store.os.fsync + nested_parent_fd: int | None = None + raw_message = f"fsync denied at {data_root} [bold]leak[/bold]" + + def record_nested_mkdir(name, mode=0o777, *, dir_fd=None): + nonlocal nested_parent_fd + result = real_mkdir(name, mode, dir_fd=dir_fd) + if name == "nested": + nested_parent_fd = dir_fd + return result + + def fail_nested_parent_fsync(fd): + if nested_parent_fd is not None and fd == nested_parent_fd: + raise PermissionError(raw_message) + return real_fsync(fd) + + monkeypatch.setattr(incident_store.os, "mkdir", record_nested_mkdir) + monkeypatch.setattr(incident_store.os, "fsync", fail_nested_parent_fsync) + + result = CliRunner().invoke( + app, + ["quarantine", "--apply"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code != 0 + assert source.read_bytes() == payload + assert not destination.exists() + assert "Failed: nested/candidate.yml (PermissionError)" in result.output + assert raw_message not in result.output + assert str(data_root) not in result.output + assert "Traceback" not in result.output + + +def test_list_zero_limit_reports_counts_without_incidents(tmp_path, sample_data): + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + incidents_dir.mkdir(parents=True) + save_incident(Incident.from_dict(sample_data), incidents_dir) + + result = CliRunner().invoke( + app, + ["list", "--limit", "0"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code == 0 + assert "Valid corpus incidents: 1" in result.output + assert "Matched incidents: 1" in result.output + assert "Returned incidents: 0" in result.output + assert sample_data["id"] not in result.output + + +def test_list_rejects_negative_limit(tmp_path): + result = CliRunner().invoke( + app, + ["list", "--limit", "-1"], + env={"FORGE_DATA_ROOT": str(tmp_path / "forge-data")}, + ) + + assert result.exit_code != 0 + assert "limit must be non-negative" in result.output + + +def test_list_exits_nonzero_with_sanitized_scan_failure( + tmp_path, monkeypatch +): + data_root = tmp_path / "forge-data" + corrupt_path = data_root / "incidents" / "2026-03" / "unsafe\nincident.yml" + corrupt_path.parent.mkdir(parents=True) + corrupt_path.write_text("incident: [unterminated") + safe_corrupt = corrupt_path.with_name("corrupt.yml") + safe_corrupt.write_text("incident: [unterminated") + raw_message = f"raw traversal failure at {data_root}\nwith payload" + + real_stat = os.stat + + def fail_stat(path, *args, **kwargs): + if path == corrupt_path.name: + error = PermissionError(raw_message) + error.filename = str(data_root / "incidents" / "unsafe\nroot") + raise error + return real_stat(path, *args, **kwargs) + + monkeypatch.setattr("forge_cli.incident_store.os.stat", fail_stat) + + result = CliRunner().invoke( + app, + ["list"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code == 1 + assert "Corrupt corpus files: 1" in result.output + assert "Scan operational errors: 1" in result.output + assert "PermissionError" in result.output + assert "YAMLError" in result.output + assert raw_message not in result.output + assert str(data_root) not in result.output + assert "\nroot" not in result.output + assert "\nincident" not in result.output + + +@pytest.mark.parametrize( + ("incident_id", "literal"), + [ + ("[unterminated", "[unterminated"), + ("[bold]markup[/bold]", "[bold]markup[/bold]"), + ("\x1b[31mcontrol", "?[31mcontrol"), + ], +) +def test_cli_errors_render_untrusted_ids_literally_without_controls( + tmp_path, incident_id, literal +): + data_root = tmp_path / "forge-data" + + result = CliRunner().invoke( + app, + ["show", incident_id], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code == 1 + assert literal in result.output + assert "\x1b" not in result.output + assert "Traceback" not in result.output + + +def test_cli_ambiguous_lookup_renders_markup_filenames_literally( + tmp_path, sample_data +): + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + first = Incident.from_dict(sample_data) + second = Incident.from_dict( + { + **sample_data, + "id": "2026-03-05-001", + "timestamp": "2026-03-05T14:30:00Z", + } + ) + first_path = save_incident(first, incidents_dir) + second_path = save_incident(second, incidents_dir) + first_path.rename(first_path.with_name("first-[bold].yml")) + second_path.rename(second_path.with_name("second-[bold].yml")) + + result = CliRunner().invoke( + app, + ["show", "[bold]"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code == 1 + assert "first-[bold]" in result.output + assert "second-[bold]" in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("command", ["show", "ref", "edit"]) +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_cli_lookup_commands_cannot_follow_corpus_symlinks( + tmp_path, sample_data, command, lookup +): + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + external = tmp_path / "external" + external_file = save_incident(Incident.from_dict(sample_data), external) + month_dir = incidents_dir / "2026-03" + month_dir.mkdir(parents=True) + (month_dir / external_file.name).symlink_to(external_file) + before = external_file.read_bytes() + + result = CliRunner().invoke( + app, + [command, lookup], + env={ + "FORGE_DATA_ROOT": str(data_root), + "EDITOR": "/usr/bin/false", + }, + ) + + assert result.exit_code == 1 + assert "operationally incomplete" in result.output + assert external_file.read_bytes() == before + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("command", [["list"], ["stats"], ["validate", "--strict"]]) +def test_cli_missing_incidents_directory_is_empty_and_read_only(tmp_path, command): + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + + result = CliRunner().invoke( + app, + command, + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code == 0 + assert "Valid corpus incidents: 0" in result.output + assert "Corrupt corpus files: 0" in result.output + assert not incidents_dir.exists() + + +def _editor_helper(tmp_path: Path, name: str, body: str) -> Path: + editor = tmp_path / name + editor.write_text(f"#!{sys.executable}\nimport os\nimport pathlib\nimport sys\nimport time\n{body}\n") + editor.chmod(0o700) + return editor + + +def _edit_fixture(tmp_path: Path, sample_data: dict) -> tuple[Path, Path, bytes]: + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + saved = save_incident(Incident.from_dict(sample_data), incidents_dir) + return data_root, saved, saved.read_bytes() + + +def _edited_payload(sample_data: dict) -> str: + return yaml.safe_dump({**sample_data, "actual_behavior": "edited safely"}) + + +def test_edit_uses_normal_stage_for_in_place_editor(tmp_path, sample_data): + data_root, saved, original = _edit_fixture(tmp_path, sample_data) + payload = _edited_payload(sample_data) + editor = _editor_helper( + tmp_path, + "in-place-editor", + f"path = pathlib.Path(sys.argv[1])\n" + f"assert path.is_file()\n" + f"assert not str(path).startswith('/dev/fd/')\n" + f"path.write_text({payload!r})", + ) + + result = CliRunner().invoke( + app, + ["edit", sample_data["id"]], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": str(editor)}, + ) + + assert result.exit_code == 0 + assert saved.read_bytes() != original + assert load_incident(saved).actual_behavior == "edited safely" + assert "/dev/fd/" not in result.output + + +def test_edit_accepts_atomic_save_editor(tmp_path, sample_data): + data_root, saved, _original = _edit_fixture(tmp_path, sample_data) + payload = _edited_payload(sample_data) + editor = _editor_helper( + tmp_path, + "atomic-editor", + f"path = pathlib.Path(sys.argv[1])\n" + f"replacement = path.with_suffix('.new')\n" + f"replacement.write_text({payload!r})\n" + f"os.replace(replacement, path)", + ) + + result = CliRunner().invoke( + app, + ["edit", sample_data["id"]], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": str(editor)}, + ) + + assert result.exit_code == 0 + assert load_incident(saved).actual_behavior == "edited safely" + + +@pytest.mark.parametrize( + ("name", "body", "expected"), + [ + ( + "failing-editor", + "pathlib.Path(sys.argv[1]).write_text('changed stage')\nsys.exit(7)", + "Editor exited with an error.", + ), + ( + "invalid-editor", + "pathlib.Path(sys.argv[1]).write_text('incident: [unterminated')", + "Edited file has invalid YAML.", + ), + ], +) +def test_edit_failure_or_invalid_stage_preserves_product_bytes( + tmp_path, sample_data, name, body, expected, monkeypatch +): + stage_root = tmp_path / "stages" + stage_root.mkdir() + monkeypatch.setattr(tempfile, "tempdir", str(stage_root)) + data_root, saved, original = _edit_fixture(tmp_path, sample_data) + editor = _editor_helper(tmp_path, name, body) + + result = CliRunner().invoke( + app, + ["edit", sample_data["id"]], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": str(editor)}, + ) + + assert result.exit_code == 1 + assert expected in result.output + assert saved.read_bytes() == original + assert "Traceback" not in result.output + assert str(data_root) not in result.output + assert list(stage_root.iterdir()) == [] + + +@pytest.mark.parametrize( + "failure_point", + [ + "root-reopen", + "parent-reopen", + "stage-create", + "stage-validation-open", + "publication-create", + "publication-write", + "file-fsync", + "replace", + "directory-fsync", + "directory-fsync-after-replace", + "cleanup", + ], +) +def test_edit_operational_storage_errors_are_sanitized_and_preserve_original( + tmp_path, sample_data, monkeypatch, failure_point +): + stage_root = tmp_path / "stages" + stage_root.mkdir() + monkeypatch.setattr(tempfile, "tempdir", str(stage_root)) + data_root = tmp_path / "absolute-[root]\x1b" + incidents_dir = data_root / "incidents" + saved = save_incident(Incident.from_dict(sample_data), incidents_dir) + original = saved.read_bytes() + payload = _edited_payload(sample_data) + editor = _editor_helper( + tmp_path, + "storage-error-editor", + f"pathlib.Path(sys.argv[1]).write_text({payload!r})", + ) + raw_primary = f"primary failure at {data_root} [bold]leak[/bold]" + raw_cleanup = f"cleanup failure at {data_root} [italic]leak[/italic]" + expected_type = "PermissionError" + + if failure_point == "root-reopen": + real_open_nofollow = incident_store._open_nofollow + root_opens = 0 + + def fail_root_reopen(path, *, directory=False, dir_fd=None): + nonlocal root_opens + if directory and Path(path) == incidents_dir and dir_fd is None: + root_opens += 1 + if root_opens == 2: + raise PermissionError(raw_primary) + return real_open_nofollow(path, directory=directory, dir_fd=dir_fd) + + monkeypatch.setattr(incident_store, "_open_nofollow", fail_root_reopen) + elif failure_point == "parent-reopen": + monkeypatch.setattr( + incident_store, + "_open_incident_parent", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + PermissionError(raw_primary) + ), + ) + elif failure_point == "stage-create": + monkeypatch.setattr( + incident_store, + "_create_stage", + lambda _original: (_ for _ in ()).throw(PermissionError(raw_primary)), + ) + elif failure_point == "stage-validation-open": + real_open_nofollow = incident_store._open_nofollow + + def fail_stage_validation_open(path, *, directory=False, dir_fd=None): + if ( + not directory + and dir_fd is None + and Path(path).name == "incident.yml" + and stage_root in Path(path).parents + ): + raise PermissionError(raw_primary) + return real_open_nofollow(path, directory=directory, dir_fd=dir_fd) + + monkeypatch.setattr( + incident_store, "_open_nofollow", fail_stage_validation_open + ) + elif failure_point == "publication-create": + real_open = incident_store.os.open + + def fail_publication_open(path, flags, *args, **kwargs): + if isinstance(path, str) and path.startswith(f".{saved.name}."): + raise PermissionError(raw_primary) + return real_open(path, flags, *args, **kwargs) + + monkeypatch.setattr(incident_store.os, "open", fail_publication_open) + elif failure_point in {"publication-write", "cleanup"}: + monkeypatch.setattr( + incident_store, + "_write_all", + lambda *_args: (_ for _ in ()).throw(PermissionError(raw_primary)), + ) + if failure_point == "cleanup": + real_unlink = incident_store.os.unlink + + def fail_publication_cleanup(path, *args, **kwargs): + if isinstance(path, str) and path.startswith(f".{saved.name}."): + raise OSError(raw_cleanup) + return real_unlink(path, *args, **kwargs) + + monkeypatch.setattr( + incident_store.os, "unlink", fail_publication_cleanup + ) + elif failure_point == "file-fsync": + real_fsync = incident_store.os.fsync + file_fsyncs = 0 + + def fail_publication_file_fsync(fd): + nonlocal file_fsyncs + if stat.S_ISREG(os.fstat(fd).st_mode): + file_fsyncs += 1 + if file_fsyncs == 2: + raise PermissionError(raw_primary) + return real_fsync(fd) + + monkeypatch.setattr(incident_store.os, "fsync", fail_publication_file_fsync) + elif failure_point == "replace": + monkeypatch.setattr( + incident_store.os, + "replace", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + PermissionError(raw_primary) + ), + ) + elif failure_point in {"directory-fsync", "directory-fsync-after-replace"}: + real_fsync = incident_store.os.fsync + directory_fsyncs = 0 + + def fail_directory_fsync(fd): + nonlocal directory_fsyncs + if stat.S_ISDIR(os.fstat(fd).st_mode): + directory_fsyncs += 1 + target_call = ( + 2 if failure_point == "directory-fsync-after-replace" else 1 + ) + if directory_fsyncs == target_call: + raise PermissionError(raw_primary) + return real_fsync(fd) + + monkeypatch.setattr(incident_store.os, "fsync", fail_directory_fsync) + + result = CliRunner().invoke( + app, + ["edit", sample_data["id"]], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": str(editor)}, + ) + + assert result.exit_code == 1 + assert f"Storage error: {expected_type}" in result.output + if failure_point in {"cleanup", "directory-fsync"}: + assert ( + "Recoverable partial state: RecoverablePartialStateError" + in result.output + ) + assert saved.read_bytes() == original + assert raw_primary not in result.output + assert raw_cleanup not in result.output + assert str(data_root) not in result.output + assert "[bold]leak[/bold]" not in result.output + assert "[italic]leak[/italic]" not in result.output + assert "Traceback" not in result.output + publication_temps = list(saved.parent.glob(f".{saved.name}.*.tmp")) + if failure_point == "cleanup": + assert len(publication_temps) == 1 + else: + assert publication_temps == [] + backups = list(saved.parent.glob(f".{saved.name}.*.bak")) + if failure_point == "directory-fsync": + assert len(backups) == 1 + assert backups[0].read_bytes() == original + else: + assert backups == [] + assert list(stage_root.iterdir()) == [] + + +@pytest.mark.parametrize("cleanup_kind", ["stage", "directory-fd"]) +@pytest.mark.parametrize("primary_fails", [False, True]) +def test_edit_session_cleanup_failure_composes_with_primary_storage_error( + tmp_path, sample_data, monkeypatch, cleanup_kind, primary_fails +): + data_root, saved, original = _edit_fixture(tmp_path, sample_data) + editor = _editor_helper( + tmp_path, + "session-cleanup-failure-editor", + f"pathlib.Path(sys.argv[1]).write_text({_edited_payload(sample_data)!r})", + ) + raw_primary = f"primary failure at {data_root} [bold]primary[/bold]" + raw_cleanup = f"cleanup failure at {data_root} [italic]cleanup[/italic]" + + if primary_fails: + monkeypatch.setattr( + incident_store, + "_write_all", + lambda *_args: (_ for _ in ()).throw(PermissionError(raw_primary)), + ) + + if cleanup_kind == "stage": + real_cleanup = tempfile.TemporaryDirectory.cleanup + + def cleanup_then_fail(stage_dir): + real_cleanup(stage_dir) + raise PermissionError(raw_cleanup) + + monkeypatch.setattr( + tempfile.TemporaryDirectory, "cleanup", cleanup_then_fail + ) + else: + real_open_parent = incident_store._open_incident_parent + real_close = incident_store.os.close + session_directory_fd = None + + def record_session_directory(*args, **kwargs): + nonlocal session_directory_fd + result = real_open_parent(*args, **kwargs) + session_directory_fd = result[0] + return result + + def close_then_fail(fd): + if fd == session_directory_fd: + real_close(fd) + raise PermissionError(raw_cleanup) + return real_close(fd) + + monkeypatch.setattr( + incident_store, "_open_incident_parent", record_session_directory + ) + monkeypatch.setattr(incident_store.os, "close", close_then_fail) + + result = CliRunner().invoke( + app, + ["edit", sample_data["id"]], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": str(editor)}, + ) + + assert result.exit_code == 1 + assert "Storage error: PermissionError" in result.output + assert "Recoverable partial state: RecoverablePartialStateError" in result.output + if primary_fails: + assert saved.read_bytes() == original + else: + assert load_incident(saved).actual_behavior == "edited safely" + assert raw_primary not in result.output + assert raw_cleanup not in result.output + assert str(data_root) not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("cleanup_failure", ["unlink", "fsync"]) +def test_edit_replace_and_backup_cleanup_failure_preserves_primary_and_recovery( + tmp_path, sample_data, monkeypatch, cleanup_failure +): + data_root = tmp_path / "absolute-[root]\x1b" + incidents_dir = data_root / "incidents" + saved = save_incident(Incident.from_dict(sample_data), incidents_dir) + original = saved.read_bytes() + editor = _editor_helper( + tmp_path, + "combined-publication-failure-editor", + f"pathlib.Path(sys.argv[1]).write_text({_edited_payload(sample_data)!r})", + ) + real_replace = incident_store.os.replace + real_unlink = incident_store.os.unlink + real_fsync = incident_store.os.fsync + directory_fsyncs = 0 + primary_message = f"publication failed at {data_root} [bold]primary[/bold]" + cleanup_message = f"cleanup failed at {data_root} [italic]cleanup[/italic]" + + def fail_publication_replace(source, destination, *args, **kwargs): + if isinstance(source, str) and source.endswith(".tmp"): + raise PermissionError(primary_message) + return real_replace(source, destination, *args, **kwargs) + + def fail_backup_cleanup(path, *args, **kwargs): + if ( + cleanup_failure == "unlink" + and isinstance(path, str) + and path.endswith(".bak") + ): + raise OSError(cleanup_message) + return real_unlink(path, *args, **kwargs) + + def fail_backup_cleanup_fsync(fd): + nonlocal directory_fsyncs + if stat.S_ISDIR(os.fstat(fd).st_mode): + directory_fsyncs += 1 + if cleanup_failure == "fsync" and directory_fsyncs == 2: + raise OSError(cleanup_message) + return real_fsync(fd) + + monkeypatch.setattr(incident_store.os, "replace", fail_publication_replace) + monkeypatch.setattr(incident_store.os, "unlink", fail_backup_cleanup) + monkeypatch.setattr(incident_store.os, "fsync", fail_backup_cleanup_fsync) + + result = CliRunner().invoke( + app, + ["edit", sample_data["id"]], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": str(editor)}, + ) + + assert result.exit_code == 1 + assert "Storage error: PermissionError" in result.output + assert "Recoverable partial state: RecoverablePartialStateError" in result.output + assert saved.read_bytes() == original + backups = list(saved.parent.glob(f".{saved.name}.*.bak")) + assert len(backups) == 1 + assert backups[0].read_bytes() == original + assert primary_message not in result.output + assert cleanup_message not in result.output + assert str(data_root) not in result.output + assert "[bold]primary[/bold]" not in result.output + assert "[italic]cleanup[/italic]" not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("cleanup_fails", [False, True]) +def test_edit_rollback_replace_failure_preserves_original_recovery_link( + tmp_path, sample_data, monkeypatch, cleanup_fails +): + data_root = tmp_path / "absolute-[root]\x1b" + incidents_dir = data_root / "incidents" + saved = save_incident(Incident.from_dict(sample_data), incidents_dir) + original = saved.read_bytes() + editor = _editor_helper( + tmp_path, + "rollback-failure-editor", + f"pathlib.Path(sys.argv[1]).write_text({_edited_payload(sample_data)!r})", + ) + real_fsync = incident_store.os.fsync + real_replace = incident_store.os.replace + real_unlink = incident_store.os.unlink + directory_fsyncs = 0 + replaces = 0 + raw_publication = f"publication fsync failed at {data_root} [bold]leak[/bold]" + raw_rollback = f"rollback failed at {data_root} [italic]leak[/italic]" + raw_cleanup = f"cleanup failed at {data_root} [underline]leak[/underline]" + + def fail_publication_directory_fsync(fd): + nonlocal directory_fsyncs + if stat.S_ISDIR(os.fstat(fd).st_mode): + directory_fsyncs += 1 + if directory_fsyncs == 2: + raise PermissionError(raw_publication) + return real_fsync(fd) + + def fail_rollback_replace(src, dst, *args, **kwargs): + nonlocal replaces + replaces += 1 + if replaces == 2: + raise OSError(raw_rollback) + return real_replace(src, dst, *args, **kwargs) + + def maybe_fail_backup_cleanup(path, *args, **kwargs): + if cleanup_fails and isinstance(path, str) and path.endswith(".bak"): + raise OSError(raw_cleanup) + return real_unlink(path, *args, **kwargs) + + monkeypatch.setattr(incident_store.os, "fsync", fail_publication_directory_fsync) + monkeypatch.setattr(incident_store.os, "replace", fail_rollback_replace) + monkeypatch.setattr(incident_store.os, "unlink", maybe_fail_backup_cleanup) + + result = CliRunner().invoke( + app, + ["edit", sample_data["id"]], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": str(editor)}, + ) + + backups = list(saved.parent.glob(f".{saved.name}.*.bak")) + assert result.exit_code != 0 + assert len(backups) == 1 + assert backups[0].read_bytes() == original + assert "Storage error: PermissionError" in result.output + assert "Recoverable partial state: RecoverablePartialStateError" in result.output + assert raw_publication not in result.output + assert raw_rollback not in result.output + assert raw_cleanup not in result.output + assert str(data_root) not in result.output + assert "Traceback" not in result.output + + +def test_edit_backup_unlink_failure_reports_recoverable_partial_state( + tmp_path, sample_data, monkeypatch +): + data_root = tmp_path / "absolute-[root]\x1b" + incidents_dir = data_root / "incidents" + saved = save_incident(Incident.from_dict(sample_data), incidents_dir) + original = saved.read_bytes() + editor = _editor_helper( + tmp_path, + "backup-unlink-failure-editor", + f"pathlib.Path(sys.argv[1]).write_text({_edited_payload(sample_data)!r})", + ) + real_unlink = incident_store.os.unlink + raw_message = f"backup unlink failed at {data_root} [bold]leak[/bold]" + + def fail_backup_unlink(path, *args, **kwargs): + if isinstance(path, str) and path.endswith(".bak"): + raise PermissionError(raw_message) + return real_unlink(path, *args, **kwargs) + + monkeypatch.setattr(incident_store.os, "unlink", fail_backup_unlink) + + result = CliRunner().invoke( + app, + ["edit", sample_data["id"]], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": str(editor)}, + ) + + backups = list(saved.parent.glob(f".{saved.name}.*.bak")) + assert result.exit_code != 0 + assert load_incident(saved).actual_behavior == "edited safely" + assert len(backups) == 1 + assert backups[0].read_bytes() == original + assert "Storage error: PermissionError" in result.output + assert "Recoverable partial state: RecoverablePartialStateError" in result.output + assert raw_message not in result.output + assert str(data_root) not in result.output + assert "Traceback" not in result.output + + +def test_edit_backup_unlink_directory_fsync_failure_reports_partial_state( + tmp_path, sample_data, monkeypatch +): + data_root = tmp_path / "absolute-[root]\x1b" + incidents_dir = data_root / "incidents" + saved = save_incident(Incident.from_dict(sample_data), incidents_dir) + editor = _editor_helper( + tmp_path, + "backup-fsync-failure-editor", + f"pathlib.Path(sys.argv[1]).write_text({_edited_payload(sample_data)!r})", + ) + real_fsync = incident_store.os.fsync + directory_fsyncs = 0 + raw_message = f"backup cleanup fsync failed at {data_root} [bold]leak[/bold]" + + def fail_backup_cleanup_fsync(fd): + nonlocal directory_fsyncs + if stat.S_ISDIR(os.fstat(fd).st_mode): + directory_fsyncs += 1 + if directory_fsyncs == 3: + raise PermissionError(raw_message) + return real_fsync(fd) + + monkeypatch.setattr(incident_store.os, "fsync", fail_backup_cleanup_fsync) + + result = CliRunner().invoke( + app, + ["edit", sample_data["id"]], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": str(editor)}, + ) + + assert result.exit_code != 0 + assert load_incident(saved).actual_behavior == "edited safely" + assert not list(saved.parent.glob(f".{saved.name}.*.bak")) + assert "Storage error: PermissionError" in result.output + assert "Recoverable partial state: RecoverablePartialStateError" in result.output + assert raw_message not in result.output + assert str(data_root) not in result.output + assert "Traceback" not in result.output + + +def test_edit_early_return_descendant_can_only_modify_stage(tmp_path, sample_data): + data_root, saved, original = _edit_fixture(tmp_path, sample_data) + editor = _editor_helper( + tmp_path, + "early-return-editor", + "path = sys.argv[1]\n" + "if os.fork() == 0:\n" + " time.sleep(0.3)\n" + " pathlib.Path(path).write_text('late descendant write')\n" + " os._exit(0)", + ) + + result = CliRunner().invoke( + app, + ["edit", sample_data["id"]], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": str(editor)}, + ) + time.sleep(0.5) + + assert result.exit_code == 0 + assert saved.read_bytes() == original + + +def test_edit_rejects_target_replacement_conflict(tmp_path, sample_data): + data_root, saved, original = _edit_fixture(tmp_path, sample_data) + replacement = b"external replacement\n" + payload = _edited_payload(sample_data) + editor = _editor_helper( + tmp_path, + "replacement-editor", + f"target = pathlib.Path(os.environ['FORGE_TEST_TARGET'])\n" + f"other = target.with_suffix('.replacement')\n" + f"other.write_bytes({replacement!r})\n" + f"os.replace(other, target)\n" + f"pathlib.Path(sys.argv[1]).write_text({payload!r})", + ) + + result = CliRunner().invoke( + app, + ["edit", sample_data["id"]], + env={ + "FORGE_DATA_ROOT": str(data_root), + "EDITOR": str(editor), + "FORGE_TEST_TARGET": str(saved), + }, + ) + + assert result.exit_code == 1 + assert "changed while the editor was open" in result.output + assert saved.read_bytes() == replacement + assert saved.read_bytes() != original + + +def test_edit_rejects_symlink_replacement_without_touching_external( + tmp_path, sample_data +): + data_root, saved, _original = _edit_fixture(tmp_path, sample_data) + external = tmp_path / "external.yml" + external.write_bytes(b"external remains unchanged\n") + before = external.read_bytes() + editor = _editor_helper( + tmp_path, + "symlink-editor", + "target = pathlib.Path(os.environ['FORGE_TEST_TARGET'])\n" + "target.unlink()\n" + "target.symlink_to(pathlib.Path(os.environ['FORGE_TEST_EXTERNAL']))", + ) + + result = CliRunner().invoke( + app, + ["edit", sample_data["id"]], + env={ + "FORGE_DATA_ROOT": str(data_root), + "EDITOR": str(editor), + "FORGE_TEST_TARGET": str(saved), + "FORGE_TEST_EXTERNAL": str(external), + }, + ) + + assert result.exit_code == 1 + assert "Cannot safely edit incident" in result.output + assert saved.is_symlink() + assert external.read_bytes() == before + + +def test_edit_breaks_existing_hard_links(tmp_path, sample_data): + data_root, saved, original = _edit_fixture(tmp_path, sample_data) + linked = tmp_path / "external-hard-link.yml" + os.link(saved, linked) + payload = _edited_payload(sample_data) + editor = _editor_helper( + tmp_path, + "hard-link-editor", + f"pathlib.Path(sys.argv[1]).write_text({payload!r})", + ) + + result = CliRunner().invoke( + app, + ["edit", sample_data["id"]], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": str(editor)}, + ) + + assert result.exit_code == 0 + assert linked.read_bytes() == original + assert saved.read_bytes() != original + assert os.stat(saved).st_ino != os.stat(linked).st_ino + + +@pytest.mark.parametrize("command", ["show", "ref", "edit"]) +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_cli_lookup_reports_corrupt_requested_candidate( + tmp_path, command, lookup +): + data_root = tmp_path / "forge-data" + candidate = data_root / "incidents" / "2026-03" / "2026-03-04-001.yml" + candidate.parent.mkdir(parents=True) + candidate.write_text("incident: [unterminated") + + result = CliRunner().invoke( + app, + [command, lookup], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": "/usr/bin/false"}, + ) + + assert result.exit_code == 1 + assert "Requested incident candidate is corrupt" in result.output + assert "No incident found" not in result.output + assert str(data_root) not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("command", ["show", "ref", "edit"]) +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_cli_lookup_prioritizes_matching_corrupt_over_valid_candidate( + tmp_path, sample_data, command, lookup +): + data_root = tmp_path / "hostile-[root]\x1b" + incidents_dir = data_root / "incidents" + valid = save_incident(Incident.from_dict(sample_data), incidents_dir) + corrupt = incidents_dir / "duplicate" / valid.name + corrupt.parent.mkdir() + corrupt.write_text("incident: [unterminated") + + result = CliRunner().invoke( + app, + [command, lookup], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": "/usr/bin/false"}, + ) + + assert result.exit_code == 1 + assert "Requested incident candidate is corrupt" in result.output + assert "duplicate/2026-03-04-001.yml" in result.output + assert str(data_root) not in result.output + assert "\x1b" not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("command", ["show", "ref", "edit"]) +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_cli_lookup_prioritizes_scan_error_over_matching_corruption_and_fallback( + tmp_path, sample_data, command, lookup +): + data_root = tmp_path / "hostile-[root]\x1b" + incidents_dir = data_root / "incidents" + valid = save_incident(Incident.from_dict(sample_data), incidents_dir) + corrupt = incidents_dir / "duplicate" / valid.name + corrupt.parent.mkdir() + corrupt.write_text("incident: [unterminated") + external = tmp_path / "external" + external.mkdir() + (incidents_dir / "unrelated").symlink_to(external, target_is_directory=True) + + result = CliRunner().invoke( + app, + [command, lookup], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": "/usr/bin/false"}, + ) + + assert result.exit_code == 1 + assert "operationally incomplete" in result.output + assert "unrelated:" in result.output + assert "SymlinkRejectedError" in result.output + assert "Requested incident candidate is corrupt" not in result.output + assert "No incident found" not in result.output + assert "duplicate/2026-03-04-001.yml" not in result.output + assert str(data_root) not in result.output + assert "\x1b" not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("command", ["show", "ref", "edit"]) +def test_cli_lookup_valid_exact_ignores_corrupt_suffix_candidate( + tmp_path, sample_data, command +): + data_root = tmp_path / "hostile-[root]\x1b" + incidents_dir = data_root / "incidents" + valid = save_incident(Incident.from_dict(sample_data), incidents_dir) + corrupt_suffix = incidents_dir / "suffix" / f"prefix-{valid.name}" + corrupt_suffix.parent.mkdir() + corrupt_suffix.write_text("incident: [unterminated") + + result = CliRunner().invoke( + app, + [command, valid.stem], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": "/usr/bin/true"}, + ) + + assert result.exit_code == 0 + assert sample_data["id"] in result.output + assert "Requested incident candidate is corrupt" not in result.output + assert f"prefix-{valid.name}" not in result.output + assert str(data_root) not in result.output + assert "\x1b" not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("command", ["show", "ref", "edit"]) +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_cli_lookup_prioritizes_inaccessible_nested_directory_over_valid_candidate( + tmp_path, sample_data, monkeypatch, command, lookup +): + data_root = tmp_path / "hostile-[root]\x1b" + incidents_dir = data_root / "incidents" + save_incident(Incident.from_dict(sample_data), incidents_dir) + (incidents_dir / "nested").mkdir() + real_listdir = os.listdir + calls = 0 + + def fail_nested_listdir(directory_fd): + nonlocal calls + calls += 1 + if calls == 3: + raise PermissionError(f"secret root {data_root}") + return real_listdir(directory_fd) + + monkeypatch.setattr("forge_cli.incident_store.os.listdir", fail_nested_listdir) + result = CliRunner().invoke( + app, + [command, lookup], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": "/usr/bin/false"}, + ) + + assert result.exit_code == 1 + assert "operationally incomplete" in result.output + assert "nested: PermissionError" in result.output + assert str(data_root) not in result.output + assert "\x1b" not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("command", ["show", "ref", "edit"]) +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_cli_lookup_prioritizes_symlinked_nested_directory_over_valid_candidate( + tmp_path, sample_data, command, lookup +): + data_root = tmp_path / "hostile-[root]\x1b" + incidents_dir = data_root / "incidents" + save_incident(Incident.from_dict(sample_data), incidents_dir) + external = tmp_path / "external" + external.mkdir() + (incidents_dir / "nested").symlink_to(external, target_is_directory=True) + + result = CliRunner().invoke( + app, + [command, lookup], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": "/usr/bin/false"}, + ) + + assert result.exit_code == 1 + assert "operationally incomplete" in result.output + assert "nested: SymlinkRejectedError" in result.output + assert str(data_root) not in result.output + assert "\x1b" not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("command", ["show", "ref", "edit"]) +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_cli_lookup_rejects_multiple_valid_exact_candidates( + tmp_path, sample_data, command, lookup +): + data_root = tmp_path / "hostile-[root]\x1b" + incidents_dir = data_root / "incidents" + valid = save_incident(Incident.from_dict(sample_data), incidents_dir) + duplicate = incidents_dir / "duplicate" / valid.name + duplicate.parent.mkdir() + duplicate.write_bytes(valid.read_bytes()) + + result = CliRunner().invoke( + app, + [command, lookup], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": "/usr/bin/false"}, + ) + + assert result.exit_code == 1 + assert "Ambiguous incident id" in result.output + assert str(data_root) not in result.output + assert "\x1b" not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("command", ["show", "ref", "edit"]) +@pytest.mark.parametrize("nested_failure", [False, True]) +def test_cli_lookup_reports_operationally_incomplete_scan( + tmp_path, monkeypatch, command, nested_failure +): + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + incidents_dir.mkdir(parents=True) + if nested_failure: + (incidents_dir / "nested").mkdir() + raw_message = f"raw scan failure {data_root}\nsecret" + real_listdir = os.listdir + calls = 0 + + def fail_listdir(directory_fd): + nonlocal calls + calls += 1 + if not nested_failure or calls == 2: + raise PermissionError(raw_message) + return real_listdir(directory_fd) + + monkeypatch.setattr("forge_cli.incident_store.os.listdir", fail_listdir) + result = CliRunner().invoke( + app, + [command, "001"], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": "/usr/bin/false"}, + ) + + assert result.exit_code == 1 + assert "operationally incomplete" in result.output + assert "No incident found" not in result.output + assert str(data_root) not in result.output + assert raw_message not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("command", [["list"], ["stats"]]) +@pytest.mark.parametrize( + ("incident_id", "timestamp", "include_timestamp"), + [ + ("2026-02-30-001", "2026-02-28T12:00:00Z", True), + ("legacy-malformed", "not-a-timestamp", True), + ("legacy-empty", "", True), + ("legacy-missing", None, False), + ], +) +def test_cli_scan_commands_classify_invalid_ordering_fields( + tmp_path, sample_data, command, incident_id, timestamp, include_timestamp +): + data_root = tmp_path / "forge-data" + candidate = data_root / "incidents" / "nested" / f"{incident_id}.yml" + candidate.parent.mkdir(parents=True) + payload = {**sample_data, "id": incident_id} + if include_timestamp: + payload["timestamp"] = timestamp + else: + payload.pop("timestamp", None) + candidate.write_text(yaml.safe_dump(payload)) + + result = CliRunner().invoke( + app, + command, + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code == 0 + assert "Corrupt corpus files: 1" in result.output + assert f"nested/{incident_id}.yml: InvalidIncidentError" in result.output + assert str(data_root) not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("valid", [True, False]) +def test_edit_output_is_literal_control_safe_and_root_relative( + tmp_path, sample_data, valid +): + data_root = tmp_path / "forge-[root]\x1b\n-data" + incidents_dir = data_root / "incidents" + saved = save_incident(Incident.from_dict(sample_data), incidents_dir) + hostile = saved.with_name("unsafe-[file]\x1b\n\r-001.yml") + saved.rename(hostile) + payload = _edited_payload(sample_data) + body = ( + f"pathlib.Path(sys.argv[1]).write_text({payload!r})" + if valid + else "pathlib.Path(sys.argv[1]).write_text('incident: [unterminated')" + ) + editor = _editor_helper(tmp_path, "editor-[name]\x1b\n\r", body) + + result = CliRunner().invoke( + app, + ["edit", "001"], + env={"FORGE_DATA_ROOT": str(data_root), "EDITOR": str(editor)}, + ) + + assert result.exit_code == (0 if valid else 1) + assert "unsafe-[file]???-001.yml" in result.output + assert "[name]???" in result.output + assert "\x1b" not in result.output + assert "\r" not in result.output + assert str(data_root) not in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("mutation", ["disappear", "same-inode-content"]) +def test_edit_rejects_target_disappearance_or_content_conflict( + tmp_path, sample_data, mutation +): + data_root, saved, original = _edit_fixture(tmp_path, sample_data) + payload = _edited_payload(sample_data) + mutation_code = ( + "target.unlink()" + if mutation == "disappear" + else "target.write_bytes(target.read_bytes() + b'external change\\n')" + ) + editor = _editor_helper( + tmp_path, + f"{mutation}-editor", + "target = pathlib.Path(os.environ['FORGE_TEST_TARGET'])\n" + f"{mutation_code}\n" + f"pathlib.Path(sys.argv[1]).write_text({payload!r})", + ) + + result = CliRunner().invoke( + app, + ["edit", sample_data["id"]], + env={ + "FORGE_DATA_ROOT": str(data_root), + "EDITOR": str(editor), + "FORGE_TEST_TARGET": str(saved), + }, + ) + + assert result.exit_code == 1 + assert "changed while the editor was open" in result.output + if mutation == "disappear": + assert not saved.exists() + else: + assert saved.read_bytes() == original + b"external change\n" + assert not list(saved.parent.glob(f".{saved.name}.*.tmp")) + + +@pytest.mark.parametrize("failure_kind", ["corrupt", "scan-error"]) +def test_analyze_prepare_only_fails_before_creating_artifact_on_incomplete_corpus( + tmp_path, sample_data, monkeypatch, failure_kind +): + data_root = tmp_path / "forge-data" + _prepare_incomplete_analysis_corpus( + data_root, sample_data, failure_kind, monkeypatch + ) + + result = CliRunner().invoke( + app, + ["analyze", "--prepare-only"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code == 1 + assert "Valid corpus incidents: 1" in result.output + assert ( + f"Corrupt corpus files: {1 if failure_kind == 'corrupt' else 0}" + in result.output + ) + assert ( + f"Scan operational errors: {1 if failure_kind == 'scan-error' else 0}" + in result.output + ) + assert not (data_root / "analysis").exists() + assert str(data_root) not in result.output + + +@pytest.mark.parametrize("failure_kind", ["corrupt", "scan-error"]) +def test_analyze_provider_path_fails_before_provider_call_on_incomplete_corpus( + tmp_path, sample_data, monkeypatch, failure_kind +): + data_root = tmp_path / "forge-data" + _prepare_incomplete_analysis_corpus( + data_root, sample_data, failure_kind, monkeypatch + ) + provider_calls = 0 + + def fail_get_provider(*_args, **_kwargs): + nonlocal provider_calls + provider_calls += 1 + raise AssertionError("provider must not be resolved") + + monkeypatch.setattr("forge_cli.providers.get_provider", fail_get_provider) + + result = CliRunner().invoke( + app, + ["analyze", "--provider", "openai"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code == 1 + assert provider_calls == 0 + assert "Valid corpus incidents: 1" in result.output + assert "Incident corpus is incomplete; analysis was not started." in result.output + assert not (data_root / "analysis").exists() + + +def test_analyze_missing_corpus_is_successful_empty_read_only_result(tmp_path): + data_root = tmp_path / "missing-forge-data" + + result = CliRunner().invoke( + app, + ["analyze", "--prepare-only"], + env={"FORGE_DATA_ROOT": str(data_root)}, + ) + + assert result.exit_code == 0 + assert "No incidents to analyze." in result.output + assert not data_root.exists() diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..3c2d07d --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,189 @@ +from io import StringIO + +import pytest +from rich.console import Console + +import forge_cli.display as display +from forge_cli.models import Incident + + +def _capture_console(monkeypatch) -> StringIO: + output = StringIO() + monkeypatch.setattr( + display, + "console", + Console(file=output, force_terminal=False, color_system=None, width=500), + ) + return output + + +def _unsafe_incident(sample_data) -> Incident: + incident = Incident.from_dict(sample_data) + incident.id = "[bold]id[/bold]\x1b\r" + incident.timestamp = "[bold]timestamp[/bold]\x1b\r" + incident.reported_by = "[bold]reporter[/bold]\x1b\r" + incident.project = "[bold]project[/bold]\x1b\r" + incident.agent = "[bold]agent[/bold]\x1b\r" + incident.platform = "[bold]platform[/bold]\x1b\r" + incident.severity = "[bold]severity[/bold]\x1b\r" + incident.failure_type = "[bold]type[/bold]\x1b\r" + incident.expected_behavior = "[bold]expected[/bold]\nsecond\x1b\r" + incident.actual_behavior = "[bold]actual[/bold]\nsecond\x1b\r" + incident.context = "[bold]context[/bold]\nsecond\x1b\r" + incident.root_cause = "[bold]cause[/bold]\nsecond\x1b\r" + incident.immediate_fix = "[bold]fix[/bold]\nsecond\x1b\r" + incident.systemic_takeaway = "[bold]takeaway[/bold]\nsecond\x1b\r" + incident.tags = ["[bold]tag[/bold]\x1b\r"] + incident.capability_area = "[bold]capability[/bold]\x1b\r" + incident.lifecycle_stage = "[bold]lifecycle[/bold]\x1b\r" + incident.issue_class = "[bold]issue[/bold]\x1b\r" + incident.workflow_archetype = "[bold]workflow[/bold]\x1b\r" + incident.subject_type = "[bold]subject[/bold]\x1b\r" + incident.blocked_use_class = "[bold]blocked[/bold]\x1b\r" + incident.observed_state = { + "[bold]state-key[/bold]\x1b\r": "[bold]state-value[/bold]\x1b\r" + } + incident.related_incidents = ["[bold]related[/bold]\x1b\r"] + incident.playbook_entry = "[bold]playbook[/bold]\x1b\r" + incident.workflow_ref = {"ref_id": "workflow:1"} + return incident + + +def _assert_literal_safe(rendered: str, *labels: str) -> None: + for label in labels: + assert f"[bold]{label}[/bold]??" in rendered + assert "\x1b" not in rendered + assert "\r" not in rendered + + +@pytest.mark.parametrize( + "printer_name", + ["print_info", "print_success", "print_warning", "print_error"], +) +def test_message_printers_render_untrusted_text_literally_without_controls( + monkeypatch, printer_name +): + output = StringIO() + monkeypatch.setattr( + display, + "console", + Console(file=output, force_terminal=False, color_system=None, width=200), + ) + message = "[bold]literal[/bold] unmatched[\x1b\n\r" + + getattr(display, printer_name)(message) + + rendered = output.getvalue() + assert "[bold]literal[/bold] unmatched[???" in rendered + assert "\x1b" not in rendered + assert "\r" not in rendered + assert rendered.count("\n") == 1 + + +def test_display_incident_panel_renders_fields_tags_axes_and_multiline_literally( + monkeypatch, sample_data +): + output = _capture_console(monkeypatch) + incident = _unsafe_incident(sample_data) + + display.display_incident_panel(incident) + + rendered = output.getvalue() + _assert_literal_safe( + rendered, + "id", + "project", + "agent", + "platform", + "severity", + "type", + "tag", + "capability", + "lifecycle", + "issue", + "workflow", + ) + assert "[bold]actual[/bold]" in rendered + assert "[bold]expected[/bold]" in rendered + assert "second?" in rendered + + +def test_display_incident_detail_renders_title_and_every_corpus_field_literally( + monkeypatch, sample_data +): + output = _capture_console(monkeypatch) + incident = _unsafe_incident(sample_data) + + display.display_incident_detail(incident) + + rendered = output.getvalue() + _assert_literal_safe( + rendered, + "id", + "timestamp", + "reporter", + "project", + "agent", + "platform", + "severity", + "type", + "tag", + "capability", + "lifecycle", + "issue", + "workflow", + "subject", + "blocked", + "state-key", + "state-value", + "related", + "playbook", + ) + for label in ["expected", "actual", "context", "cause", "fix", "takeaway"]: + assert f"[bold]{label}[/bold]" in rendered + assert "[bold]id[/bold]??" in rendered.splitlines()[0] + assert "second?" in rendered + assert "workflow_ref" in rendered + + +def test_display_incident_table_renders_all_corpus_cells_literally( + monkeypatch, sample_data +): + output = _capture_console(monkeypatch) + incident = _unsafe_incident(sample_data) + + display.display_incident_table([incident]) + + rendered = output.getvalue() + _assert_literal_safe( + rendered, + "id", + "project", + "platform", + "severity", + "type", + ) + assert "[bold]actual[/bold]" in rendered + + +def test_display_stats_renders_all_corpus_cells_literally( + monkeypatch, sample_data +): + output = _capture_console(monkeypatch) + incident = _unsafe_incident(sample_data) + incident.timestamp = "[date]\x1b\r" + + display.display_stats([incident]) + + rendered = output.getvalue() + _assert_literal_safe( + rendered, + "project", + "platform", + "severity", + "type", + "tag", + "issue", + "capability", + ) + assert "[date]??" in rendered diff --git a/tests/test_incident_store.py b/tests/test_incident_store.py index a88ce0b..9d16285 100644 --- a/tests/test_incident_store.py +++ b/tests/test_incident_store.py @@ -1,19 +1,149 @@ +from concurrent.futures import ProcessPoolExecutor from datetime import date +import errno +import multiprocessing +import os from pathlib import Path +import yaml +import pytest +import forge_cli.incident_store as incident_store + from forge_cli.incident_store import ( AmbiguousIncidentLookupError, DuplicateIncidentError, + IncidentLookupCorruptError, + IncidentLookupIncompleteError, + UnsafeIncidentPathError, find_incident, find_incident_path, generate_id, + get_all_incidents, list_incidents, + list_incidents_result, load_incident, + stage_incident_for_edit, + save_generated_incident, save_incident, + scan_incidents, ) from forge_cli.models import Incident +class _FakeNativeRename: + def __init__(self, result=0): + self.result = result + self.calls = [] + self.argtypes = None + self.restype = None + + def __call__(self, *args): + self.calls.append(args) + return self.result + + +class _FakeLibc: + pass + + +@pytest.mark.parametrize( + ("platform", "symbol", "expected_flag"), + [ + ("darwin", "renameatx_np", 0x00000004), + ("linux", "renameat2", 0x00000001), + ], +) +def test_native_noreplace_loader_selects_exact_symbol_and_flag( + monkeypatch, platform, symbol, expected_flag +): + native = _FakeNativeRename() + libc = _FakeLibc() + setattr(libc, symbol, native) + monkeypatch.setattr(incident_store.sys, "platform", platform) + monkeypatch.setattr( + incident_store.ctypes, "CDLL", lambda *_args, **_kwargs: libc + ) + + rename_noreplace = incident_store._load_native_rename_noreplace() + + assert rename_noreplace is not None + rename_noreplace(11, "source", 22, "destination") + assert native.calls == [ + (11, b"source", 22, b"destination", expected_flag) + ] + + +@pytest.mark.parametrize("platform", ["darwin", "linux", "win32"]) +def test_native_noreplace_loader_fails_closed_without_platform_symbol( + monkeypatch, platform +): + monkeypatch.setattr(incident_store.sys, "platform", platform) + monkeypatch.setattr( + incident_store.ctypes, "CDLL", lambda *_args, **_kwargs: _FakeLibc() + ) + + assert incident_store._load_native_rename_noreplace() is None + + +@pytest.mark.parametrize( + "error_number", + [ + errno.ENOSYS, + errno.EINVAL, + errno.ENOTSUP, + errno.EOPNOTSUPP, + ], +) +def test_native_noreplace_maps_unsupported_errno( + monkeypatch, error_number +): + native = _FakeNativeRename(result=-1) + libc = _FakeLibc() + libc.renameatx_np = native + monkeypatch.setattr(incident_store.sys, "platform", "darwin") + monkeypatch.setattr( + incident_store.ctypes, "CDLL", lambda *_args, **_kwargs: libc + ) + monkeypatch.setattr(incident_store.ctypes, "get_errno", lambda: error_number) + rename_noreplace = incident_store._load_native_rename_noreplace() + + assert rename_noreplace is not None + with pytest.raises(incident_store.AtomicRenameUnsupportedError): + rename_noreplace(11, "source", 22, "destination") + + +@pytest.mark.parametrize( + ("error_number", "expected_error"), + [ + (errno.EEXIST, FileExistsError), + (errno.EACCES, PermissionError), + (errno.EIO, OSError), + ], +) +def test_native_noreplace_preserves_supported_errno_classification( + monkeypatch, error_number, expected_error +): + native = _FakeNativeRename(result=-1) + libc = _FakeLibc() + libc.renameat2 = native + monkeypatch.setattr(incident_store.sys, "platform", "linux") + monkeypatch.setattr( + incident_store.ctypes, "CDLL", lambda *_args, **_kwargs: libc + ) + monkeypatch.setattr(incident_store.ctypes, "get_errno", lambda: error_number) + rename_noreplace = incident_store._load_native_rename_noreplace() + + assert rename_noreplace is not None + with pytest.raises(expected_error): + rename_noreplace(11, "source", 22, "destination") + + +def _save_generated_worker(incidents_dir: str, sample_data: dict) -> tuple[str, str]: + incident = Incident.from_dict(sample_data) + path = save_generated_incident(incident, Path(incidents_dir)) + return incident.id, str(path) + + def test_generate_id_first_of_day(tmp_incidents_dir): incident_id = generate_id(tmp_incidents_dir, date(2026, 3, 4)) assert incident_id == "2026-03-04-001" @@ -30,6 +160,98 @@ def test_generate_id_sequential(tmp_incidents_dir): assert incident_id == "2026-03-04-003" +def test_generate_id_transitions_from_998_through_1000(tmp_incidents_dir): + month_dir = tmp_incidents_dir / "2026-03" + month_dir.mkdir() + (month_dir / "2026-03-04-998.yml").touch() + assert generate_id(tmp_incidents_dir, date(2026, 3, 4)) == "2026-03-04-999" + + (month_dir / "2026-03-04-999.yml").touch() + assert generate_id(tmp_incidents_dir, date(2026, 3, 4)) == "2026-03-04-1000" + + (month_dir / "2026-03-04-1000.yml").touch() + assert generate_id(tmp_incidents_dir, date(2026, 3, 4)) == "2026-03-04-1001" + + +def test_generate_id_uses_numeric_max_across_boundaries_and_gaps(tmp_incidents_dir): + month_dir = tmp_incidents_dir / "2026-03" + month_dir.mkdir() + for sequence in (998, 999, 1000, 1002): + (month_dir / f"2026-03-04-{sequence:03d}.yml").touch() + + assert generate_id(tmp_incidents_dir, date(2026, 3, 4)) == "2026-03-04-1003" + + +def test_generate_id_ignores_nonconforming_neighboring_filenames(tmp_incidents_dir): + month_dir = tmp_incidents_dir / "2026-03" + month_dir.mkdir() + (month_dir / "2026-03-04-007.yml").touch() + (month_dir / "2026-03-04-invalid.yml").touch() + (month_dir / "2026-03-04-999-copy.yml").touch() + (month_dir / "prefix-2026-03-04-999.yml").touch() + + assert generate_id(tmp_incidents_dir, date(2026, 3, 4)) == "2026-03-04-008" + + +@pytest.mark.parametrize("max_attempts", [0, -1]) +def test_save_generated_incident_rejects_nonpositive_max_attempts( + tmp_incidents_dir, sample_data, max_attempts +): + incident = Incident.from_dict({**sample_data, "id": ""}) + + with pytest.raises(ValueError, match="max_attempts must be positive"): + save_generated_incident( + incident, + tmp_incidents_dir, + max_attempts=max_attempts, + ) + + assert not list(tmp_incidents_dir.rglob("*.yml")) + + +def test_save_generated_incident_retries_first_publication_collision( + tmp_incidents_dir, sample_data, monkeypatch +): + incident = Incident.from_dict({**sample_data, "id": ""}) + real_save = save_incident + attempts = 0 + + def collide_once(candidate, incidents_dir): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise DuplicateIncidentError("deterministic collision") + return real_save(candidate, incidents_dir) + + monkeypatch.setattr("forge_cli.incident_store.save_incident", collide_once) + + path = save_generated_incident(incident, tmp_incidents_dir, max_attempts=2) + + assert attempts == 2 + assert path.exists() + assert incident.id == "2026-03-04-001" + + +def test_save_generated_incident_exhausts_bounded_collision_retries( + tmp_incidents_dir, sample_data, monkeypatch +): + incident = Incident.from_dict({**sample_data, "id": ""}) + attempts = 0 + + def always_collide(_candidate, _incidents_dir): + nonlocal attempts + attempts += 1 + raise DuplicateIncidentError("deterministic collision") + + monkeypatch.setattr("forge_cli.incident_store.save_incident", always_collide) + + with pytest.raises(DuplicateIncidentError, match="after 3 attempts"): + save_generated_incident(incident, tmp_incidents_dir, max_attempts=3) + + assert attempts == 3 + assert not list(tmp_incidents_dir.rglob("*.yml")) + + def test_save_and_load_roundtrip(tmp_incidents_dir, sample_data): incident = Incident.from_dict(sample_data) filepath = save_incident(incident, tmp_incidents_dir) @@ -82,6 +304,761 @@ def test_list_incidents_severity_filter(tmp_incidents_dir, sample_data): assert len(result) == 0 +def test_missing_incidents_directory_is_an_empty_read_only_corpus(tmp_path): + incidents_dir = tmp_path / "missing-incidents" + + scan = scan_incidents(incidents_dir) + + assert scan.incidents == () + assert scan.errors == () + assert scan.scan_errors == () + assert list_incidents(incidents_dir) == [] + assert get_all_incidents(incidents_dir) == [] + assert not incidents_dir.exists() + + +def test_scan_incidents_reports_corruption_without_modifying_source( + tmp_incidents_dir, sample_data +): + save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + corrupt_path = tmp_incidents_dir / "2026-03" / "2026-03-04-002.yml" + corrupt_payload = b"incident: [unterminated" + corrupt_path.write_bytes(corrupt_payload) + + result = scan_incidents(tmp_incidents_dir) + + assert [incident.id for incident in result.incidents] == ["2026-03-04-001"] + assert result.valid_corpus_count == 1 + assert result.corrupt_corpus_count == 1 + assert result.errors[0].path == "2026-03/2026-03-04-002.yml" + assert result.errors[0].error_type == "YAMLError" + assert corrupt_path.read_bytes() == corrupt_payload + + +def test_list_result_distinguishes_corpus_matches_and_returned_count( + tmp_incidents_dir, sample_data +): + first = Incident.from_dict(sample_data) + second = Incident.from_dict( + { + **sample_data, + "id": "2026-03-04-002", + "project": "other-project", + } + ) + third = Incident.from_dict( + { + **sample_data, + "id": "2026-03-04-003", + "timestamp": "2026-03-04T16:30:00Z", + } + ) + for incident in (first, second, third): + save_incident(incident, tmp_incidents_dir) + corrupt_path = tmp_incidents_dir / "2026-03" / "2026-03-04-004.yml" + corrupt_path.write_text("incident: [unterminated") + + result = list_incidents_result( + tmp_incidents_dir, + project=sample_data["project"], + limit=1, + ) + + assert result.valid_corpus_count == 3 + assert result.corrupt_corpus_count == 1 + assert result.matched_count == 2 + assert result.returned_count == 1 + + +def test_list_result_zero_limit_keeps_corpus_diagnostics(tmp_incidents_dir, sample_data): + save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + corrupt_path = tmp_incidents_dir / "2026-03" / "2026-03-04-002.yml" + corrupt_path.write_text("incident: [unterminated") + + result = list_incidents_result(tmp_incidents_dir, limit=0) + + assert result.incidents == () + assert result.valid_corpus_count == 1 + assert result.corrupt_corpus_count == 1 + assert result.matched_count == 1 + assert result.returned_count == 0 + assert list_incidents(tmp_incidents_dir, limit=0) == [] + + +@pytest.mark.parametrize("list_function", [list_incidents, list_incidents_result]) +def test_store_list_functions_reject_negative_limit( + tmp_incidents_dir, list_function +): + with pytest.raises(ValueError, match="limit must be non-negative"): + list_function(tmp_incidents_dir, limit=-1) + + +def test_scan_does_not_follow_directory_symlinks(tmp_incidents_dir, sample_data): + external = tmp_incidents_dir.parent / "external" + save_incident(Incident.from_dict(sample_data), external) + linked = tmp_incidents_dir / "linked" + linked.symlink_to(external, target_is_directory=True) + + result = scan_incidents(tmp_incidents_dir) + + assert result.incidents == () + assert result.valid_corpus_count == 0 + assert result.corrupt_corpus_count == 0 + + + +def test_scan_rejects_file_and_directory_symlinks(tmp_incidents_dir, sample_data): + external = tmp_incidents_dir.parent / "external" + external_file = save_incident(Incident.from_dict(sample_data), external) + month_dir = tmp_incidents_dir / "2026-03" + month_dir.mkdir() + file_link = month_dir / external_file.name + file_link.symlink_to(external_file) + directory_link = tmp_incidents_dir / "linked" + directory_link.symlink_to(external, target_is_directory=True) + + result = scan_incidents(tmp_incidents_dir) + + assert result.incidents == () + assert [(error.path, error.error_type) for error in result.scan_errors] == [ + ("2026-03/2026-03-04-001.yml", "SymlinkRejectedError"), + ("linked", "SymlinkRejectedError"), + ] + + +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_exact_and_suffix_lookup_reject_file_symlinks( + tmp_incidents_dir, sample_data, lookup +): + external = tmp_incidents_dir.parent / "external" + external_file = save_incident(Incident.from_dict(sample_data), external) + month_dir = tmp_incidents_dir / "2026-03" + month_dir.mkdir() + (month_dir / external_file.name).symlink_to(external_file) + + for lookup_function in (find_incident_path, find_incident): + with pytest.raises(IncidentLookupIncompleteError, match="operationally incomplete"): + lookup_function(tmp_incidents_dir, lookup) + + +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_exact_and_suffix_lookup_reject_symlink_directories( + tmp_incidents_dir, sample_data, lookup +): + external = tmp_incidents_dir.parent / "external" + save_incident(Incident.from_dict(sample_data), external) + (tmp_incidents_dir / "2026-03").symlink_to( + external / "2026-03", target_is_directory=True + ) + + for lookup_function in (find_incident_path, find_incident): + with pytest.raises(IncidentLookupIncompleteError, match="operationally incomplete"): + lookup_function(tmp_incidents_dir, lookup) + + +def test_lookup_rejects_symlink_replacement_at_safe_open_seam( + tmp_incidents_dir, sample_data, monkeypatch +): + saved = save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + external = tmp_incidents_dir.parent / "replacement.yml" + external.write_text(saved.read_text()) + real_open = os.open + replaced = False + + def replace_before_file_open(path, flags, *args, **kwargs): + nonlocal replaced + if Path(path) == saved and kwargs.get("dir_fd") is None and not replaced: + replaced = True + saved.unlink() + saved.symlink_to(external) + return real_open(path, flags, *args, **kwargs) + + monkeypatch.setattr("forge_cli.incident_store.os.open", replace_before_file_open) + + with pytest.raises(UnsafeIncidentPathError, match="symlink"): + load_incident(saved) + + assert replaced is True + assert external.exists() + +def test_edit_stage_rejects_symlink_replacement_after_lookup( + tmp_incidents_dir, sample_data, monkeypatch +): + saved = save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + external = tmp_incidents_dir.parent / "replacement.yml" + external.write_text(saved.read_text()) + real_open = os.open + file_opens = 0 + + def replace_after_lookup(path, flags, *args, **kwargs): + nonlocal file_opens + if path == saved.name and kwargs.get("dir_fd") is not None: + file_opens += 1 + if file_opens == 2: + saved.unlink() + saved.symlink_to(external) + return real_open(path, flags, *args, **kwargs) + + monkeypatch.setattr("forge_cli.incident_store.os.open", replace_after_lookup) + + with pytest.raises(UnsafeIncidentPathError, match="symlink"): + with stage_incident_for_edit(tmp_incidents_dir, "001"): + pass + + assert file_opens == 2 + assert external.exists() + + +def test_edit_stage_nonblocking_open_rejects_fifo_replacement( + tmp_incidents_dir, sample_data, monkeypatch +): + saved = save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + real_open = os.open + file_opens = 0 + + def replace_with_fifo(path, flags, *args, **kwargs): + nonlocal file_opens + if path == saved.name and kwargs.get("dir_fd") is not None: + file_opens += 1 + if file_opens == 2: + saved.unlink() + os.mkfifo(saved) + assert flags & os.O_NONBLOCK + return real_open(path, flags, *args, **kwargs) + + monkeypatch.setattr("forge_cli.incident_store.os.open", replace_with_fifo) + + with pytest.raises(UnsafeIncidentPathError, match="regular file"): + with stage_incident_for_edit(tmp_incidents_dir, "001"): + pass + + assert file_opens == 2 + + +def test_no_follow_unavailable_has_clear_error_contract( + tmp_incidents_dir, sample_data, monkeypatch +): + saved = save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + monkeypatch.setattr(os, "O_NOFOLLOW", 0) + + result = scan_incidents(tmp_incidents_dir) + + assert result.incidents == () + assert result.scan_errors[0].error_type == "UnsafeIncidentPathError" + with pytest.raises( + UnsafeIncidentPathError, + match="requires POSIX O_NOFOLLOW support", + ): + load_incident(saved) + + +def test_scan_routes_read_oserror_after_open_to_scan_errors( + tmp_incidents_dir, monkeypatch +): + unsafe_name = "2026-03-04-\n001.yml" + candidate = tmp_incidents_dir / "2026-03" / unsafe_name + candidate.parent.mkdir() + candidate.touch() + raw_message = f"raw failure at {tmp_incidents_dir}\nwith payload" + + def fail_read(_fd, _size): + raise OSError(raw_message) + + monkeypatch.setattr("forge_cli.incident_store.os.read", fail_read) + + result = scan_incidents(tmp_incidents_dir) + rendered = ( + f"{result.scan_errors[0].path}: {result.scan_errors[0].error_type}" + ) + + assert result.errors == () + assert result.scan_errors[0].error_type == "OSError" + assert "\n" not in rendered + assert raw_message not in rendered + assert str(tmp_incidents_dir) not in rendered + + +def test_scan_routes_regular_file_open_permission_error_to_scan_errors( + tmp_incidents_dir, sample_data, monkeypatch +): + saved = save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + real_open_nofollow = __import__( + "forge_cli.incident_store", fromlist=["_open_nofollow"] + )._open_nofollow + + def fail_regular_file_open(path, *, directory=False, dir_fd=None): + if not directory and Path(path).name == saved.name: + raise PermissionError(f"secret root {tmp_incidents_dir}") + return real_open_nofollow(path, directory=directory, dir_fd=dir_fd) + + monkeypatch.setattr( + "forge_cli.incident_store._open_nofollow", fail_regular_file_open + ) + + result = scan_incidents(tmp_incidents_dir) + + assert result.errors == () + assert [(error.path, error.error_type) for error in result.scan_errors] == [ + ("2026-03/2026-03-04-001.yml", "PermissionError") + ] + + +@pytest.mark.parametrize("lookup_function", [find_incident, find_incident_path]) +def test_lookup_fails_closed_when_unrelated_yaml_cannot_be_opened( + tmp_incidents_dir, sample_data, monkeypatch, lookup_function +): + requested = save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + unrelated = save_incident( + Incident.from_dict( + { + **sample_data, + "id": "2026-03-04-002", + "timestamp": "2026-03-04T15:30:00Z", + } + ), + tmp_incidents_dir, + ) + real_open_nofollow = __import__( + "forge_cli.incident_store", fromlist=["_open_nofollow"] + )._open_nofollow + + def fail_unrelated_open(path, *, directory=False, dir_fd=None): + if not directory and Path(path).name == unrelated.name: + raise PermissionError(f"secret root {tmp_incidents_dir}") + return real_open_nofollow(path, directory=directory, dir_fd=dir_fd) + + monkeypatch.setattr( + "forge_cli.incident_store._open_nofollow", fail_unrelated_open + ) + + with pytest.raises(IncidentLookupIncompleteError) as exc_info: + lookup_function(tmp_incidents_dir, requested.stem) + + message = str(exc_info.value) + assert f"2026-03/{unrelated.name}: PermissionError" in message + assert str(tmp_incidents_dir) not in message + + +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +@pytest.mark.parametrize("lookup_function", [find_incident, find_incident_path]) +def test_lookup_fails_closed_for_unreadable_exact_and_suffix_candidates( + tmp_incidents_dir, sample_data, monkeypatch, lookup, lookup_function +): + saved = save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + real_open_nofollow = __import__( + "forge_cli.incident_store", fromlist=["_open_nofollow"] + )._open_nofollow + + def fail_candidate_open(path, *, directory=False, dir_fd=None): + if not directory and Path(path).name == saved.name: + raise PermissionError(f"secret root {tmp_incidents_dir}") + return real_open_nofollow(path, directory=directory, dir_fd=dir_fd) + + monkeypatch.setattr( + "forge_cli.incident_store._open_nofollow", fail_candidate_open + ) + + with pytest.raises(IncidentLookupIncompleteError) as exc_info: + lookup_function(tmp_incidents_dir, lookup) + + message = str(exc_info.value) + assert f"2026-03/{saved.name}: PermissionError" in message + assert str(tmp_incidents_dir) not in message + + +def test_scan_sanitizes_traversal_failures(tmp_incidents_dir, monkeypatch): + raw_message = f"raw traversal failure at {tmp_incidents_dir}\nwith payload" + + real_listdir = os.listdir + + def fail_root_list(directory_fd): + if directory_fd >= 0: + error = PermissionError(raw_message) + error.filename = str(tmp_incidents_dir / "unsafe\npath") + raise error + return real_listdir(directory_fd) + + monkeypatch.setattr("forge_cli.incident_store.os.listdir", fail_root_list) + + result = scan_incidents(tmp_incidents_dir) + rendered = f"{result.scan_errors[0].path}: {result.scan_errors[0].error_type}" + + assert result.scan_errors[0].error_type == "PermissionError" + assert "\n" not in rendered + assert raw_message not in rendered + assert str(tmp_incidents_dir) not in rendered + + +def test_ambiguous_lookup_sanitizes_markup_and_control_characters( + tmp_incidents_dir, sample_data +): + first = Incident.from_dict(sample_data) + second = Incident.from_dict( + { + **sample_data, + "id": "2026-03-05-001", + "timestamp": "2026-03-05T14:30:00Z", + } + ) + first_path = save_incident(first, tmp_incidents_dir) + second_path = save_incident(second, tmp_incidents_dir) + first_markup = first_path.with_name("first-[bold]\x1b-001.yml") + second_markup = second_path.with_name("second-[bold]\x1b-001.yml") + first_path.rename(first_markup) + second_path.rename(second_markup) + + with pytest.raises(AmbiguousIncidentLookupError) as exc_info: + find_incident_path(tmp_incidents_dir, "001") + + message = str(exc_info.value) + assert "[bold]" in message + assert "\x1b" not in message + assert "first-[bold]?-001" in message + assert "second-[bold]?-001" in message + + +def test_scan_preview_performs_zero_writes(tmp_incidents_dir): + corrupt_path = tmp_incidents_dir / "2026-03" / "2026-03-04-001.yml" + corrupt_path.parent.mkdir() + corrupt_path.write_bytes(b"incident: [unterminated") + before = { + path.relative_to(tmp_incidents_dir).as_posix(): path.read_bytes() + for path in tmp_incidents_dir.rglob("*") + if path.is_file() + } + + result = scan_incidents(tmp_incidents_dir) + + after = { + path.relative_to(tmp_incidents_dir).as_posix(): path.read_bytes() + for path in tmp_incidents_dir.rglob("*") + if path.is_file() + } + assert [candidate.path for candidate in result.errors] == [ + "2026-03/2026-03-04-001.yml" + ] + assert after == before + + +def test_save_generated_incident_is_unique_across_processes( + tmp_incidents_dir, sample_data +): + worker_data = sample_data.copy() + worker_data["id"] = "" + context = multiprocessing.get_context("spawn") + + with ProcessPoolExecutor(max_workers=4, mp_context=context) as executor: + results = list( + executor.map( + _save_generated_worker, + [str(tmp_incidents_dir)] * 8, + [worker_data] * 8, + ) + ) + + incident_ids = [incident_id for incident_id, _ in results] + saved_paths = [Path(path) for _, path in results] + assert len(set(incident_ids)) == 8 + assert len(list(tmp_incidents_dir.rglob("*.yml"))) == 8 + assert all(path.exists() for path in saved_paths) + for incident_id in incident_ids: + assert find_incident(tmp_incidents_dir, incident_id).id == incident_id + suffix = incident_id.rsplit("-", 1)[-1] + assert find_incident(tmp_incidents_dir, suffix).id == incident_id + + +def test_incident_ordering_is_canonical_numeric_then_legacy_timestamp_and_path( + tmp_incidents_dir, sample_data +): + canonical = [ + ("2025-12-31-001", "2025-12-31T23:59:59Z"), + ("2026-01-01-998", "2099-01-01T00:00:00Z"), + ("2026-01-01-999", "2000-01-01T00:00:00Z"), + ("2026-01-01-1000", "2000-01-01T00:00:00Z"), + ("2026-01-01-1001", "2000-01-01T00:00:00Z"), + ("2026-02-01-001", "2000-01-01T00:00:00Z"), + ] + for incident_id, timestamp in canonical: + save_incident( + Incident.from_dict( + {**sample_data, "id": incident_id, "timestamp": timestamp} + ), + tmp_incidents_dir, + ) + + legacy_root = tmp_incidents_dir / "legacy-root.yml" + legacy_nested = tmp_incidents_dir / "nested" / "legacy-nested.yml" + legacy_nested.parent.mkdir() + legacy_data = { + **sample_data, + "id": "legacy-root", + "timestamp": "2026-01-15T12:00:00Z", + } + legacy_root.write_text(yaml.safe_dump(legacy_data)) + legacy_nested.write_text( + yaml.safe_dump({**legacy_data, "id": "legacy-nested"}) + ) + + oldest_first = [incident.id for incident in get_all_incidents(tmp_incidents_dir)] + newest_first = [incident.id for incident in list_incidents(tmp_incidents_dir, limit=20)] + + assert oldest_first == [ + "2025-12-31-001", + "2026-01-01-998", + "2026-01-01-999", + "2026-01-01-1000", + "2026-01-01-1001", + "legacy-root", + "legacy-nested", + "2026-02-01-001", + ] + assert newest_first == list(reversed(oldest_first)) + + +@pytest.mark.parametrize( + ("incident_id", "timestamp", "include_timestamp"), + [ + ("2026-02-30-001", "2026-02-28T12:00:00Z", True), + ("legacy-malformed", "not-a-timestamp", True), + ("legacy-empty", "", True), + ("legacy-missing", None, False), + ], +) +def test_scan_classifies_invalid_ordering_fields_per_file( + tmp_incidents_dir, sample_data, incident_id, timestamp, include_timestamp +): + candidate = tmp_incidents_dir / "nested" / f"{incident_id}.yml" + candidate.parent.mkdir() + payload = {**sample_data, "id": incident_id} + if include_timestamp: + payload["timestamp"] = timestamp + else: + payload.pop("timestamp", None) + candidate.write_text(yaml.safe_dump(payload)) + + scan = scan_incidents(tmp_incidents_dir) + listed = list_incidents_result(tmp_incidents_dir) + + assert scan.incidents == () + assert listed.incidents == () + assert [(error.path, error.error_type) for error in scan.errors] == [ + (f"nested/{incident_id}.yml", "InvalidIncidentError") + ] + assert scan.scan_errors == () + + +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_lookup_distinguishes_corrupt_requested_candidate( + tmp_incidents_dir, lookup +): + candidate = tmp_incidents_dir / "2026-03" / "2026-03-04-001.yml" + candidate.parent.mkdir() + candidate.write_text("incident: [unterminated") + + with pytest.raises(IncidentLookupCorruptError) as exc_info: + find_incident(tmp_incidents_dir, lookup) + + message = str(exc_info.value) + assert "requested incident candidate is corrupt" in message.lower() + assert "2026-03/2026-03-04-001.yml" in message + assert str(tmp_incidents_dir) not in message + + +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +@pytest.mark.parametrize("lookup_function", [find_incident, find_incident_path]) +def test_lookup_prioritizes_matching_corrupt_candidate_over_valid_candidate( + tmp_incidents_dir, sample_data, lookup, lookup_function +): + valid = save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + corrupt = tmp_incidents_dir / "duplicate" / valid.name + corrupt.parent.mkdir() + corrupt.write_text("incident: [unterminated") + + with pytest.raises(IncidentLookupCorruptError) as exc_info: + lookup_function(tmp_incidents_dir, lookup) + + message = str(exc_info.value) + assert "duplicate/2026-03-04-001.yml" in message + assert str(tmp_incidents_dir) not in message + + +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +@pytest.mark.parametrize("lookup_function", [find_incident, find_incident_path]) +def test_lookup_prioritizes_scan_error_over_matching_corruption_and_valid_fallback( + tmp_incidents_dir, sample_data, lookup, lookup_function +): + valid = save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + corrupt = tmp_incidents_dir / "duplicate" / valid.name + corrupt.parent.mkdir() + corrupt.write_text("incident: [unterminated") + external = tmp_incidents_dir.parent / "external" + external.mkdir() + (tmp_incidents_dir / "unrelated").symlink_to( + external, target_is_directory=True + ) + + with pytest.raises(IncidentLookupIncompleteError) as exc_info: + lookup_function(tmp_incidents_dir, lookup) + + message = str(exc_info.value) + assert "unrelated: SymlinkRejectedError" in message + assert "Requested incident candidate is corrupt" not in message + assert "duplicate/2026-03-04-001.yml" not in message + assert str(tmp_incidents_dir) not in message + + +@pytest.mark.parametrize("lookup_function", [find_incident, find_incident_path]) +def test_lookup_valid_exact_ignores_corrupt_suffix_candidate( + tmp_incidents_dir, sample_data, lookup_function +): + valid = save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + corrupt_suffix = ( + tmp_incidents_dir / "suffix" / f"prefix-{valid.name}" + ) + corrupt_suffix.parent.mkdir() + corrupt_suffix.write_text("incident: [unterminated") + + result = lookup_function(tmp_incidents_dir, valid.stem) + + if lookup_function is find_incident: + assert result is not None + assert result.id == sample_data["id"] + else: + assert result == valid + + +@pytest.mark.parametrize("lookup_function", [find_incident, find_incident_path]) +def test_lookup_corrupt_exact_ignores_valid_suffix_candidate( + tmp_incidents_dir, sample_data, lookup_function +): + valid_suffix = save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + valid_suffix.rename(valid_suffix.with_name(f"prefix-{valid_suffix.name}")) + corrupt_exact = ( + tmp_incidents_dir / "exact" / f"{sample_data['id']}.yml" + ) + corrupt_exact.parent.mkdir() + corrupt_exact.write_text("incident: [unterminated") + + with pytest.raises(IncidentLookupCorruptError) as exc_info: + lookup_function(tmp_incidents_dir, sample_data["id"]) + + message = str(exc_info.value) + assert f"exact/{sample_data['id']}.yml: YAMLError" in message + assert f"prefix-{sample_data['id']}.yml" not in message + assert str(tmp_incidents_dir) not in message + + +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +@pytest.mark.parametrize("lookup_function", [find_incident, find_incident_path]) +def test_lookup_prioritizes_inaccessible_nested_directory_over_valid_candidate( + tmp_incidents_dir, sample_data, monkeypatch, lookup, lookup_function +): + save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + (tmp_incidents_dir / "nested").mkdir() + real_listdir = os.listdir + calls = 0 + + def fail_nested_listdir(directory_fd): + nonlocal calls + calls += 1 + if calls == 3: + raise PermissionError(f"secret root {tmp_incidents_dir}") + return real_listdir(directory_fd) + + monkeypatch.setattr("forge_cli.incident_store.os.listdir", fail_nested_listdir) + + with pytest.raises(IncidentLookupIncompleteError) as exc_info: + lookup_function(tmp_incidents_dir, lookup) + + message = str(exc_info.value) + assert "nested: PermissionError" in message + assert str(tmp_incidents_dir) not in message + + +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +@pytest.mark.parametrize("lookup_function", [find_incident, find_incident_path]) +def test_lookup_prioritizes_symlinked_nested_directory_over_valid_candidate( + tmp_incidents_dir, sample_data, lookup, lookup_function +): + save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + external = tmp_incidents_dir.parent / "external" + external.mkdir() + (tmp_incidents_dir / "nested").symlink_to(external, target_is_directory=True) + + with pytest.raises(IncidentLookupIncompleteError) as exc_info: + lookup_function(tmp_incidents_dir, lookup) + + message = str(exc_info.value) + assert "nested: SymlinkRejectedError" in message + assert str(tmp_incidents_dir) not in message + + +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +@pytest.mark.parametrize("lookup_function", [find_incident, find_incident_path]) +def test_lookup_rejects_multiple_valid_exact_candidates( + tmp_incidents_dir, sample_data, lookup, lookup_function +): + valid = save_incident(Incident.from_dict(sample_data), tmp_incidents_dir) + duplicate = tmp_incidents_dir / "duplicate" / valid.name + duplicate.parent.mkdir() + duplicate.write_bytes(valid.read_bytes()) + + with pytest.raises(AmbiguousIncidentLookupError) as exc_info: + lookup_function(tmp_incidents_dir, lookup) + + message = str(exc_info.value) + assert "Ambiguous incident id" in message + assert str(tmp_incidents_dir) not in message + + +def test_lookup_distinguishes_operationally_incomplete_root_scan( + tmp_incidents_dir, monkeypatch +): + raw_message = f"cannot scan {tmp_incidents_dir}\nsecret" + + def fail_listdir(_directory_fd): + raise PermissionError(raw_message) + + monkeypatch.setattr("forge_cli.incident_store.os.listdir", fail_listdir) + + with pytest.raises(IncidentLookupIncompleteError) as exc_info: + find_incident(tmp_incidents_dir, "001") + + message = str(exc_info.value) + assert "operationally incomplete" in message.lower() + assert str(tmp_incidents_dir) not in message + assert raw_message not in message + assert "\n" not in message + + +def test_lookup_distinguishes_operationally_incomplete_nested_scan( + tmp_incidents_dir, monkeypatch +): + nested = tmp_incidents_dir / "nested" + nested.mkdir() + real_listdir = os.listdir + calls = 0 + + def fail_nested_listdir(directory_fd): + nonlocal calls + calls += 1 + if calls == 2: + raise PermissionError("nested raw failure") + return real_listdir(directory_fd) + + monkeypatch.setattr("forge_cli.incident_store.os.listdir", fail_nested_listdir) + + with pytest.raises(IncidentLookupIncompleteError) as exc_info: + find_incident_path(tmp_incidents_dir, "001") + + assert "nested: PermissionError" in str(exc_info.value) + + +def test_clean_lookup_not_found_remains_none(tmp_incidents_dir): + assert find_incident(tmp_incidents_dir, "missing") is None + assert find_incident_path(tmp_incidents_dir, "missing") is None + + def test_yaml_multiline_block_style(tmp_incidents_dir): data = { "id": "2026-03-04-001", @@ -303,3 +1280,50 @@ def test_claims_rate_source_example_is_pointer_and_summary_only(): assert incident.observed_state["boundary_note"].startswith("Forge stores only") assert all(term not in raw for term in forbidden_terms) + + +def test_generated_write_rejects_symlinked_month_directory( + tmp_incidents_dir, sample_data +): + external = tmp_incidents_dir.parent / "external-month" + external.mkdir() + before = tuple(external.iterdir()) + (tmp_incidents_dir / "2026-03").symlink_to(external, target_is_directory=True) + incident = Incident.from_dict({**sample_data, "id": ""}) + + with pytest.raises(UnsafeIncidentPathError, match="symlink"): + save_generated_incident(incident, tmp_incidents_dir) + + assert tuple(external.iterdir()) == before + assert (tmp_incidents_dir / "2026-03").is_symlink() + + +def test_generate_id_rejects_symlinked_month_without_writes( + tmp_incidents_dir +): + external = tmp_incidents_dir.parent / "external-id-month" + external.mkdir() + (tmp_incidents_dir / "2026-03").symlink_to(external, target_is_directory=True) + + with pytest.raises(UnsafeIncidentPathError, match="symlink"): + generate_id(tmp_incidents_dir, date(2026, 3, 4)) + + assert tuple(external.iterdir()) == () + + +def test_duplicate_save_cleans_descriptor_relative_temp_files( + tmp_incidents_dir, sample_data +): + incident = Incident.from_dict(sample_data) + saved = save_incident(incident, tmp_incidents_dir) + original = saved.read_bytes() + + with pytest.raises(DuplicateIncidentError): + save_incident(incident, tmp_incidents_dir) + + assert saved.read_bytes() == original + assert [ + path.name + for path in saved.parent.iterdir() + if path.name.startswith(f".{incident.id}.") + ] == [] diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index bfced20..d92582b 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -4,11 +4,21 @@ mcp = pytest.importorskip("mcp") import json # noqa: E402 +import os # noqa: E402 import yaml # noqa: E402 +from pathlib import Path # noqa: E402 +import time # noqa: E402 -from forge_cli.incident_store import save_incident # noqa: E402 +from forge_cli.incident_store import DuplicateIncidentError, save_incident # noqa: E402 from forge_cli.models import Incident # noqa: E402 -from forge_cli.mcp_server import forge_list, forge_log, forge_schema # noqa: E402 +from forge_cli.mcp_server import ( # noqa: E402 + forge_incident_ref, + forge_list, + forge_log, + forge_schema, + forge_show, + forge_stats, +) from forge_cli.mcp_http import ( # noqa: E402 MCPHTTPServerOptions, resolve_transport_security, @@ -16,6 +26,18 @@ ) +def _minimal_mcp_log() -> str: + return forge_log( + project="project", + agent="agent", + platform="platform", + severity="functional", + failure_type="other", + expected_behavior="Expected behavior", + actual_behavior="Actual behavior", + ) + + def test_validate_server_options_allows_loopback_defaults(): validate_server_options(MCPHTTPServerOptions()) @@ -107,6 +129,371 @@ def test_forge_list_filters_structured_axes(tmp_path, monkeypatch, sample_data): assert "2026-03-04-002" not in result +def test_mcp_list_and_stats_report_loaded_and_skipped_corrupt_files( + tmp_path, monkeypatch, sample_data +): + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + incidents_dir.mkdir(parents=True) + save_incident(Incident.from_dict(sample_data), incidents_dir) + corrupt_path = incidents_dir / "2026-03" / "2026-03-04-002.yml" + corrupt_payload = "incident: [unterminated" + corrupt_path.write_text(corrupt_payload) + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + list_result = forge_list() + stats_result = forge_stats() + + for result in (list_result, stats_result): + assert "Valid corpus incidents: 1" in result + assert "Corrupt corpus files: 1" in result + assert "Matched incidents: 1" in result + assert "Returned incidents: 1" in result + assert "2026-03/2026-03-04-002.yml" in result + assert "YAMLError" in result + assert str(data_root) not in result + assert corrupt_path.read_text() == corrupt_payload + + +def test_mcp_diagnostics_preserve_established_list_and_stats_markers( + tmp_path, monkeypatch, sample_data +): + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + incidents_dir.mkdir(parents=True) + save_incident(Incident.from_dict(sample_data), incidents_dir) + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + list_lines = forge_list().splitlines() + stats_lines = forge_stats().splitlines() + + assert list_lines[0] == "Found 1 incident(s):" + assert "Valid corpus incidents: 1" in list_lines[1:] + assert stats_lines[0] == "Total incidents: 1" + assert "Valid corpus incidents: 1" in stats_lines[1:] + + +def test_mcp_empty_marker_remains_first_and_missing_corpus_is_read_only( + tmp_path, monkeypatch +): + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + list_result = forge_list() + stats_result = forge_stats() + + assert list_result.splitlines()[0] == "No incidents found matching the given filters." + assert stats_result.splitlines()[0] == "Total incidents: 0" + assert "Valid corpus incidents: 0" in list_result + assert "Valid corpus incidents: 0" in stats_result + assert not incidents_dir.exists() + + +def test_mcp_list_zero_limit_reports_diagnostics_without_incidents( + tmp_path, monkeypatch, sample_data +): + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + incidents_dir.mkdir(parents=True) + save_incident(Incident.from_dict(sample_data), incidents_dir) + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + result = forge_list(limit=0) + + assert "Valid corpus incidents: 1" in result + assert "Matched incidents: 1" in result + assert "Returned incidents: 0" in result + assert sample_data["id"] not in result + + +def test_mcp_list_rejects_negative_limit(tmp_path, monkeypatch): + monkeypatch.setenv("FORGE_DATA_ROOT", str(tmp_path / "forge-data")) + + result = forge_list(limit=-1) + + assert result == "Invalid limit: must be non-negative" + + +def test_mcp_list_and_stats_fail_closed_on_operational_scan_error( + tmp_path, monkeypatch, sample_data +): + data_root = tmp_path / "forge-data" + corrupt_path = data_root / "incidents" / "2026-03" / "unsafe\nincident.yml" + corrupt_path.parent.mkdir(parents=True) + corrupt_path.write_text("incident: [unterminated") + safe_corrupt = corrupt_path.with_name("corrupt.yml") + safe_corrupt.write_text("incident: [unterminated") + save_incident( + Incident.from_dict( + { + **sample_data, + "project": "secret-valid-project", + } + ), + data_root / "incidents", + ) + raw_message = f"raw traversal failure at {data_root}\nwith payload" + + real_stat = os.stat + + def fail_stat(path, *args, **kwargs): + if path == corrupt_path.name: + error = PermissionError(raw_message) + error.filename = str(data_root / "incidents" / "unsafe\nroot") + raise error + return real_stat(path, *args, **kwargs) + + monkeypatch.setattr("forge_cli.incident_store.os.stat", fail_stat) + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + for result in (forge_list(), forge_stats()): + assert result.splitlines()[0] == "Incident corpus scan incomplete." + assert "Scan operational errors: 1" in result + assert "PermissionError" in result + assert "YAMLError" in result + assert "Found " not in result + assert sample_data["id"] not in result + assert "secret-valid-project" not in result + assert "Total incidents:" not in result + assert "By Severity:" not in result + assert "By Type:" not in result + assert "By Project:" not in result + assert "By Platform:" not in result + assert "Top Tags:" not in result + assert raw_message not in result + assert str(data_root) not in result + assert "\nroot" not in result + assert "\nincident" not in result + + +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +@pytest.mark.parametrize("lookup_tool", [forge_show, forge_incident_ref]) +def test_mcp_lookup_reports_corrupt_requested_candidate( + tmp_path, monkeypatch, lookup, lookup_tool +): + data_root = tmp_path / "forge-data" + candidate = data_root / "incidents" / "2026-03" / "2026-03-04-001.yml" + candidate.parent.mkdir(parents=True) + candidate.write_text("incident: [unterminated") + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + result = lookup_tool(lookup) + + assert "Requested incident candidate is corrupt" in result + assert "No incident found" not in result + assert str(data_root) not in result + assert "Traceback" not in result + + +@pytest.mark.parametrize("lookup_tool", [forge_show, forge_incident_ref]) +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_mcp_lookup_prioritizes_matching_corrupt_over_valid_candidate( + tmp_path, monkeypatch, sample_data, lookup_tool, lookup +): + data_root = tmp_path / "hostile-[root]\x1b" + incidents_dir = data_root / "incidents" + valid = save_incident(Incident.from_dict(sample_data), incidents_dir) + corrupt = incidents_dir / "duplicate" / valid.name + corrupt.parent.mkdir() + corrupt.write_text("incident: [unterminated") + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + result = lookup_tool(lookup) + + assert "Requested incident candidate is corrupt" in result + assert "duplicate/2026-03-04-001.yml" in result + assert str(data_root) not in result + assert "\x1b" not in result + assert "Traceback" not in result + + +@pytest.mark.parametrize("lookup_tool", [forge_show, forge_incident_ref]) +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_mcp_lookup_prioritizes_scan_error_over_matching_corruption_and_fallback( + tmp_path, monkeypatch, sample_data, lookup_tool, lookup +): + data_root = tmp_path / "hostile-[root]\x1b" + incidents_dir = data_root / "incidents" + valid = save_incident(Incident.from_dict(sample_data), incidents_dir) + corrupt = incidents_dir / "duplicate" / valid.name + corrupt.parent.mkdir() + corrupt.write_text("incident: [unterminated") + external = tmp_path / "external" + external.mkdir() + (incidents_dir / "unrelated").symlink_to(external, target_is_directory=True) + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + result = lookup_tool(lookup) + + assert "operationally incomplete" in result + assert "unrelated: SymlinkRejectedError" in result + assert "Requested incident candidate is corrupt" not in result + assert "No incident found" not in result + assert "duplicate/2026-03-04-001.yml" not in result + assert str(data_root) not in result + assert "\x1b" not in result + assert "Traceback" not in result + + +@pytest.mark.parametrize("lookup_tool", [forge_show, forge_incident_ref]) +def test_mcp_lookup_valid_exact_ignores_corrupt_suffix_candidate( + tmp_path, monkeypatch, sample_data, lookup_tool +): + data_root = tmp_path / "hostile-[root]\x1b" + incidents_dir = data_root / "incidents" + valid = save_incident(Incident.from_dict(sample_data), incidents_dir) + corrupt_suffix = incidents_dir / "suffix" / f"prefix-{valid.name}" + corrupt_suffix.parent.mkdir() + corrupt_suffix.write_text("incident: [unterminated") + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + result = lookup_tool(valid.stem) + + assert sample_data["id"] in result + assert "Requested incident candidate is corrupt" not in result + assert f"prefix-{valid.name}" not in result + assert str(data_root) not in result + assert "\x1b" not in result + assert "Traceback" not in result + + +@pytest.mark.parametrize("lookup_tool", [forge_show, forge_incident_ref]) +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_mcp_lookup_prioritizes_inaccessible_nested_directory_over_valid_candidate( + tmp_path, monkeypatch, sample_data, lookup_tool, lookup +): + data_root = tmp_path / "hostile-[root]\x1b" + incidents_dir = data_root / "incidents" + save_incident(Incident.from_dict(sample_data), incidents_dir) + (incidents_dir / "nested").mkdir() + real_listdir = os.listdir + calls = 0 + + def fail_nested_listdir(directory_fd): + nonlocal calls + calls += 1 + if calls == 3: + raise PermissionError(f"secret root {data_root}") + return real_listdir(directory_fd) + + monkeypatch.setattr("forge_cli.incident_store.os.listdir", fail_nested_listdir) + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + result = lookup_tool(lookup) + + assert "operationally incomplete" in result + assert "nested: PermissionError" in result + assert str(data_root) not in result + assert "\x1b" not in result + assert "Traceback" not in result + + +@pytest.mark.parametrize("lookup_tool", [forge_show, forge_incident_ref]) +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_mcp_lookup_prioritizes_symlinked_nested_directory_over_valid_candidate( + tmp_path, monkeypatch, sample_data, lookup_tool, lookup +): + data_root = tmp_path / "hostile-[root]\x1b" + incidents_dir = data_root / "incidents" + save_incident(Incident.from_dict(sample_data), incidents_dir) + external = tmp_path / "external" + external.mkdir() + (incidents_dir / "nested").symlink_to(external, target_is_directory=True) + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + result = lookup_tool(lookup) + + assert "operationally incomplete" in result + assert "nested: SymlinkRejectedError" in result + assert str(data_root) not in result + assert "\x1b" not in result + assert "Traceback" not in result + + +@pytest.mark.parametrize("lookup_tool", [forge_show, forge_incident_ref]) +@pytest.mark.parametrize("lookup", ["2026-03-04-001", "001"]) +def test_mcp_lookup_rejects_multiple_valid_exact_candidates( + tmp_path, monkeypatch, sample_data, lookup_tool, lookup +): + data_root = tmp_path / "hostile-[root]\x1b" + incidents_dir = data_root / "incidents" + valid = save_incident(Incident.from_dict(sample_data), incidents_dir) + duplicate = incidents_dir / "duplicate" / valid.name + duplicate.parent.mkdir() + duplicate.write_bytes(valid.read_bytes()) + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + result = lookup_tool(lookup) + + assert "Ambiguous incident id" in result + assert str(data_root) not in result + assert "\x1b" not in result + assert "Traceback" not in result + + +@pytest.mark.parametrize("lookup_tool", [forge_show, forge_incident_ref]) +@pytest.mark.parametrize("nested_failure", [False, True]) +def test_mcp_lookup_reports_operationally_incomplete_scan( + tmp_path, monkeypatch, lookup_tool, nested_failure +): + data_root = tmp_path / "forge-data" + incidents_dir = data_root / "incidents" + incidents_dir.mkdir(parents=True) + if nested_failure: + (incidents_dir / "nested").mkdir() + raw_message = f"raw scan failure {data_root}\nsecret" + real_listdir = os.listdir + calls = 0 + + def fail_listdir(directory_fd): + nonlocal calls + calls += 1 + if not nested_failure or calls == 2: + raise PermissionError(raw_message) + return real_listdir(directory_fd) + + monkeypatch.setattr("forge_cli.incident_store.os.listdir", fail_listdir) + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + result = lookup_tool("001") + + assert "operationally incomplete" in result + assert "No incident found" not in result + assert str(data_root) not in result + assert raw_message not in result + assert "Traceback" not in result + + +@pytest.mark.parametrize( + ("incident_id", "timestamp", "include_timestamp"), + [ + ("2026-02-30-001", "2026-02-28T12:00:00Z", True), + ("legacy-malformed", "not-a-timestamp", True), + ("legacy-empty", "", True), + ("legacy-missing", None, False), + ], +) +def test_mcp_scan_classifies_invalid_ordering_fields( + tmp_path, monkeypatch, sample_data, incident_id, timestamp, include_timestamp +): + data_root = tmp_path / "forge-data" + candidate = data_root / "incidents" / "nested" / f"{incident_id}.yml" + candidate.parent.mkdir(parents=True) + payload = {**sample_data, "id": incident_id} + if include_timestamp: + payload["timestamp"] = timestamp + else: + payload.pop("timestamp", None) + candidate.write_text(yaml.safe_dump(payload)) + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + for result in (forge_list(), forge_stats()): + assert "Corrupt corpus files: 1" in result + assert f"nested/{incident_id}.yml: InvalidIncidentError" in result + assert str(data_root) not in result + + def test_forge_log_rejects_raw_payload_pointer_keys(tmp_path, monkeypatch): data_root = tmp_path / "forge-data" monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) @@ -211,3 +598,107 @@ def test_forge_log_accepts_control_refs(tmp_path, monkeypatch): "control:document_ops_redaction_required:g2", "control:document_ops_use_gate:g2", ] + + +def test_mcp_log_success_reports_only_id_and_safe_relative_path( + tmp_path, monkeypatch +): + data_root = tmp_path / "absolute-[hostile]\x1b-root" + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + result = _minimal_mcp_log() + + saved = next((data_root / "incidents").rglob("*.yml")) + relative = saved.relative_to(data_root / "incidents").as_posix() + assert f"Incident logged: {saved.stem}" in result + assert f"Saved to: {relative}" in result + assert str(data_root) not in result + assert "\x1b" not in result + assert "Traceback" not in result + + +@pytest.mark.parametrize( + ("failure_scope", "exception_type", "classification"), + [ + ("root", PermissionError, "PermissionError"), + ("month", PermissionError, "PermissionError"), + ("root", OSError, "OSError"), + ], +) +def test_mcp_log_storage_failures_are_stable_and_do_not_leak_roots( + tmp_path, + monkeypatch, + failure_scope, + exception_type, + classification, +): + data_root = tmp_path / "absolute-[hostile]\x1b-root" + incidents_dir = data_root / "incidents" + real_open = __import__( + "forge_cli.incident_store", + fromlist=["_open_nofollow"], + )._open_nofollow + raw_message = f"raw storage failure at {data_root}\nsecret\x1b" + + def fail_open(path, *, directory=False, dir_fd=None): + is_root = dir_fd is None and Path(path) == incidents_dir + is_month = dir_fd is not None and directory + if (failure_scope == "root" and is_root) or ( + failure_scope == "month" and is_month + ): + raise exception_type(raw_message) + return real_open(path, directory=directory, dir_fd=dir_fd) + + monkeypatch.setattr("forge_cli.incident_store._open_nofollow", fail_open) + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + result = _minimal_mcp_log() + + assert result == f"Storage error: {classification}" + assert raw_message not in result + assert str(data_root) not in result + assert "\x1b" not in result + assert "Traceback" not in result + + +def test_mcp_log_rejects_symlinked_month_without_root_leakage( + tmp_path, monkeypatch +): + data_root = tmp_path / "absolute-[hostile]\x1b-root" + incidents_dir = data_root / "incidents" + incidents_dir.mkdir(parents=True) + external = tmp_path / "external-month" + external.mkdir() + month = time.strftime("%Y-%m", time.gmtime()) + (incidents_dir / month).symlink_to(external, target_is_directory=True) + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + result = _minimal_mcp_log() + + assert result == "Storage error: UnsafeIncidentPathError" + assert str(data_root) not in result + assert "\x1b" not in result + assert "Traceback" not in result + assert list(external.iterdir()) == [] + + +def test_mcp_log_preserves_duplicate_classification_without_raw_error( + tmp_path, monkeypatch +): + data_root = tmp_path / "absolute-[hostile]\x1b-root" + + def fail_duplicate(*_args, **_kwargs): + raise DuplicateIncidentError(f"raw duplicate {data_root}\nsecret\x1b") + + monkeypatch.setattr( + "forge_cli.mcp_server.save_generated_incident", + fail_duplicate, + ) + monkeypatch.setenv("FORGE_DATA_ROOT", str(data_root)) + + result = _minimal_mcp_log() + + assert result == "Duplicate incident id" + assert str(data_root) not in result + assert "\x1b" not in result + assert "Traceback" not in result