oct
Loading...
Searching...
No Matches
project_root.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/core/project_root.py
4
5"""
6Purpose
7-------
8Provide shared logic for detecting the root of an Option C project.
9
10Responsibilities
11----------------
12- Walk upward from the current directory.
13- Identify the project root by required directories.
14
15Diagnostics
16-----------
17Domain: OCT-CORE
18Levels:
19 L2 - (none) a failed search raises through click rather than logging
20 L3 - root search entry, and each containment check with its verdict
21 L4 - (none)
22
23Contracts
24---------
25- Root discovery never adopts a root at or above the OS temp boundary.
26- ``.octrc.json`` files larger than :data:`MAX_OCTRC_SIZE` are skipped
27 rather than read, so an oversized parent config cannot exhaust memory.
28- ``is_within_project_root`` is the containment predicate; every caller
29 that needs to refuse an escaping path goes through it or
30 ``validate_path_containment``.
31
32Dependencies
33------------
34- oct.core.diagnostics (_dbg structured logger)
35- click (ClickException for a failed root search, a rejected --root-dir or an escaping path; echo for the diagnostics/ deprecation warning; the Context carrying --root-dir)
36"""
37
38import json
39import sys
40import tempfile
41from pathlib import Path
42import click
43from oct.core.diagnostics import _dbg
44
45
46# Maximum size of an .octrc.json file that will be read during root discovery.
47# Larger files are skipped to prevent memory exhaustion from malicious or
48# accidentally oversized config files in parent directories (OI-414).
49MAX_OCTRC_SIZE = 64 * 1024 # 64 KB
50
51
52def _at_or_above_temp_boundary(candidate: Path, temp_boundary: Path | None) -> bool:
53 """Return True if *candidate* is the OS temp dir or an ancestor of it.
54
55 FS-560 / Plan 151 T6: root discovery must never adopt a project root at
56 or above ``tempfile.gettempdir()``. A stray ``.option_c/`` (or other)
57 root marker at the temp root would otherwise let the walk-up ESCAPE a
58 per-test tmp sandbox and run git-mutating operations at the temp root -
59 the reproducible environment-poisoning loop diagnosed in Plan 151 T3
60 (a marker at ``%TEMP%\\.option_c`` fabricated 14 oc_chat + 17 oct false
61 failures by git-initialising ``%TEMP%``). Markers STRICTLY BELOW the
62 temp boundary - legitimate pytest ``tmp_path`` projects, which always
63 live under a ``pytest-*`` subdir of the temp root - are unaffected.
64
65 Never raises: an unresolvable path is treated as "not above the
66 boundary" so discovery degrades to its pre-fix behaviour rather than
67 failing closed on an IO error.
68 """
69 if temp_boundary is None:
70 return False
71 try:
72 candidate_r = candidate.resolve()
73 boundary_r = temp_boundary.resolve()
74 except OSError:
75 return False
76 if candidate_r == boundary_r:
77 return True
78 try:
79 boundary_r.relative_to(candidate_r)
80 except ValueError:
81 return False
82 return True
83
84
85def find_project_root(start: Path) -> Path:
86 # No _dbg here on purpose: OctGroup.invoke calls this to build the
87 # timing sample BEFORE the group callback runs oc_diagnostics.init(),
88 # so a call at this seam warns "called before init()" on every single
89 # oct command. The module reports at its containment seam instead,
90 # which runs after the CLI is initialised.
91 current = start.resolve()
92 workspace_root: Path | None = None
93 # FS-560: resolve the temp boundary once per walk so a stray marker at
94 # or above ``%TEMP%`` cannot be adopted as a project root (Plan 151 T6).
95 try:
96 temp_boundary: Path | None = Path(tempfile.gettempdir())
97 except Exception: # pragma: no cover - gettempdir is effectively total
98 temp_boundary = None
99 # The sandbox-escape hazard exists ONLY when the walk ORIGINATES inside
100 # the OS temp dir (a per-test tmp sandbox that could escape upward to a
101 # stray marker at/above ``%TEMP%``). When ``start`` is outside the temp
102 # dir the bound must NOT apply - otherwise a project whose ``TMPDIR`` is a
103 # SUBDIRECTORY of itself (e.g. ``TMPDIR=<root>/logs``, as oct's own tool
104 # runner sets) would have its own root rejected as an "ancestor of the
105 # temp dir". (Plan 151 T6 self-fix: the first form over-rejected.)
106 start_in_temp = (
107 temp_boundary is not None
108 and is_within_project_root(current, temp_boundary)
109 )
110 for parent in [current] + list(current.parents):
111 # FS-560: never adopt (or record as a fallback) a candidate at or
112 # above the OS temp boundary - the Plan 151 T3 poisoning cure - but
113 # only when the walk started inside the temp sandbox.
114 if start_in_temp and _at_or_above_temp_boundary(parent, temp_boundary):
115 continue
116
117 # FS-539: .option_c/ is the canonical root marker on migrated
118 # projects - its presence alone is sufficient evidence that this
119 # directory is an Option C project root. Checked first so the
120 # detector is fastest on post-v0.19.0 projects.
121 if (parent / ".option_c").is_dir():
122 return parent
123
124 # Check for explicit root marker next
125 octrc = parent / ".octrc.json"
126 if octrc.is_file():
127 try:
128 size = octrc.stat().st_size
129 if size > MAX_OCTRC_SIZE:
130 print(
131 f"Warning: skipping {octrc} ({size} bytes > "
132 f"{MAX_OCTRC_SIZE} limit)",
133 file=sys.stderr,
134 )
135 else:
136 cfg = json.loads(octrc.read_text(encoding="utf-8"))
137 if cfg.get("root_marker", False):
138 return parent
139 except (json.JSONDecodeError, OSError):
140 pass
141
142 has_oc_diag = (parent / "oc_diagnostics").exists()
143 has_legacy = (parent / "diagnostics").exists()
144 has_diag = has_oc_diag or has_legacy
145 if (parent / "docs").exists() and (parent / "tests").exists() and has_diag:
146 if has_legacy and not has_oc_diag:
147 click.echo(
148 "Warning: 'diagnostics/' is deprecated and will be removed in v1.0.0. "
149 "Rename to 'oc_diagnostics/'.",
150 err=True,
151 )
152 return parent
153
154 # F1 / L1: workspace manifest acts as a fallback root for
155 # commands that need a working directory but no per-project
156 # markers (e.g. ``oct git status`` from the workspace root in
157 # a monorepo). Recorded but not returned yet - a project root
158 # closer to ``start`` always wins.
159 if workspace_root is None and (
160 parent / ".option_c_workspace.json"
161 ).is_file():
162 workspace_root = parent
163
164 if workspace_root is not None:
165 return workspace_root
166 raise click.ClickException("Could not locate project root.")
167
168
169def get_project_root(ctx: click.Context) -> Path:
170 """Return project root from --root-dir override or auto-detection.
171
172 When ``--root-dir`` is supplied, OI-405 requires the target directory to
173 look like an Option C project (contain ``pyproject.toml`` or
174 ``.octrc.json``). If the user intentionally wants to point at an
175 unrelated directory, they must also pass ``--allow-outside-project``,
176 which is recorded in ``ctx.obj``.
177 """
178 root_dir = ctx.obj.get("root_dir") if ctx.obj else None
179 if root_dir:
180 resolved = Path(root_dir).resolve()
181 if not resolved.is_dir():
182 raise click.ClickException(f"--root-dir path does not exist: {resolved}")
183 allow_outside = bool(ctx.obj.get("allow_outside_project")) if ctx.obj else False
184 if not allow_outside:
185 has_marker = (
186 (resolved / "pyproject.toml").is_file()
187 or (resolved / ".octrc.json").is_file()
188 or (resolved / ".option_c").is_dir() # FS-539
189 or (resolved / ".option_c_workspace.json").is_file() # F1
190 )
191 if not has_marker:
192 raise click.ClickException(
193 f"--root-dir {resolved} does not appear to be an Option C project "
194 f"(no pyproject.toml, .octrc.json, or .option_c/). Pass "
195 f"--allow-outside-project to override."
196 )
197 return resolved
198 return find_project_root(Path.cwd())
199
200
201def is_within_project_root(path: Path, project_root: Path) -> bool:
202 """Pure predicate: True if ``path`` resolves inside ``project_root``.
203
204 Symlink-safe: both sides are resolved before comparison so symlink
205 targets cannot escape the project root. Never raises - any ``OSError``
206 during resolution is treated as "not inside". Intended for library
207 callers (e.g. ``oct.core.git``) that must not depend on Click.
208 """
209 try:
210 resolved = Path(path).resolve()
211 root_resolved = Path(project_root).resolve()
212 except OSError:
213 return False
214 try:
215 resolved.relative_to(root_resolved)
216 return True
217 except ValueError:
218 return False
219
220
222 path: Path, project_root: Path, allow_outside: bool = False
223) -> Path:
224 """Resolve ``path`` and verify it is within ``project_root``.
225
226 If ``allow_outside`` is True, the resolved path is returned without
227 containment enforcement. Otherwise, a ``click.ClickException`` is raised
228 when the path falls outside ``project_root``. Used by export and clean
229 commands to prevent accidental operations on files outside the project
230 via symlink tricks or bare ``..`` paths (OI-405).
231 """
232 func = "validate_path_containment"
233 _dbg("OCT-CORE", func, f"containment check for {path} (allow_outside={allow_outside})", 3)
234 resolved = Path(path).resolve()
235 if allow_outside:
236 return resolved
237 root_resolved = Path(project_root).resolve()
238 if not is_within_project_root(resolved, root_resolved):
239 raise click.ClickException(
240 f"Path {resolved} is outside project root {root_resolved}. "
241 f"Use --allow-outside-project to override."
242 )
243 return resolved
bool _at_or_above_temp_boundary(Path candidate, Path|None temp_boundary)
Path validate_path_containment(Path path, Path project_root, bool allow_outside=False)
Path get_project_root(click.Context ctx)
Path find_project_root(Path start)
bool is_within_project_root(Path path, Path project_root)