Skip to content

Commit 4a885d7

Browse files
jawwad-aliclaude
andcommitted
fix(extensions): preserve config mtime on keep-config restore (mirror copy2)
Per review: the restore comment claimed it mirrors shutil.copy2 (mode + mtime) but only restored permission bits. Capture and re-apply the original atime/mtime via os.utime alongside the mode, so the keep-config leftover restore truly matches copy2's timestamp preservation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e211658 commit 4a885d7

2 files changed

Lines changed: 21 additions & 11 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,11 +1404,12 @@ 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-
# 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]] = {}
1407+
# Capture (bytes, mode, atime, mtime) so restore preserves both
1408+
# permissions and timestamps — truly mirroring the --force restore's
1409+
# shutil.copy2 (which preserves mode/mtime). Config files may hold
1410+
# secrets (API keys); recreating them with default perms could widen
1411+
# access.
1412+
preserved_configs: dict[str, tuple[bytes, int, float, float]] = {}
14121413
if dest_dir.exists():
14131414
for cfg_file in dest_dir.iterdir():
14141415
if (
@@ -1419,9 +1420,12 @@ def install_from_directory(
14191420
or cfg_file.name.endswith("-config.local.yml")
14201421
)
14211422
):
1423+
st = cfg_file.stat()
14221424
preserved_configs[cfg_file.name] = (
14231425
cfg_file.read_bytes(),
1424-
cfg_file.stat().st_mode,
1426+
st.st_mode,
1427+
st.st_atime,
1428+
st.st_mtime,
14251429
)
14261430

14271431
# Install extension (dest_dir computed above during self-install guard)
@@ -1432,14 +1436,15 @@ def install_from_directory(
14321436
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
14331437

14341438
# 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+
# preserving the original file mode and timestamps. The --force
1440+
# backup-restore below (did_remove) still takes precedence for that
1441+
# path, which uses .backup/ rather than an in-place leftover.
1442+
for name, (data, mode, atime, mtime) in preserved_configs.items():
14391443
dest_cfg = dest_dir / name
14401444
dest_cfg.write_bytes(data)
14411445
try:
14421446
os.chmod(dest_cfg, mode & 0o7777) # permission bits only
1447+
os.utime(dest_cfg, (atime, mtime)) # and timestamps
14431448
except OSError:
14441449
pass
14451450

tests/test_extensions.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1571,6 +1571,9 @@ def test_reinstall_preserves_keep_config_leftover(self, extension_dir, project_d
15711571
# Restrictive perms — a config may hold secrets; the restore must not
15721572
# widen them (POSIX only; Windows chmod ignores group/other bits).
15731573
config_file.chmod(0o600)
1574+
# Distinctive mtime to prove timestamps survive too (mirrors copy2).
1575+
old_mtime = 1_600_000_000
1576+
os.utime(config_file, (old_mtime, old_mtime))
15741577

15751578
# keep-config removal: registry entry dropped, config left in place.
15761579
assert manager.remove("test-ext", keep_config=True) is True
@@ -1582,7 +1585,9 @@ def test_reinstall_preserves_keep_config_leftover(self, extension_dir, project_d
15821585
# The user's config must survive the reinstall (was silently wiped).
15831586
assert config_file.exists()
15841587
assert "MY-CUSTOMIZED-VALUE" in config_file.read_text()
1585-
# ...and keep its original (restrictive) mode, not default perms.
1588+
# ...and keep its original mtime (copy2-style timestamp preservation).
1589+
assert config_file.stat().st_mtime == old_mtime
1590+
# ...and its original (restrictive) mode, not default perms.
15861591
if platform.system() != "Windows":
15871592
assert config_file.stat().st_mode & 0o777 == 0o600
15881593

0 commit comments

Comments
 (0)