Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
39e3457
Fix reinstall-overwrites-kept-config: preserve config on plain reinst…
github-actions[bot] Jul 10, 2026
f43ae8c
fix: restore method decl, move config restore before registration, pr…
Copilot Jul 13, 2026
0224824
fix: mask setuid/setgid bits when restoring stranded config file mode
Copilot Jul 13, 2026
0c86359
fix: add copytree rollback path and strengthen regression test with p…
Copilot Jul 13, 2026
79bce54
fix: restore configs with secure atomic writes
Copilot Jul 13, 2026
532049b
fix: write secure temp file then chmod to preserved_mode; add copytre…
Copilot Jul 13, 2026
c824cf3
Potential fix for pull request finding 'Unused local variable'
mnriem Jul 13, 2026
fdb9159
Potential fix for pull request finding 'Module is imported with 'impo…
mnriem Jul 13, 2026
0c782d7
fix: durable staging for stranded configs and import style fix
Copilot Jul 14, 2026
6106e0e
fix: harden rescue staging dir - symlink checks, secure writes, clean…
Copilot Jul 14, 2026
3cca6e1
fix: mask file-type bits from chmod, harden staging dir symlink check
Copilot Jul 14, 2026
a76c181
fix: use completion marker for rescue staging, abort on staging failu…
Copilot Jul 14, 2026
68e965c
fix(extensions): make rescue staging durable
mnriem Jul 14, 2026
86f93b6
test(extensions): fix flaky copytree regression test
mnriem Jul 14, 2026
9be19be
test(extensions): fix module import alias for review feedback
mnriem Jul 14, 2026
7030d91
fix(workflows): keep cleanup warnings single-line and remove dead helper
mnriem Jul 14, 2026
02bc8d2
Potential fix for pull request finding 'Module is imported with 'impo…
mnriem Jul 14, 2026
a80ac2b
Preserve rescued extension config across retry
mnriem Jul 14, 2026
eb28ce2
Clarify ignored directory fsync cleanup errors
mnriem Jul 14, 2026
c870bed
Fix reinstall durability and workflow cleanup warnings
mnriem Jul 14, 2026
e942583
Open rescue staging file in binary mode to fix Windows CRLF corruption
mnriem Jul 14, 2026
1f01cef
Load .extensionignore before deleting dest_dir on reinstall
mnriem Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 253 additions & 2 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
from __future__ import annotations

import copy
import errno
import hashlib
import json
import os
import re
import shutil
import stat
import tempfile
import zipfile
from dataclasses import dataclass
Expand Down Expand Up @@ -101,6 +103,51 @@ def _load_core_command_names() -> frozenset[str]:
CORE_COMMAND_NAMES = _load_core_command_names()


def _fsync_fd(fd: int) -> None:
"""Sync a file descriptor, raising on real storage errors."""
try:
os.fsync(fd)
except AttributeError:
return
except NotImplementedError:
return
except OSError as exc:
if exc.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
return
raise


