Skip to content

Commit 5246aaa

Browse files
jawwad-aliclaude
andcommitted
fix(extensions): validate ignore before --force removal; reject symlinked ancestors in config restore
Three review-driven hardening fixes to the keep-config reinstall, adopting the repo's own safe-write contract (shared_infra) instead of leaf-only guards: 1. Validate the source ignore file BEFORE the --force self.remove(). It was loaded only after removal, so a malformed/invalid-UTF-8 ignore file uninstalled a working extension (files + registry entry) before validation raised. Moved the single _load_extensionignore call above the force-removal block. 2. Reject a symlinked ANCESTOR of the config destination. _atomic_restore_config's mkstemp(dir=dest.parent)+os.replace operated inside dest.parent, so if dest_dir was swapped to a symlink after copytree, config bytes (secrets) were created in and replaced into the external target. It now calls the repo's shared_infra._ensure_safe_shared_directory to refuse any symlinked ancestor under the project root before writing. 3. Rollback no longer merges through a symlinked/partial dir. It reset dest_dir with rmtree(ignore_errors=True) then copytree(dirs_exist_ok=True), which could follow a symlink. It now resets via a new _reset_dir (unlink a symlink / rmtree a real dir, surfacing failures) and recreates dest_dir under a verified non-symlink ancestor chain before copying; a rejected ancestor leaves the durable backup on disk. Tests: --force reinstall with a malformed ignore leaves the extension installed (fails before: it was removed); a symlinked dest_dir is refused and never receives config bytes, rollback recreating a real dir (skipped without symlink privilege, runs on CI). Existing reinstall/keep-config tests unchanged (341 pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6d8cd19 commit 5246aaa

2 files changed

Lines changed: 169 additions & 35 deletions

File tree

‎src/specify_cli/extensions/__init__.py‎

