diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a09399..e46437c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +## 3.4.12 - 2026-11-09 + +- Fix Windows backup preview and copying when a project contains a reserved device + name such as `NUL`. Skipped entries are logged during preview and listed in + the completed backup report. +- Exclude the root-level Codex `app-server-control` runtime directory before + checking its metadata. Keep the destination's live directory during restore. +- Add regression coverage for reserved NTFS entries and Windows error 1920. +- Retry brief Windows file locks when replacing the restored session index. + ## 3.4.11 - 2026-11-03 - Exclude only root-level `.codex-provisioning-<16-hex>.guard` files from backup, diff --git a/README.md b/README.md index 1b21879..f194eb9 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,15 @@ **One-click Codex backup, restore, and Windows PC migration.** [![Windows](https://img.shields.io/badge/Windows-10%20%7C%2011-0078D4?logo=windows)](https://github.com/dkwolf1/Codex-Lifeboat) -[![Stable: 3.4.11](https://img.shields.io/badge/stable-v3.4.11-16a34a)](https://github.com/dkwolf1/Codex-Lifeboat/releases/tag/v3.4.11) +[![Stable release](https://img.shields.io/github/v/release/dkwolf1/Codex-Lifeboat?label=stable)](https://github.com/dkwolf1/Codex-Lifeboat/releases/latest) [![CI](https://github.com/dkwolf1/Codex-Lifeboat/actions/workflows/ci.yml/badge.svg)](https://github.com/dkwolf1/Codex-Lifeboat/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [Wiki](https://github.com/dkwolf1/Codex-Lifeboat/wiki) · [Nederlands](docs/nl/README.md) · [Security](SECURITY.md) · [Documentation](docs/IMPLEMENTATION-ROADMAP.md) -> **Stable release:** version 3.4.11 safely ignores only Codex's root-level, -> delete-on-close Windows provisioning guards while keeping every ordinary or -> near-matching file mandatory. It retains the reviewed dependency preflight and -> large-restore protections from 3.4.10, and passes all 97 source checks and 12 -> automated compatibility scenarios. +> **Version 3.4.12** fixes Windows backup preview and copying for reserved +> device names such as `NUL` and skips Codex's root-level `app-server-control` +> runtime directory. Reserved entries are listed in the backup report. > The migration workflow also has prior > physical Windows 11 PC A → PC B → PC A evidence. Keep an independent copy of > irreplaceable data and read the [testing guide](docs/TESTING-GUIDE.md). @@ -22,7 +20,7 @@ https://mechscript.nl/en/codex-lifeboat-backup-restore-migrate-en/ ## Download for Windows -### [Download Codex Lifeboat 3.4.11 for Windows](https://github.com/dkwolf1/Codex-Lifeboat/releases/tag/v3.4.11) +### [Download the latest Codex Lifeboat release for Windows](https://github.com/dkwolf1/Codex-Lifeboat/releases/latest) On the release page, download only: diff --git a/build.ps1 b/build.ps1 index c50b1c7..fb9f147 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param( - [string]$Version = '3.4.11', + [string]$Version = '3.4.12', [string]$PythonPath, [switch]$SkipTests ) diff --git a/docs/KNOWN-LIMITATIONS.md b/docs/KNOWN-LIMITATIONS.md index 489ad22..264d278 100644 --- a/docs/KNOWN-LIMITATIONS.md +++ b/docs/KNOWN-LIMITATIONS.md @@ -1,4 +1,4 @@ -# Known limitations of release 3.4.11 +# Known limitations 1. There is no published stable import contract for the local Codex database. The assistant therefore inspects schemas and must be retested after major future @@ -48,3 +48,5 @@ Version 3.4.11 conservatively accounts for logical bytes, estimated allocation overhead and a safety reserve, and rechecks free space during large writes; another process can still consume space unexpectedly. +15. The backup omits entries with reserved Windows device names. Review the + warnings in the backup report for the omitted paths. diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index e8d7db6..d2b26c7 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -2,44 +2,48 @@ [Nederlands](nl/RELEASE-CHECKLIST.md) -This checklist publishes Codex Lifeboat 3.4.11 as a **stable release**. The tag +This checklist publishes Codex Lifeboat 3.4.12 as a **stable release**. The tag workflow builds and validates fresh release assets, creates their checksums and provenance attestations, and publishes the GitHub release. ## Before pushing -- Confirm that `git status --short` contains only intended 3.4.11 changes. +- Confirm that `git status --short` contains only intended 3.4.12 changes. - Confirm that no real backup, database, token, `.env`, EXE, ZIP, or build directory is staged. - Keep the generated `dist` directory locally; `.gitignore` deliberately prevents it from entering the repository. -- Do not create the `v3.4.11` tag until the `main` CI run is green. +- Do not create the `v3.4.12` tag until the `main` CI run is green. ## Publish with PowerShell Run these commands from the repository directory: ```powershell -git add -A -git status --short +git switch -c dkw1/fix-issue-13 +git add -u +git add -- docs/releases/v3.4.12.md docs/nl/releases/v3.4.12.md git diff --cached --check -git commit -m "Add reviewed dependency preflight exclusions" -git push origin main +git commit -m "Fix Windows backup preview and copy edge cases" +git push -u origin dkw1/fix-issue-13 +gh pr create --base main --head dkw1/fix-issue-13 --title "Fix Windows backup preview and copy edge cases" --body "Addresses issue #13." ``` -Wait until the `main` CI run is green at +Merge the PR after its checks pass, then wait until the `main` CI run is green at . Then run: ```powershell -git tag -a v3.4.11 -m "Codex Lifeboat 3.4.11 stable release" -git push origin v3.4.11 +git switch main +git pull --ff-only origin main +git tag -a v3.4.12 -m "Codex Lifeboat 3.4.12 stable release" +git push origin v3.4.12 ``` The tag triggers `.github/workflows/release.yml`. Do **not** also create a manual release with the same tag: the workflow creates it as the stable latest release. Follow it at , then -open . +open . ## Verify the published release @@ -64,8 +68,8 @@ gh attestation verify .\Codex-Lifeboat-Windows-x64-Portable.zip --repo dkwolf1/C ## Stable-release evidence -The 97-check source/EXE/ZIP gate, 12/12 automated Windows scenarios, and a physical -Windows 11 PC A → PC B → PC A workflow all passed. The physical workflow included +The release workflow runs the 99-check source/EXE/ZIP gate, including 12 automated +Windows scenarios. A prior physical Windows 11 PC A → PC B → PC A workflow included both verify operations and opening and continuing work in Codex after restore. Windows 10 and USB-interruption coverage remain documented as pending. Public testers must never upload real backups or unsanitized logs. diff --git a/docs/nl/KNOWN-LIMITATIONS.md b/docs/nl/KNOWN-LIMITATIONS.md index 40a69f9..9cd6fb5 100644 --- a/docs/nl/KNOWN-LIMITATIONS.md +++ b/docs/nl/KNOWN-LIMITATIONS.md @@ -1,4 +1,4 @@ -# Bekende grenzen van release 3.4.11 +# Bekende grenzen 1. OpenAI publiceert geen stabiel importcontract voor de lokale Codex-database. De app gebruikt daarom schema-inspectie en moet na toekomstige grote @@ -51,3 +51,5 @@ Versie 3.4.11 telt logische bytes, geschatte bestandssysteemallocatie en een veiligheidsreserve conservatief mee en controleert vrije ruimte opnieuw tijdens grote schrijfacties; een ander proces kan ruimte nog steeds onverwacht vullen. +15. De back-up slaat paden met gereserveerde Windows-apparaatnamen over. + De waarschuwingen in het back-uprapport vermelden de overgeslagen paden. diff --git a/docs/nl/README.md b/docs/nl/README.md index 307f6ea..f4f2b71 100644 --- a/docs/nl/README.md +++ b/docs/nl/README.md @@ -4,18 +4,17 @@ [Wiki](https://github.com/dkwolf1/Codex-Lifeboat/wiki) · [English](../../README.md) · [Beveiliging](../../SECURITY.md) · [Documentatie](IMPLEMENTATION-ROADMAP.md) -> **Stabiele release:** versie 3.4.11 negeert veilig alleen de root-level, -> delete-on-close Windows-provisioningguards van Codex; gewone en bijna gelijknamige -> bestanden blijven verplicht. De gecontroleerde dependency-preflight en -> restorebeveiliging uit 3.4.10 blijven behouden. Alle 97 broncontroles en 12 -> geautomatiseerde compatibiliteitsscenario's slagen. Voor de +> **Versie 3.4.12** verhelpt Windows-fouten bij de back-upvoorvertoning en het +> kopiëren van gereserveerde apparaatnamen zoals `NUL`. De runtime-map +> `app-server-control` direct onder `.codex` wordt overgeslagen. Het +> back-uprapport vermeldt overgeslagen apparaatnamen. Voor de > migratiewerkwijze bestaat bovendien eerder fysiek bewijs van een Windows 11- > route van pc A naar pc B en terug. Bewaar onvervangbare gegevens ook apart en > lees de [testhandleiding](TESTHANDLEIDING.md). ## Downloaden voor Windows -### [Download Codex Lifeboat 3.4.11](https://github.com/dkwolf1/Codex-Lifeboat/releases/tag/v3.4.11) +### [Download de nieuwste Codex Lifeboat-release](https://github.com/dkwolf1/Codex-Lifeboat/releases/latest) Download op de releasepagina uitsluitend dit bestand: diff --git a/docs/nl/RELEASE-CHECKLIST.md b/docs/nl/RELEASE-CHECKLIST.md index 10630ae..a93b26f 100644 --- a/docs/nl/RELEASE-CHECKLIST.md +++ b/docs/nl/RELEASE-CHECKLIST.md @@ -2,42 +2,46 @@ [English](../RELEASE-CHECKLIST.md) -Deze checklist publiceert Codex Lifeboat 3.4.11 als **stabiele release**. De +Deze checklist publiceert Codex Lifeboat 3.4.12 als **stabiele release**. De tag-workflow bouwt en controleert nieuwe releasebestanden, maakt de checksums en herkomstverklaringen en publiceert de GitHub-release. ## Vóór het pushen -- Controleer dat `git status --short` alleen bedoelde 3.4.11-wijzigingen bevat. +- Controleer dat `git status --short` alleen bedoelde 3.4.12-wijzigingen bevat. - Stage geen echte back-up, database, token, `.env`, EXE, ZIP of bouwmap. - De lokale map `dist` hoort niet in Git en wordt bewust genegeerd. -- Maak tag `v3.4.11` pas nadat de CI-run van `main` groen is. +- Maak tag `v3.4.12` pas nadat de CI-run van `main` groen is. ## Publiceren met PowerShell Voer in de repositorymap uit: ```powershell -git add -A -git status --short +git switch -c dkw1/fix-issue-13 +git add -u +git add -- docs/releases/v3.4.12.md docs/nl/releases/v3.4.12.md git diff --cached --check -git commit -m "Add reviewed dependency preflight exclusions" -git push origin main +git commit -m "Fix Windows backup preview and copy edge cases" +git push -u origin dkw1/fix-issue-13 +gh pr create --base main --head dkw1/fix-issue-13 --title "Fix Windows backup preview and copy edge cases" --body "Addresses issue #13." ``` -Wacht tot de `main`-CI groen is op +Merge de pull request nadat de controles slagen. Wacht daarna tot de `main`-CI groen is op . Voer dan uit: ```powershell -git tag -a v3.4.11 -m "Codex Lifeboat 3.4.11 stable release" -git push origin v3.4.11 +git switch main +git pull --ff-only origin main +git tag -a v3.4.12 -m "Codex Lifeboat 3.4.12 stable release" +git push origin v3.4.12 ``` De tag start `.github/workflows/release.yml`. Maak niet daarnaast handmatig een release met dezelfde tag: de workflow maakt deze als stabiele nieuwste release. Volg de uitvoering op en open -daarna . +daarna . ## Gepubliceerde release controleren @@ -60,8 +64,9 @@ Controleer na downloaden eventueel de GitHub-herkomstverklaring: gh attestation verify .\Codex-Lifeboat-Windows-x64-Portable.zip --repo dkwolf1/Codex-Lifeboat ``` -De 97 bron-/EXE-/ZIP-controles, 12/12 geautomatiseerde Windows-scenario's en een -fysieke Windows 11-route van pc A naar pc B en terug zijn geslaagd. Die route bevatte +De releaseworkflow voert 99 bron-/EXE-/ZIP-controles uit, waaronder 12 +geautomatiseerde Windows-scenario's. Een eerdere fysieke Windows 11-route van +pc A naar pc B en terug bevatte beide controles en verder werken in Codex na herstel. Windows 10 en USB-onderbreking blijven als open aanvullende dekking vermeld. Testers mogen nooit echte back-ups of niet-opgeschoonde logs uploaden. diff --git a/docs/nl/releases/v3.4.12.md b/docs/nl/releases/v3.4.12.md new file mode 100644 index 0000000..caa4afb --- /dev/null +++ b/docs/nl/releases/v3.4.12.md @@ -0,0 +1,21 @@ +# Codex Lifeboat 3.4.12 — fixes voor de Windows-back-upvoorvertoning + +[English](../../releases/v3.4.12.md) + +Deze update pakt beide Windows-back-upfouten uit [issue #13](https://github.com/dkwolf1/Codex-Lifeboat/issues/13) aan. + +- De voorvertoning en de kopie herkennen gereserveerde Windows-apparaatnamen + zoals `NUL` en `NUL.txt` vóór het pad wordt benaderd. Overgeslagen paden + staan in het activiteitenlogboek en in `reports/backup-report.json`. Als + het om een map gaat, wordt ook de inhoud overgeslagen. Hernoem het pad veilig + aan de bron vóór het maken van de back-up als je die inhoud nodig hebt. +- De socket- en lockmap `.codex/app-server-control` wordt vóór de + metadatacontrole overgeslagen om Windows-fout 1920 te voorkomen. Bij herstel + blijft de eigen runtime-map op de doel-pc behouden. Een gelijknamige + submap in een project gaat gewoon mee. +- De vervanging van de herstelde sessie-index wordt kort opnieuw geprobeerd + als Windows tijdelijk toegang weigert. + +De regressietests gebruiken echte gereserveerde NTFS-namen en simuleren +Windows-fout 1920. Pak `Codex-Lifeboat-Windows-x64-Portable.zip` volledig +uit en start `Codex-Lifeboat.exe`. diff --git a/docs/releases/v3.4.12.md b/docs/releases/v3.4.12.md new file mode 100644 index 0000000..0f02647 --- /dev/null +++ b/docs/releases/v3.4.12.md @@ -0,0 +1,21 @@ +# Codex Lifeboat 3.4.12 — Windows backup preview fixes + +[Nederlands](../nl/releases/v3.4.12.md) + +This update addresses both Windows backup failures reported in [issue #13](https://github.com/dkwolf1/Codex-Lifeboat/issues/13). + +- Backup preview and copy now identify reserved Windows device names such as + `NUL` and `NUL.txt` before accessing the entry. The preview activity log + and the finished package's `reports/backup-report.json` list omitted entries. + If a reserved name is a directory, its entire subtree is omitted. Rename the + entry safely at the source before backing up if you need its contents. +- The root-level `.codex/app-server-control` socket and lock directory is + excluded before metadata access, avoiding Windows error 1920. Restore keeps + the destination's own runtime directory. A project subfolder with the same + name is still included. +- The restored session index now uses atomic replacement with bounded retries + when Windows temporarily denies access. + +The regression tests exercise real reserved NTFS entries and a simulated +Windows error 1920. Download and fully extract +`Codex-Lifeboat-Windows-x64-Portable.zip`, then run `Codex-Lifeboat.exe`. diff --git a/pyproject.toml b/pyproject.toml index 1de65cd..42924e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-lifeboat" -version = "3.4.11" +version = "3.4.12" description = "One-click Codex backup, restore and Windows PC migration utility" readme = "README.md" license = "MIT" diff --git a/src/codex_transfer/__init__.py b/src/codex_transfer/__init__.py index 21ff74c..4cb9c26 100644 --- a/src/codex_transfer/__init__.py +++ b/src/codex_transfer/__init__.py @@ -1 +1 @@ -__version__ = "3.4.11" +__version__ = "3.4.12" diff --git a/src/codex_transfer/atomic_io.py b/src/codex_transfer/atomic_io.py index 5913713..4900fa1 100644 --- a/src/codex_transfer/atomic_io.py +++ b/src/codex_transfer/atomic_io.py @@ -46,7 +46,7 @@ def _sync_parent(path: Path) -> None: os.close(descriptor) -def _replace(source: Path, target: Path) -> None: +def replace_with_retry(source: Path, target: Path) -> None: """Retry brief Windows scanner/indexer locks without hiding real failures.""" for attempt in range(_WINDOWS_REPLACE_ATTEMPTS): @@ -86,7 +86,7 @@ def write_text( raise OSError(f"Temporary metadata verification failed: {path.name}") if validate: validate(persisted) - _replace(temporary, path) + replace_with_retry(temporary, path) _sync_parent(path) finally: try: diff --git a/src/codex_transfer/backup.py b/src/codex_transfer/backup.py index be019ba..a5ebed9 100644 --- a/src/codex_transfer/backup.py +++ b/src/codex_transfer/backup.py @@ -30,7 +30,7 @@ FORMAT_ID = "codex-portable-backup" FORMAT_VERSION = "2.4" -GENERATOR_VERSION = "3.4.11" +GENERATOR_VERSION = "3.4.12" PROGRESS_CALLBACK = None STATUS_CALLBACK = None _RUNTIME_EXECUTABLE: Path | None = None @@ -62,6 +62,7 @@ "thread_history_1.sqlite-shm", } ) +APP_SERVER_CONTROL_DIR = "app-server-control" BLOCKED_CODEX_DIRS = { ".sandbox", ".sandbox-bin", @@ -69,6 +70,7 @@ ".tmp", "tmp", "cache", + APP_SERVER_CONTROL_DIR, "computer-use", "node_repl", "plugins", @@ -78,6 +80,9 @@ CODEX_PROVISIONING_GUARD_PATTERN = re.compile( r"^\.codex-provisioning-[0-9a-f]{16}\.guard$", re.IGNORECASE ) +WINDOWS_DEVICE_NAME = re.compile( + r"(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|(?:COM|LPT)[1-9¹²³])" +) ATTACHMENT_PATTERN = re.compile( r"(?i)([a-z]:[\\/][^\x00\r\n\"<>|?*]{1,2048}?\.(?:png|jpe?g|webp|gif|bmp|pdf|docx?|xlsx?|pptx?|csv|zip))" ) @@ -96,6 +101,16 @@ def is_codex_provisioning_guard_name(name: str) -> bool: return CODEX_PROVISIONING_GUARD_PATTERN.fullmatch(str(name)) is not None +def is_windows_device_name(name: str) -> bool: + """Match reserved Windows device names, including names with extensions.""" + base = name.partition(":")[0].partition(".")[0].rstrip(" ").upper() + return WINDOWS_DEVICE_NAME.fullmatch(base) is not None + + +def windows_device_skip_warning(path: Path) -> str: + return f"Skipped reserved Windows device name (including any contents): {path}" + + def new_path_budget() -> dict[str, Any]: return { "longest": None, @@ -758,6 +773,8 @@ def dependency_preflight(source_codex: Path) -> dict[str, Any]: continue for entry in entries: + if is_windows_device_name(entry.name): + continue path = Path(entry.path) path_type = "unknown" try: @@ -899,6 +916,8 @@ def copy_tree( dependency_exclusions: dict[str, dict[str, Any]] | None = None, applied_dependency_exclusions: list[dict[str, Any]] | None = None, before_copy: Callable[[Path, Path, int], None] | None = None, + *, + allow_windows_device_skips: bool = True, ) -> tuple[int, int]: count = 0 byte_count = 0 @@ -928,10 +947,17 @@ def copy_tree( except OSError as exc: raise BackupError(f"Cannot read directory: {current_source}: {exc}") from exc for entry in entries: - entry_source = Path(entry.path) + entry_source = current_source / entry.name + if is_windows_device_name(entry.name): + if not allow_windows_device_skips: + raise BackupError( + f"Cannot copy a reserved Windows device name: {entry_source}" + ) + warnings.append(windows_device_skip_warning(entry_source)) + continue entry_destination = current_destination / entry.name confirmed_file = False - relative_text = os.path.relpath(entry_source, source).lower() + relative_text = str(entry_source.relative_to(source)).lower() if any(fragment and fragment in relative_text for fragment in excluded): warnings.append(f"Excluded by configuration: {entry_source}") continue @@ -987,52 +1013,9 @@ def tree_stats( package_prefix: str | None = None, path_budget: dict[str, Any] | None = None, ) -> tuple[int, int]: - count = 0 - byte_count = 0 - excluded = [value.replace("/", os.sep).lower() for value in exclude_fragments] - if path_budget is not None and package_prefix: - record_projected_path(path_budget, source, package_prefix) - for root, directories, files in os.walk(source, followlinks=False): - root_path = Path(root) - directories[:] = [ - name - for name in directories - if not (root_path / name).is_symlink() - and not any( - fragment in os.path.relpath(root_path / name, source).lower() - for fragment in excluded - if fragment - ) - ] - if path_budget is not None and package_prefix: - for name in directories: - directory = root_path / name - relative = Path(os.path.relpath(directory, source)) - record_projected_path( - path_budget, - directory, - PurePosixPath(package_prefix, *relative.parts).as_posix(), - ) - for name in files: - path = root_path / name - if path.is_symlink(): - continue - relative_text = os.path.relpath(path, source).lower() - if any(fragment in relative_text for fragment in excluded if fragment): - continue - try: - byte_count += path.stat().st_size - count += 1 - if path_budget is not None and package_prefix: - relative = Path(os.path.relpath(path, source)) - record_projected_path( - path_budget, - path, - PurePosixPath(package_prefix, *relative.parts).as_posix(), - reconstructable_cache=is_reconstructable_python_cache(path), - ) - except OSError: - pass + count, byte_count, _ = tree_stats_with_breakdown( + source, exclude_fragments, package_prefix=package_prefix, path_budget=path_budget + ) return count, byte_count @@ -1052,30 +1035,33 @@ def tree_stats_with_breakdown( record_projected_path(path_budget, source, package_prefix) for root, directories, files in os.walk(source, followlinks=False): root_path = Path(root) - directories[:] = [ - name - for name in directories - if not (root_path / name).is_symlink() - and not any( - fragment in os.path.relpath(root_path / name, source).lower() - for fragment in excluded - if fragment - ) - ] - if path_budget is not None and package_prefix: - for name in directories: - directory = root_path / name - relative = Path(os.path.relpath(directory, source)) + kept_directories: list[str] = [] + for name in directories: + directory = root_path / name + if is_windows_device_name(name): + log(windows_device_skip_warning(directory)) + continue + relative = directory.relative_to(source) + if directory.is_symlink() or any( + fragment and fragment in str(relative).lower() for fragment in excluded + ): + continue + kept_directories.append(name) + if path_budget is not None and package_prefix: record_projected_path( path_budget, directory, PurePosixPath(package_prefix, *relative.parts).as_posix(), ) + directories[:] = kept_directories for name in files: path = root_path / name + if is_windows_device_name(name): + log(windows_device_skip_warning(path)) + continue if path.is_symlink(): continue - relative = Path(os.path.relpath(path, source)) + relative = path.relative_to(source) relative_text = str(relative).lower() if any(fragment in relative_text for fragment in excluded if fragment): continue @@ -1124,6 +1110,9 @@ def _portable_profile_stats( details: list[dict[str, Any]] = [] for entry in sorted(source_codex.iterdir(), key=lambda item: item.name.lower()): lowered = entry.name.lower() + if is_windows_device_name(entry.name): + log(windows_device_skip_warning(entry)) + continue if lowered in {value.lower() for value in handled_names}: continue if ( @@ -1951,6 +1940,9 @@ def copy_portable_codex_profile( } for entry in sorted(source_codex.iterdir(), key=lambda item: item.name.lower()): lowered = entry.name.lower() + if is_windows_device_name(entry.name): + warnings.append(windows_device_skip_warning(entry)) + continue if lowered in {value.lower() for value in handled_names}: continue if lowered in {value.lower() for value in BLOCKED_CODEX_NAMES}: diff --git a/src/codex_transfer/compatibility_matrix.py b/src/codex_transfer/compatibility_matrix.py index 44ac243..b5a1e6d 100644 --- a/src/codex_transfer/compatibility_matrix.py +++ b/src/codex_transfer/compatibility_matrix.py @@ -66,6 +66,8 @@ "dependencyExclusionReportRoundTrips", "otherUnreadableDirectoriesFailClosed", "provisioningGuardRuntimeExcluded", + "reservedWindowsDevicePathsReported", + "appServerControlRuntimeExcluded", ), ), ( diff --git a/src/codex_transfer/restore.py b/src/codex_transfer/restore.py index 60397e5..25d5426 100644 --- a/src/codex_transfer/restore.py +++ b/src/codex_transfer/restore.py @@ -12,9 +12,10 @@ from typing import Any, Callable try: - from . import backup, lineage, location_mapper, path_model, portability_audit, project_identity, recovery, restore_plan, rollout as rollout_io, windows + from . import atomic_io, backup, lineage, location_mapper, path_model, portability_audit, project_identity, recovery, restore_plan, rollout as rollout_io, windows from .validate import validate except ImportError: + import atomic_io import backup import lineage import location_mapper @@ -28,21 +29,9 @@ from validate import validate -RESTORE_VERSION = "3.4.11" +RESTORE_VERSION = "3.4.12" PROTECTED_NAMES = {"auth.json", "installation_id", "cap_sid"} -RUNTIME_NAMES = { - ".sandbox", - ".sandbox-bin", - ".sandbox-secrets", - ".tmp", - "tmp", - "cache", - "computer-use", - "node_repl", - "plugins", - "process_manager", - "thread-writer-locks", -} +RUNTIME_NAMES = backup.BLOCKED_CODEX_DIRS PORTABLE_STATE_KEYS = backup.PORTABLE_STATE_KEYS PAGINATED_HISTORY_DATABASE_NAMES = backup.PAGINATED_HISTORY_DATABASE_NAMES @@ -174,6 +163,7 @@ def check_space(_source: Path, target: Path, size: int) -> None: [], warnings, before_copy=check_space, + allow_windows_device_skips=False, ) @@ -1100,12 +1090,18 @@ def _rebuild_session_index(database: Path, target_codex: Path) -> int: seen.add(value) active.append({"id": value, "title": str(title or value)}) destination = target_codex / "session_index.jsonl" - temporary = destination.with_name(destination.name + ".restore.tmp") + temporary = destination.with_name(f".{destination.name}.{uuid.uuid4().hex}.tmp") destination.parent.mkdir(parents=True, exist_ok=True) - with temporary.open("w", encoding="utf-8", newline="\n") as handle: - for item in active: - handle.write(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n") - os.replace(temporary, destination) + try: + with temporary.open("x", encoding="utf-8", newline="\n") as handle: + for item in active: + handle.write(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n") + atomic_io.replace_with_retry(temporary, destination) + finally: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass return len(active) diff --git a/src/codex_transfer/restore_plan.py b/src/codex_transfer/restore_plan.py index 63c6582..7c95a6e 100644 --- a/src/codex_transfer/restore_plan.py +++ b/src/codex_transfer/restore_plan.py @@ -1057,11 +1057,17 @@ def build_restore_plan( if path.startswith("codex/") or path.startswith("attachments/") ) codex_target = target_profile / ".codex" - codex_target_bytes = sum( - path.stat().st_size - for path in codex_target.rglob("*") - if path.is_file() - ) if codex_target.is_dir() else 0 + codex_target_bytes = 0 + if codex_target.is_dir(): + for entry in codex_target.iterdir(): + if entry.name.lower() == backup.APP_SERVER_CONTROL_DIR: + continue + if entry.is_file(): + codex_target_bytes += entry.stat().st_size + elif not entry.is_symlink() and entry.is_dir(): + codex_target_bytes += sum( + path.stat().st_size for path in entry.rglob("*") if path.is_file() + ) items.append( { "key": "codex/managed-state", diff --git a/src/codex_transfer/selftest.py b/src/codex_transfer/selftest.py index 0d38a79..0a45918 100644 --- a/src/codex_transfer/selftest.py +++ b/src/codex_transfer/selftest.py @@ -1659,6 +1659,94 @@ def text(value: str) -> str: ) +def _reserved_windows_device_path_test(root: Path) -> bool: + source = root / "reserved-device-source" + source.mkdir() + nested = source / "nested" + nested.mkdir() + (source / "NUL-notes.txt").write_bytes(b"ordinary") + (nested / "COM10.txt").write_bytes(b"kept") + # The extended path creates real NTFS entries instead of opening a device. + raw_nested = Path("\\\\?\\" + str(nested)) if os.name == "nt" else nested + device_file = raw_nested / "NUL" + device_dir = raw_nested / "lpt1.txt" + device_file.write_bytes(b"reserved") + try: + device_dir.mkdir() + try: + (device_dir / "child.txt").write_bytes(b"excluded") + destination = root / "reserved-device-copy" + warnings: list[str] = [] + budget = backup.new_path_budget() + with mock.patch.object(backup, "log") as preview_log: + files, size, _ = backup.tree_stats_with_breakdown( + source, [], package_prefix="projects/example", path_budget=budget + ) + copied = backup.copy_tree(source, destination, [], warnings) + return bool( + (files, size) == copied == (2, 12) + and len(warnings) == preview_log.call_count == 2 + and {str(call.args[0]) for call in preview_log.call_args_list} == set(warnings) + and all("reserved Windows device name" in warning for warning in warnings) + and (destination / "NUL-notes.txt").read_bytes() == b"ordinary" + and (destination / "nested" / "COM10.txt").read_bytes() == b"kept" + and {path.name for path in (destination / "nested").iterdir()} == {"COM10.txt"} + and device_file.read_bytes() == b"reserved" + and (device_dir / "child.txt").read_bytes() == b"excluded" + and backup.is_windows_device_name("NuL.tar.gz") + and backup.is_windows_device_name("COM¹") + ) + finally: + shutil.rmtree(device_dir) + finally: + device_file.unlink() + + +def _app_server_control_policy_test(root: Path) -> bool: + fixture = root / "app-server-control-policy" + source = fixture / "source" / ".codex" + target = fixture / "target" / ".codex" + package = fixture / "package" + safety = fixture / "safety" + runtime = "App-Server-Control" + for codex in (source, target): + (codex / runtime).mkdir(parents=True) + (codex / runtime / "app-server-startup.lock").write_bytes(b"live") + nested = source / "skills" / runtime + nested.mkdir(parents=True) + (nested / "notes.bin").write_bytes(b"portable") + + original_stat = Path.stat + + def stat_with_blocked_runtime(path: Path, *args, **kwargs): + if path.name.lower() == runtime.lower() and path.parent in {source, target}: + raise OSError(0, "The file cannot be accessed by the system", str(path), 1920) + return original_stat(path, *args, **kwargs) + + class IdentityTranslator: + @staticmethod + def text(value: str) -> str: + return value + + with mock.patch.object(Path, "stat", stat_with_blocked_runtime): + files, size, details = backup._portable_profile_stats(source) + records = backup.copy_portable_codex_profile(source, package, []) + restore._copy_codex_safety(source, safety) + portable = package / "codex" / "portable-profile" + (portable / runtime).mkdir() + (portable / runtime / "app-server-startup.lock").write_bytes(b"legacy") + restored = restore._restore_portable_profile(package, target, IdentityTranslator()) + return bool( + (files, size, restored) == (1, 8, 1) + and [item["name"] for item in details] == ["skills"] + and [item["name"] for item in records] == ["skills"] + and not (safety / runtime).exists() + and (safety / "skills" / runtime / "notes.bin").read_bytes() == b"portable" + and (target / runtime / "app-server-startup.lock").read_bytes() == b"live" + and (target / "skills" / runtime / "notes.bin").read_bytes() == b"portable" + ) + + def _paginated_history_projection_policy_test(root: Path) -> bool: fixture = root / "paginated-history-projection-policy" source_codex = fixture / "source-user" / ".codex" @@ -2177,6 +2265,35 @@ def _atomic_metadata_test(root: Path) -> bool: writers / "locations.json", location_mapper.empty_registry() ) diagnostics.save_report(writers / "diagnostics.json", {"safe": True}) + if os.name == "nt": + database = folder / "index.sqlite" + connection = sqlite3.connect(database) + try: + connection.execute('CREATE TABLE threads (id TEXT, title TEXT)') + connection.execute("INSERT INTO threads VALUES ('active', 'Active thread')") + connection.commit() + finally: + connection.close() + index = folder / "session_index.jsonl" + index.write_text('{"id":"previous"}\n', encoding="utf-8") + original_replace = os.replace + replace_attempts = 0 + + def replace_after_lock(source: Path, destination: Path) -> None: + nonlocal replace_attempts + replace_attempts += 1 + if replace_attempts == 1: + raise PermissionError(13, "Simulated access denied", str(destination), 5) + original_replace(source, destination) + + with mock.patch.object(atomic_io.os, "replace", side_effect=replace_after_lock): + count = restore._rebuild_session_index(database, folder) + if ( + replace_attempts != 2 + or count != 1 + or backup.read_session_index(index)["threadIds"] != ["active"] + ): + return False return bool( interrupted and old_survived @@ -4761,6 +4878,8 @@ def fail_error_journal(path: Path, value: Any) -> None: "attachmentDetectionAndAccessResilience": _attachment_resilience_test(root), "pluginRuntimeExcludedAndTargetPreserved": _plugin_runtime_policy_test(root), "provisioningGuardRuntimeExcluded": _provisioning_guard_policy_test(root), + "reservedWindowsDevicePathsReported": _reserved_windows_device_path_test(root), + "appServerControlRuntimeExcluded": _app_server_control_policy_test(root), "projectRootInventoryAnalysis": _project_inventory_analysis_test(root), "lineageChangeModel": _lineage_model_test(), "streamingLineageDigest": _streaming_lineage_digest_test(root), diff --git a/version_info.txt b/version_info.txt index ab91e78..3a1ebb9 100644 --- a/version_info.txt +++ b/version_info.txt @@ -1,7 +1,7 @@ VSVersionInfo( ffi=FixedFileInfo( - filevers=(3, 4, 11, 0), - prodvers=(3, 4, 11, 0), + filevers=(3, 4, 12, 0), + prodvers=(3, 4, 12, 0), mask=0x3f, flags=0x0, OS=0x40004, @@ -16,11 +16,11 @@ VSVersionInfo( [ StringStruct(u'CompanyName', u'Codex Lifeboat community project'), StringStruct(u'FileDescription', u'Codex Lifeboat'), - StringStruct(u'FileVersion', u'3.4.11'), + StringStruct(u'FileVersion', u'3.4.12'), StringStruct(u'InternalName', u'Codex-Lifeboat'), StringStruct(u'OriginalFilename', u'Codex-Lifeboat.exe'), StringStruct(u'ProductName', u'Codex Lifeboat'), - StringStruct(u'ProductVersion', u'3.4.11') + StringStruct(u'ProductVersion', u'3.4.12') ] ) ]),