Skip to content

Commit 1c7a86c

Browse files
committed
fix(init): harden preview staging filesystem checks
Reject Windows junctions across supported Python versions and skip unreadable unmanaged staging entries while preserving failures for managed artifacts.
1 parent 72e3773 commit 1c7a86c

2 files changed

Lines changed: 292 additions & 19 deletions

File tree

src/specify_cli/commands/init.py

Lines changed: 126 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -825,25 +825,119 @@ def _remap_in_project_symlinks(project_root: Path, staged_root: Path) -> None:
825825
raise RuntimeError("staged symlink isolation did not converge")
826826

827827

828-
def _ignore_special_files(directory: str, names: list[str]) -> set[str]:
829-
"""Skip sockets, devices, and other non-file tree entries during staging."""
828+
def _windows_path_is_junction(path: Path) -> bool:
829+
"""Detect a Windows junction using Python 3.11-compatible reparse data."""
830+
try:
831+
path_stat = path.lstat()
832+
except FileNotFoundError:
833+
return False
834+
except OSError as exc:
835+
raise ValueError(
836+
f"Cannot determine whether preview path is a junction: {path}: {exc}"
837+
) from exc
838+
839+
reparse_tag = getattr(path_stat, "st_reparse_tag", None)
840+
if reparse_tag is None:
841+
raise ValueError(
842+
f"Cannot determine whether preview path is a junction: {path}"
843+
)
844+
mount_point_tag = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003)
845+
return reparse_tag == mount_point_tag
846+
847+
848+
def _path_is_junction(path: Path) -> bool:
849+
"""Return whether *path* is a Windows directory junction."""
850+
checker = getattr(path, "is_junction", None)
851+
if callable(checker):
852+
try:
853+
return checker()
854+
except OSError as exc:
855+
raise ValueError(
856+
f"Cannot determine whether preview path is a junction: {path}: {exc}"
857+
) from exc
858+
if os.name == "nt":
859+
return _windows_path_is_junction(path)
860+
return False
861+
862+
863+
def _preview_path_is_managed(path: Path, copy_root: Path) -> bool:
864+
"""Return whether an unreadable path may affect initialization behavior."""
865+
try:
866+
relative = path.relative_to(copy_root)
867+
except ValueError:
868+
return True
869+
parts = (copy_root.name, *relative.parts)
870+
if copy_root.name == ".specify" and relative.parts:
871+
return relative.parts[0] in {
872+
".gitignore",
873+
"extensions",
874+
"extensions.yml",
875+
"init-options.json",
876+
"integration.json",
877+
"integrations",
878+
"memory",
879+
"presets",
880+
"scripts",
881+
"templates",
882+
"workflows",
883+
}
884+
return any(part.startswith(("speckit-", "speckit.")) for part in parts)
885+
886+
887+
def _path_is_readable_for_staging(path: Path) -> bool:
888+
"""Probe whether copytree can read a regular file or enumerate a directory."""
889+
try:
890+
if path.is_file():
891+
with path.open("rb"):
892+
pass
893+
elif path.is_dir():
894+
with os.scandir(path):
895+
pass
896+
return True
897+
except OSError:
898+
return False
899+
900+
901+
def _ignore_special_files(
902+
directory: str,
903+
names: list[str],
904+
*,
905+
copy_root: Path | None = None,
906+
) -> set[str]:
907+
"""Skip inaccessible unrelated entries and unsupported filesystem nodes."""
830908
ignored: set[str] = set()
909+
root = copy_root or Path(directory)
831910
for name in names:
832911
candidate = Path(directory) / name
833912
try:
913+
if _path_is_junction(candidate):
914+
raise ValueError(
915+
f"Preview staging refuses Windows directory junction: {candidate}"
916+
)
834917
if (
835918
not candidate.is_symlink()
836919
and not candidate.is_file()
837920
and not candidate.is_dir()
838921
):
839922
ignored.add(name)
923+
continue
924+
if (
925+
not candidate.is_symlink()
926+
and not _path_is_readable_for_staging(candidate)
927+
and not _preview_path_is_managed(candidate, root)
928+
):
929+
ignored.add(name)
840930
except OSError:
841931
ignored.add(name)
842932
return ignored
843933

844934

