Package replacement safety

package_model() protects a caller from accidentally naming the wrong output directory. It replaces only a recognizable pstrain package, requires explicit consent for a recognizable pre-marker package, never accepts an invalid or unsupported marker, restricts a package name to one path component, and refuses known source/destination overlap. Those checks apply through the public Python API as well as the command line.

That mistake-protection guarantee applies on every supported platform. It assumes that another process is not actively changing the same directory entries while a package operation is running.

Concurrent pathname changes

Protection against a process deliberately or accidentally racing the transaction is a stronger, platform-dependent guarantee.

On macOS and Linux, pstrain anchors the destination parent, source model, staging directory, retained package, and recovery directory with open descriptors. It derives identity from fstat() before moving an object, uses descriptor-relative exclusive renames, validates the retained descriptor rather than reopening its backup name, and performs recursive traversal relative to descriptors. After every rename return, including an exceptional return, it compares the open identity with both directory entries before deciding what moved. Recovery repeats that reconciliation rather than trusting in-memory transition bookkeeping. An outer pathname substitution therefore cannot redirect validation or recursive cleanup to the substituted tree.

Windows does not expose the descriptor-relative exclusive directory rename used by that transaction through Python or the system interface used here. Packaging keeps the same ownership checks and atomic no-replace publication there, but its retain, validation, recovery, and cleanup steps remain path-based. A process that changes those names between system calls can redirect an operation on Windows. Stop writers and package from a directory that other processes cannot modify when replacement must be protected from concurrent interference.

Windows also cannot reconcile an asynchronous interruption that arrives after an unnamed-package rename has completed but before Python records its result. The public output can then contain new entries already published, such as acoustic, beside old entries not yet replaced, such as README.txt, dict, and pstrain-package.json. Old entries already retained may remain in a sibling .<output-name>-old-* directory, and a later recovery error can obscure the original interruption. Stop writers, move the mixed public output aside without deleting it, and inspect that recovery directory. Restore its retained entries to reconstruct the old package, or publish again into an empty controlled output and move unrelated entries back afterward. Do not infer from the old public marker that the mixed tree is internally consistent.

Even on macOS and Linux, the system interfaces do not provide a portable operation meaning “unlink the directory entry only if it still names this open descriptor.” Cleanup uses unlinkat() or directory-relative rmdir() against an open parent and checks the entry identity immediately beforehand. Another process with write access to that same open directory can still substitute the final component between the check and the removal call. A non-empty substituted directory normally makes rmdir() fail, but a substituted file, symlink, or empty directory can be removed. The same mkdir-to-open interval exists when creating a private staging or recovery directory: an active process able to discover and replace its unpredictable, mode-0700 name in that interval can cause pstrain to adopt the replacement.

Package structure and marker reads are descriptor-relative, but open descriptors do not lock a directory’s contents. A concurrent writer can still change children inside the retained package while validation or cleanup walks them. pstrain fails closed when it observes an identity mismatch or cannot reconcile an operation, but it cannot guarantee detection of every change made and reversed entirely between two system calls.

For an operation exposed to untrusted or concurrent writers, place the output in a directory writable only by the packaging process, stop those writers for the duration, or publish into a separately controlled directory and hand off the final package after package_model() returns. Recovery directories named .<package>-old-* or .pstrain-package-old-* are intentionally retained whenever the transaction cannot prove that cleanup or restoration is safe; inspect them before removing them.

Cleanup occurs after publication. If cleanup cannot prove that a recovery object is still the one it owns, package_model() raises even though the new package may already be fully published at the requested destination. The recovery directory is preserved when it may still contain data. A caller must inspect both the destination and the reported recovery directory after such an exception; treating every exception as “nothing was published” is incorrect.