From d7d3c35ffdf05f09fc2e9311fc6e40adfcc19679 Mon Sep 17 00:00:00 2001 From: dkwolf1 <102389032+dkwolf1@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:03:18 +0100 Subject: [PATCH] Harden dependency preflight and restore safety for v3.4.10 --- CHANGELOG.md | 16 + README.md | 18 +- build.ps1 | 4 +- docs/KNOWN-LIMITATIONS.md | 19 +- docs/RELEASE-CHECKLIST.md | 16 +- docs/nl/KNOWN-LIMITATIONS.md | 19 +- docs/nl/README.md | 18 +- docs/nl/RELEASE-CHECKLIST.md | 16 +- docs/nl/releases/v3.4.10.md | 66 +++ docs/releases/v3.4.10.md | 61 +++ pyproject.toml | 2 +- src/codex_transfer/__init__.py | 2 +- src/codex_transfer/backup.py | 440 +++++++++++++++----- src/codex_transfer/compatibility_matrix.py | 17 +- src/codex_transfer/gui.py | 201 ++++++++- src/codex_transfer/restore.py | 101 ++++- src/codex_transfer/restore_plan.py | 69 +++- src/codex_transfer/selftest.py | 459 ++++++++++++++------- src/codex_transfer/validate.py | 191 +++++++++ version_info.txt | 8 +- 20 files changed, 1431 insertions(+), 312 deletions(-) create mode 100644 docs/nl/releases/v3.4.10.md create mode 100644 docs/releases/v3.4.10.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f62b40..2c6e5c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +## 3.4.10 - 2026-11-02 + +- Replaced the single `*.dist-info` exception with a complete, read-only preflight + of every reachable `.chatgpt-projects/**/work/_deps` dependency tree. +- Show every inaccessible dependency path in one review window and require the + user to approve that exact set before any omission is allowed. A changed or new + read failure after review stops the backup instead of widening the exclusion. +- Keep conversations, project registrations, paths outside `work/_deps`, sharing + violations and non-permission errors mandatory and fail closed. +- Added hash-protected preflight and exclusion reports plus end-to-end coverage for + simultaneous inaccessible `PIL` and `pillow-*.dist-info` directories. +- Hardened large restores after the issue #8 field report: peak-space planning now + includes per-file allocation overhead plus a reserve, space is rechecked before + restore writes, and a full-disk failure while updating the error journal can no + longer prevent automatic rollback from being attempted. + ## 3.4.9 - 2026-11-02 - Allow only access-denied directory enumeration for versioned `*.dist-info` diff --git a/README.md b/README.md index 173c654..477a499 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,17 @@ **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.9](https://img.shields.io/badge/stable-v3.4.9-16a34a)](https://github.com/dkwolf1/Codex-Lifeboat/releases/tag/v3.4.9) +[![Stable: 3.4.10](https://img.shields.io/badge/stable-v3.4.10-16a34a)](https://github.com/dkwolf1/Codex-Lifeboat/releases/tag/v3.4.10) [![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.9 safely reports and skips only inaccessible, -> reconstructable Python distribution metadata below Codex project dependency -> roots. It passes all 90 source checks and 12 automated compatibility scenarios. +> **Stable release:** version 3.4.10 preflights complete Codex project dependency +> trees and requires explicit review of the exact inaccessible paths before they +> can be excluded. Large restores use conservative peak-space and runtime reserve +> checks, and rollback is still attempted if a full disk prevents an error-journal +> update. It passes all 95 source checks and 12 automated compatibility scenarios. > 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). @@ -20,7 +22,7 @@ https://mechscript.nl/en/codex-lifeboat-backup-restore-migrate-en/ ## Download for Windows -### [Download Codex Lifeboat 3.4.9 for Windows](https://github.com/dkwolf1/Codex-Lifeboat/releases/tag/v3.4.9) +### [Download Codex Lifeboat 3.4.10 for Windows](https://github.com/dkwolf1/Codex-Lifeboat/releases/tag/v3.4.10) On the release page, download only: @@ -54,7 +56,8 @@ It includes: - Consistent SQLite snapshots and SHA-256 integrity validation - Automatic Windows profile and project-path translation - A reviewed Project Location Mapper for external-drive, USB, and network roots -- A read-only comparison plan with conflicts, selected actions, and free-space checks +- A read-only comparison plan with conflicts, selected actions, and conservative + peak-space checks including file-allocation overhead and a safety reserve - Per-chat keep-backup, keep-computer, keep-both, skip, and cancel decisions - Destination-only projects retained by default, with explicit archive or recoverable-delete choices @@ -72,6 +75,9 @@ It includes: - An explicit destination picker on every backup, even when one USB drive is found - A Windows path-length preflight that takes the short staging directory, final backup name, selected payload, long-path setting, and filename limits into account +- A complete read-only preflight of `.chatgpt-projects/**/work/_deps`, with a + scrollable review and explicit approval of the exact inaccessible dependency + paths; new, changed, or ordinary-data failures remain fatal - Narrow copy-error tolerance for reconstructable Python bytecode cache only; skipped cache files remain visible as warnings while ordinary files stay mandatory - Clearly marked incomplete staging evidence that is never offered as a restore source diff --git a/build.ps1 b/build.ps1 index 74d40be..eec953b 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param( - [string]$Version = '3.4.9', + [string]$Version = '3.4.10', [string]$PythonPath, [switch]$SkipTests ) @@ -55,7 +55,7 @@ if (-not (Test-Path -LiteralPath $python -PathType Leaf)) { } } -& $python -c "import PyInstaller, zstandard; assert PyInstaller.__version__ == '6.22.3'; assert zstandard.__version__ == '0.23.0'" +& $python -c "import PyInstaller, zstandard; assert PyInstaller.__version__ == '6.22.3'; assert zstandard.__version__ == '0.25.0'" if ($LASTEXITCODE -ne 0) { & $python -m pip install --disable-pip-version-check -r (Join-Path $repoRoot 'requirements-build.txt') if ($LASTEXITCODE -ne 0) { throw 'Installing build dependencies failed.' } diff --git a/docs/KNOWN-LIMITATIONS.md b/docs/KNOWN-LIMITATIONS.md index ae146b9..b6939a1 100644 --- a/docs/KNOWN-LIMITATIONS.md +++ b/docs/KNOWN-LIMITATIONS.md @@ -1,4 +1,4 @@ -# Known limitations of release 3.4.4 +# Known limitations of release 3.4.10 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 @@ -24,10 +24,12 @@ 9. Phase 10.1 reports logical file sizes. Package-manager hardlinks or deduplicated files (for example a pnpm store plus `node_modules`) can use less physical space on the source disk, but a portable backup stores each visible file independently. -10. Selection is currently per complete project. Codex data is always included and - individual dependency/cache subfolders are not silently removed. Excluding a - project keeps its chats as projectless history, but its files are not recoverable - from that backup and the original working directory may not exist after restore. +10. Selection is per complete project. Codex data is always included. Inaccessible + paths below `.chatgpt-projects/**/work/_deps` are excluded only after a complete + read-only preflight displays the exact set and the user explicitly approves it. + Any path outside that dependency boundary, any changed set, and any unclassified + error remains fatal. Excluded dependency payload must be reconstructed by Codex + or the relevant package manager after restore. 11. Git conflict explanations require a readable Git worktree and the Git executable. When either side or the required history is unavailable, Lifeboat reports insufficient evidence and falls back to its complete file hashes and explicit @@ -38,6 +40,11 @@ normalization. Edited records, changed metadata, malformed JSONL, an empty rollout, destination-ahead history, and arbitrary divergence require an explicit user decision. Lifeboat does not perform a general-purpose chat merge. -13. Version 3.4.4 is stable after automated source/EXE/ZIP testing and a successful +13. Version 3.4.10 is stable after automated source/EXE/ZIP testing and a successful physical Windows 11 A-to-B-to-A hand-off. A physical Windows 10 run and removal of a real USB drive during an active write are not yet documented. +14. Restore is transactional with automatic rollback but is not yet a general + resumable file-copy engine. Very large restores can take substantial time. + Version 3.4.10 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. diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index 6452d84..dee62ff 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -2,18 +2,18 @@ [Nederlands](nl/RELEASE-CHECKLIST.md) -This checklist publishes Codex Lifeboat 3.4.9 as a **stable release**. The tag +This checklist publishes Codex Lifeboat 3.4.10 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.9 changes. +- Confirm that `git status --short` contains only intended 3.4.10 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.9` tag until the `main` CI run is green. +- Do not create the `v3.4.10` tag until the `main` CI run is green. ## Publish with PowerShell @@ -23,7 +23,7 @@ Run these commands from the repository directory: git add -A git status --short git diff --cached --check -git commit -m "Handle inaccessible dependency metadata directories" +git commit -m "Add reviewed dependency preflight exclusions" git push origin main ``` @@ -31,15 +31,15 @@ Wait until the `main` CI run is green at . Then run: ```powershell -git tag -a v3.4.9 -m "Codex Lifeboat 3.4.9 stable release" -git push origin v3.4.9 +git tag -a v3.4.10 -m "Codex Lifeboat 3.4.10 stable release" +git push origin v3.4.10 ``` 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,7 +64,7 @@ gh attestation verify .\Codex-Lifeboat-Windows-x64-Portable.zip --repo dkwolf1/C ## Stable-release evidence -The 90-check source/EXE/ZIP gate, 12/12 automated Windows scenarios, and a physical +The 95-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 both verify operations and opening and continuing work in Codex after restore. Windows 10 and USB-interruption coverage remain documented as pending. Public diff --git a/docs/nl/KNOWN-LIMITATIONS.md b/docs/nl/KNOWN-LIMITATIONS.md index 93fb2af..8b87a8e 100644 --- a/docs/nl/KNOWN-LIMITATIONS.md +++ b/docs/nl/KNOWN-LIMITATIONS.md @@ -1,4 +1,4 @@ -# Bekende grenzen van release 3.4.4 +# Bekende grenzen van release 3.4.10 1. OpenAI publiceert geen stabiel importcontract voor de lokale Codex-database. De app gebruikt daarom schema-inspectie en moet na toekomstige grote @@ -27,10 +27,12 @@ package manager (bijvoorbeeld een pnpm-store naast `node_modules`) kunnen op de bronschijf fysiek minder ruimte gebruiken, maar een overdraagbare back-up bewaart ieder zichtbaar bestand afzonderlijk. -10. Selectie werkt nu per compleet project. Codex-data gaat altijd mee en afzonderlijke - dependency- of cachemappen worden niet stil verwijderd. Een uitgesloten project - bewaart zijn chats als projectloze geschiedenis, maar de bestanden zijn niet uit - die back-up herstelbaar en de oorspronkelijke werkmap kan na herstel ontbreken. +10. Selectie werkt per compleet project en Codex-data gaat altijd mee. Ontoegankelijke + paden onder `.chatgpt-projects/**/work/_deps` worden alleen uitgesloten nadat een + volledige alleen-lezen preflight de exacte lijst toont en de gebruiker deze + expliciet goedkeurt. Paden buiten die dependencygrens, een gewijzigde lijst en + niet-geclassificeerde fouten blijven fataal. Uitgesloten dependencies moeten na + herstel opnieuw door Codex of de relevante package manager worden opgebouwd. 11. Git-conflictuitleg vereist leesbare Git-werkmappen en het Git-programma. Als een kant of benodigde geschiedenis ontbreekt, meldt Lifeboat onvoldoende bewijs en vallen de volledige bestandshashes en expliciete conflictkeuzes terug als basis. @@ -41,6 +43,11 @@ Gewijzigde records of metadata, ongeldige JSONL, een lege rollout, een langere doelchat en iedere andere afwijking vragen een expliciete gebruikerskeuze. Lifeboat voert geen algemene chatmerge uit. -13. Versie 3.4.4 is stabiel na bron-, EXE- en uitgepakte-ZIP-tests en een geslaagde +13. Versie 3.4.10 is stabiel na bron-, EXE- en uitgepakte-ZIP-tests en een geslaagde fysieke Windows 11-route van A naar B en terug. Een fysieke Windows 10-test en het lostrekken van een echte USB-stick tijdens schrijven zijn nog niet vastgelegd. +14. Herstel is transactioneel met automatische rollback, maar nog geen algemeen + hervatbare bestandskopieerengine. Zeer grote restores kunnen veel tijd kosten. + Versie 3.4.10 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. diff --git a/docs/nl/README.md b/docs/nl/README.md index fe60a63..d6e683b 100644 --- a/docs/nl/README.md +++ b/docs/nl/README.md @@ -4,17 +4,19 @@ [Wiki](https://github.com/dkwolf1/Codex-Lifeboat/wiki) · [English](../../README.md) · [Beveiliging](../../SECURITY.md) · [Documentatie](IMPLEMENTATION-ROADMAP.md) -> **Stabiele release:** versie 3.4.9 rapporteert en negeert uitsluitend -> ontoegankelijke, reconstrueerbare Python-distributiemetadata onder Codex- -> dependencyroots. Alle 90 broncontroles en 12 geautomatiseerde -> compatibiliteitsscenario's slagen. Voor de +> **Stabiele release:** versie 3.4.10 controleert vooraf volledige Codex- +> dependencybomen en vereist expliciete goedkeuring van de exacte ontoegankelijke +> paden voordat ze mogen worden uitgesloten. Grote restores gebruiken een +> conservatieve piekruimte- en runtime-reservecontrole; rollback wordt ook gestart +> als een volle schijf het foutjournaal niet kan bijwerken. Alle 95 broncontroles en +> 12 geautomatiseerde compatibiliteitsscenario's slagen. 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.9](https://github.com/dkwolf1/Codex-Lifeboat/releases/tag/v3.4.9) +### [Download Codex Lifeboat 3.4.10](https://github.com/dkwolf1/Codex-Lifeboat/releases/tag/v3.4.10) Download op de releasepagina uitsluitend dit bestand: @@ -52,7 +54,11 @@ paden veilig passen. Is die ondersteuning uitgeschakeld of onbekend en bereikt e verplicht verwacht pad de klassieke grens van 260 tekens, dan vraagt Lifeboat vóór het grote kopieerwerk om een kortere doelmap. Alleen een te lange reconstrueerbare Python-cache blokkeert de kernback-up niet: weigert Windows die kopie, dan wordt dit -vastgelegd en eindigt de back-up geldig met waarschuwingen. +vastgelegd en eindigt de back-up geldig met waarschuwingen. Codex-projectdependencies +onder `work/_deps` krijgen vooraf een volledige alleen-lezen controle. +Ontoegankelijke dependency-paden worden samen in één venster getoond en alleen na +expliciete goedkeuring exact uitgesloten; iedere nieuwe of gewone-datafout blijft +blokkerend. ## Herstellen op een nieuwe computer diff --git a/docs/nl/RELEASE-CHECKLIST.md b/docs/nl/RELEASE-CHECKLIST.md index 0d6684c..5e30e82 100644 --- a/docs/nl/RELEASE-CHECKLIST.md +++ b/docs/nl/RELEASE-CHECKLIST.md @@ -2,16 +2,16 @@ [English](../RELEASE-CHECKLIST.md) -Deze checklist publiceert Codex Lifeboat 3.4.9 als **stabiele release**. De +Deze checklist publiceert Codex Lifeboat 3.4.10 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.9-wijzigingen bevat. +- Controleer dat `git status --short` alleen bedoelde 3.4.10-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.9` pas nadat de CI-run van `main` groen is. +- Maak tag `v3.4.10` pas nadat de CI-run van `main` groen is. ## Publiceren met PowerShell @@ -21,7 +21,7 @@ Voer in de repositorymap uit: git add -A git status --short git diff --cached --check -git commit -m "Handle inaccessible dependency metadata directories" +git commit -m "Add reviewed dependency preflight exclusions" git push origin main ``` @@ -29,15 +29,15 @@ Wacht tot de `main`-CI groen is op . Voer dan uit: ```powershell -git tag -a v3.4.9 -m "Codex Lifeboat 3.4.9 stable release" -git push origin v3.4.9 +git tag -a v3.4.10 -m "Codex Lifeboat 3.4.10 stable release" +git push origin v3.4.10 ``` 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,7 +60,7 @@ Controleer na downloaden eventueel de GitHub-herkomstverklaring: gh attestation verify .\Codex-Lifeboat-Windows-x64-Portable.zip --repo dkwolf1/Codex-Lifeboat ``` -De 90 bron-/EXE-/ZIP-controles, 12/12 geautomatiseerde Windows-scenario's en een +De 95 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 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 diff --git a/docs/nl/releases/v3.4.10.md b/docs/nl/releases/v3.4.10.md new file mode 100644 index 0000000..5b664e7 --- /dev/null +++ b/docs/nl/releases/v3.4.10.md @@ -0,0 +1,66 @@ +# Codex Lifeboat 3.4.10 — gecontroleerde dependency-preflight + +[English](../../releases/v3.4.10.md) + +Codex Lifeboat 3.4.10 vervangt de smalle `*.dist-info`-uitzondering uit 3.4.9 +door een volledige alleen-lezen preflight van ieder bereikbaar Codex- +dependency-pad onder `.chatgpt-projects/**/work/_deps`. + +## Opgelost + +- Vindt alle bereikbare dependencybestanden en volledige mapsubtrees met + toegangsfout in één preflight, in plaats van per back-uppoging bij het volgende + geblokkeerde package te stoppen. +- Toont vóór de bestemmingskeuze de volledige relatieve-padenlijst in een + scrollbaar controlevenster. +- Vereist expliciete goedkeuring van exact die lijst. Annuleren sluit niets uit en + maakt geen back-up. +- Herhaalt de preflight direct vóór het kopiëren. Een nieuwe, verdwenen of + gewijzigde uitsluiting maakt de goedkeuring ongeldig en stopt de back-up. +- Staat uitsluitingen alleen toe binnen `.chatgpt-projects/**/work/_deps`. + Gesprekken, projectregistraties, gewone projectdata en ieder pad buiten die grens + blijven verplicht. +- Houdt sharing violations, fouten die geen toegangsfout zijn en onbekende + padtypen fataal. +- Schrijft hash-beveiligde rapporten `reports/dependency-preflight.json` en, bij + goedgekeurde uitsluitingen, `reports/dependency-exclusions.json`. + +## Herstelveiligheid uit issue #8 + +- De planning van vrije piekruimte telt nu logische payloadbytes, conservatieve + bestandssysteemallocatie per bestand en een veiligheidsreserve mee. Dit is vooral + relevant bij packages met miljoenen kleine bestanden. +- Het gecontroleerde plan wordt direct vóór de veiligheidskopie opnieuw berekend; + grote project- en sessieschrijfacties controleren ook tijdens herstel de reserve. +- Als een volle schijf het schrijven van het foutjournaal verhindert, wordt + automatische rollback alsnog geprobeerd en blijft de oorspronkelijke fout de + hoofdoorzaak. + +Dit verhelpt de bevestigde ENOSPC-veiligheidsfout uit issue #8. De +incidentspecifieke rescue-tool, Defender-waarnemingen, nested-projectpatch en +prestatiedoelen van de melder worden hiermee niet als overgenomen beschouwd. + +Als Windows mapenumeratie weigert, rapporteert Lifeboat de volledige mapsubtree als +uitsluitingsbereik. Het programma doet nooit alsof het bestandsnamen kent die het +niet mocht inventariseren. Uitgesloten dependencies moeten na herstel opnieuw door +Codex of de relevante package manager worden opgebouwd. + +## Controle + +- Gelijktijdige WinError 5-simulaties toegevoegd voor `PIL` en + `pillow-12.3.0.dist-info` onder dezelfde projectdependencyroot. +- Negatieve dekking toegevoegd voor ontbrekende goedkeuring, gewijzigde + preflightresultaten, gewone projectdata, sharing violations en fouten die geen + toegangsfout zijn. +- End-to-end back-up, onafhankelijke validatie, herstel en manipulatieafwijzing + toegevoegd voor de goedgekeurde uitsluitingsrapporten. +- Alle 95 broncontroles en alle 12 geautomatiseerde Windows- + compatibiliteitsscenario's slagen. + +## Download + +Download `Codex-Lifeboat-Windows-x64-Portable.zip`, pak het bestand volledig uit en +start `Codex-Lifeboat.exe`. + +De EXE blijft onondertekend. Houd de oude computer en een onafhankelijke +herstelkopie ongewijzigd totdat de doelcomputer alle controles na herstel doorstaat. diff --git a/docs/releases/v3.4.10.md b/docs/releases/v3.4.10.md new file mode 100644 index 0000000..252f657 --- /dev/null +++ b/docs/releases/v3.4.10.md @@ -0,0 +1,61 @@ +# Codex Lifeboat 3.4.10 — reviewed dependency preflight + +[Nederlands](../nl/releases/v3.4.10.md) + +Codex Lifeboat 3.4.10 replaces the narrow 3.4.9 `*.dist-info` exception with a +complete, read-only preflight of every reachable Codex project dependency path +below `.chatgpt-projects/**/work/_deps`. + +## Fixed + +- Discovers all reachable access-denied dependency files and directory subtrees in + one preflight instead of stopping at one blocked package per backup attempt. +- Shows the complete relative-path list in a scrollable review window before the + destination is selected. +- Requires explicit approval of that exact set. Canceling the review excludes + nothing and creates no backup. +- Repeats the preflight immediately before copying. A new, removed or changed + exclusion invalidates the approval and stops the backup. +- Permits exclusions only within `.chatgpt-projects/**/work/_deps`. Conversations, + project registrations, ordinary project data and every path outside that boundary + remain mandatory. +- Keeps sharing violations, non-permission errors and unknown path types fatal. +- Writes hash-protected `reports/dependency-preflight.json` and, when approved paths + are excluded, `reports/dependency-exclusions.json`. + +## Restore safety from issue #8 + +- Peak free-space planning now includes logical payload bytes, conservative + per-file allocation overhead, and a safety reserve. This matters for packages + containing millions of small files. +- The reviewed plan is refreshed immediately before the safety copy is created, + and large project/session writes recheck the runtime reserve. +- A full-disk error while writing the failure journal no longer prevents automatic + rollback from being attempted; the original failure remains the raised cause. + +These changes address the confirmed ENOSPC safety failure reported in issue #8. +They do not claim that the reporter's incident-specific rescue utility, Defender +observations, nested-project patch, or performance targets have been incorporated. + +If Windows denies enumeration of a directory, Lifeboat reports that entire +directory subtree as the exclusion scope. It never claims to know filenames it was +not allowed to enumerate. Excluded dependency payload must be reconstructed by Codex +or the relevant package manager after restore. + +## Validation + +- Added simultaneous simulated WinError 5 failures for `PIL` and + `pillow-12.3.0.dist-info` under the same project dependency root. +- Added negative coverage for missing approval, changed preflight results, ordinary + project data, sharing violations and non-access-denied errors. +- Added end-to-end backup, independent validation, restore and tamper rejection for + the approved exclusion reports. +- All 95 source checks and all 12 automated Windows compatibility scenarios pass. + +## Download + +Download `Codex-Lifeboat-Windows-x64-Portable.zip`, extract it completely, and run +`Codex-Lifeboat.exe`. + +The executable remains unsigned. Keep the old computer and an independent recovery +copy unchanged until the destination has passed post-restore checks. diff --git a/pyproject.toml b/pyproject.toml index 8c30788..572e91a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-lifeboat" -version = "3.4.9" +version = "3.4.10" 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 7111693..3a426aa 100644 --- a/src/codex_transfer/__init__.py +++ b/src/codex_transfer/__init__.py @@ -1 +1 @@ -__version__ = "3.4.9" +__version__ = "3.4.10" diff --git a/src/codex_transfer/backup.py b/src/codex_transfer/backup.py index 603168a..0e376e5 100644 --- a/src/codex_transfer/backup.py +++ b/src/codex_transfer/backup.py @@ -14,6 +14,7 @@ import re import shutil import sqlite3 +import stat import subprocess import sys import tempfile @@ -29,7 +30,7 @@ FORMAT_ID = "codex-portable-backup" FORMAT_VERSION = "2.4" -GENERATOR_VERSION = "3.4.9" +GENERATOR_VERSION = "3.4.10" PROGRESS_CALLBACK = None STATUS_CALLBACK = None _RUNTIME_EXECUTABLE: Path | None = None @@ -631,25 +632,21 @@ def record_skipped_reconstructable_cache( return True -def is_reconstructable_dependency_metadata_directory( +def dependency_payload_relative_path( path: Path, traversal_root: Path -) -> bool: - """Match only installed-distribution metadata directly below a _deps root.""" +) -> PurePosixPath | None: + """Return the relative path only for a Codex project work/_deps payload.""" + if traversal_root.name.casefold() != ".chatgpt-projects": + return None try: relative = path.relative_to(traversal_root) except ValueError: - return False - name = path.name.casefold() - stem = name[: -len(".dist-info")] if name.endswith(".dist-info") else "" - distribution, separator, version = stem.rpartition("-") - return bool( - len(relative.parts) >= 2 - and relative.parts[-2].casefold() == "_deps" - and distribution - and separator - and version - and version[0].isdigit() - ) + return None + folded = tuple(part.casefold() for part in relative.parts) + for index in range(1, len(folded) - 1): + if folded[index : index + 2] == ("work", "_deps"): + return PurePosixPath(*relative.parts) + return None def is_access_denied_error(error: OSError) -> bool: @@ -662,36 +659,227 @@ def is_access_denied_error(error: OSError) -> bool: ) -def record_skipped_reconstructable_directory( +def dependency_preflight_issue( source: Path, - destination: Path, traversal_root: Path, error: OSError, - warnings: list[str], - records: list[dict[str, Any]] | None, -) -> bool: - """Record one narrowly classified unreadable dependency-metadata directory.""" - if records is None or not ( - is_access_denied_error(error) - and is_reconstructable_dependency_metadata_directory(source, traversal_root) - ): - return False - record = { + path_type: str, +) -> dict[str, Any]: + relative = source.relative_to(traversal_root) + return { "sourcePath": str(source), - "destinationPath": str(destination), + "sourceRelativePath": PurePosixPath(*relative.parts).as_posix(), + "backupRelativePath": PurePosixPath( + "codex", "portable-profile", ".chatgpt-projects", *relative.parts + ).as_posix(), "errorType": type(error).__name__, "error": str(error), - "kind": "python-distribution-metadata", - "pathType": "directory", - "contentsEnumerated": False, - "fileCount": None, + "winError": getattr(error, "winerror", None), + "kind": "python-dependency-payload", + "pathType": path_type, + "contentsEnumerated": False if path_type == "directory" else None, + "fileCount": None if path_type == "directory" else 1, + "scope": ( + "entire-directory-subtree" if path_type == "directory" else "single-file" + ), } + + +def dependency_preflight(source_codex: Path) -> dict[str, Any]: + """Inventory every reachable read failure below project work/_deps trees.""" + traversal_root = source_codex / ".chatgpt-projects" + exclusions: list[dict[str, Any]] = [] + blockers: list[dict[str, Any]] = [] + scanned_directories = 0 + scanned_files = 0 + try: + root_stat = os.stat(traversal_root) + except FileNotFoundError: + return { + "preflightVersion": 1, + "rootPath": str(traversal_root), + "scannedDirectories": 0, + "scannedFiles": 0, + "exclusionCount": 0, + "blockingCount": 0, + "exclusions": [], + "blockingIssues": [], + } + except OSError as exc: + issue = dependency_preflight_issue( + traversal_root, traversal_root, exc, "directory" + ) + issue["kind"] = "mandatory-data" + return { + "preflightVersion": 1, + "rootPath": str(traversal_root), + "scannedDirectories": 0, + "scannedFiles": 0, + "exclusionCount": 0, + "blockingCount": 1, + "exclusions": [], + "blockingIssues": [issue], + } + if not stat.S_ISDIR(root_stat.st_mode): + return { + "preflightVersion": 1, + "rootPath": str(traversal_root), + "scannedDirectories": 0, + "scannedFiles": 0, + "exclusionCount": 0, + "blockingCount": 0, + "exclusions": [], + "blockingIssues": [], + } + + stack = [traversal_root] + while stack: + current = stack.pop() + try: + entries = list(os.scandir(current)) + scanned_directories += 1 + except OSError as exc: + issue = dependency_preflight_issue(current, traversal_root, exc, "directory") + if is_access_denied_error(exc) and dependency_payload_relative_path( + current, traversal_root + ): + exclusions.append(issue) + else: + issue["kind"] = "mandatory-data" + blockers.append(issue) + continue + + for entry in entries: + path = Path(entry.path) + path_type = "unknown" + try: + if entry.is_symlink(): + continue + if entry.is_dir(follow_symlinks=False): + path_type = "directory" + stack.append(path) + continue + if not entry.is_file(follow_symlinks=False): + continue + path_type = "file" + scanned_files += 1 + if dependency_payload_relative_path(path, traversal_root): + try: + with path.open("rb") as stream: + stream.read(1) + except OSError as exc: + issue = dependency_preflight_issue( + path, traversal_root, exc, "file" + ) + if is_access_denied_error(exc): + exclusions.append(issue) + else: + issue["kind"] = "mandatory-data" + blockers.append(issue) + except OSError as exc: + issue = dependency_preflight_issue( + path, traversal_root, exc, path_type + ) + if ( + path_type in {"directory", "file"} + and is_access_denied_error(exc) + and dependency_payload_relative_path(path, traversal_root) + ): + exclusions.append(issue) + else: + issue["kind"] = "mandatory-data" + blockers.append(issue) + + def sort_key(item: dict[str, Any]) -> str: + return str(item.get("sourceRelativePath", "")).casefold() + + exclusions.sort(key=sort_key) + blockers.sort(key=sort_key) + return { + "preflightVersion": 1, + "rootPath": str(traversal_root), + "scannedDirectories": scanned_directories, + "scannedFiles": scanned_files, + "exclusionCount": len(exclusions), + "blockingCount": len(blockers), + "exclusions": exclusions, + "blockingIssues": blockers, + } + + +def dependency_exclusion_key(item: dict[str, Any]) -> tuple[str, str]: + return ( + normalized_source_key(clean_windows_path(str(item.get("sourcePath", "")))), + str(item.get("pathType", "")).casefold(), + ) + + +def approved_dependency_exclusion_map( + preflight: dict[str, Any], approved: Any +) -> dict[str, dict[str, Any]]: + discovered = { + dependency_exclusion_key(item): item + for item in preflight.get("exclusions", []) + if isinstance(item, dict) + } + approved_items = approved if isinstance(approved, list) else [] + approved_keys = { + dependency_exclusion_key(item) + for item in approved_items + if isinstance(item, dict) + } + if set(discovered) != approved_keys: + missing = len(set(discovered) - approved_keys) + stale = len(approved_keys - set(discovered)) + raise BackupError( + "Dependency exclusions were not explicitly approved or changed after " + f"review (unapproved={missing}, no-longer-detected={stale}). Run the " + "backup preview again and review the complete dependency preflight." + ) + return {key[0]: value for key, value in discovered.items()} + + +def record_approved_dependency_exclusion( + item: dict[str, Any], + destination: Path, + warnings: list[str], + records: list[dict[str, Any]], +) -> None: + record = {**item, "destinationPath": str(destination)} records.append(record) warnings.append( - "Skipped inaccessible reconstructable Python distribution metadata directory; " - f"contents could not be enumerated: {source}: {type(error).__name__}: {error}" + "Skipped explicitly approved inaccessible Python dependency payload: " + f"{item.get('sourcePath')}: {item.get('errorType')}: {item.get('error')}" + ) + + +def confirm_approved_dependency_exclusion( + source: Path, item: dict[str, Any] +) -> dict[str, Any]: + """Re-prove an approved path is still inaccessible immediately before omission.""" + try: + if item.get("pathType") == "directory": + list(os.scandir(source)) + elif item.get("pathType") == "file": + with source.open("rb") as stream: + stream.read(1) + else: + raise BackupError(f"Unknown approved dependency path type: {source}") + except OSError as exc: + if not is_access_denied_error(exc): + raise BackupError( + f"Approved dependency path changed error type: {source}: {exc}" + ) from exc + return { + **item, + "errorType": type(exc).__name__, + "error": str(exc), + "winError": getattr(exc, "winerror", None), + } + raise BackupError( + "An approved dependency exclusion became readable before copying; " + f"review the preflight again instead of omitting it: {source}" ) - return True def copy_tree( @@ -700,8 +888,9 @@ def copy_tree( exclude_fragments: Iterable[str], warnings: list[str], skipped_reconstructable_cache: list[dict[str, Any]] | None = None, - skipped_reconstructable_directories: list[dict[str, Any]] | None = None, - allow_dependency_metadata_skip: bool = False, + 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, ) -> tuple[int, int]: count = 0 byte_count = 0 @@ -709,26 +898,26 @@ def copy_tree( stack: list[tuple[Path, Path]] = [(source, destination)] while stack: current_source, current_destination = stack.pop() + approved = (dependency_exclusions or {}).get( + normalized_source_key(current_source) + ) + if approved and approved.get("pathType") == "directory": + if applied_dependency_exclusions is None: + raise BackupError("Approved dependency exclusion has no audit record.") + confirmed = confirm_approved_dependency_exclusion( + current_source, approved + ) + record_approved_dependency_exclusion( + confirmed, + current_destination, + warnings, + applied_dependency_exclusions, + ) + continue current_destination.mkdir(parents=True, exist_ok=True) try: entries = list(os.scandir(current_source)) except OSError as exc: - if allow_dependency_metadata_skip and record_skipped_reconstructable_directory( - current_source, - current_destination, - source, - exc, - warnings, - skipped_reconstructable_directories, - ): - try: - current_destination.rmdir() - except OSError as cleanup_error: - raise BackupError( - "Cannot remove the empty destination for skipped directory " - f"{current_destination}: {cleanup_error}" - ) from cleanup_error - continue raise BackupError(f"Cannot read directory: {current_source}: {exc}") from exc for entry in entries: entry_source = Path(entry.path) @@ -738,6 +927,22 @@ def copy_tree( if any(fragment and fragment in relative_text for fragment in excluded): warnings.append(f"Excluded by configuration: {entry_source}") continue + approved = (dependency_exclusions or {}).get( + normalized_source_key(entry_source) + ) + if approved and approved.get("pathType") == "file": + if applied_dependency_exclusions is None: + raise BackupError("Approved dependency exclusion has no audit record.") + confirmed = confirm_approved_dependency_exclusion( + entry_source, approved + ) + record_approved_dependency_exclusion( + confirmed, + entry_destination, + warnings, + applied_dependency_exclusions, + ) + continue try: if entry.is_symlink(): warnings.append(f"Symbolic link or reparse point skipped: {entry_source}") @@ -746,8 +951,10 @@ def copy_tree( stack.append((entry_source, entry_destination)) elif entry.is_file(follow_symlinks=False): confirmed_file = True - copy_file(entry_source, entry_destination) size = entry.stat(follow_symlinks=False).st_size + if before_copy: + before_copy(entry_source, entry_destination, size) + copy_file(entry_source, entry_destination) count += 1 byte_count += size if count % 1000 == 0: @@ -1029,6 +1236,7 @@ def build_backup_preview( profile_files, profile_bytes, profile_details = _portable_profile_stats( codex_home, codex_path_budget ) + dependency_scan = dependency_preflight(codex_home) codex_files += profile_files codex_bytes += profile_bytes codex_details.extend(profile_details) @@ -1074,6 +1282,7 @@ def build_backup_preview( }, "projects": projects, "portabilityAudit": portability, + "dependencyPreflight": dependency_scan, "totals": { "fileCount": codex_files + total_project_files, "totalBytes": codex_bytes + total_project_bytes, @@ -1713,7 +1922,8 @@ def copy_portable_codex_profile( package_root: Path, warnings: list[str], skipped_reconstructable_cache: list[dict[str, Any]] | None = None, - skipped_reconstructable_directories: list[dict[str, Any]] | None = None, + dependency_exclusions: dict[str, dict[str, Any]] | None = None, + applied_dependency_exclusions: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: """Copy all user data not classified as identity/runtime/live database state.""" records: list[dict[str, Any]] = [] @@ -1745,8 +1955,8 @@ def copy_portable_codex_profile( [], warnings, skipped_reconstructable_cache, - skipped_reconstructable_directories, - allow_dependency_metadata_skip=(lowered == ".chatgpt-projects"), + dependency_exclusions if lowered == ".chatgpt-projects" else None, + applied_dependency_exclusions, ) elif entry.is_file(): destination = package_root / relative @@ -2695,7 +2905,9 @@ def build_backup(args: argparse.Namespace) -> Path: error_path = building / "reports" / "backup-error.txt" warnings: list[str] = [] skipped_reconstructable_cache: list[dict[str, Any]] = [] - skipped_reconstructable_directories: list[dict[str, Any]] = [] + applied_dependency_exclusions: list[dict[str, Any]] = [] + dependency_exclusion_lookup: dict[str, dict[str, Any]] = {} + dependency_preflight_report: dict[str, Any] = {} lineage_manifest: dict[str, Any] | None = None current_phase = "snapshot" @@ -2809,6 +3021,30 @@ def build_backup(args: argparse.Namespace) -> Path: ) excludes = [str(item) for item in config.get("excludeDirectoryNames", [])] + current_phase = "dependency-preflight" + log("Preflight: scanning every reachable Codex project dependency path...") + dependency_preflight_report = dependency_preflight(source_codex) + dependency_preflight_path = building / "reports" / "dependency-preflight.json" + write_json(dependency_preflight_path, dependency_preflight_report) + blocking_dependency_issues = dependency_preflight_report.get( + "blockingIssues", [] + ) + if blocking_dependency_issues: + first = blocking_dependency_issues[0] + raise BackupError( + "Dependency preflight found " + f"{len(blocking_dependency_issues)} unreadable mandatory path(s). " + f"First path: {first.get('sourcePath')}. Full details: " + f"{dependency_preflight_path}" + ) + try: + dependency_exclusion_lookup = approved_dependency_exclusion_map( + dependency_preflight_report, + config.get("approvedDependencyExclusions", []), + ) + except BackupError as exc: + raise BackupError(f"{exc} Full details: {dependency_preflight_path}") from exc + report_status(0, 0, "Step 2/6 — Calculating required backup space...") current_phase = "capacity-and-path-preflight" projected_paths = new_path_budget() @@ -2997,8 +3233,18 @@ def build_backup(args: argparse.Namespace) -> Path: building, warnings, skipped_reconstructable_cache, - skipped_reconstructable_directories, + dependency_exclusion_lookup, + applied_dependency_exclusions, ) + applied_dependency_keys = { + dependency_exclusion_key(item)[0] + for item in applied_dependency_exclusions + } + if applied_dependency_keys != set(dependency_exclusion_lookup): + raise BackupError( + "The approved dependency exclusions changed during copying; " + "the backup stopped without accepting a partial exclusion set." + ) if index_source.is_file(): copy_file(index_source, building / "codex" / "session_index.jsonl") @@ -3063,36 +3309,19 @@ def build_backup(args: argparse.Namespace) -> Path: }, ) - skipped_directory_report_path = ( - building / "reports" / "skipped-reconstructable-directories.json" + dependency_exclusion_report_path = ( + building / "reports" / "dependency-exclusions.json" ) - if skipped_reconstructable_directories: - skipped_directory_items: list[dict[str, Any]] = [] - for item in skipped_reconstructable_directories: - destination = Path(str(item.get("destinationPath", ""))) - try: - backup_relative = portable_relative(destination, building) - except ValueError: - backup_relative = None - skipped_directory_items.append( - { - "sourcePath": item.get("sourcePath"), - "backupRelativePath": backup_relative, - "errorType": item.get("errorType"), - "error": item.get("error"), - "kind": item.get("kind"), - "pathType": item.get("pathType"), - "contentsEnumerated": item.get("contentsEnumerated"), - "fileCount": item.get("fileCount"), - } - ) + if applied_dependency_exclusions: write_json( - skipped_directory_report_path, + dependency_exclusion_report_path, { "reportVersion": 1, "createdAtUtc": utc_now(), - "count": len(skipped_directory_items), - "items": skipped_directory_items, + "approvalRequired": True, + "preflightRelativePath": "reports/dependency-preflight.json", + "count": len(applied_dependency_exclusions), + "items": applied_dependency_exclusions, }, ) @@ -3268,11 +3497,20 @@ def build_backup(args: argparse.Namespace) -> Path: else None ), }, - "skippedReconstructableDirectories": { - "count": len(skipped_reconstructable_directories), + "dependencyPreflight": { + "reportRelativePath": "reports/dependency-preflight.json", + "scannedDirectories": dependency_preflight_report.get( + "scannedDirectories", 0 + ), + "scannedFiles": dependency_preflight_report.get("scannedFiles", 0), + "blockingCount": 0, + }, + "dependencyExclusions": { + "count": len(applied_dependency_exclusions), + "explicitlyApproved": True, "reportRelativePath": ( - "reports/skipped-reconstructable-directories.json" - if skipped_reconstructable_directories + "reports/dependency-exclusions.json" + if applied_dependency_exclusions else None ), }, @@ -3401,13 +3639,25 @@ def finalize_lineage(hash_rows: list[dict[str, Any]]) -> Path: ), "count": len(skipped_reconstructable_cache), }, - "skippedReconstructableDirectories": { + "dependencyPreflight": { + "reportRelativePath": "reports/dependency-preflight.json", + "preflightVersion": dependency_preflight_report.get( + "preflightVersion", 1 + ), + "scannedDirectories": dependency_preflight_report.get( + "scannedDirectories", 0 + ), + "scannedFiles": dependency_preflight_report.get("scannedFiles", 0), + "blockingCount": 0, + }, + "dependencyExclusions": { "reportRelativePath": ( - "reports/skipped-reconstructable-directories.json" - if skipped_reconstructable_directories + "reports/dependency-exclusions.json" + if applied_dependency_exclusions else None ), - "count": len(skipped_reconstructable_directories), + "count": len(applied_dependency_exclusions), + "explicitlyApproved": True, }, "lineage": { "manifestRelativePath": "manifest/lineage.json", @@ -3435,9 +3685,7 @@ def finalize_lineage(hash_rows: list[dict[str, Any]]) -> Path: "skippedReconstructablePythonCache": len( skipped_reconstructable_cache ), - "skippedReconstructableDirectories": len( - skipped_reconstructable_directories - ), + "dependencyExclusions": len(applied_dependency_exclusions), "hashedFiles": hashed_count, }, "selection": { diff --git a/src/codex_transfer/compatibility_matrix.py b/src/codex_transfer/compatibility_matrix.py index f0e9e03..ac6fc1a 100644 --- a/src/codex_transfer/compatibility_matrix.py +++ b/src/codex_transfer/compatibility_matrix.py @@ -28,12 +28,21 @@ ( "low-disk-space", "Low destination disk space is rejected before copying", - ("lowDiskSpaceRejected",), + ( + "lowDiskSpaceRejected", + "restorePeakSpaceIncludesAllocationOverhead", + "restoreRuntimeSpaceReserveEnforced", + ), ), ( "interruption-and-rollback", "Interrupted project and conversation restore rolls back", - ("rollbackAfterProjectQuarantine", "rollbackAfterProjectActivation", "conversationRollbackPreserved"), + ( + "rollbackAfterProjectQuarantine", + "rollbackAfterProjectActivation", + "conversationRollbackPreserved", + "rollbackSurvivesFailureJournalENOSPC", + ), ), ( "corrupt-backup", @@ -52,7 +61,9 @@ "completePhase3Inventory", "conversationMirrorExact", "paginatedHistoryProjectionOffsetsReset", - "inaccessibleDependencyMetadataReported", + "dependencyPreflightFindsFullScope", + "dependencyExclusionsRequireExplicitApproval", + "dependencyExclusionReportRoundTrips", "otherUnreadableDirectoriesFailClosed", ), ), diff --git a/src/codex_transfer/gui.py b/src/codex_transfer/gui.py index 75adc62..26160e9 100644 --- a/src/codex_transfer/gui.py +++ b/src/codex_transfer/gui.py @@ -162,6 +162,19 @@ def ask_backup_destination(parent, title: str, initial_directory: str) -> str: "warning_missing_projects": "{count} geregistreerde projectlocaties bestaan niet meer", "warning_python_cache": "{count} reconstrueerbare Python-cachebestanden konden niet worden gekopieerd en zijn veilig overgeslagen", "warning_dependency_metadata": "{count} ontoegankelijke, reconstrueerbare Python-dependencymappen zijn overgeslagen en op mapniveau gerapporteerd", + "warning_dependency_payload": "{count} vooraf gecontroleerde en expliciet goedgekeurde dependency-paden zijn overgeslagen", + "dependency_preflight_title": "Ontoegankelijke dependencies controleren", + "dependency_preflight_review": "De volledige dependencycontrole vond {count} ontoegankelijke paden onder .chatgpt-projects/**/work/_deps. Alleen de hieronder genoemde paden kunnen worden uitgesloten. Chats en gewone projectbestanden blijven verplicht. Controleer de volledige lijst en keur deze expliciet goed.", + "dependency_preflight_blocked": "De controle vond {count} ontoegankelijke verplichte paden buiten het veilige dependencybeleid. De back-up kan niet doorgaan. Er wordt niets uitgesloten.", + "dependency_preflight_action": "Actie", + "dependency_preflight_path": "Relatief bronpad", + "dependency_preflight_type": "Type", + "dependency_preflight_error": "Windows-fout", + "dependency_preflight_exclude": "Uitsluiten na goedkeuring", + "dependency_preflight_stop": "Blokkeert back-up", + "dependency_preflight_approve": "Deze exacte uitsluitingen goedkeuren", + "dependency_preflight_copy": "Lijst kopiëren", + "dependency_preflight_copied": "De volledige preflightlijst staat op het klembord.", "warning_other": "{count} overige aandachtspunten staan in het volledige rapport", "full_report": "Volledig rapport: {path}", "result_location": "Back-uplocatie: {path}", @@ -291,7 +304,7 @@ def ask_backup_destination(parent, title: str, initial_directory: str) -> str: "plan_target": "Doel", "plan_action": "Actie", "plan_size": "Omvang", - "plan_disk": "Benodigde vrije ruimte: {required} · Beschikbaar: {free}", + "plan_disk": "Benodigde piekruimte incl. reserve: {required} · Beschikbaar: {free}", "plan_blockers": "Blokkeringen:\n{details}", "plan_decision_help": "Selecteer een chat of project en kies wat u wilt bewaren.", "plan_item_details": "Geselecteerd onderdeel:\n{reason}\n\nGit-uitleg: {git}", @@ -431,6 +444,19 @@ def ask_backup_destination(parent, title: str, initial_directory: str) -> str: "warning_missing_projects": "{count} registered project locations no longer exist", "warning_python_cache": "{count} reconstructable Python cache files could not be copied and were safely skipped", "warning_dependency_metadata": "{count} inaccessible reconstructable Python dependency metadata directories were skipped with directory-level reports", + "warning_dependency_payload": "{count} preflighted and explicitly approved dependency paths were skipped", + "dependency_preflight_title": "Review inaccessible dependencies", + "dependency_preflight_review": "The complete dependency preflight found {count} inaccessible paths below .chatgpt-projects/**/work/_deps. Only the paths listed below can be excluded. Chats and ordinary project files remain mandatory. Review the complete list and approve it explicitly.", + "dependency_preflight_blocked": "The preflight found {count} inaccessible mandatory paths outside the safe dependency policy. The backup cannot continue. Nothing will be excluded.", + "dependency_preflight_action": "Action", + "dependency_preflight_path": "Relative source path", + "dependency_preflight_type": "Type", + "dependency_preflight_error": "Windows error", + "dependency_preflight_exclude": "Exclude after approval", + "dependency_preflight_stop": "Blocks backup", + "dependency_preflight_approve": "Approve these exact exclusions", + "dependency_preflight_copy": "Copy list", + "dependency_preflight_copied": "The complete preflight list is on the clipboard.", "warning_other": "{count} other notices are recorded in the full report", "full_report": "Full report: {path}", "result_location": "Backup location: {path}", @@ -560,7 +586,7 @@ def ask_backup_destination(parent, title: str, initial_directory: str) -> str: "plan_target": "Target", "plan_action": "Action", "plan_size": "Size", - "plan_disk": "Required free space: {required} · Available: {free}", + "plan_disk": "Required peak space incl. reserve: {required} · Available: {free}", "plan_blockers": "Blocking issues:\n{details}", "plan_decision_help": "Select a conversation or project and choose what to keep.", "plan_item_details": "Selected item:\n{reason}\n\nGit explanation: {git}", @@ -671,13 +697,15 @@ def _backup_result_model( skipped_dependency_metadata = int( counts.get("skippedReconstructableDirectories", 0) ) + approved_dependency_payload = int(counts.get("dependencyExclusions", 0)) categorized = min( len(warnings), missing_attachments + link_warnings + missing_projects + skipped_python_cache - + skipped_dependency_metadata, + + skipped_dependency_metadata + + approved_dependency_payload, ) valid = bool( validation.get("valid") if validation is not None else package.get("backupComplete") @@ -693,6 +721,7 @@ def _backup_result_model( "missingProjects": missing_projects, "pythonCache": skipped_python_cache, "dependencyMetadata": skipped_dependency_metadata, + "dependencyPayload": approved_dependency_payload, "other": max(len(warnings) - categorized, 0), }, "metrics": { @@ -817,6 +846,7 @@ def __init__(self, parent: "TransferApp", model: dict, verified: bool): ("missingProjects", "warning_missing_projects"), ("pythonCache", "warning_python_cache"), ("dependencyMetadata", "warning_dependency_metadata"), + ("dependencyPayload", "warning_dependency_payload"), ("other", "warning_other"), ): count = int(model["warnings"].get(key, 0)) @@ -1180,7 +1210,14 @@ def _refresh_summary(self) -> None: f"{disk.get('volume')}: " + self.parent_app.t( "plan_disk", - required=_format_bytes(int(disk.get("requiredBytes", 0))), + required=_format_bytes( + int( + disk.get( + "peakRequiredBytes", + disk.get("requiredBytes", 0), + ) + ) + ), free=_format_bytes(int(disk.get("freeBytes", 0))), ) ) @@ -1233,6 +1270,143 @@ def _approve(self) -> None: self.destroy() +class DependencyPreflightDialog(tk.Toplevel): + def __init__(self, parent: "TransferApp", preflight: dict) -> None: + super().__init__(parent) + self.parent_app = parent + self.preflight = preflight + self.approved = False + self.exclusions = list(preflight.get("exclusions", [])) + self.blockers = list(preflight.get("blockingIssues", [])) + self.title(parent.t("dependency_preflight_title")) + self.geometry("1180x680") + self.minsize(900, 540) + self.configure(bg=parent.BG) + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + + blocked = bool(self.blockers) + intro = parent.t( + "dependency_preflight_blocked" if blocked else "dependency_preflight_review", + count=len(self.blockers) if blocked else len(self.exclusions), + ) + tk.Label( + self, + text=intro, + bg="#FDECEC" if blocked else "#FFF5E6", + fg="#B42318" if blocked else "#8A5A00", + font=("Segoe UI", 10, "bold"), + wraplength=1100, + justify="left", + anchor="w", + padx=20, + pady=16, + ).grid(row=0, column=0, sticky="ew", padx=20, pady=(20, 12)) + + frame = ttk.Frame(self, style="App.TFrame") + frame.grid(row=1, column=0, sticky="nsew", padx=20) + frame.columnconfigure(0, weight=1) + frame.rowconfigure(0, weight=1) + columns = ("action", "path", "type", "error") + tree = ttk.Treeview(frame, columns=columns, show="headings", height=20) + tree.heading("action", text=parent.t("dependency_preflight_action")) + tree.heading("path", text=parent.t("dependency_preflight_path")) + tree.heading("type", text=parent.t("dependency_preflight_type")) + tree.heading("error", text=parent.t("dependency_preflight_error")) + tree.column("action", width=190, minwidth=160, stretch=False) + tree.column("path", width=560, minwidth=320) + tree.column("type", width=100, minwidth=80, stretch=False) + tree.column("error", width=260, minwidth=160) + for index, item in enumerate(self.exclusions): + tree.insert( + "", + "end", + iid=f"exclude-{index}", + values=( + parent.t("dependency_preflight_exclude"), + item.get("sourceRelativePath") or item.get("sourcePath"), + item.get("pathType"), + item.get("error"), + ), + tags=("exclude",), + ) + for index, item in enumerate(self.blockers): + tree.insert( + "", + "end", + iid=f"block-{index}", + values=( + parent.t("dependency_preflight_stop"), + item.get("sourceRelativePath") or item.get("sourcePath"), + item.get("pathType"), + item.get("error"), + ), + tags=("blocked",), + ) + tree.tag_configure("exclude", background="#FFF9ED", foreground="#694100") + tree.tag_configure("blocked", background="#FDECEC", foreground="#B42318") + tree.grid(row=0, column=0, sticky="nsew") + vertical = ttk.Scrollbar(frame, orient="vertical", command=tree.yview) + vertical.grid(row=0, column=1, sticky="ns") + horizontal = ttk.Scrollbar(frame, orient="horizontal", command=tree.xview) + horizontal.grid(row=1, column=0, sticky="ew") + tree.configure(yscrollcommand=vertical.set, xscrollcommand=horizontal.set) + + footer = ttk.Frame(self, style="App.TFrame") + footer.grid(row=2, column=0, sticky="ew", padx=20, pady=20) + ttk.Button( + footer, + text=parent.t("dependency_preflight_copy"), + command=self._copy, + style="Secondary.TButton", + ).pack(side="left") + ttk.Button( + footer, + text=parent.t("close" if blocked else "selection_cancel"), + command=self.destroy, + style="Secondary.TButton", + ).pack(side="right") + if not blocked: + ttk.Button( + footer, + text=parent.t("dependency_preflight_approve"), + command=self._approve, + style="Primary.TButton", + ).pack(side="right", padx=(0, 10)) + self.protocol("WM_DELETE_WINDOW", self.destroy) + self.after_idle(self._present) + + def _present(self) -> None: + self.lift() + self.focus_force() + + def _copy(self) -> None: + rows = [] + for action, items in (("EXCLUDE", self.exclusions), ("BLOCK", self.blockers)): + for item in items: + rows.append( + "\t".join( + ( + action, + str(item.get("pathType") or ""), + str(item.get("sourceRelativePath") or item.get("sourcePath") or ""), + str(item.get("error") or ""), + ) + ) + ) + self.clipboard_clear() + self.clipboard_append("\n".join(rows)) + messagebox.showinfo( + self.parent_app.t("dependency_preflight_title"), + self.parent_app.t("dependency_preflight_copied"), + parent=self, + ) + + def _approve(self) -> None: + self.approved = True + self.destroy() + + class BackupSelectionDialog(tk.Toplevel): def __init__(self, parent: "TransferApp", preview: dict) -> None: super().__init__(parent) @@ -3253,6 +3427,18 @@ def _continue_backup_from_preview(self, preview: dict) -> None: # it from the taskbar; keep the main actions inactive until it closes. for button in self.action_buttons: button.configure(state="disabled") + dependency_scan = preview.get("dependencyPreflight") or {} + dependency_exclusions = list(dependency_scan.get("exclusions", [])) + dependency_blockers = list(dependency_scan.get("blockingIssues", [])) + if dependency_exclusions or dependency_blockers: + dependency_dialog = DependencyPreflightDialog(self, dependency_scan) + self.wait_window(dependency_dialog) + if not dependency_dialog.approved: + for button in self.action_buttons: + button.configure( + state="disabled" if self.launch_blocked else "normal" + ) + return dialog = BackupSelectionDialog(self, preview) self.wait_window(dialog) for button in self.action_buttons: @@ -3337,6 +3523,13 @@ def work(): "excludedProjectPaths": dialog.excluded_paths, "additionalPortablePaths": [], "excludeDirectoryNames": [], + "approvedDependencyExclusions": [ + { + "sourcePath": item.get("sourcePath"), + "pathType": item.get("pathType"), + } + for item in dependency_exclusions + ], "versionCheck": self.last_version_check, "knownFolders": windows.known_folders(), }, diff --git a/src/codex_transfer/restore.py b/src/codex_transfer/restore.py index a6927dd..a2f0b24 100644 --- a/src/codex_transfer/restore.py +++ b/src/codex_transfer/restore.py @@ -28,7 +28,7 @@ from validate import validate -RESTORE_VERSION = "3.4.9" +RESTORE_VERSION = "3.4.10" PROTECTED_NAMES = {"auth.json", "installation_id", "cap_sid"} RUNTIME_NAMES = { ".sandbox", @@ -51,6 +51,41 @@ class RestoreError(RuntimeError): pass +def _existing_space_probe(path: Path) -> Path: + probe = path.resolve(strict=False) + while not probe.exists() and probe != probe.parent: + probe = probe.parent + return probe + + +def _ensure_restore_space( + destination: Path, + pending_bytes: int = 0, + pending_files: int = 0, +) -> None: + allocation_overhead = max(0, pending_files) * restore_plan.allocation_unit( + destination + ) + required = ( + max(0, pending_bytes) + + allocation_overhead + + restore_plan.RESTORE_SPACE_RESERVE_BYTES + ) + try: + free = shutil.disk_usage(_existing_space_probe(destination)).free + except OSError as exc: + raise RestoreError( + f"Could not determine free space for restore destination: {destination}" + ) from exc + if free < required: + raise RestoreError( + "Restore stopped before exhausting the destination volume: " + f"{free:,} bytes free; at least {required:,} bytes are required " + "for the pending write, allocation overhead and safety reserve. " + "Free space and start the restore again." + ) + + def _remove_path(path: Path) -> None: if not path.exists() and not path.is_symlink(): return @@ -117,7 +152,29 @@ def plan_restore_locations( def _copy_all(source: Path, destination: Path) -> tuple[int, int]: warnings: list[str] = [] - return backup.copy_tree(source, destination, [], warnings) + files_since_check = 0 + bytes_since_check = 0 + + def check_space(_source: Path, target: Path, size: int) -> None: + nonlocal files_since_check, bytes_since_check + files_since_check += 1 + bytes_since_check += size + if ( + files_since_check == 1 + or files_since_check >= 1024 + or bytes_since_check >= 256 * 1024 * 1024 + ): + _ensure_restore_space(target, size, 1) + files_since_check = 0 + bytes_since_check = 0 + + return backup.copy_tree( + source, + destination, + [], + warnings, + before_copy=check_space, + ) def _same_volume(first: Path, second: Path) -> bool: @@ -263,8 +320,16 @@ def prepare_restore( "The reviewed restore plan is inconsistent: " + "; ".join(decision_errors[:5]) ) + # Free space may have changed since the plan was reviewed. Refresh the + # conservative peak requirement immediately before creating any safety + # copy or modifying a destination. + restore_plan._refresh_plan(comparison_plan) if not comparison_plan.get("ready"): - raise RestoreError("The reviewed restore plan still contains blocking issues.") + reasons = comparison_plan.get("blockingReasons", []) + raise RestoreError( + "The reviewed restore plan still contains blocking issues: " + + "; ".join(str(value) for value in reasons[:3]) + ) location_plan = plan_restore_locations( package_root, target_profile, external_roots ) @@ -899,6 +964,7 @@ def _build_restored_database( def _rewrite_copy(source: Path, destination: Path, translator: PathTranslator) -> None: + _ensure_restore_space(destination, source.stat().st_size, 1) destination.parent.mkdir(parents=True, exist_ok=True) temporary = destination.with_name( f".{destination.name}.restore.tmp" @@ -1248,6 +1314,11 @@ def _restore_projects( backup.write_json(safety_root / "restore-journal.json", journal) if progress: progress(f"Staging project on destination volume: {target}") + _ensure_restore_space( + staging, + int(item.get("totalBytes", 0)), + int(item.get("fileCount", 0)), + ) files, _ = _copy_all(source, staging) record["status"] = "staged" record["stagedAtUtc"] = backup.utc_now() @@ -1938,10 +2009,28 @@ def restore_backup( journal, ) return journal - except Exception: + except Exception as original_error: journal["error"] = traceback.format_exc() - backup.write_json(journal_path, journal) + journal_write_error: Exception | None = None + try: + backup.write_json(journal_path, journal) + except Exception as exc: + # ENOSPC or another metadata-write failure must never prevent the + # already-prepared rollback from running. + journal_write_error = exc if progress: progress("Restore failed; running automatic rollback...") - rollback_restore(safety_root, target_profile) + try: + rollback_restore(safety_root, target_profile) + except Exception as rollback_error: + details = [ + f"original failure: {type(original_error).__name__}: {original_error}", + f"rollback failure: {type(rollback_error).__name__}: {rollback_error}", + ] + if journal_write_error is not None: + details.append( + "failure journal could not be updated: " + f"{type(journal_write_error).__name__}: {journal_write_error}" + ) + raise RestoreError("; ".join(details)) from original_error raise diff --git a/src/codex_transfer/restore_plan.py b/src/codex_transfer/restore_plan.py index 0896822..63c6582 100644 --- a/src/codex_transfer/restore_plan.py +++ b/src/codex_transfer/restore_plan.py @@ -23,6 +23,9 @@ PLAN_VERSION = 2 +RESTORE_SPACE_RESERVE_BYTES = 1_000_000_000 +ESTIMATED_ALLOCATION_BYTES_PER_FILE = 4096 +RESTORE_SPACE_MARGIN_PERCENT = 5 PLAN_STATES = {"identical", "incoming", "destination-only", "removed", "conflicting"} CONVERSATION_DECISIONS = ( "keep-source", @@ -399,8 +402,44 @@ def _target_conversations( return result +def allocation_unit(path: str | Path) -> int: + """Return a conservative filesystem allocation unit for space planning.""" + candidate = Path(path) + anchor = candidate.anchor or str(candidate.resolve(strict=False).anchor) + if os.name == "nt": + try: + import ctypes + + sectors_per_cluster = ctypes.c_ulong() + bytes_per_sector = ctypes.c_ulong() + free_clusters = ctypes.c_ulong() + total_clusters = ctypes.c_ulong() + success = ctypes.windll.kernel32.GetDiskFreeSpaceW( + str(anchor), + ctypes.byref(sectors_per_cluster), + ctypes.byref(bytes_per_sector), + ctypes.byref(free_clusters), + ctypes.byref(total_clusters), + ) + if success: + measured = sectors_per_cluster.value * bytes_per_sector.value + if measured > 0: + return max(ESTIMATED_ALLOCATION_BYTES_PER_FILE, measured) + except (AttributeError, OSError, TypeError, ValueError): + pass + else: + try: + measured = os.statvfs(anchor).f_frsize + if measured > 0: + return max(ESTIMATED_ALLOCATION_BYTES_PER_FILE, measured) + except (AttributeError, OSError, ValueError): + pass + return ESTIMATED_ALLOCATION_BYTES_PER_FILE + + def _free_space(items: list[dict[str, Any]]) -> list[dict[str, Any]]: required: dict[str, int] = {} + files: dict[str, int] = {} for item in items: if item.get("proposedAction") not in { "create", "replace", "replace-managed-data", "keep-both", @@ -412,8 +451,16 @@ def _free_space(items: list[dict[str, Any]]) -> list[dict[str, Any]]: continue anchor = Path(str(target)).anchor or str(Path(str(target)).resolve(strict=False).anchor) required[anchor] = required.get(anchor, 0) + int(item.get("requiredBytes", 0)) + files[anchor] = files.get(anchor, 0) + int(item.get("sourceFiles", 0)) result: list[dict[str, Any]] = [] for anchor, needed in sorted(required.items()): + unit = allocation_unit(anchor) + allocation_overhead = files.get(anchor, 0) * unit + margin = max( + RESTORE_SPACE_RESERVE_BYTES, + ((needed + allocation_overhead) * RESTORE_SPACE_MARGIN_PERCENT + 99) // 100, + ) + peak_required = needed + allocation_overhead + margin try: free = shutil.disk_usage(anchor).free except OSError: @@ -422,8 +469,13 @@ def _free_space(items: list[dict[str, Any]]) -> list[dict[str, Any]]: { "volume": anchor, "requiredBytes": needed, + "sourceFiles": files.get(anchor, 0), + "allocationUnitBytes": unit, + "allocationOverheadBytes": allocation_overhead, + "reserveBytes": margin, + "peakRequiredBytes": peak_required, "freeBytes": free, - "sufficient": free >= needed, + "sufficient": free >= peak_required, } ) return result @@ -450,7 +502,12 @@ def _refresh_plan(plan: dict[str, Any]) -> dict[str, Any]: blocking_reasons.append("One or more external project roots are unresolved.") for disk in disks: if not disk["sufficient"]: - blocking_reasons.append(f"Insufficient free space on {disk['volume']}.") + blocking_reasons.append( + "Insufficient peak free space on " + f"{disk['volume']} (required including allocation overhead and " + f"reserve: {disk['peakRequiredBytes']:,} bytes; " + f"available: {disk['freeBytes']:,} bytes)." + ) if plan.get("cancelled"): blocking_reasons.append("Restore was cancelled during conflict review.") write_set = [ @@ -753,6 +810,7 @@ def build_restore_plan( "proposedAction": action, "selected": action in {"create", "replace"}, "sourceBytes": source_bytes, + "sourceFiles": int(project.get("fileCount", 0)), "targetBytes": target_bytes, "targetFiles": target_files, "requiredBytes": source_bytes + (target_bytes if action == "replace" else 0), @@ -824,6 +882,7 @@ def build_restore_plan( "proposedAction": action, "selected": action in {"create", "replace"}, "sourceBytes": source_bytes, + "sourceFiles": 1 if source_bytes else 0, "targetBytes": int(target.get("size", 0)) if target else 0, "requiredBytes": source_bytes, "blocking": blocking, @@ -992,6 +1051,11 @@ def build_restore_plan( for path, (size, _digest) in hash_rows.items() if path.startswith("codex/") or path.startswith("attachments/") ) + codex_source_files = sum( + 1 + for path in hash_rows + if path.startswith("codex/") or path.startswith("attachments/") + ) codex_target = target_profile / ".codex" codex_target_bytes = sum( path.stat().st_size @@ -1009,6 +1073,7 @@ def build_restore_plan( "proposedAction": "replace-managed-data", "selected": True, "sourceBytes": codex_source_bytes, + "sourceFiles": codex_source_files, "targetBytes": codex_target_bytes, "requiredBytes": codex_source_bytes + codex_target_bytes, "blocking": False, diff --git a/src/codex_transfer/selftest.py b/src/codex_transfer/selftest.py index 4598e87..811b39a 100644 --- a/src/codex_transfer/selftest.py +++ b/src/codex_transfer/selftest.py @@ -11,7 +11,7 @@ import tempfile import uuid from unittest import mock -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any from . import ( @@ -793,6 +793,7 @@ def _gui_smoke_test() -> bool: from .gui import ( BackupResultDialog, BackupSelectionDialog, + DependencyPreflightDialog, DiagnosticsDialog, PathPortabilityDialog, RecoveryDialog, @@ -817,6 +818,8 @@ def _gui_smoke_test() -> bool: and app.t("delete") == "Project verwijderen" and app.t("recovery") == "Herstelpunten" and app.t("diagnostics") == "Diagnose" + and app.t("dependency_preflight_approve") + == "Deze exacte uitsluitingen goedkeuren" ) app.language.set("en") app._translate() @@ -831,6 +834,8 @@ def _gui_smoke_test() -> bool: and app.t("delete") == "Delete project" and app.t("recovery") == "Recovery points" and app.t("diagnostics") == "Diagnostics" + and app.t("dependency_preflight_approve") + == "Approve these exact exclusions" ) app._show_tab("overview") app.backup_button.invoke() @@ -984,6 +989,23 @@ def _gui_smoke_test() -> bool: app.update_idletasks() result_dialog_created = result_dialog.winfo_exists() == 1 result_dialog.destroy() + dependency_dialog = DependencyPreflightDialog( + app, + { + "exclusions": [ + { + "sourceRelativePath": "project/work/_deps/PIL", + "pathType": "directory", + "error": "[WinError 5] Access is denied", + } + ], + "blockingIssues": [], + }, + ) + app.update_idletasks() + dependency_dialog_created = dependency_dialog.winfo_exists() == 1 + dependency_dialog._approve() + dependency_dialog_approved = dependency_dialog.approved selection_dialog = BackupSelectionDialog( app, { @@ -1104,6 +1126,7 @@ def _gui_smoke_test() -> bool: and decision_button_enabled and decision_resolved and archive_button_enabled and project_decision_resolved and result_dialog_created and recovery_dialog_created + and dependency_dialog_created and dependency_dialog_approved and diagnostics_dialog_created and selection_default_complete and selection_exclusion_visible and portability_dialog_created and portability_local_toggle @@ -1209,6 +1232,52 @@ def _low_disk_space_test() -> bool: return bool(rejected and backup.ensure_backup_space(estimated, required) == required) +def _restore_peak_space_test() -> tuple[bool, bool]: + target = Path.cwd() / "restore-space-test" + items = [ + { + "proposedAction": "replace-managed-data", + "target": str(target), + "requiredBytes": 2_000_000_000, + "sourceFiles": 5_000_000, + } + ] + expected_overhead = ( + 5_000_000 * restore_plan.ESTIMATED_ALLOCATION_BYTES_PER_FILE + ) + with mock.patch.object( + restore_plan, "allocation_unit", return_value=4096 + ), mock.patch.object( + restore_plan.shutil, + "disk_usage", + return_value=shutil._ntuple_diskusage(0, 0, 10_000_000_000), + ): + requirement = restore_plan._free_space(items)[0] + conservative_plan = bool( + requirement.get("allocationOverheadBytes") == expected_overhead + and requirement.get("reserveBytes", 0) + >= restore_plan.RESTORE_SPACE_RESERVE_BYTES + and requirement.get("peakRequiredBytes", 0) + > requirement.get("freeBytes", 0) + and requirement.get("sufficient") is False + ) + runtime_rejected = False + with mock.patch.object( + restore_plan, "allocation_unit", return_value=4096 + ), mock.patch.object( + restore.shutil, + "disk_usage", + return_value=shutil._ntuple_diskusage( + 0, 0, restore_plan.RESTORE_SPACE_RESERVE_BYTES + ), + ): + try: + restore._ensure_restore_space(target, 1, 1) + except restore.RestoreError: + runtime_rejected = True + return conservative_plan, runtime_rejected + + def _project_identity_model_test(root: Path) -> bool: fixture = root / "identity-fixture" first_path = fixture / "first" / "SameName" @@ -1421,8 +1490,8 @@ def reject_plugin_copy( exclude_fragments: list[str], warnings: list[str], skipped_reconstructable_cache: list[dict[str, Any]] | None = None, - skipped_reconstructable_directories: list[dict[str, Any]] | None = None, - allow_dependency_metadata_skip: bool = False, + dependency_exclusions: dict[str, dict[str, Any]] | None = None, + applied_dependency_exclusions: list[dict[str, Any]] | None = None, ) -> tuple[int, int]: if source.name.lower() == "plugins": raise PermissionError(87, "plugin runtime must not be opened", str(source)) @@ -1432,8 +1501,8 @@ def reject_plugin_copy( exclude_fragments, warnings, skipped_reconstructable_cache, - skipped_reconstructable_directories, - allow_dependency_metadata_skip, + dependency_exclusions, + applied_dependency_exclusions, ) warnings: list[str] = [] @@ -2482,57 +2551,97 @@ def fail_mandatory_copy(_source: Path, _destination: Path) -> None: return cache_warning_complete, mandatory_stays_strict -def _dependency_metadata_directory_policy_test(root: Path) -> tuple[bool, bool]: - profile = root / "dependency-metadata-user" +def _dependency_preflight_policy_test(root: Path) -> tuple[bool, bool, bool, bool]: + profile = root / "dependency-preflight-user" codex, project = _write_source(profile) chatgpt_projects = codex / ".chatgpt-projects" - blocked = ( - chatgpt_projects - / "work" - / "_deps" - / "pillow-12.3.0.dist-info" - ) - blocked.mkdir(parents=True) - (blocked / "METADATA").write_text( + work = chatgpt_projects / "project-123" / "work" + blocked_metadata = work / "_deps" / "pillow-12.3.0.dist-info" + blocked_library = work / "_deps" / "PIL" + blocked_metadata.mkdir(parents=True) + blocked_library.mkdir(parents=True) + (blocked_metadata / "METADATA").write_text( "Name: Pillow\nVersion: 12.3.0\n", encoding="utf-8" ) - portable_marker = chatgpt_projects / "work" / "project-registration.json" + (blocked_library / "Image.py").write_text("class Image: pass\n", encoding="utf-8") + portable_marker = work / "project-registration.json" portable_marker.write_text('{"portable":true}\n', encoding="utf-8") source_before = _hash_tree(profile) - destination = root / "dependency-metadata-usb" - config = root / "dependency-metadata-config.json" - backup.write_json( - config, - { - "configVersion": 1, - "destinationRoot": str(destination), - "includeAttachments": True, - "projects": [{"path": str(project), "required": True}], - "additionalPortablePaths": [], - "excludeDirectoryNames": [], - "projectRegistryPath": str( - root / "dependency-metadata-state" / "project-registry.json" - ), - "lineageStatePath": str( - root / "dependency-metadata-state" / "lineage-state.json" - ), - "deviceStatePath": str( - root / "dependency-metadata-state" / "device.json" - ), - }, - ) + destination = root / "dependency-preflight-usb" + config = root / "dependency-preflight-config.json" + config_data = { + "configVersion": 1, + "destinationRoot": str(destination), + "includeAttachments": True, + "projects": [{"path": str(project), "required": True}], + "additionalPortablePaths": [], + "excludeDirectoryNames": [], + "projectRegistryPath": str( + root / "dependency-preflight-state" / "project-registry.json" + ), + "lineageStatePath": str( + root / "dependency-preflight-state" / "lineage-state.json" + ), + "deviceStatePath": str( + root / "dependency-preflight-state" / "device.json" + ), + } + backup.write_json(config, config_data) original_scandir = backup.os.scandir - blocked_key = backup.normalized_source_key(blocked) - blocked_error = PermissionError(13, "Access is denied", str(blocked)) - blocked_error.winerror = 5 + blocked_paths = (blocked_metadata, blocked_library) + blocked_keys = {backup.normalized_source_key(path) for path in blocked_paths} - def deny_blocked_directory(path: str | os.PathLike[str]): + def deny_blocked_directories(path: str | os.PathLike[str]): candidate = Path(path) - if backup.normalized_source_key(candidate) == blocked_key: - raise blocked_error + if backup.normalized_source_key(candidate) in blocked_keys: + error = PermissionError(13, "Access is denied", str(candidate)) + error.winerror = 5 + raise error return original_scandir(path) - with mock.patch.object(backup.os, "scandir", side_effect=deny_blocked_directory): + with mock.patch.object(backup.os, "scandir", side_effect=deny_blocked_directories): + preview = backup.build_backup_preview(profile, codex) + + dependency_scan = preview.get("dependencyPreflight", {}) + preview_items = dependency_scan.get("exclusions", []) + preview_paths = { + str(item.get("sourceRelativePath")) for item in preview_items + } + expected_preview_paths = { + "project-123/work/_deps/PIL", + "project-123/work/_deps/pillow-12.3.0.dist-info", + } + full_scope_found = bool( + dependency_scan.get("exclusionCount") == 2 + and dependency_scan.get("blockingCount") == 0 + and preview_paths == expected_preview_paths + and all(item.get("pathType") == "directory" for item in preview_items) + ) + + unapproved_failure: Exception | None = None + with mock.patch.object(backup.os, "scandir", side_effect=deny_blocked_directories): + try: + backup.build_backup( + argparse.Namespace( + config=str(config), + destination=str(destination), + source_profile=str(profile), + source_codex_home=str(codex), + allow_running_test=True, + ) + ) + except Exception as exc: + unapproved_failure = exc + + config_data["approvedDependencyExclusions"] = [ + { + "sourcePath": item.get("sourcePath"), + "pathType": item.get("pathType"), + } + for item in preview_items + ] + backup.write_json(config, config_data) + with mock.patch.object(backup.os, "scandir", side_effect=deny_blocked_directories): package = backup.build_backup( argparse.Namespace( config=str(config), @@ -2543,32 +2652,37 @@ def deny_blocked_directory(path: str | os.PathLike[str]): ) ) - directory_report_path = ( - package / "reports" / "skipped-reconstructable-directories.json" - ) - directory_report = backup.read_json(directory_report_path) + preflight_report_path = package / "reports" / "dependency-preflight.json" + exclusion_report_path = package / "reports" / "dependency-exclusions.json" + preflight_report = backup.read_json(preflight_report_path) + exclusion_report = backup.read_json(exclusion_report_path) package_manifest = backup.read_json(package / "manifest" / "package.json") backup_report = backup.read_json(package / "reports" / "backup-report.json") validation = validate(package, False) from .gui import _backup_result_model result_model = _backup_result_model(package, validation) - report_item = (directory_report.get("items") or [{}])[0] - blocked_relative = ( - "codex/portable-profile/.chatgpt-projects/" - "work/_deps/pillow-12.3.0.dist-info" - ) + report_paths = { + str(item.get("backupRelativePath")) + for item in exclusion_report.get("items", []) + } + blocked_relatives = { + "codex/portable-profile/.chatgpt-projects/project-123/work/_deps/PIL", + "codex/portable-profile/.chatgpt-projects/project-123/work/_deps/" + "pillow-12.3.0.dist-info", + } copied_marker = ( package / "codex" / "portable-profile" / ".chatgpt-projects" + / "project-123" / "work" / "project-registration.json" ) - target_profile = root / "dependency-metadata-target" - _write_target(target_profile, "DEPENDENCY-METADATA") + target_profile = root / "dependency-preflight-target" + _write_target(target_profile, "DEPENDENCY-PREFLIGHT") prepared = restore.prepare_restore( package, target_profile, allow_running_test=True ) @@ -2579,64 +2693,67 @@ def deny_blocked_directory(path: str | os.PathLike[str]): target_profile / ".codex" / ".chatgpt-projects" + / "project-123" / "work" / "project-registration.json" ) - restored_blocked = ( - target_profile - / ".codex" - / ".chatgpt-projects" - / "work" - / "_deps" - / "pillow-12.3.0.dist-info" + restored_dependency_root = ( + target_profile / ".codex" / ".chatgpt-projects" / "project-123" / "work" / "_deps" + ) + restored_blocked_paths = ( + restored_dependency_root / "PIL", + restored_dependency_root / "pillow-12.3.0.dist-info", ) - supported_case = bool( + explicit_approval_required = bool( + isinstance(unapproved_failure, backup.BackupError) + and "not explicitly approved" in str(unapproved_failure) + ) + supported_round_trip = bool( validation.get("valid") - and directory_report.get("count") == 1 - and report_item.get("sourcePath") == str(blocked) - and report_item.get("backupRelativePath") == blocked_relative - and report_item.get("errorType") == "PermissionError" - and report_item.get("kind") == "python-distribution-metadata" - and report_item.get("pathType") == "directory" - and report_item.get("contentsEnumerated") is False - and report_item.get("fileCount") is None - and package_manifest.get("counts", {}).get( - "skippedReconstructableDirectories" + and preflight_report.get("exclusionCount") == 2 + and preflight_report.get("blockingCount") == 0 + and exclusion_report.get("approvalRequired") is True + and exclusion_report.get("count") == 2 + and report_paths == blocked_relatives + and all( + item.get("kind") == "python-dependency-payload" + and item.get("pathType") == "directory" + and item.get("scope") == "entire-directory-subtree" + for item in exclusion_report.get("items", []) ) - == 1 - and package_manifest.get("skippedReconstructableDirectories", {}).get( + and package_manifest.get("counts", {}).get("dependencyExclusions") == 2 + and package_manifest.get("dependencyExclusions", {}).get( "reportRelativePath" ) - == "reports/skipped-reconstructable-directories.json" - and backup_report.get("skippedReconstructableDirectories", {}).get( - "count" + == "reports/dependency-exclusions.json" + and package_manifest.get("dependencyPreflight", {}).get( + "reportRelativePath" ) - == 1 + == "reports/dependency-preflight.json" + and backup_report.get("dependencyExclusions", {}).get("count") == 2 and result_model.get("valid") is True - and result_model.get("warnings", {}).get("dependencyMetadata") == 1 + and result_model.get("warnings", {}).get("dependencyPayload") == 2 and copied_marker.is_file() - and not (package / Path(*Path(blocked_relative).parts)).exists() + and all( + not (package / Path(*PurePosixPath(relative).parts)).exists() + for relative in blocked_relatives + ) and restored.get("status") == "complete" and restore.verify_restored(package, target_profile).get("valid") and restored_marker.is_file() - and not restored_blocked.exists() + and all(not path.exists() for path in restored_blocked_paths) and source_before == _hash_tree(profile) ) - strict_root = root / "dependency-metadata-strict" + strict_root = root / "dependency-preflight-strict" sharing_violation = PermissionError(13, "Sharing violation") sharing_violation.winerror = 32 - def strict_case( - label: str, - relative: Path, - error: OSError, - allow_skip: bool, - ) -> bool: - source = strict_root / label / ".chatgpt-projects" - blocked_path = source / relative + def scan_case(label: str, relative: Path, error: OSError) -> dict[str, Any]: + source_codex = strict_root / label / ".codex" + source = source_codex / ".chatgpt-projects" + blocked_path = source / "project-123" / relative blocked_path.mkdir(parents=True) - destination_path = strict_root / label / "destination" denied_key = backup.normalized_source_key(blocked_path) def deny_case(path: str | os.PathLike[str]): @@ -2645,72 +2762,72 @@ def deny_case(path: str | os.PathLike[str]): raise error return original_scandir(path) - records: list[dict[str, Any]] = [] - warnings: list[str] = [] - failure: Exception | None = None with mock.patch.object(backup.os, "scandir", side_effect=deny_case): - try: - backup.copy_tree( - source, - destination_path, - [], - warnings, - [], - records, - allow_skip, - ) - except Exception as exc: - failure = exc - return bool( - isinstance(failure, backup.BackupError) - and not records - and not warnings - ) + return backup.dependency_preflight(source_codex) - strict_cases = bool( - strict_case( + outside = scan_case( "outside-deps", - Path("work") / "not-deps" / "pillow-12.3.0.dist-info", + Path("work") / "project-data", PermissionError(13, "Access is denied"), - True, ) - and strict_case( - "non-metadata", - Path("work") / "_deps" / "important-project-data", - PermissionError(13, "Access is denied"), - True, - ) - and strict_case( + wrong_error = scan_case( "wrong-error", Path("work") / "_deps" / "pillow-12.3.0.dist-info", OSError(123, "Not an access-denied error"), - True, ) - and strict_case( + sharing = scan_case( "sharing-violation", Path("work") / "_deps" / "pillow-12.3.0.dist-info", sharing_violation, - True, ) - and strict_case( - "not-opted-in", - Path("work") / "_deps" / "pillow-12.3.0.dist-info", - PermissionError(13, "Access is denied"), - False, + ordinary_data_strict = bool( + outside.get("blockingCount") == 1 + and outside.get("exclusionCount") == 0 + and wrong_error.get("blockingCount") == 1 + and wrong_error.get("exclusionCount") == 0 + and sharing.get("blockingCount") == 1 + and sharing.get("exclusionCount") == 0 + ) + + changed_preflight = dict(dependency_scan) + changed_item = dict(preview_items[0]) + changed_item["sourcePath"] = str(work / "_deps" / "new-package") + changed_item["sourceRelativePath"] = "project-123/work/_deps/new-package" + changed_item["backupRelativePath"] = ( + "codex/portable-profile/.chatgpt-projects/project-123/" + "work/_deps/new-package" + ) + changed_preflight["exclusions"] = [*preview_items, changed_item] + changed_preflight["exclusionCount"] = 3 + changed_rejected = False + try: + backup.approved_dependency_exclusion_map( + changed_preflight, config_data["approvedDependencyExclusions"] ) - and not backup.record_skipped_reconstructable_directory( - blocked, - strict_root / "unreported-destination", - chatgpt_projects, - blocked_error, - [], - None, + except backup.BackupError: + changed_rejected = True + readable_rejected = False + try: + backup.confirm_approved_dependency_exclusion( + blocked_library, + next( + item + for item in preview_items + if str(item.get("sourcePath")) == str(blocked_library) + ), ) - ) + except backup.BackupError as exc: + readable_rejected = "became readable" in str(exc) - directory_report_path.unlink() + exclusion_report_path.unlink() missing_report_rejected = not validate(package, False).get("valid") - return bool(supported_case and missing_report_rejected), strict_cases + report_round_trip = bool(supported_round_trip and missing_report_rejected) + return ( + full_scope_found, + explicit_approval_required, + report_round_trip, + bool(ordinary_data_strict and changed_rejected and readable_rejected), + ) def _rollout_resolution_test( @@ -3018,8 +3135,13 @@ def run_self_test(work_root: Path | None = None) -> dict[str, Any]: reconstructable_cache_warning, mandatory_copy_failure = ( _reconstructable_cache_policy_test(root, source_profile) ) - dependency_metadata_warning, dependency_metadata_strict = ( - _dependency_metadata_directory_policy_test(root) + ( + dependency_preflight_scope, + dependency_explicit_approval, + dependency_report_round_trip, + dependency_payload_strict, + ) = ( + _dependency_preflight_policy_test(root) ) identical_rollout_resolved, different_rollout_resolved = ( _rollout_resolution_test(root, source_profile) @@ -3210,7 +3332,7 @@ def run_self_test(work_root: Path | None = None) -> dict[str, Any]: visual_backup_summary = _backup_result_model(package, package_validation) # The path-budget evidence report is part of every package. A frozen portable # build also includes its own executable so the restore tool travels with it. - expected_visual_file_count = 23 if getattr(sys, "frozen", False) else 22 + expected_visual_file_count = 24 if getattr(sys, "frozen", False) else 23 visual_backup_summary_complete = bool( visual_backup_summary.get("valid") and visual_backup_summary.get("metrics", {}).get("chats") == 3 @@ -3577,13 +3699,33 @@ def run_self_test(work_root: Path | None = None) -> dict[str, Any]: comparison_plan=conversation_rollback_plan, ) conversation_rollback_triggered = False + failure_journal_write_attempted = False + conversation_journal_path = ( + Path(conversation_rollback_prepared["safetyRoot"]) + / "restore-journal.json" + ) + original_write_json = backup.write_json + + def fail_error_journal(path: Path, value: Any) -> None: + nonlocal failure_journal_write_attempted + if ( + Path(path).resolve(strict=False) + == conversation_journal_path.resolve(strict=False) + and isinstance(value, dict) + and value.get("error") + ): + failure_journal_write_attempted = True + raise OSError(28, "simulated full disk while writing failure journal") + original_write_json(path, value) + try: - restore.restore_backup( - package, - conversation_rollback_profile, - Path(conversation_rollback_prepared["safetyRoot"]), - fail_after_conversations_for_test=True, - ) + with mock.patch.object(backup, "write_json", side_effect=fail_error_journal): + restore.restore_backup( + package, + conversation_rollback_profile, + Path(conversation_rollback_prepared["safetyRoot"]), + fail_after_conversations_for_test=True, + ) except Exception: conversation_rollback_triggered = True conversation_rollback_journal = backup.read_json( @@ -3592,6 +3734,7 @@ def run_self_test(work_root: Path | None = None) -> dict[str, Any]: ) conversation_rollback_preserved = bool( conversation_rollback_triggered + and failure_journal_write_attempted and conversation_rollback_journal.get("status") == "rolled-back" and _conversation_snapshot(conversation_rollback_profile) == conversation_rollback_before @@ -4447,6 +4590,9 @@ def run_self_test(work_root: Path | None = None) -> dict[str, Any]: ) git_conflict_explanation, git_advisory_only = _git_conflict_insight_test(root) atomic_metadata_complete = _atomic_metadata_test(root) + restore_peak_space_conservative, restore_runtime_space_guard = ( + _restore_peak_space_test() + ) checks = { "packageValid": package_validation["valid"], "visualBackupSummaryComplete": visual_backup_summary_complete, @@ -4462,8 +4608,10 @@ def run_self_test(work_root: Path | None = None) -> dict[str, Any]: ), "unreadablePythonCacheWarnsAndContinues": reconstructable_cache_warning, "unreadableNormalFileStillFailsClosed": mandatory_copy_failure, - "inaccessibleDependencyMetadataReported": dependency_metadata_warning, - "otherUnreadableDirectoriesFailClosed": dependency_metadata_strict, + "dependencyPreflightFindsFullScope": dependency_preflight_scope, + "dependencyExclusionsRequireExplicitApproval": dependency_explicit_approval, + "dependencyExclusionReportRoundTrips": dependency_report_round_trip, + "otherUnreadableDirectoriesFailClosed": dependency_payload_strict, "identicalDuplicateRolloutResolved": identical_rollout_resolved, "differentDuplicateRolloutResolved": different_rollout_resolved, "compressedDatabaseRolloutPathResolved": compressed_database_rollout_resolved, @@ -4491,6 +4639,8 @@ def run_self_test(work_root: Path | None = None) -> dict[str, Any]: "portablePathModel": _portable_path_model_test(), "longUnicodePathModel": _long_unicode_path_model_test(), "lowDiskSpaceRejected": _low_disk_space_test(), + "restorePeakSpaceIncludesAllocationOverhead": restore_peak_space_conservative, + "restoreRuntimeSpaceReserveEnforced": restore_runtime_space_guard, "portableLocationsRecorded": portable_locations_recorded, "legacyFormat20Supported": supports_format("2.0"), "legacyFormat21Supported": supports_format("2.1"), @@ -4517,6 +4667,9 @@ def run_self_test(work_root: Path | None = None) -> dict[str, Any]: "repeatedConversationRestoreIsIdempotent": repeated_conversation_idempotent, "explicitConversationDelete": explicit_conversation_delete, "conversationRollbackPreserved": conversation_rollback_preserved, + "rollbackSurvivesFailureJournalENOSPC": bool( + failure_journal_write_attempted and conversation_rollback_preserved + ), "destinationProjectRetainedByDefault": destination_project_retained_by_default, "destinationProjectArchiveComplete": destination_project_archive_complete, "destinationProjectDeleteRecoverable": destination_project_delete_recoverable, diff --git a/src/codex_transfer/validate.py b/src/codex_transfer/validate.py index 1e4c26a..344e954 100644 --- a/src/codex_transfer/validate.py +++ b/src/codex_transfer/validate.py @@ -461,6 +461,197 @@ def chunk_progress(chunk_size: int) -> None: ) checks["skippedReconstructableDirectories"] = declared_directory_skips + dependency_preflight_meta = ( + package.get("dependencyPreflight", {}) + if isinstance(package.get("dependencyPreflight"), dict) + else {} + ) + dependency_meta = ( + package.get("dependencyExclusions", {}) + if isinstance(package.get("dependencyExclusions"), dict) + else {} + ) + declared_dependency_exclusions = counts.get("dependencyExclusions", 0) + if dependency_meta.get("count", 0) != declared_dependency_exclusions: + errors.append( + "Dependency-exclusion count differs between package metadata and counts." + ) + + def valid_dependency_payload_path(value: str) -> bool: + if not valid_relative_path(value): + return False + parts = tuple( + part.casefold() for part in PurePosixPath(value).parts + ) + if len(parts) < 6 or parts[:3] != ( + "codex", + "portable-profile", + ".chatgpt-projects", + ): + return False + payload = parts[3:] + return any( + index >= 1 and payload[index : index + 2] == ("work", "_deps") + for index in range(len(payload) - 1) + ) + + dependency_preflight_relative = dependency_preflight_meta.get( + "reportRelativePath" + ) + preflight_items: list[dict[str, Any]] = [] + if dependency_preflight_relative: + if not valid_relative_path(str(dependency_preflight_relative)): + errors.append("Dependency-preflight report path is invalid.") + else: + dependency_preflight_path = package_root / Path( + *PurePosixPath(str(dependency_preflight_relative)).parts + ) + if not dependency_preflight_path.is_file(): + errors.append("Dependency-preflight report is missing.") + else: + try: + dependency_preflight_report = read_json( + dependency_preflight_path + ) + preflight_items = dependency_preflight_report.get( + "exclusions", [] + ) + blocking_items = dependency_preflight_report.get( + "blockingIssues", [] + ) + if ( + not isinstance(preflight_items, list) + or not isinstance(blocking_items, list) + or dependency_preflight_report.get("exclusionCount") + != declared_dependency_exclusions + or dependency_preflight_report.get("blockingCount") != 0 + or blocking_items + ): + errors.append( + "Dependency-preflight report is inconsistent or contains blockers." + ) + if str(dependency_preflight_relative) not in hash_rows: + errors.append( + "Dependency-preflight report is not hash-protected." + ) + except Exception as exc: + errors.append(f"Dependency-preflight report is invalid: {exc}") + elif dependency_meta or ( + package_root / "reports" / "dependency-preflight.json" + ).exists(): + errors.append("Dependency-preflight metadata or report is incomplete.") + + dependency_report_relative = dependency_meta.get("reportRelativePath") + if declared_dependency_exclusions: + if ( + not isinstance(declared_dependency_exclusions, int) + or declared_dependency_exclusions < 0 + or dependency_meta.get("explicitlyApproved") is not True + ): + errors.append("Dependency exclusions are not explicitly approved.") + elif not dependency_report_relative or not valid_relative_path( + str(dependency_report_relative) + ): + errors.append("Dependency-exclusion report path is missing or invalid.") + else: + dependency_report_path = package_root / Path( + *PurePosixPath(str(dependency_report_relative)).parts + ) + if not dependency_report_path.is_file(): + errors.append("Dependency-exclusion report is missing.") + else: + try: + dependency_report = read_json(dependency_report_path) + dependency_items = dependency_report.get("items", []) + if ( + dependency_report.get("approvalRequired") is not True + or dependency_report.get("count") + != declared_dependency_exclusions + or not isinstance(dependency_items, list) + or len(dependency_items) != declared_dependency_exclusions + ): + errors.append( + "Dependency-exclusion report count or approval state is invalid." + ) + else: + for item in dependency_items: + backup_relative = str( + item.get("backupRelativePath") or "" + ) + path_type = item.get("pathType") + classification_valid = bool( + item.get("kind") == "python-dependency-payload" + and path_type in {"directory", "file"} + and ( + ( + path_type == "directory" + and item.get("contentsEnumerated") is False + and item.get("fileCount") is None + and item.get("scope") + == "entire-directory-subtree" + ) + or ( + path_type == "file" + and item.get("contentsEnumerated") is None + and item.get("fileCount") == 1 + and item.get("scope") == "single-file" + ) + ) + ) + if not item.get("sourcePath") or not item.get("error"): + errors.append( + "Dependency-exclusion item lacks source or error details." + ) + if not classification_valid: + errors.append( + "Dependency-exclusion item has an invalid classification." + ) + if not valid_dependency_payload_path(backup_relative): + errors.append( + "Dependency-exclusion item is outside a project work/_deps payload." + ) + elif ( + package_root + / Path(*PurePosixPath(backup_relative).parts) + ).exists(): + errors.append( + "Dependency-exclusion payload unexpectedly exists: " + f"{backup_relative}" + ) + preflight_keys = { + ( + str(item.get("backupRelativePath") or "").casefold(), + str(item.get("pathType") or "").casefold(), + ) + for item in preflight_items + if isinstance(item, dict) + } + report_keys = { + ( + str(item.get("backupRelativePath") or "").casefold(), + str(item.get("pathType") or "").casefold(), + ) + for item in dependency_items + if isinstance(item, dict) + } + if preflight_keys != report_keys: + errors.append( + "Approved dependency exclusions differ from the preflight." + ) + if str(dependency_report_relative) not in hash_rows: + errors.append( + "Dependency-exclusion report is not hash-protected." + ) + except Exception as exc: + errors.append(f"Dependency-exclusion report is invalid: {exc}") + elif dependency_report_relative or ( + package_root / "reports" / "dependency-exclusions.json" + ).exists(): + errors.append( + "Dependency-exclusion report exists without declared exclusions." + ) + checks["dependencyExclusions"] = declared_dependency_exclusions + actual_all = {rel(path, package_root).lower() for path in package_root.rglob("*")} for forbidden in FORBIDDEN_CODEX_PATHS: if forbidden.lower() in actual_all: diff --git a/version_info.txt b/version_info.txt index 76efe42..aade882 100644 --- a/version_info.txt +++ b/version_info.txt @@ -1,7 +1,7 @@ VSVersionInfo( ffi=FixedFileInfo( - filevers=(3, 4, 9, 0), - prodvers=(3, 4, 9, 0), + filevers=(3, 4, 10, 0), + prodvers=(3, 4, 10, 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.9'), + StringStruct(u'FileVersion', u'3.4.10'), 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.9') + StringStruct(u'ProductVersion', u'3.4.10') ] ) ]),