Skip to content

Commit 34813e9

Browse files
authored
fix: reject/flag symlinked preserved configs on reinstall
Assisted-by: GitHub Copilot (model: GPT-5.6-Sol, autonomous)
1 parent 899e7a6 commit 34813e9

2 files changed

Lines changed: 132 additions & 2 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1546,7 +1546,15 @@ def _recognized_config_names(directory: Path) -> set[str]:
15461546
staged_stat = staged_file.stat()
15471547
staged_bytes = staged_file.read_bytes()
15481548
live_file = dest_dir / staged_name
1549-
if live_file.is_file() and not live_file.is_symlink():
1549+
if live_file.is_symlink():
1550+
# A user may have replaced the live config with a symlink
1551+
# after the interrupted attempt. It cannot be compared by
1552+
# bytes/mode against the staged copy, and the rmtree below
1553+
# would silently delete this newer choice and restore the
1554+
# older staged file. Treat any live symlink as a conflict so
1555+
# both are preserved and the user resolves it.
1556+
conflicting.add(staged_name)
1557+
elif live_file.is_file():
15501558
# A live config that cannot be read or stat'ed must not be
15511559
# treated as non-conflicting: the rmtree below would delete
15521560
# it and restore the stale staged copy. Abort while dest_dir
@@ -1584,7 +1592,22 @@ def _recognized_config_names(directory: Path) -> set[str]:
15841592
list(dest_dir.glob("*-config.yml"))
15851593
+ list(dest_dir.glob("*-config.local.yml"))
15861594
):
1587-
if cfg_file.is_file() and not cfg_file.is_symlink():
1595+
if cfg_file.is_symlink():
1596+
# `remove --keep-config` preserves a symlinked config
1597+
# because Path.is_file() follows symlinks. Its bytes cannot
1598+
# be safely rescued (the target may live outside dest_dir),
1599+
# and the rmtree below would delete the link and silently
1600+
# discard the kept configuration. Reject the reinstall while
1601+
# dest_dir is untouched so the user resolves it rather than
1602+
# losing the linked config.
1603+
raise ValidationError(
1604+
"Preserved extension config for "
1605+
f"'{manifest.id}' is a symlink ({cfg_file.name}) in "
1606+
f"{dest_dir}, which cannot be safely rescued during "
1607+
"reinstall. Resolve manually — replace the symlink with "
1608+
"a regular file or remove it — then reinstall."
1609+
)
1610+
if cfg_file.is_file():
15881611
stranded_configs[cfg_file.name] = (
15891612
cfg_file.read_bytes(),
15901613
cfg_file.stat().st_mode,

tests/test_extensions.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1290,6 +1290,113 @@ def test_reinstall_after_keep_config_preserves_local_config(
12901290
assert local_cfg.exists()
12911291
assert "local_override: true" in local_cfg.read_text()
12921292

1293+
def test_reinstall_with_symlinked_config_rejects_install(
1294+
self, extension_dir, project_dir
1295+
):
1296+
"""A preserved symlinked config must abort reinstall, not be deleted."""
1297+
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
1298+
if not can_create_symlink(ext_dir.parent if ext_dir.parent.exists() else project_dir):
1299+
pytest.skip("Current platform/user cannot create symlinks")
1300+
1301+
manager = ExtensionManager(project_dir)
1302+
packaged_config = extension_dir / "test-ext-config.yml"
1303+
packaged_config.write_text("model: default-model\n")
1304+
manager.install_from_directory(
1305+
extension_dir, "0.1.0", register_commands=False
1306+
)
1307+
1308+
# Replace the installed config with a symlink to a file outside dest_dir.
1309+
config_file = ext_dir / "test-ext-config.yml"
1310+
external_target = project_dir / "external-config.yml"
1311+
external_target.write_text("model: linked-model\n")
1312+
config_file.unlink()
1313+
os.symlink(external_target, config_file)
1314+
assert config_file.is_symlink()
1315+
1316+
# `remove --keep-config` follows the symlink via is_file() and keeps it.
1317+
manager.remove("test-ext", keep_config=True)
1318+
assert not manager.registry.is_installed("test-ext")
1319+
assert config_file.is_symlink()
1320+
1321+
# Plain reinstall must reject rather than silently delete the link.
1322+
with pytest.raises(ValidationError, match="is a symlink"):
1323+
manager.install_from_directory(
1324+
extension_dir, "0.1.0", register_commands=False
1325+
)
1326+
1327+
# The symlink and its target survive; nothing was silently discarded.
1328+
assert config_file.is_symlink()
1329+
assert external_target.read_text() == "model: linked-model\n"
1330+
assert not manager.registry.is_installed("test-ext")
1331+
1332+
def test_retry_with_symlinked_live_config_aborts_and_preserves_both(
1333+
self, extension_dir, project_dir, monkeypatch
1334+
):
1335+
"""A live config replaced by a symlink on retry is a conflict, not overwritten."""
1336+
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
1337+
if not can_create_symlink(project_dir):
1338+
pytest.skip("Current platform/user cannot create symlinks")
1339+
1340+
manager = ExtensionManager(project_dir)
1341+
1342+
packaged_config = extension_dir / "test-ext-config.yml"
1343+
packaged_config.write_text("model: default-model\n")
1344+
1345+
manager.install_from_directory(
1346+
extension_dir, "0.1.0", register_commands=False
1347+
)
1348+
1349+
config_file = ext_dir / "test-ext-config.yml"
1350+
config_file.write_text("model: custom-model\nmax_iterations: 99\n")
1351+
staged_bytes = config_file.read_bytes()
1352+
1353+
manager.remove("test-ext", keep_config=True)
1354+
assert not manager.registry.is_installed("test-ext")
1355+
1356+
staging_dir = manager._rescue_staging_dir("test-ext")
1357+
1358+
original_copytree = shutil.copytree
1359+
copytree_calls = 0
1360+
1361+
def flaky_copytree(*args, **kwargs):
1362+
nonlocal copytree_calls
1363+
copytree_calls += 1
1364+
if copytree_calls == 1:
1365+
dst = args[1]
1366+
Path(dst).mkdir(parents=True, exist_ok=True)
1367+
(Path(dst) / "_partial.txt").write_text("partial")
1368+
raise OSError("simulated disk full")
1369+
return original_copytree(*args, **kwargs)
1370+
1371+
monkeypatch.setattr(_ext_module.shutil, "copytree", flaky_copytree)
1372+
1373+
with pytest.raises(OSError, match="simulated disk full"):
1374+
manager.install_from_directory(
1375+
extension_dir, "0.1.0", register_commands=False
1376+
)
1377+
1378+
assert staging_dir.exists()
1379+
assert (staging_dir / ".rescue-complete").exists()
1380+
1381+
# Simulate the user replacing the live config with a symlink before retry.
1382+
external_target = project_dir / "external-config.yml"
1383+
external_target.write_text("model: newer-linked-model\n")
1384+
config_file.unlink()
1385+
os.symlink(external_target, config_file)
1386+
assert config_file.is_symlink()
1387+
1388+
with pytest.raises(ValidationError, match="Preserved extension config conflict"):
1389+
manager.install_from_directory(
1390+
extension_dir, "0.1.0", register_commands=False
1391+
)
1392+
1393+
# Both copies survive: the live symlink choice and the staged backup.
1394+
assert config_file.is_symlink()
1395+
assert external_target.read_text() == "model: newer-linked-model\n"
1396+
assert staging_dir.exists()
1397+
assert (staging_dir / "test-ext-config.yml").read_bytes() == staged_bytes
1398+
assert not manager.registry.is_installed("test-ext")
1399+
12931400
def test_copytree_failure_restores_stranded_config(
12941401
self, extension_dir, project_dir, monkeypatch
12951402
):

0 commit comments

Comments
 (0)