Skip to content

Commit defdd7c

Browse files
mnriemCopilot
andcommitted
Harden preserved-config rescue against divergence and long names
Address three review findings on the reinstall config-rescue path: - A complete .rescue-complete marker proves only that staging finished, not that dest_dir was modified. A crash after staging sync but before the rmtree leaves the live kept config intact; if the user edits it before retrying, preferring the staged bytes silently overwrote the newer config. The two copies are indistinguishable in provenance from disk, so detect divergence between a complete staging copy and the live config and abort (preserving both) instead of unconditionally choosing staging. - The staging directory embedded the full extension ID in one path component. Extension IDs are length-unbounded, so a valid long ID could install at dest_dir yet fail every reinstall-after-keep-config with ENAMETOOLONG. Derive the staging component from a fixed-length hash via a new _rescue_staging_dir() helper. - The stranded-config restore used the full config filename as a NamedTemporaryFile prefix; a name already near the component limit plus the random suffix raised ENAMETOOLONG. Use a short fixed prefix. Updates the retry regression test to the new divergence semantics and adds conflict-abort, long-ID, and fixed-prefix coverage. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 57d90dc commit defdd7c

2 files changed

Lines changed: 155 additions & 10 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,20 @@ def __init__(self, project_root: Path):
746746
self.extensions_dir = project_root / ".specify" / "extensions"
747747
self.registry = ExtensionRegistry(self.extensions_dir)
748748