def _fsync_directory(path: Path) -> None:
"""Sync a directory when the platform supports it."""
if not path.exists():
return
Comment thread
mnriem marked this conversation as resolved.
if os.name == "nt":
return
try:
dir_fd = os.open(str(path), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
except (AttributeError, NotImplementedError):
return
except OSError as exc:
if exc.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
return
try:
dir_fd = os.open(str(path), os.O_RDONLY)
except (AttributeError, NotImplementedError):
return
except OSError as exc2:
if exc2.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
return
raise
try:
_fsync_fd(dir_fd)
finally:
try:
os.close(dir_fd)
except OSError:
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
# Cleanup after an fsync failure should not mask the original error.
pass


class ExtensionError(Exception):
"""Base exception for extension-related errors."""

Expand Down Expand Up @@ -1402,12 +1449,216 @@ def install_from_directory(
backup_config_dir.unlink()
did_remove = self.remove(manifest.id)

# Rescue any config files left behind by a prior `remove --keep-config`.
# When an extension is removed with --keep-config, it is no longer in
# the registry but its config files remain in dest_dir. A subsequent
# plain (non-force) install would delete that directory unconditionally,
# silently discarding the preserved config. We read those files into
# memory and also write a durable staging copy outside dest_dir so
# that a partial rmtree, failed copytree, or partial restore cannot
# permanently discard the user's original bytes on a retry. The
# staging dir is removed only after every config has been successfully
# restored.
stranded_configs: dict[str, tuple[bytes, int]] = {}
rescue_staging_dir = self.extensions_dir / f".rescue-staging-{manifest.id}"
# A staging directory is trusted only when this completion marker is
# present. The marker is written after every staged file is complete
# and removed before the non-atomic cleanup, so a crash mid-staging or
# mid-cleanup can never leave a partial directory that a retry mistakes
# for a complete durable backup.
rescue_complete_marker = rescue_staging_dir / ".rescue-complete"
staging_is_complete = (
rescue_staging_dir.is_dir()
and not rescue_staging_dir.is_symlink()
and rescue_complete_marker.is_file()
and not rescue_complete_marker.is_symlink()
)

if staging_is_complete and not self.registry.is_installed(manifest.id):
# A previous install attempt staged the configs but never
# completed cleanly. Reload from the durable backup so the
# original bytes are used on retry rather than whatever
# mixture of packaged defaults and partial restores remains
Comment thread
mnriem marked this conversation as resolved.
# on disk. Only load non-symlinked files whose names match
# the two recognised config suffixes so a tampered staging
# directory cannot inject arbitrary files.
for staged_file in sorted(rescue_staging_dir.iterdir()):
if (
staged_file.is_file()
and not staged_file.is_symlink()
and staged_file.name.endswith(("-config.yml", "-config.local.yml"))
):
stranded_configs[staged_file.name] = (
staged_file.read_bytes(),
staged_file.stat().st_mode,
)
elif dest_dir.exists() and not self.registry.is_installed(manifest.id):
for cfg_file in (
list(dest_dir.glob("*-config.yml"))
+ list(dest_dir.glob("*-config.local.yml"))
):
if cfg_file.is_file() and not cfg_file.is_symlink():
stranded_configs[cfg_file.name] = (
cfg_file.read_bytes(),
cfg_file.stat().st_mode,
)

if stranded_configs and not staging_is_complete:
# Write a durable backup outside dest_dir before any
# destructive operation so the original bytes survive a
# crash or partial failure at any later step. The staging
# dir is cleaned up only after every restore succeeds.
#
# Any pre-existing staging dir here lacks the completion marker
# (staging_is_complete is False), so it is a stale partial from an
# interrupted attempt — remove it first for a clean write.
if rescue_staging_dir.is_symlink():
rescue_staging_dir.unlink()
elif rescue_staging_dir.is_dir():
shutil.rmtree(rescue_staging_dir)
elif rescue_staging_dir.exists():
rescue_staging_dir.unlink()
try:
rescue_staging_dir.mkdir(parents=True, exist_ok=True)
for filename, (content, mode) in stranded_configs.items():
staged = rescue_staging_dir / filename
# Create the staging file with mode 0600 before writing so
# the preserved bytes are never transiently readable by other
# local users, even on a umask that would produce 0644.
# O_BINARY (0 on POSIX) is required so Windows does not open
# the descriptor in text mode and translate the preserved
# bytes' "\n" into "\r\n" as they are written.
fd = os.open(
str(staged),
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
0o600,
)
try:
# os.write() may write fewer bytes than requested, so
# loop until the whole buffer is on disk — a truncated
# "durable" backup would be trusted over the intact
# config on a retry and cause silent data loss.
view = memoryview(content)
written = 0
while written < len(view):
written += os.write(fd, view[written:])
try:
os.fchmod(fd, stat.S_IMODE(mode))
except (AttributeError, NotImplementedError, OSError):
try:
staged.chmod(stat.S_IMODE(mode))
except (NotImplementedError, OSError):
pass # Best-effort; chmod may not be supported on all platforms.
_fsync_fd(fd)
finally:
os.close(fd)
# Flush the staging directory metadata before publishing the
# completion marker so a crash cannot leave a visible marker with
# only a subset of staged files.
_fsync_directory(rescue_staging_dir)
# Write the completion marker only after every staged file is
# fully written so a retry trusts staging only when it is whole.
marker_fd = os.open(
str(rescue_complete_marker),
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
0o600,
)
try:
_fsync_fd(marker_fd)
finally:
os.close(marker_fd)
_fsync_directory(rescue_staging_dir)
_fsync_directory(rescue_staging_dir.parent)
except BaseException:
# Durable staging failed (or was interrupted). Continuing with
# only the in-memory copy would reintroduce the permanent-loss
# path this staging exists to close: the rmtree below could
# delete the originals and a later restore failure would leave
# no on-disk copy. dest_dir is still untouched here, so clean
# up the partial staging dir and abort the install instead of
# proceeding destructively.
shutil.rmtree(rescue_staging_dir, ignore_errors=True)
raise

# Load and validate .extensionignore BEFORE deleting dest_dir. The
# loader can raise ValidationError (invalid UTF-8) or OSError; doing it
# first means such a failure leaves the kept config untouched in its
# documented location rather than only in the hidden staging directory.
ignore_fn = self._load_extensionignore(source_dir)

# Install extension (dest_dir computed above during self-install guard)
if dest_dir.exists():
shutil.rmtree(dest_dir)

ignore_fn = self._load_extensionignore(source_dir)
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
def _restore_stranded_config_file(
target: Path, content: bytes, preserved_mode: int
) -> None:
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
dir=target.parent,
prefix=f".{target.name}.",
delete=False,
) as tmp:
tmp_path = Path(tmp.name)
tmp.write(content)
tmp.flush()
_fsync_fd(tmp.fileno())
try:
tmp_path.chmod(stat.S_IMODE(preserved_mode))
except (NotImplementedError, OSError):
pass # Best-effort; chmod may not be supported on all platforms.
os.replace(tmp_path, target)
try:
target_fd = os.open(str(target), os.O_RDONLY)
except (AttributeError, OSError, NotImplementedError):
target_fd = None
try:
if target_fd is not None:
_fsync_fd(target_fd)
finally:
if target_fd is not None:
try:
os.close(target_fd)
except OSError:
pass
_fsync_directory(target.parent)
except BaseException:
if tmp_path is not None and tmp_path.exists():
tmp_path.unlink()
raise

try:
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
except BaseException:
# copytree failed — dest_dir may be absent or only partially
# created. Write the rescued configs back now so they are not
# permanently lost even though the install did not complete.
if stranded_configs:
Comment thread
mnriem marked this conversation as resolved.
dest_dir.mkdir(parents=True, exist_ok=True)
for filename, (content, mode) in stranded_configs.items():
target = dest_dir / filename
_restore_stranded_config_file(target, content, mode)
raise

# Restore stranded configs rescued before the rmtree above.
for filename, (content, mode) in stranded_configs.items():
Comment thread
mnriem marked this conversation as resolved.
target = dest_dir / filename
_restore_stranded_config_file(target, content, mode)

# Every stranded config has been restored successfully — the
# durable staging backup is no longer needed. Raise on failure so
# the install is not reported as successful while a stale backup
# that could be misread on the next retry remains on disk.
if rescue_staging_dir.is_dir() and not rescue_staging_dir.is_symlink():
Comment thread
mnriem marked this conversation as resolved.
# Remove the completion marker before the non-atomic rmtree so a
# crash mid-cleanup cannot leave a staging dir that a retry would
# wrongly trust as a complete durable backup.
rescue_complete_marker.unlink(missing_ok=True)
_fsync_directory(rescue_staging_dir)
shutil.rmtree(rescue_staging_dir)
Comment thread
mnriem marked this conversation as resolved.
Comment thread
Copilot marked this conversation as resolved.
_fsync_directory(rescue_staging_dir.parent)

# Register commands with AI agents
registered_commands = {}
Expand Down
8 changes: 4 additions & 4 deletions src/specify_cli/workflows/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -1666,8 +1666,8 @@ def _validate_and_install_local(
except OSError as cleanup_exc:
console.print(
"[yellow]Warning:[/yellow] Could not remove temporary "
f"download file {_escape_markup(str(tmp_path))}: "
f"{_escape_markup(str(cleanup_exc))}"
f"workflow download file: {_escape_markup(str(cleanup_exc))} "
f"(path: {_escape_markup(str(tmp_path))})"
)
console.print(f"[red]Error:[/red] Failed to download workflow: {_escape_markup(str(exc))}")
raise typer.Exit(1)
Expand All @@ -1691,8 +1691,8 @@ def _validate_and_install_local(
except OSError as exc:
console.print(
"[yellow]Warning:[/yellow] Could not remove temporary "
f"download file {_escape_markup(str(tmp_path))}: "
f"{_escape_markup(str(exc))}"
f"workflow download file: {_escape_markup(str(exc))} "
f"(path: {_escape_markup(str(tmp_path))})"
)
return

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

from tests.conftest import requires_bash
from tests.extensions.test_extension_agent_context import (
BASH,
POWERSHELL,
_bash_posix_path,
_run_bash_agent_context_script,
Expand Down
Loading
Loading