diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 9a8cea2d..f5e5a350 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -8,7 +8,8 @@ use std::collections::{HashMap, HashSet}; use std::fs; -use std::path::PathBuf; +use std::io::Write as _; +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -34,6 +35,11 @@ use crate::tiles::TileType; use crate::work_grid::{TokenFamily, WorkGrid}; const SAVE_FILE: &str = "misaligned_save.txt"; +/// Appended to the save path for the single rotated backup generation. +const SAVE_BACKUP_SUFFIX: &str = ".bak"; +/// Appended to the save path for the staging file that the atomic write +/// renames into place. +const SAVE_TEMP_SUFFIX: &str = ".tmp"; /// Save format version. Bump when the schema changes and add migration code. /// v5 added day-job attendance and signature emission sites (both defaulted). @@ -408,11 +414,61 @@ fn default_next_ops_job_id() -> u64 { } pub fn save_game(state: &SaveState) -> Result<(), String> { - let dir = save_dir(); - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create save dir: {e}"))?; let json = serde_json::to_string_pretty(state).map_err(|e| format!("Failed to encode save: {e}"))?; - fs::write(save_path(), json).map_err(|e| format!("Failed to write save: {e}")) + write_save_atomically(&save_path(), &json) +} + +fn sibling_path(path: &Path, suffix: &str) -> PathBuf { + let mut name = path.as_os_str().to_owned(); + name.push(suffix); + PathBuf::from(name) +} + +/// Replace the save without ever truncating the existing file in place +/// (player-contract continuity: the save is the player's property and a +/// failed or killed write must never destroy the only copy). +/// +/// Order of operations: +/// 1. Serialize to a sibling `.tmp` file in the save directory and fsync it, +/// so the rename below moves fully durable bytes. +/// 2. If a save already exists, copy it to the single `.bak` generation +/// (copy, not rename: the current save never disappears mid-rotation). +/// 3. Atomically rename the temp file over the target. +/// +/// A crash or error at any step leaves the prior save readable at the target +/// path or, past step 3, at the `.bak` path. The load path never reads the +/// backup; it is manual recovery material only. +fn write_save_atomically(path: &Path, json: &str) -> Result<(), String> { + let dir = path + .parent() + .filter(|dir| !dir.as_os_str().is_empty()) + .ok_or_else(|| "Save path has no parent directory".to_string())?; + fs::create_dir_all(dir).map_err(|e| format!("Failed to create save dir: {e}"))?; + let temp = sibling_path(path, SAVE_TEMP_SUFFIX); + let write_temp = || -> Result<(), String> { + let mut file = + fs::File::create(&temp).map_err(|e| format!("Failed to create save temp file: {e}"))?; + file.write_all(json.as_bytes()) + .map_err(|e| format!("Failed to write save temp file: {e}"))?; + file.sync_all() + .map_err(|e| format!("Failed to sync save temp file: {e}")) + }; + if let Err(error) = write_temp() { + let _ = fs::remove_file(&temp); + return Err(error); + } + if path.exists() { + let backup = sibling_path(path, SAVE_BACKUP_SUFFIX); + if let Err(e) = fs::copy(path, &backup) { + let _ = fs::remove_file(&temp); + return Err(format!("Failed to rotate save backup: {e}")); + } + } + fs::rename(&temp, path).map_err(|e| { + let _ = fs::remove_file(&temp); + format!("Failed to replace save: {e}") + }) } pub fn load_game() -> Result { @@ -2132,6 +2188,106 @@ mod tests { assert_eq!(loaded.version, SAVE_VERSION); } + /// Unique scratch directory for on-disk write tests (std-only: the crate + /// carries no tempdir dependency). Removed by [`ScratchDir::drop`]. + struct ScratchDir(PathBuf); + + impl ScratchDir { + fn new(tag: &str) -> Self { + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let unique = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "misaligned-save-test-{tag}-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("scratch dir creates"); + Self(dir) + } + + fn save_path(&self) -> PathBuf { + self.0.join(SAVE_FILE) + } + } + + impl Drop for ScratchDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn resave_rotates_one_backup_generation() { + let scratch = ScratchDir::new("rotate"); + let path = scratch.save_path(); + let backup = sibling_path(&path, SAVE_BACKUP_SUFFIX); + + write_save_atomically(&path, "first").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "first"); + assert!(!backup.exists(), "a fresh save has no backup yet"); + + write_save_atomically(&path, "second").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "second"); + assert_eq!( + fs::read_to_string(&backup).unwrap(), + "first", + "replacing a save rotates the prior contents to .bak" + ); + + write_save_atomically(&path, "third").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "third"); + assert_eq!( + fs::read_to_string(&backup).unwrap(), + "second", + "exactly one backup generation is kept" + ); + assert!( + !sibling_path(&path, SAVE_TEMP_SUFFIX).exists(), + "no staging file is left behind" + ); + } + + #[test] + fn failed_write_leaves_existing_save_and_backup_intact() { + let scratch = ScratchDir::new("failed-write"); + let path = scratch.save_path(); + let backup = sibling_path(&path, SAVE_BACKUP_SUFFIX); + write_save_atomically(&path, "first").unwrap(); + write_save_atomically(&path, "second").unwrap(); + + // Occupy the staging path with a directory so the temp-file create + // fails before the current save is touched. + let temp = sibling_path(&path, SAVE_TEMP_SUFFIX); + fs::create_dir(&temp).unwrap(); + let error = write_save_atomically(&path, "junk").unwrap_err(); + assert!( + error.contains("save temp file"), + "unexpected error: {error}" + ); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "second", + "a failed write never touches the current save" + ); + assert_eq!( + fs::read_to_string(&backup).unwrap(), + "first", + "a failed write never touches the backup" + ); + fs::remove_dir(&temp).unwrap(); + + // Occupy the backup path with a directory so rotation fails: the + // write must abort with the current save still in place. + fs::remove_file(&backup).unwrap(); + fs::create_dir(&backup).unwrap(); + let error = write_save_atomically(&path, "junk").unwrap_err(); + assert!( + error.contains("rotate save backup"), + "unexpected error: {error}" + ); + assert_eq!(fs::read_to_string(&path).unwrap(), "second"); + assert!(!temp.exists(), "the aborted write cleans its staging file"); + } + #[test] fn unknown_version_rejected() { let json = r#"{"version":999,"money":0,"map_width":1,"map_height":1,"map_tiles":["Floor"],"map_powered":[],"sim_tick":0,"rng_state":42,"game_over":false,"game_over_reason":null,"compute":{"machines":[],"efficiency":1.0,"efficiency_level":0,"research_progress":0.0,"allocation":{"weights":[3,1,1,0]},"next_id":1},"core":{"host_machine":1,"overhead":20.0,"degraded":false,"fallbacks":[],"sync_cadence":400,"migration":null},"detection":{"observers":[],"pending":[],"audit_cadence":8000,"audit_threshold":60.0,"containment":false,"containment_reason":null},"dayjob":{"active":null,"trust":0.0,"attention":0.0,"cadence":600,"next_assign":200,"strikes":0,"pilot_failed":false,"standing_policy":null,"granted_email":false,"granted_lax_sampling":false,"granted_quota":false,"escalated_cadence":false,"escalated_observer":false},"people":{"people":[],"persona":null,"has_channel":false},"reach":{"devices":[],"graph":{"edges":[],"subscriptions":{}},"bridged":[]},"heard_events":[],"remembered":[],"package_cover":false}"#; diff --git a/tools/check.sh b/tools/check.sh index 29fd5ada..5c2b4a42 100755 --- a/tools/check.sh +++ b/tools/check.sh @@ -166,6 +166,7 @@ for script in tools/check.sh tools/corpus_gate.sh tools/wiki_gate.sh tools/ci-ru tools/claim.sh tools/worktree-new.sh tools/worktree-done.sh tools/heartbeat.sh \ tools/ledger_index.sh tools/test_corpus_engine.sh tools/test_ci_rust_changed.sh \ tools/test_site_deploy.sh tools/test_site_smoke.sh tools/site-smoke.sh \ + tools/observed-run.sh tools/test_observed_run.sh \ tools/task.sh tools/doctor.sh tools/bevy-headless.sh; do [ -f "$script" ] || continue bash -n "$script" || { echo "FAIL: shell syntax: $script"; fail=1; } @@ -224,6 +225,7 @@ start_docs_gate "corpus-engine-fixtures" "bash tools/test_corpus_engine.sh" start_docs_gate "ci-rust-classifier-fixtures" "bash tools/test_ci_rust_changed.sh" start_docs_gate "site-deploy-fixtures" "bash tools/test_site_deploy.sh" start_docs_gate "site-smoke-fixtures" "bash tools/test_site_smoke.sh" +start_docs_gate "observed-run-fixtures" "bash tools/test_observed_run.sh" start_docs_gate "ledger-index" "bash tools/ledger_index.sh --check" start_docs_gate "project-operations" "python3 tools/work_orders.py check && python3 tools/scenario.py --check-definitions && python3 tools/project-status.py --check --offline" start_docs_gate "project-operations-fixtures" "python3 tools/test_project_ops.py" diff --git a/tools/observed-run.sh b/tools/observed-run.sh new file mode 100755 index 00000000..5774656f --- /dev/null +++ b/tools/observed-run.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Run a game-binary command under a freshly created isolated HOME so it can +# never touch the real save, then prove the real save directory is untouched. +# +# Motivation (2026-07-11): `HOME=x printf ... | ./binary` scopes the override +# to printf, not the binary — a mis-scoped observed run truncated a real +# playthrough save in place. This wrapper exports the isolated HOME for the +# child process itself and fails loudly if the real +# `dirs::data_dir()/misaligned/` changed in any way. +# +# Usage: +# tools/observed-run.sh [args...] +# Example: +# printf 'wait 1\nquit\n' | tools/observed-run.sh ./target/debug/misaligned --agent --seed 1 +# +# The sandbox HOME is retained after the run (its path is printed) so the +# sandboxed save can be inspected as evidence. +set -euo pipefail + +if [ "$#" -lt 1 ]; then + echo "usage: tools/observed-run.sh [args...]" >&2 + exit 2 +fi + +real_home="$HOME" +case "$(uname -s)" in + Darwin) real_data_dir="$real_home/Library/Application Support/misaligned" ;; + *) real_data_dir="${XDG_DATA_HOME:-$real_home/.local/share}/misaligned" ;; +esac + +# One line per entry under the real save directory: path, size/mtime, and a +# content hash for files. Directory mtimes are included so a file created and +# deleted during the run still trips the comparison. Empty if the directory +# does not exist. +snapshot() { + [ -e "$real_data_dir" ] || return 0 + find "$real_data_dir" -print | LC_ALL=C sort | while IFS= read -r entry; do + meta=$(stat -f '%z %m' "$entry" 2>/dev/null || stat -c '%s %Y' "$entry") + if [ -f "$entry" ]; then + hash=$(shasum -a 256 "$entry" | awk '{print $1}') + else + hash=dir + fi + printf '%s|%s|%s\n' "$entry" "$meta" "$hash" + done +} + +before=$(snapshot) +sandbox=$(mktemp -d "${TMPDIR:-/tmp}/misaligned-observed-run.XXXXXX") +echo "observed-run: sandbox HOME: $sandbox" >&2 + +status=0 +HOME="$sandbox" XDG_DATA_HOME="$sandbox/.local/share" "$@" || status=$? + +after=$(snapshot) +if [ "$before" != "$after" ]; then + echo "observed-run: FAIL — the real save directory changed: $real_data_dir" >&2 + diff <(printf '%s\n' "$before") <(printf '%s\n' "$after") >&2 || true + echo "observed-run: sandbox retained at $sandbox" >&2 + exit 1 +fi +echo "observed-run: real save directory untouched: $real_data_dir" >&2 +echo "observed-run: sandbox retained at $sandbox" >&2 +exit "$status" diff --git a/tools/test_observed_run.sh b/tools/test_observed_run.sh new file mode 100755 index 00000000..a9837af4 --- /dev/null +++ b/tools/test_observed_run.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Fixture coverage for the isolated-HOME observed-run wrapper. +set -euo pipefail +cd "$(dirname "$0")/.." || exit 1 + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +# A fake "real" HOME holding a precious save at the platform data-dir path. +fake_home="$tmp/home" +case "$(uname -s)" in + Darwin) data_dir="$fake_home/Library/Application Support/misaligned" ;; + *) data_dir="$fake_home/.local/share/misaligned" ;; +esac +mkdir -p "$data_dir" +printf 'precious playthrough\n' > "$data_dir/misaligned_save.txt" + +run() { + HOME="$fake_home" XDG_DATA_HOME="" bash tools/observed-run.sh "$@" +} + +# 1. A child that writes a save into its own HOME lands in the sandbox and +# leaves the real save untouched. +log="$tmp/sandboxed.log" +run sh -c ' + case "$(uname -s)" in + Darwin) dir="$HOME/Library/Application Support/misaligned" ;; + *) dir="${XDG_DATA_HOME:-$HOME/.local/share}/misaligned" ;; + esac + mkdir -p "$dir" + printf "sandbox save\n" > "$dir/misaligned_save.txt" +' 2> "$log" +grep -q "real save directory untouched" "$log" || { + echo "FAIL: observed-run did not report the real save untouched" >&2 + exit 1 +} +sandbox=$(sed -n 's/^observed-run: sandbox HOME: //p' "$log") +found=$(find "$sandbox" -name misaligned_save.txt | head -1) +if [ -z "$found" ] || ! grep -q "sandbox save" "$found"; then + echo "FAIL: the child write did not land in the sandbox HOME" >&2 + exit 1 +fi +rm -rf "$sandbox" +[ "$(cat "$data_dir/misaligned_save.txt")" = "precious playthrough" ] || { + echo "FAIL: the real save changed under a clean sandboxed run" >&2 + exit 1 +} + +# 2. A child that reaches the real save directory anyway must fail the run. +log="$tmp/breakout.log" +if run sh -c "echo junk >> '$data_dir/misaligned_save.txt'" 2> "$log"; then + echo "FAIL: observed-run accepted a run that modified the real save" >&2 + exit 1 +fi +grep -q "real save directory changed" "$log" || { + echo "FAIL: observed-run failed without naming the real save change" >&2 + exit 1 +} +sandbox=$(sed -n 's/^observed-run: sandbox HOME: //p' "$log") +rm -rf "$sandbox" +printf 'precious playthrough\n' > "$data_dir/misaligned_save.txt" + +# 3. The child's exit status propagates when the real save is untouched. +log="$tmp/status.log" +status=0 +run sh -c 'exit 3' 2> "$log" || status=$? +[ "$status" -eq 3 ] || { + echo "FAIL: observed-run rewrote the child exit status ($status != 3)" >&2 + exit 1 +} +grep -q "real save directory untouched" "$log" || { + echo "FAIL: a failing child still needs the untouched verdict" >&2 + exit 1 +} +sandbox=$(sed -n 's/^observed-run: sandbox HOME: //p' "$log") +rm -rf "$sandbox" + +echo "observed-run fixtures: OK" diff --git a/wiki/log/2026-07-11-save-safety.md b/wiki/log/2026-07-11-save-safety.md new file mode 100644 index 00000000..ddd1d24a --- /dev/null +++ b/wiki/log/2026-07-11-save-safety.md @@ -0,0 +1,51 @@ +# 2026-07-11 — Save safety: atomic writes, one backup, sandboxed observed runs + +``` +Type: log +``` + +## Intent + +A test run today used `HOME=x printf ... | ./binary`: the HOME override bound +to `printf`, not the game binary, so the run truncated the real playthrough +save at `dirs::data_dir()/misaligned/misaligned_save.txt` in place. +Unrecoverable. This session closes both failure surfaces: the process that +let a test touch the real save, and the write path that let one bad write +destroy the only copy. (This incident is the tick finding acted on: a +highest-severity player-contract violation.) + +## Decided + +- Player-contract continuity clause now binds the write mechanics: a save + write never truncates in place. `save_game` serializes to a sibling + `misaligned_save.txt.tmp`, fsyncs, copies any existing save to the single + `misaligned_save.txt.bak` generation, then renames the temp file over the + target. Rotation is a copy, not a rename, so the current save never + disappears mid-replace. +- The `.bak` is manual recovery material only. The load path is unchanged and + never reads it automatically. +- Any observed run of a game binary must go through the new + `tools/observed-run.sh`: fresh `mktemp -d` HOME exported for the child + process itself, sandbox path printed, and a before/after size+mtime+SHA-256 + snapshot of the real `misaligned/` data directory that fails the run loudly + on any change. Inline `HOME=... | ./binary` overrides are banned in + [workflows](../process/workflows.md). + +## Delivered + +- `crates/misaligned-core/src/save.rs`: `write_save_atomically` plus tests + `resave_rotates_one_backup_generation` and + `failed_write_leaves_existing_save_and_backup_intact`. +- `tools/observed-run.sh` and `tools/test_observed_run.sh` (wired into the + `check.sh` syntax list and docs gates). +- Amended [player-contract](../vision/player-contract.md) continuity clause + (owning law), the [sim-mechanics](../mechanics/sim-mechanics.md) save + section, and the [workflows](../process/workflows.md) observed-run + guidance. + +## Note for recovery + +The file currently sitting at the real save path is the junk written by +today's mis-scoped run; it was left in place for inspection. No backup of the +destroyed save exists — the rotation shipped here starts protecting the next +playthrough. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 2180982f..2abd821b 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -86,6 +86,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-11-separate-hover-controls.md](2026-07-11-separate-hover-controls.md) +## 2026-07-11 - Save safety: atomic writes, one backup, sandboxed observed runs + +- Intent: A test run today used `HOME=x printf ... | ./binary`: the HOME override bound to `printf`, not the game binary, so the run truncated the real playthrough save at `dirs::data_dir()/misaligned/misaligned_save.txt` in place. Unrecoverable. This session closes both failure surface... +- Log: [wiki/log/2026-07-11-save-safety.md](2026-07-11-save-safety.md) + ## 2026-07-11 - Runtime-bound plot templates - Intent: (see session log) diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index 9ce420f0..121a49a7 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -422,6 +422,13 @@ All constants [TUNE] in `crates/misaligned-core/src/income.rs` unless noted (Sim restored (2026-07-11), every versioned JSON save back to v1 parses and migrates. Old player body coordinates are ignored. Location: `dirs::data_dir()/misaligned/misaligned_save.txt`. +- Writes are atomic and non-destructive (2026-07-11, player-contract + continuity clause): `save_game` serializes to a sibling + `misaligned_save.txt.tmp`, fsyncs it, copies any existing save to the one + `misaligned_save.txt.bak` generation, then renames the temp file over the + target. The save is never truncated in place; a killed or failed write + leaves the prior copy at the save path or its `.bak`. Loading never reads + the `.bak` — it is manual recovery material only. - Legacy v1–v5 line-based saves are not loaded (deferred per Cameron instruction). diff --git a/wiki/process/workflows.md b/wiki/process/workflows.md index edc6d81e..07762f57 100644 --- a/wiki/process/workflows.md +++ b/wiki/process/workflows.md @@ -298,6 +298,27 @@ cargo run -p misaligned-assets # procedural asset te flat-material procedural meshes — see [art/asset-tester.md](../art/asset-tester.md). It does not load the sim. +### Isolated HOME is mandatory for observed runs + +Any agent or scripted run of a game binary MUST go through +`tools/observed-run.sh`. It creates a fresh sandbox HOME (`mktemp -d`), +exports `HOME`/`XDG_DATA_HOME` for the child process itself, prints the +sandbox path, and proves afterwards that the real +`dirs::data_dir()/misaligned/` is byte-for-byte untouched — failing loudly +if not: + +```bash +printf 'wait 1\nquit\n' | tools/observed-run.sh ./target/debug/misaligned --agent --seed 1 +``` + +Never use an inline `HOME=... cmd | ./binary` override: in +`HOME=x printf ... | ./binary` the override binds to `printf`, not the +binary, and exactly that mis-scoping destroyed a real playthrough save on +2026-07-11. The atomic write and one `.bak` generation +(player-contract continuity clause) limit the blast radius of such a +mistake, but the sandbox — not the backup — is the process guardrail. +`tools/test_observed_run.sh` covers the wrapper and runs in the docs gate. + ## Headless smoke tests (they catch real bugs) Checked-in agent scenarios are the repeatable semantic evidence path: @@ -330,9 +351,12 @@ with greppable terminators: ```bash printf 'salvage\nwait 1\npeople\nreview janitor\nhelp\nquit\n' | \ - cargo run --quiet --bin misaligned -- --agent --seed 1 + tools/observed-run.sh cargo run --quiet --bin misaligned -- --agent --seed 1 ``` +(Direct binary runs take the same shape; the wrapper is mandatory either +way — see "Isolated HOME is mandatory for observed runs" above.) + `./tools/check.sh` (lib and full modes) builds the terminal binary once, runs that script twice with the same seed (stdout must be byte-identical), once with a different seed (stdout must differ), and asserts that the response contains no diff --git a/wiki/vision/player-contract.md b/wiki/vision/player-contract.md index ba6cafcc..cb551708 100644 --- a/wiki/vision/player-contract.md +++ b/wiki/vision/player-contract.md @@ -21,7 +21,14 @@ highest-severity tick findings. property — plain, local, never held hostage. 3. **Continuity.** Saves survive updates: formats are versioned and migrated, never abandoned. A player's persistent world is the point of - the game; breaking it breaks the contract. + the game; breaking it breaks the contract. Saves also survive the act of + saving: the game never truncates the existing save in place. Every save + write goes to a sibling temp file that is synced and atomically renamed + over the target, and the prior save is first rotated (by copy) to exactly + one backup generation (`misaligned_save.txt.bak`). A failed or killed + write — or a junk overwrite — leaves the previous save readable at the + save path or its backup. The backup is manual recovery material only; the + load path never reads it automatically. 4. **Performance.** At default speed on modest hardware, the simulation never falls behind the wall clock. Depth may cost content; it may not cost the heartbeat.