8Provide shared logic for detecting the root of an Option C project.
12- Walk upward from the current directory.
13- Identify the project root by required directories.
19 L2 - (none) a failed search raises through click rather than logging
20 L3 - root search entry, and each containment check with its verdict
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``.
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)
41from pathlib
import Path
43from oct.core.diagnostics
import _dbg
49MAX_OCTRC_SIZE = 64 * 1024
53 """Return True if *candidate* is the OS temp dir or an ancestor of it.
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.
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.
69 if temp_boundary
is None:
72 candidate_r = candidate.resolve()
73 boundary_r = temp_boundary.resolve()
76 if candidate_r == boundary_r:
79 boundary_r.relative_to(candidate_r)
91 current = start.resolve()
92 workspace_root: Path |
None =
None
96 temp_boundary: Path |
None = Path(tempfile.gettempdir())
107 temp_boundary
is not None
110 for parent
in [current] + list(current.parents):
121 if (parent /
".option_c").is_dir():
125 octrc = parent /
".octrc.json"
128 size = octrc.stat().st_size
129 if size > MAX_OCTRC_SIZE:
131 f
"Warning: skipping {octrc} ({size} bytes > "
132 f
"{MAX_OCTRC_SIZE} limit)",
136 cfg = json.loads(octrc.read_text(encoding=
"utf-8"))
137 if cfg.get(
"root_marker",
False):
139 except (json.JSONDecodeError, OSError):
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:
148 "Warning: 'diagnostics/' is deprecated and will be removed in v1.0.0. "
149 "Rename to 'oc_diagnostics/'.",
159 if workspace_root
is None and (
160 parent /
".option_c_workspace.json"
162 workspace_root = parent
164 if workspace_root
is not None:
165 return workspace_root
166 raise click.ClickException(
"Could not locate project root.")
170 """Return project root from --root-dir override or auto-detection.
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``.
178 root_dir = ctx.obj.get(
"root_dir")
if ctx.obj
else None
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:
186 (resolved /
"pyproject.toml").is_file()
187 or (resolved /
".octrc.json").is_file()
188 or (resolved /
".option_c").is_dir()
189 or (resolved /
".option_c_workspace.json").is_file()
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."
202 """Pure predicate: True if ``path`` resolves inside ``project_root``.
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.
210 resolved = Path(path).resolve()
211 root_resolved = Path(project_root).resolve()
215 resolved.relative_to(root_resolved)
222 path: Path, project_root: Path, allow_outside: bool =
False
224 """Resolve ``path`` and verify it is within ``project_root``.
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).
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()
237 root_resolved = Path(project_root).resolve()
239 raise click.ClickException(
240 f
"Path {resolved} is outside project root {root_resolved}. "
241 f
"Use --allow-outside-project to override."
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)