845935
def _copy_staged_path(source: Path, destination: Path) -> None:
846936
"""Copy one selected path without following symlinks."""
937+
if _path_is_junction(source):
938+
raise ValueError(
939+
f"Preview staging refuses Windows directory junction: {source}"
940+
)
847941
if source.is_symlink():
848942
if os.path.lexists(destination):
849943
return
@@ -857,7 +951,9 @@ def _copy_staged_path(source: Path, destination: Path) -> None:
857951
destination,
858952
symlinks=True,
859953
dirs_exist_ok=True,
860-
ignore=_ignore_special_files,
954+
ignore=lambda directory, names: _ignore_special_files(
955+
directory, names, copy_root=source
956+
),
861957
)
862958
elif source.is_file():
863959
destination.parent.mkdir(parents=True, exist_ok=True)
@@ -875,6 +971,10 @@ def _copy_selected_staged_path(
875971
current /= part
876972
source = project_path / current
877973
destination = staged_root / current
974+
if _path_is_junction(source):
975+
raise ValueError(
976+
f"Preview staging refuses Windows directory junction: {source}"
977+
)
878978
if source.is_symlink():
879979
_copy_staged_path(source, destination)
880980
return
@@ -925,7 +1025,9 @@ def _stage_project_copy(
9251025
project_path,
9261026
staged_root,
9271027
symlinks=True,
928-
ignore=_ignore_special_files,
1028+
ignore=lambda directory, names: _ignore_special_files(
1029+
directory, names, copy_root=project_path
1030+
),
9291031
)
9301032
else:
9311033
staged_root.mkdir(parents=True, exist_ok=True)
@@ -1069,21 +1171,26 @@ def _preview_init(
10691171
staged_root = Path(tmp_dir) / "project"
10701172
staged_home = Path(tmp_dir) / "home"
10711173
staged_home.mkdir()
1072-
_seed_preview_home(staged_home, real_home)
1073-
if home_seed_paths:
1074-
_stage_project_copy(real_home, staged_home, home_seed_paths)
1075-
if project_path.exists():
1076-
_stage_project_copy(
1077-
project_path,
1078-
staged_root,
1079-
_preview_seed_paths(
1080-
selected_integration,
1081-
integration_options,
1082-
script_type,
1083-
),
1084-
)
1085-
else:
1086-
staged_root.mkdir()
1174+
try:
1175+
_seed_preview_home(staged_home, real_home)
1176+
if home_seed_paths:
1177+
_stage_project_copy(real_home, staged_home, home_seed_paths)
1178+
if project_path.exists():
1179+
_stage_project_copy(
1180+
project_path,
1181+
staged_root,
1182+
_preview_seed_paths(
1183+
selected_integration,
1184+
integration_options,
1185+
script_type,
1186+
),
1187+
)
1188+
else:
1189+
staged_root.mkdir()
1190+
except (OSError, RuntimeError, ValueError, shutil.Error) as exc:
1191+
payload["error"] = f"failed to stage preview inputs: {exc}"
1192+
_emit_dry_run_preview(payload, json_output=json_output)
1193+
raise typer.Exit(1) from None
10871194

10881195
initial_project_files = _snapshot_files(staged_root)
10891196
initial_home_files = _snapshot_files(staged_home)

tests/test_init_dry_run.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
import json
66
import os
77
import shutil
8+
import stat
89
import subprocess
910
from pathlib import Path
11+
from types import SimpleNamespace
1012

1113
import pytest
1214
from typer.testing import CliRunner
@@ -24,6 +26,7 @@
2426
_snapshot_tree_entries,
2527
_stage_project_copy,
2628
_strip_windows_extended_prefix,
29+
_windows_path_is_junction,
2730
)
2831

2932
_PROVENANCE_CATEGORIES = {"core", "integration", "preset", "workflow", "extension"}
@@ -1368,6 +1371,169 @@ def test_dry_run_ignores_unreadable_unmanaged_file(tmp_path: Path) -> None:
13681371
)
13691372

13701373

