Skip to content

Commit e211658

Browse files
jawwad-aliclaude
andcommitted
fix(extensions): preserve config file mode on keep-config restore
Per review: config files can hold secrets, so recreating them with write_bytes() (default perms) could widen access. Capture the original st_mode alongside the bytes and re-apply it (permission bits) after restore, matching the --force path's shutil.copy2 mode preservation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 08c7e70 commit e211658

2 files changed

Lines changed: 26 additions & 7 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,7 +1404,11 @@ def install_from_directory(
14041404
# preserved configs and the backup-restore path never runs. Capture
14051405
# them here and write them back after the fresh copytree, mirroring
14061406
# the *-config filter the --force restore path uses.
1407-
preserved_configs: dict[str, bytes] = {}
1407+
# Capture (bytes, mode) so we can restore permissions too — config
1408+
# files may hold secrets (API keys), and recreating them with default
1409+
# perms could widen access. Mirrors the --force restore's shutil.copy2
1410+
# (which preserves mode/mtime).
1411+
preserved_configs: dict[str, tuple[bytes, int]] = {}
14081412
if dest_dir.exists():
14091413
for cfg_file in dest_dir.iterdir():
14101414
if (
@@ -1415,7 +1419,10 @@ def install_from_directory(
14151419
or cfg_file.name.endswith("-config.local.yml")
14161420
)
14171421
):
1418-
preserved_configs[cfg_file.name] = cfg_file.read_bytes()
1422+
preserved_configs[cfg_file.name] = (
1423+
cfg_file.read_bytes(),
1424+
cfg_file.stat().st_mode,
1425+
)
14191426

14201427
# Install extension (dest_dir computed above during self-install guard)
14211428
if dest_dir.exists():
@@ -1424,11 +1431,17 @@ def install_from_directory(
14241431
ignore_fn = self._load_extensionignore(source_dir)
14251432
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
14261433

1427-
# Restore configs preserved from a keep-config leftover (see above).
1428-
# The --force backup-restore below (did_remove) still takes precedence
1429-
# for that path, which uses .backup/ rather than an in-place leftover.
1430-
for name, data in preserved_configs.items():
1431-
(dest_dir / name).write_bytes(data)
1434+
# Restore configs preserved from a keep-config leftover (see above),
1435+
# preserving the original file mode. The --force backup-restore below
1436+
# (did_remove) still takes precedence for that path, which uses
1437+
# .backup/ rather than an in-place leftover.
1438+
for name, (data, mode) in preserved_configs.items():
1439+
dest_cfg = dest_dir / name
1440+
dest_cfg.write_bytes(data)
1441+
try:
1442+
os.chmod(dest_cfg, mode & 0o7777) # permission bits only
1443+
except OSError:
1444+
pass
14321445

14331446
# Register commands with AI agents
14341447
registered_commands = {}

tests/test_extensions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1568,6 +1568,9 @@ def test_reinstall_preserves_keep_config_leftover(self, extension_dir, project_d
15681568
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
15691569
config_file = ext_dir / "test-ext-config.yml"
15701570
config_file.write_text("api_key: MY-CUSTOMIZED-VALUE")
1571+
# Restrictive perms — a config may hold secrets; the restore must not
1572+
# widen them (POSIX only; Windows chmod ignores group/other bits).
1573+
config_file.chmod(0o600)
15711574

15721575
# keep-config removal: registry entry dropped, config left in place.
15731576
assert manager.remove("test-ext", keep_config=True) is True
@@ -1579,6 +1582,9 @@ def test_reinstall_preserves_keep_config_leftover(self, extension_dir, project_d
15791582
# The user's config must survive the reinstall (was silently wiped).
15801583
assert config_file.exists()
15811584
assert "MY-CUSTOMIZED-VALUE" in config_file.read_text()
1585+
# ...and keep its original (restrictive) mode, not default perms.
1586+
if platform.system() != "Windows":
1587+
assert config_file.stat().st_mode & 0o777 == 0o600
15821588

15831589

15841590
# ===== CommandRegistrar Tests =====

0 commit comments

Comments
 (0)