Context
Audited against current main:
3dd593c9ba8206b1d5a8fa8fc592229cdb1ed7f7
This is the merge of CMS-024N / PR #37.
The Astra audit identified AUD-001 — Sobrescritura PGN no atómica:
- severity: Medium;
- category: Data Integrity;
- status: Confirmed;
- priority: P1.
The current PGN writers build/validate the complete PGN content first, but then publish directly to the requested destination with std::fs::write(...).
Current public APIs:
pub fn write_pgn(
puzzles: &[config::Puzzle],
path: &Path,
) -> Result<(), String>
pub fn write_project_pgn(
project_name: &str,
chapters: &[ProjectPgnChapter],
path: &Path,
) -> Result<(), String>
pub fn to_pgn(
puzzles: &[config::Puzzle],
_lang: &lang::Language,
path: String,
) -> Result<(), String>
Runtime callers already converge on those writers:
src/main.rs
ExportPGN
→ export::to_pgn()
→ write_pgn()
src/project_tab.rs
export_selected_puzzles_pgn()
→ write_pgn()
export_project_pgn()
→ write_project_pgn()
export_selected_chapters_pgn()
→ write_project_pgn()
Therefore this task should harden the writers internally without changing the caller APIs.
Objective
Prevent a failed PGN export from truncating or partially overwriting an existing destination file.
The new publication flow must be conceptually:
build complete PGN content
→ create unique sibling temporary file
→ write complete bytes
→ sync temporary file
→ close temporary file
→ rename temporary file over destination
The existing destination must not be deliberately modified or removed before the final publication step.
Required publication semantics
Implement one small internal helper in src/export.rs used by both PGN writers.
The helper should:
- derive a temporary path in the same directory as the destination;
- create the temporary file with
OpenOptions::create_new(true);
- avoid clobbering an already-existing temporary file;
- write all bytes with
write_all;
- call
sync_all();
- close/drop the file handle before publication;
- publish with
std::fs::rename(temp, destination);
- remove the temporary file best-effort when an error happens before successful publication;
- return an error that includes useful destination/path context.
A reasonable temporary naming scheme is conceptually:
<destination filename>.cms-<pid>-<counter>.tmp
Exact naming may follow existing project style, but it must allow retry/collision avoidance and must not overwrite a stale temporary file.
Do not use a fixed sibling .tmp path if that can be silently truncated or can block subsequent exports after an interrupted previous run.
Existing destination behavior
Destination does not exist
On success:
- publish the complete PGN at the requested destination;
- no temporary file remains.
On failure before publication:
- destination remains absent;
- temporary file is cleaned best-effort.
Destination already exists
Until the final publication step:
- existing destination bytes must remain unchanged.
On successful publication:
- destination contains only the new complete PGN.
On a detected failure before successful publication:
- return
Err;
- do not fabricate success;
- do not intentionally truncate or remove the existing destination;
- clean temporary output best-effort.
Atomicity / durability contract
This task must not claim stronger crash-durability guarantees than the implementation proves.
The intended guarantee is:
CMS builds and writes the new PGN into a sibling temporary file and only attempts to replace the destination after that temporary file has been fully written and synchronized.
Do not promise:
the destination is guaranteed to survive every possible power loss, kernel crash, filesystem failure, or hardware failure.
Portable directory-fsync / full power-loss durability is outside this task.
The goal is safe publication against application-visible build/write/sync/publication failures, with the previous destination not deliberately truncated during preparation.
Windows portability
The implementation must remain portable and use the Rust standard library only.
Requirements:
- sibling temporary file, so source and destination are on the same filesystem;
- close the temporary file before rename;
- never delete the destination first just to make the rename work;
- no Windows-specific FFI;
- no
ReplaceFileW;
- no new crate solely for this task.
If implementation reveals a real cross-platform blocker that cannot be solved within these constraints, stop and report it instead of adding platform-specific complexity silently.
Reuse existing project patterns
Chess Material Studio already has related safe-write patterns:
src/config.rs writes to a sibling temporary file, syncs, closes, then renames;
src/download_db.rs uses a backup/publish/restore flow for a different use case.
For CMS-025A, prefer the simpler write sibling temp → sync → close → rename shape.
Do not introduce a destination → backup → new destination sequence unless a demonstrated platform requirement forces it.
Scope
Expected production file:
Tests should remain in the existing src/export.rs test module unless there is a strong reason otherwise.
No caller change is expected.
Do not modify
Do not modify unless a real blocker is found:
src/main.rs
src/project_tab.rs
src/project.rs
src/pgn_import.rs
src/pgn_tab.rs
src/pgn_review.rs
src/settings.rs
src/config.rs
src/download_db.rs
src/schema.rs
project_migrations/
translations/
README.md
Cargo.toml
Cargo.lock
If a scoped-out file genuinely becomes necessary, stop and report why before broadening the task.
No dependency changes
Do not add:
tempfile;
- Windows API crates;
- new filesystem crates;
- any other dependency for this feature.
Use the standard library and existing dependencies only.
Public API compatibility
Preserve the current signatures and behavior contract of:
write_pgn(...)
write_project_pgn(...)
to_pgn(...)
Callers in main.rs and project_tab.rs should not require production changes.
Existing user-facing success/error propagation must continue to work.
Tests
Add focused regression tests around the shared safe-publication helper and the two writers.
At minimum cover:
Existing destination is replaced only after success
- seed destination with known sentinel bytes;
- perform a valid export;
- verify final destination contains the new complete PGN;
- verify old sentinel content is gone;
- verify no temporary file remains.
Build failure preserves existing destination
- seed destination with known bytes;
- use an invalid puzzle that makes PGN construction fail;
- verify
write_pgn(...) returns Err;
- verify destination bytes are unchanged.
Also preserve the existing equivalent project-PGN invalid-game behavior.
Publication failure preserves existing destination
Provide a small test seam around the final rename/publication step if needed.
Inject a deterministic publication failure and verify:
- writer/helper returns
Err;
- previous destination bytes remain unchanged;
- no success is fabricated;
- temporary output is cleaned best-effort.
Keep the injection local to src/export.rs; do not create generic infrastructure.
Temporary creation/write failure
Create a deterministic failure before publication.
Verify:
- existing destination remains unchanged;
- error propagates;
- no destination truncation occurs.
New destination
When no destination exists:
- valid export succeeds;
- destination contains complete PGN;
- no temporary file remains.
Stale temporary collision
Create a file using the first candidate temporary name or otherwise force a collision.
Verify:
- the stale file is not truncated;
- export can choose another candidate and succeed, or returns a controlled error according to the helper contract;
- destination safety is preserved.
Both PGN writers use the safe path
Keep at least one integration-level regression for:
write_pgn;
write_project_pgn.
Do not duplicate every helper-level failure test for both APIs.
Existing tests to preserve
Current tests already cover:
- received-order preservation;
- build/write error propagation;
- editorial tags;
- PGN tag escaping;
- invalid game rejection;
- normal export wrapper behavior.
These must remain green.
Acceptance criteria
Verification
Run:
git status
cargo fmt
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test
cargo check
cargo build
git diff --check
git diff --name-only
git diff -- src/export.rs
git status
Also run the focused CMS-025A tests explicitly if useful during development.
Because the current CI runs tests on Ubuntu but Windows jobs primarily build/package, perform a local Windows cargo test before considering the task complete.
Out of scope
Do not implement:
- safe PDF replacement;
- generic filesystem abstraction;
- generic transaction framework for files;
- backup/restore UI;
- PGN parser changes;
- PGN terminal result reconciliation;
- resource/CWD fixes;
- stale search fixes;
- UCI evaluation changes;
- schema or migration changes;
- project persistence changes;
- caller/UI refactors;
- unrelated cleanup;
- large refactors of
export.rs.
Completion report
Before finishing, report:
- exact files changed;
- exact helper/publication design used;
- tests added;
- commands executed and results;
- whether
Cargo.toml / Cargo.lock changed;
- whether any scoped-out file had to be touched;
git diff --check result;
- final
git status.
Do not commit, push, open a PR, merge, or modify GitHub metadata beyond this implementation task without explicit authorization.
Context
Audited against current
main:This is the merge of CMS-024N / PR #37.
The Astra audit identified AUD-001 — Sobrescritura PGN no atómica:
The current PGN writers build/validate the complete PGN content first, but then publish directly to the requested destination with
std::fs::write(...).Current public APIs:
Runtime callers already converge on those writers:
Therefore this task should harden the writers internally without changing the caller APIs.
Objective
Prevent a failed PGN export from truncating or partially overwriting an existing destination file.
The new publication flow must be conceptually:
The existing destination must not be deliberately modified or removed before the final publication step.
Required publication semantics
Implement one small internal helper in
src/export.rsused by both PGN writers.The helper should:
OpenOptions::create_new(true);write_all;sync_all();std::fs::rename(temp, destination);A reasonable temporary naming scheme is conceptually:
Exact naming may follow existing project style, but it must allow retry/collision avoidance and must not overwrite a stale temporary file.
Do not use a fixed sibling
.tmppath if that can be silently truncated or can block subsequent exports after an interrupted previous run.Existing destination behavior
Destination does not exist
On success:
On failure before publication:
Destination already exists
Until the final publication step:
On successful publication:
On a detected failure before successful publication:
Err;Atomicity / durability contract
This task must not claim stronger crash-durability guarantees than the implementation proves.
The intended guarantee is:
Do not promise:
Portable directory-fsync / full power-loss durability is outside this task.
The goal is safe publication against application-visible build/write/sync/publication failures, with the previous destination not deliberately truncated during preparation.
Windows portability
The implementation must remain portable and use the Rust standard library only.
Requirements:
ReplaceFileW;If implementation reveals a real cross-platform blocker that cannot be solved within these constraints, stop and report it instead of adding platform-specific complexity silently.
Reuse existing project patterns
Chess Material Studio already has related safe-write patterns:
src/config.rswrites to a sibling temporary file, syncs, closes, then renames;src/download_db.rsuses a backup/publish/restore flow for a different use case.For CMS-025A, prefer the simpler write sibling temp → sync → close → rename shape.
Do not introduce a destination → backup → new destination sequence unless a demonstrated platform requirement forces it.
Scope
Expected production file:
Tests should remain in the existing
src/export.rstest module unless there is a strong reason otherwise.No caller change is expected.
Do not modify
Do not modify unless a real blocker is found:
If a scoped-out file genuinely becomes necessary, stop and report why before broadening the task.
No dependency changes
Do not add:
tempfile;Use the standard library and existing dependencies only.
Public API compatibility
Preserve the current signatures and behavior contract of:
Callers in
main.rsandproject_tab.rsshould not require production changes.Existing user-facing success/error propagation must continue to work.
Tests
Add focused regression tests around the shared safe-publication helper and the two writers.
At minimum cover:
Existing destination is replaced only after success
Build failure preserves existing destination
write_pgn(...)returnsErr;Also preserve the existing equivalent project-PGN invalid-game behavior.
Publication failure preserves existing destination
Provide a small test seam around the final rename/publication step if needed.
Inject a deterministic publication failure and verify:
Err;Keep the injection local to
src/export.rs; do not create generic infrastructure.Temporary creation/write failure
Create a deterministic failure before publication.
Verify:
New destination
When no destination exists:
Stale temporary collision
Create a file using the first candidate temporary name or otherwise force a collision.
Verify:
Both PGN writers use the safe path
Keep at least one integration-level regression for:
write_pgn;write_project_pgn.Do not duplicate every helper-level failure test for both APIs.
Existing tests to preserve
Current tests already cover:
These must remain green.
Acceptance criteria
write_pgnno longer callsstd::fs::writedirectly on the destination.write_project_pgnno longer callsstd::fs::writedirectly on the destination.write_all.sync_all()'d before publication.Errwithout fabricating success.Cargo.tomlandCargo.lockremain unchanged.src/export.rs, unless a blocker is reported first.Verification
Run:
Also run the focused CMS-025A tests explicitly if useful during development.
Because the current CI runs tests on Ubuntu but Windows jobs primarily build/package, perform a local Windows
cargo testbefore considering the task complete.Out of scope
Do not implement:
export.rs.Completion report
Before finishing, report:
Cargo.toml/Cargo.lockchanged;git diff --checkresult;git status.Do not commit, push, open a PR, merge, or modify GitHub metadata beyond this implementation task without explicit authorization.