749+
def _rescue_staging_dir(self, extension_id: str) -> Path:
750+
"""Fixed-length staging directory path for a preserved-config rescue.
751+
752+
The extension ID can be arbitrarily long (manifest validation caps only
753+
the character set, not the length), so embedding it verbatim in a single
754+
path component could push the ``.rescue-staging-<id>`` directory past a
755+
filesystem's per-component byte limit and make every reinstall after
756+
``--keep-config`` fail with ``ENAMETOOLONG`` even though the extension
757+
installs fine at ``dest_dir``. Hash the ID to a fixed-length suffix so
758+
the component length is bounded regardless of ID length.
759+
"""
760+
digest = hashlib.sha256(extension_id.encode("utf-8")).hexdigest()[:16]
761+
return self.extensions_dir / f".rescue-staging-{digest}"
762+
749763
@staticmethod
750764
def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, str]:
751765
"""Collect command and alias names declared by a manifest.
@@ -1470,7 +1484,7 @@ def install_from_directory(
14701484
# staging dir is removed only after every config has been successfully
14711485
# restored.
14721486
stranded_configs: dict[str, tuple[bytes, int]] = {}
1473-
rescue_staging_dir = self.extensions_dir / f".rescue-staging-{manifest.id}"
1487+
rescue_staging_dir = self._rescue_staging_dir(manifest.id)
14741488
# A staging directory is trusted only when this completion marker is
14751489
# present. The marker is written after every staged file is complete
14761490
# and removed before the non-atomic cleanup, so a crash mid-staging or
@@ -1492,16 +1506,49 @@ def install_from_directory(
14921506
# on disk. Only load non-symlinked files whose names match
14931507
# the two recognised config suffixes so a tampered staging
14941508
# directory cannot inject arbitrary files.
1509+
#
1510+
# A complete staging directory proves only that staging finished,
1511+
# not that dest_dir was ever modified: a crash after staging was
1512+
# synced but before the rmtree below leaves the live kept config
1513+
# intact. If the user then edits that live config before retrying,
1514+
# blindly preferring the staged bytes would silently overwrite the
1515+
# newer config. The staged and live copies are indistinguishable
1516+
# in provenance from disk alone (a genuine post-crash edit vs. a
1517+
# packaged default written by a partially-completed copytree), so
1518+
# when a live config disagrees with its staged copy we must not
1519+
# silently pick either — preserve both and abort, letting the user
1520+
# resolve it. dest_dir is still untouched here, so raising is safe.
1521+
conflicting: list[str] = []
14951522
for staged_file in sorted(rescue_staging_dir.iterdir()):
14961523
if (
14971524
staged_file.is_file()
14981525
and not staged_file.is_symlink()
14991526
and staged_file.name.endswith(("-config.yml", "-config.local.yml"))
15001527
):
1528+
staged_bytes = staged_file.read_bytes()
1529+
live_file = dest_dir / staged_file.name
1530+
if live_file.is_file() and not live_file.is_symlink():
1531+
try:
1532+
live_bytes = live_file.read_bytes()
1533+
except OSError:
1534+
live_bytes = None
1535+
if live_bytes is not None and live_bytes != staged_bytes:
1536+
conflicting.append(staged_file.name)
15011537
stranded_configs[staged_file.name] = (
1502-
staged_file.read_bytes(),
1538+
staged_bytes,
15031539
staged_file.stat().st_mode,
15041540
)
1541+
if conflicting:
1542+
names = ", ".join(sorted(conflicting))
1543+
raise ValidationError(
1544+
"Preserved extension config conflict for "
1545+
f"'{manifest.id}': the current config file(s) ({names}) in "
1546+
f"{dest_dir} differ from the rescued backup left by an "
1547+
f"interrupted install in {rescue_staging_dir}. Both copies "
1548+
"have been preserved. Resolve manually — keep the current "
1549+
f"file and delete {rescue_staging_dir}, or restore the "
1550+
"backup over the current file — then reinstall."
1551+
)
15051552
elif dest_dir.exists() and not self.registry.is_installed(manifest.id):
15061553
for cfg_file in (
15071554
list(dest_dir.glob("*-config.yml"))
@@ -1599,10 +1646,16 @@ def _restore_stranded_config_file(
15991646
) -> None:
16001647
tmp_path: Path | None = None
16011648
try:
1649+
# A short fixed prefix, not f".{target.name}.": the preserved
1650+
# config filename may itself already be near the filesystem's
1651+
# per-component byte limit, and NamedTemporaryFile appends a
1652+
# random suffix to the prefix — reusing the full name would push
1653+
# the temp file past the limit and raise ENAMETOOLONG on every
1654+
# retry. tempfile already guarantees collision avoidance.
16021655
with tempfile.NamedTemporaryFile(
16031656
mode="wb",
16041657
dir=target.parent,
1605-
prefix=f".{target.name}.",
1658+
prefix=".cfg-restore.",
16061659
delete=False,
16071660
) as tmp:
16081661
tmp_path = Path(tmp.name)

tests/test_extensions.py

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1421,7 +1421,12 @@ def test_extensionignore_load_failure_preserves_kept_config(
14211421
def test_retry_after_staging_backup_restores_stranded_config(
14221422
self, extension_dir, project_dir, monkeypatch
14231423
):
1424-
"""A retry after an interrupted install should restore the rescued config from staging."""
1424+
"""A retry after an interrupted install restores the rescued config.
1425+
1426+
When the live config is unchanged since the interrupted attempt (it
1427+
still matches the staged backup), the retry proceeds and yields the
1428+
preserved bytes, and the staging directory is cleaned up on success.
1429+
"""
14251430
import stat
14261431

14271432
manager = ExtensionManager(project_dir)
@@ -1446,7 +1451,7 @@ def test_retry_after_staging_backup_restores_stranded_config(
14461451
assert not manager.registry.is_installed("test-ext")
14471452
assert config_file.exists()
14481453

1449-
staging_dir = manager.extensions_dir / ".rescue-staging-test-ext"
1454+
staging_dir = manager._rescue_staging_dir("test-ext")
14501455
assert not staging_dir.exists()
14511456

14521457
original_copytree = shutil.copytree
@@ -1473,11 +1478,9 @@ def flaky_copytree(*args, **kwargs):
14731478
assert (staging_dir / ".rescue-complete").exists()
14741479
assert (staging_dir / "test-ext-config.yml").exists()
14751480

1476-
# Corrupt the rollback-restored copy to prove the next retry must rely
1477-
# on the durable staging backup, not whatever was written to dest_dir
1478-
# by the earlier failed install attempt.
1479-
config_file.write_text("model: wrong-model\n", encoding="utf-8")
1480-
assert config_file.read_bytes() != original_bytes
1481+
# The rollback restored the preserved bytes to the live config, so it
1482+
# still agrees with the staged backup — the retry proceeds normally.
1483+
assert config_file.read_bytes() == original_bytes
14811484

14821485
manifest = manager.install_from_directory(
14831486
extension_dir, "0.1.0", register_commands=False
@@ -1493,6 +1496,95 @@ def flaky_copytree(*args, **kwargs):
14931496
restored_mode = config_file.stat().st_mode
14941497
assert stat.S_IMODE(restored_mode) == stat.S_IMODE(original_mode)
14951498

1499+
def test_retry_with_edited_live_config_aborts_and_preserves_both(
1500+
self, extension_dir, project_dir, monkeypatch
1501+
):
1502+
"""A retry must not silently overwrite a config edited after a crash.
1503+
1504+
A complete staging directory proves only that staging finished, not
1505+
that dest_dir was modified. If the user edits the live kept config
1506+
before retrying, the retry must detect the divergence, preserve both
1507+
copies, and abort rather than blindly restoring the older staged bytes.
1508+
"""
1509+
manager = ExtensionManager(project_dir)
1510+
1511+
packaged_config = extension_dir / "test-ext-config.yml"
1512+
packaged_config.write_text("model: default-model\n")
1513+
1514+
manager.install_from_directory(
1515+
extension_dir, "0.1.0", register_commands=False
1516+
)
1517+
1518+
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
1519+
config_file = ext_dir / "test-ext-config.yml"
1520+
config_file.write_text("model: custom-model\nmax_iterations: 99\n")
1521+
staged_bytes = config_file.read_bytes()
1522+
1523+
manager.remove("test-ext", keep_config=True)
1524+
assert not manager.registry.is_installed("test-ext")
1525+
1526+
staging_dir = manager._rescue_staging_dir("test-ext")
1527+
1528+
original_copytree = shutil.copytree
1529+
copytree_calls = 0
1530+
1531+
def flaky_copytree(*args, **kwargs):
1532+
nonlocal copytree_calls
1533+
copytree_calls += 1
1534+
if copytree_calls == 1:
1535+
dst = args[1]
1536+
Path(dst).mkdir(parents=True, exist_ok=True)
1537+
(Path(dst) / "_partial.txt").write_text("partial")
1538+
raise OSError("simulated disk full")
1539+
return original_copytree(*args, **kwargs)
1540+
1541+
monkeypatch.setattr(_ext_module.shutil, "copytree", flaky_copytree)
1542+
1543+
with pytest.raises(OSError, match="simulated disk full"):
1544+
manager.install_from_directory(
1545+
extension_dir, "0.1.0", register_commands=False
1546+
)
1547+
1548+
assert staging_dir.exists()
1549+
assert (staging_dir / ".rescue-complete").exists()
1550+
1551+
# Simulate the user editing the live config before retrying so it now
1552+
# diverges from the staged backup.
1553+
config_file.write_text("model: newer-edited-model\n")
1554+
edited_bytes = config_file.read_bytes()
1555+
assert edited_bytes != staged_bytes
1556+
1557+
with pytest.raises(ValidationError, match="Preserved extension config conflict"):
1558+
manager.install_from_directory(
1559+
extension_dir, "0.1.0", register_commands=False
1560+
)
1561+
1562+
# Both copies must survive: the edited live config and the staged backup.
1563+
assert config_file.read_bytes() == edited_bytes
1564+
assert staging_dir.exists()
1565+
assert (staging_dir / "test-ext-config.yml").read_bytes() == staged_bytes
1566+
assert not manager.registry.is_installed("test-ext")
1567+
1568+
def test_rescue_staging_dir_is_fixed_length_for_long_ids(self, project_dir):
1569+
"""The rescue staging component length must not grow with the ID length.
1570+
1571+
Manifest validation caps the ID character set but not its length, so a
1572+
very long (but valid) ID must not lengthen the single staging path
1573+
component past a filesystem's per-component byte limit.
1574+
"""
1575+
manager = ExtensionManager(project_dir)
1576+
1577+
short_dir = manager._rescue_staging_dir("a")
1578+
long_id = "a" * 250
1579+
long_dir = manager._rescue_staging_dir(long_id)
1580+
1581+
# Same fixed component length regardless of ID length.
1582+
assert len(short_dir.name) == len(long_dir.name)
1583+
# Comfortably within the common 255-byte component limit.
1584+
assert len(long_dir.name.encode("utf-8")) <= 255
1585+
# Distinct IDs still map to distinct staging directories.
1586+
assert manager._rescue_staging_dir("b") != short_dir
1587+
14961588
def test_install_force_without_existing(self, extension_dir, project_dir):
14971589
"""Test force-install when extension is NOT already installed (works normally)."""
14981590
manager = ExtensionManager(project_dir)

0 commit comments

Comments
 (0)