Lines changed: 82 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,23 +1344,56 @@ def check_compatibility(
13441344
return True
13451345

13461346
@staticmethod
1347+
def _reset_dir(path: Path) -> None:
1348+
"""Remove whatever occupies *path* so a fresh directory can take its place.
1349+
1350+
A symlink is unlinked (never followed); a real directory is rmtree'd; any
1351+
other entry is unlinked. Unlike ``rmtree(..., ignore_errors=True)`` this
1352+
surfaces failures so a caller never merges into a partially-removed or
1353+
still-symlinked path.
1354+
"""
1355+
if path.is_symlink():
1356+
path.unlink()
1357+
elif path.is_dir():
1358+
shutil.rmtree(path)
1359+
elif path.exists():
1360+
path.unlink()
1361+
13471362
def _atomic_restore_config(
1348-
dest: Path, data: bytes, mode: int, atime: float, mtime: float
1363+
self, dest: Path, data: bytes, mode: int, atime: float, mtime: float
13491364
) -> None:
1350-
"""Restore a preserved config to *dest* atomically (temp file + os.replace).
1365+
"""Restore a preserved config to *dest* atomically and symlink-safely.
1366+
1367+
Two guards, both required because *dest*'s parent is a fresh copytree
1368+
output that a racing process could swap for a symlink:
1369+
1370+
1. Reject a symlinked ancestor of *dest* (``dest.parent`` and up, under
1371+
the project root) via the repo's shared safe-write guard
1372+
(``shared_infra._ensure_safe_shared_directory``). Without this,
1373+
``mkstemp(dir=dest.parent)`` + ``os.replace`` would create and install
1374+
the config (possibly secrets) *inside a symlinked external target*.
1375+
2. Write to a sibling temp file, then ``os.replace`` swaps it into the
1376+
directory entry — never following a symlink that occupies the leaf
1377+
*dest*, and never leaving a partial write.
1378+
1379+
Mode/timestamps are set on the temp file (independently — either can
1380+
succeed alone) so the installed file carries them from the first moment.
1381+
"""
1382+
from ..shared_infra import _ensure_safe_shared_directory
1383+
1384+
# (1) Refuse a symlinked ancestor of the destination. dest.parent must
1385+
# already exist (a fresh copytree dir, the backup dir, or a rollback dir
1386+
# recreated safely just above the call).
1387+
_ensure_safe_shared_directory(
1388+
self.project_root,
1389+
dest.parent,
1390+
create=False,
1391+
context="extension config directory",
1392+
)
13511393

1352-
Writes to a sibling temp file, then ``os.replace()`` swaps it into place.
1353-
``os.replace`` operates on the directory entry, so it (a) never follows a
1354-
symlink that may occupy *dest* — closing the check-then-use race where a
1355-
racing process swaps in a symlink to overwrite an external target — and
1356-
(b) leaves either the old file or the fully-written new one, never a
1357-
partial write. Mirrors ``shared_infra._write_shared_bytes``. Mode and
1358-
timestamps are set on the temp file (independently — either can succeed
1359-
on its own) so the installed file carries them from the first moment.
1360-
"""
13611394
# os.replace cannot replace a directory with a file; clear a stray
1362-
# (non-symlink) directory at the path first. A symlink is handled by
1363-
# os.replace itself (it swaps the link entry without following it).
1395+
# (non-symlink) directory at the leaf first. A symlink at the leaf is
1396+
# handled by os.replace itself (it swaps the link entry, never follows).
13641397
if dest.is_dir() and not dest.is_symlink():
13651398
shutil.rmtree(dest, ignore_errors=True)
13661399
fd, temp_name = tempfile.mkstemp(prefix=f".{dest.name}.", dir=dest.parent)
@@ -1448,6 +1481,13 @@ def install_from_directory(
14481481
f"extension. Install from a copy in a different location instead."
14491482
)
14501483

1484+
# Validate the source ignore file BEFORE any destructive step. It can
1485+
# raise on a malformed/invalid-UTF-8 file, and neither the --force
1486+
# self.remove() below nor the later rmtree must run if it does — otherwise
1487+
# a bad ignore file would uninstall a working extension (and drop its
1488+
# registry entry) before validation fails. Reused as-is at the copytree.
1489+
ignore_fn = self._load_extensionignore(source_dir)
1490+
14511491
# Remove existing installation AFTER all validations pass so that a
14521492
# validation failure doesn't leave the user with a half-uninstalled
14531493
# extension (configs stranded in .backup/).
@@ -1499,21 +1539,18 @@ def install_from_directory(
14991539

15001540
# Install extension (dest_dir computed above during self-install guard).
15011541
# Preserved configs are the ONLY surviving copy once the old extension dir
1502-
# is removed, so guard the whole destructive reinstall against loss at any
1503-
# step (a malformed source ignore file, the rmtree, copytree, or a
1504-
# restore):
1505-
# 1. Validate the source ignore file BEFORE deleting anything — it can
1506-
# raise on a malformed file, and nothing is destroyed yet if it does.
1542+
# is removed, so guard the whole destructive reinstall against loss:
1543+
# 1. The source ignore file is already validated above (before any
1544+
# destructive step), so a malformed one never reaches here.
15071545
# 2. Stage a durable on-disk backup of the configs.
15081546
# 3. Run the destructive rmtree + copytree + restore inside one block;
1509-
# on ANY failure, roll the configs back into a clean dest_dir and
1510-
# re-raise. The backup is removed only once the configs are provably
1511-
# in place (normal success OR a completed rollback) — otherwise it is
1512-
# left on disk so nothing is ever lost, even on a rollback failure.
1513-
# Each config write is atomic (temp file + os.replace) so it never follows
1514-
# a symlink that may occupy the path (see _atomic_restore_config).
1515-
ignore_fn = self._load_extensionignore(source_dir)
1516-
1547+
# on ANY failure, reset dest_dir to a clean, symlink-safe directory
1548+
# and roll the configs back, then re-raise. The backup is removed
1549+
# only once the configs are provably in place (normal success OR a
1550+
# completed rollback) — otherwise it is left on disk so nothing is
1551+
# ever lost, even on a rollback failure.
1552+
# Each config write is atomic (temp file + os.replace) AND refuses a
1553+
# symlinked ancestor of the destination (see _atomic_restore_config).
15171554
backup_dir: Path | None = None
15181555
if preserved_configs:
15191556
backup_dir = Path(
@@ -1534,16 +1571,29 @@ def install_from_directory(
15341571
self._atomic_restore_config(dest_dir / name, data, mode, atime, mtime)
15351572
restored_ok = True
15361573
except Exception:
1537-
# Roll the configs back into a clean dest_dir from the durable backup
1538-
# so a failed reinstall leaves them intact, then re-raise the original.
1574+
# Roll the configs back into a clean, symlink-safe dest_dir from the
1575+
# durable backup so a failed reinstall leaves them intact, then
1576+
# re-raise the original error. Reset dest_dir (unlink a symlink /
1577+
# rmtree a real dir — surfacing failures, never ignore_errors) and
1578+
# recreate it under a verified non-symlink ancestor chain BEFORE
1579+
# copying, so recovery can never merge through a symlinked or
1580+
# partially-removed directory into an external target.
15391581
if backup_dir is not None:
15401582
try:
1541-
if dest_dir.exists():
1542-
shutil.rmtree(dest_dir, ignore_errors=True)
1583+
from ..shared_infra import _ensure_safe_shared_directory
1584+
self._reset_dir(dest_dir)
1585+
_ensure_safe_shared_directory(
1586+
self.project_root,
1587+
dest_dir,
1588+
create=True,
1589+
context="extension directory",
1590+
)
15431591
shutil.copytree(backup_dir, dest_dir, dirs_exist_ok=True)
15441592
restored_ok = True
1545-
except OSError:
1546-
restored_ok = False # keep the backup below for recovery
1593+
except (OSError, ValueError):
1594+
# ValueError covers SymlinkedSharedPathError (a symlinked
1595+
# ancestor): leave the durable backup on disk for recovery.
1596+
restored_ok = False
15471597
raise
15481598
finally:
15491599
# Drop the backup only when the configs are provably in dest_dir.

‎tests/test_extensions.py‎

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1768,13 +1768,13 @@ def test_reinstall_failed_config_restore_is_not_silent(
17681768
# staging (a ".cfgbak-" temp dir) succeed so rollback can recover.
17691769
real_restore = ExtensionManager._atomic_restore_config
17701770

1771-
def flaky_restore(dest, data, mode, atime, mtime):
1771+
def flaky_restore(self, dest, data, mode, atime, mtime):
17721772
if "cfgbak" not in dest.parent.name:
17731773
raise OSError("simulated restore failure")
1774-
return real_restore(dest, data, mode, atime, mtime)
1774+
return real_restore(self, dest, data, mode, atime, mtime)
17751775

17761776
monkeypatch.setattr(
1777-
ExtensionManager, "_atomic_restore_config", staticmethod(flaky_restore)
1777+
ExtensionManager, "_atomic_restore_config", flaky_restore
17781778
)
17791779

17801780
with pytest.raises(OSError):
@@ -1786,6 +1786,90 @@ def flaky_restore(dest, data, mode, atime, mtime):
17861786
assert config_file.is_file()
17871787
assert "MY-CUSTOMIZED-VALUE" in config_file.read_text()
17881788

1789+
def test_reinstall_force_validates_ignore_before_removal(
1790+
self, extension_dir, project_dir, monkeypatch
1791+
):
1792+
"""A --force reinstall must validate the source ignore file BEFORE
1793+
self.remove() runs. Otherwise a malformed ignore file uninstalls a
1794+
working extension (dropping its files and registry entry) before the
1795+
validation raises. (The leftover-path test covers the non-force case.)"""
1796+
manager = ExtensionManager(project_dir)
1797+
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
1798+
assert manager.registry.is_installed("test-ext")
1799+
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
1800+
manifest_file = ext_dir / "extension.yml"
1801+
assert manifest_file.is_file()
1802+
1803+
def bad_ignore(src):
1804+
raise ValueError("malformed .extensionignore")
1805+
1806+
monkeypatch.setattr(manager, "_load_extensionignore", bad_ignore)
1807+
1808+
with pytest.raises(ValueError):
1809+
manager.install_from_directory(
1810+
extension_dir, "0.1.0", register_commands=False, force=True
1811+
)
1812+
1813+
# The working extension was NOT removed by the failed --force reinstall.
1814+
assert manager.registry.is_installed("test-ext")
1815+
assert manifest_file.is_file()
1816+
1817+
def test_reinstall_refuses_symlinked_dest_dir(
1818+
self, extension_dir, project_dir, monkeypatch
1819+
):
1820+
"""If dest_dir is swapped to a symlink after copytree, the config restore
1821+
must NOT create bytes (potential secrets) inside the symlinked external
1822+
target — the repo's ancestor guard rejects it, and rollback recreates a
1823+
real directory. Guards the symlinked-parent gap (leaf-only guards miss
1824+
it). Skipped where symlinks need privilege; runs on Linux CI."""
1825+
probe_t = project_dir / "_pt"
1826+
probe_l = project_dir / "_pl"
1827+
try:
1828+
probe_t.mkdir()
1829+
probe_l.symlink_to(probe_t, target_is_directory=True)
1830+
except (OSError, NotImplementedError):
1831+
pytest.skip("symlinks not supported in this environment")
1832+
finally:
1833+
if probe_l.is_symlink():
1834+
probe_l.unlink()
1835+
if probe_t.exists():
1836+
shutil.rmtree(probe_t, ignore_errors=True)
1837+
1838+
manager = ExtensionManager(project_dir)
1839+
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
1840+
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
1841+
config_file = ext_dir / "test-ext-config.yml"
1842+
config_file.write_text("api_key: SECRET-VALUE")
1843+
assert manager.remove("test-ext", keep_config=True) is True
1844+
1845+
victim = project_dir / "victim_target"
1846+
victim.mkdir()
1847+
1848+
real_copytree = shutil.copytree
1849+
calls = {"n": 0}
1850+
1851+
def copytree_symlinks_destdir_first(src, dst, *args, **kwargs):
1852+
calls["n"] += 1
1853+
if calls["n"] == 1:
1854+
# Simulate a racing swap: dest_dir is a symlink to an external dir.
1855+
Path(dst).symlink_to(victim, target_is_directory=True)
1856+
return dst
1857+
return real_copytree(src, dst, *args, **kwargs)
1858+
1859+
monkeypatch.setattr(shutil, "copytree", copytree_symlinks_destdir_first)
1860+
1861+
with pytest.raises(Exception):
1862+
manager.install_from_directory(
1863+
extension_dir, "0.1.0", register_commands=False
1864+
)
1865+
1866+
# Secrets never landed in the external target.
1867+
assert list(victim.iterdir()) == []
1868+
# dest_dir is a real directory again (rollback), with the config recovered.
1869+
assert not ext_dir.is_symlink()
1870+
assert config_file.is_file()
1871+
assert "SECRET-VALUE" in config_file.read_text()
1872+
17891873

17901874
# ===== CommandRegistrar Tests =====
17911875

0 commit comments

Comments
 (0)