1374+
@pytest.mark.skipif(os.name == "nt", reason="chmod does not remove read access on Windows")
1375+
def test_dry_run_ignores_unreadable_unmanaged_file_in_selected_root(
1376+
tmp_path: Path,
1377+
) -> None:
1378+
target = tmp_path / "unreadable-selected-root"
1379+
unrelated = target / ".github" / "skills" / "private" / "secret.txt"
1380+
unrelated.parent.mkdir(parents=True)
1381+
unrelated.write_text("not managed by Spec Kit\n", encoding="utf-8")
1382+
unrelated.chmod(0)
1383+
1384+
try:
1385+
result = CliRunner().invoke(
1386+
app,
1387+
[
1388+
"init",
1389+
str(target),
1390+
"--force",
1391+
"--dry-run",
1392+
"--json",
1393+
"--integration",
1394+
"copilot",
1395+
"--script",
1396+
"sh",
1397+
"--ignore-agent-tools",
1398+
],
1399+
catch_exceptions=False,
1400+
)
1401+
finally:
1402+
unrelated.chmod(0o600)
1403+
1404+
assert result.exit_code == 0, result.output
1405+
assert all(
1406+
action["path"] != ".github/skills/private/secret.txt"
1407+
for action in json.loads(result.output)["actions"]
1408+
)
1409+
assert unrelated.read_text(encoding="utf-8") == "not managed by Spec Kit\n"
1410+
1411+
1412+
@pytest.mark.skipif(os.name == "nt", reason="chmod does not remove read access on Windows")
1413+
def test_dry_run_reports_unreadable_managed_file_in_selected_root(
1414+
tmp_path: Path,
1415+
) -> None:
1416+
target = tmp_path / "unreadable-managed-output"
1417+
managed = target / ".github" / "skills" / "speckit-plan" / "SKILL.md"
1418+
managed.parent.mkdir(parents=True)
1419+
managed.write_text("managed but unreadable\n", encoding="utf-8")
1420+
managed.chmod(0)
1421+
1422+
try:
1423+
result = CliRunner().invoke(
1424+
app,
1425+
[
1426+
"init",
1427+
str(target),
1428+
"--force",
1429+
"--dry-run",
1430+
"--json",
1431+
"--integration",
1432+
"copilot",
1433+
"--script",
1434+
"sh",
1435+
"--ignore-agent-tools",
1436+
],
1437+
catch_exceptions=False,
1438+
)
1439+
finally:
1440+
managed.chmod(0o600)
1441+
1442+
assert result.exit_code == 1, result.output
1443+
payload = json.loads(result.output)
1444+
assert "failed to stage preview inputs" in payload["error"]
1445+
assert "Permission denied" in payload["error"]
1446+
assert managed.read_text(encoding="utf-8") == "managed but unreadable\n"
1447+
1448+
1449+
def test_dry_run_rejects_selected_directory_junction_before_copy(
1450+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1451+
) -> None:
1452+
target = tmp_path / "junction-preview"
1453+
selected_root = target / ".github" / "skills"
1454+
selected_root.mkdir(parents=True)
1455+
(selected_root / "keep.txt").write_text("external-like\n", encoding="utf-8")
1456+
real_is_junction = getattr(Path, "is_junction", None)
1457+
1458+
def fake_is_junction(path: Path) -> bool:
1459+
return path == selected_root or bool(
1460+
real_is_junction and real_is_junction(path)
1461+
)
1462+
1463+
monkeypatch.setattr(Path, "is_junction", fake_is_junction, raising=False)
1464+
1465+
result = CliRunner().invoke(
1466+
app,
1467+
[
1468+
"init",
1469+
str(target),
1470+
"--force",
1471+
"--dry-run",
1472+
"--json",
1473+
"--integration",
1474+
"copilot",
1475+
"--script",
1476+
"sh",
1477+
"--ignore-agent-tools",
1478+
],
1479+
catch_exceptions=False,
1480+
)
1481+
1482+
assert result.exit_code == 1, result.output
1483+
payload = json.loads(result.output)
1484+
assert "junction" in payload["error"].lower()
1485+
assert (selected_root / "keep.txt").read_text(encoding="utf-8") == "external-like\n"
1486+
1487+
1488+
def test_windows_junction_fallback_uses_reparse_tag(
1489+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1490+
) -> None:
1491+
mount_point_tag = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003)
1492+
monkeypatch.setattr(
1493+
Path,
1494+
"lstat",
1495+
lambda _path: SimpleNamespace(st_reparse_tag=mount_point_tag),
1496+
)
1497+
1498+
assert _windows_path_is_junction(tmp_path)
1499+
1500+
1501+
@pytest.mark.skipif(os.name != "nt", reason="Windows junction behavior")
1502+
def test_dry_run_rejects_real_windows_junction_before_copy(tmp_path: Path) -> None:
1503+
target = tmp_path / "real-junction-preview"
1504+
external = tmp_path / "external-skills"
1505+
external.mkdir()
1506+
selected_root = target / ".github" / "skills"
1507+
selected_root.parent.mkdir(parents=True)
1508+
subprocess.run(
1509+
["cmd", "/c", "mklink", "/J", str(selected_root), str(external)],
1510+
check=True,
1511+
capture_output=True,
1512+
text=True,
1513+
)
1514+
1515+
result = CliRunner().invoke(
1516+
app,
1517+
[
1518+
"init",
1519+
str(target),
1520+
"--force",
1521+
"--dry-run",
1522+
"--json",
1523+
"--integration",
1524+
"copilot",
1525+
"--script",
1526+
"ps",
1527+
"--ignore-agent-tools",
1528+
],
1529+
catch_exceptions=False,
1530+
)
1531+
1532+
assert result.exit_code == 1, result.output
1533+
assert "junction" in json.loads(result.output)["error"].lower()
1534+
assert list(external.iterdir()) == []
1535+
1536+
13711537
def test_remap_in_project_absolute_symlinks_points_at_staged_copy(
13721538
tmp_path: Path,
13731539
) -> None:

0 commit comments

Comments
 (0)