From b0f1889072d116b69327bb15637bbec3ebff81b7 Mon Sep 17 00:00:00 2001 From: hungv47-katalon Date: Mon, 5 Oct 2026 00:32:41 +0700 Subject: [PATCH] feat(core): add guarded lifecycle plans, trash and recovery --- .gitignore | 3 + CHANGELOG.md | 8 + Cargo.lock | 73 ++++ PROGRESS.md | 3 + README.md | 7 + ROADMAP.md | 5 + VISION.md | 3 + crates/cli/Cargo.toml | 4 + crates/cli/src/main.rs | 85 +++++ crates/core/Cargo.toml | 6 + crates/core/build.rs | 21 ++ crates/core/src/guarded/journal.rs | 401 ++++++++++++++++++++++ crates/core/src/guarded/mod.rs | 404 +++++++++++++++++++++++ crates/core/src/guarded/plan.rs | 280 ++++++++++++++++ crates/core/src/guarded/snapshot.rs | 321 ++++++++++++++++++ crates/core/src/lib.rs | 11 + crates/core/src/registry.rs | 9 +- crates/core/src/rpc.rs | 1 + docs/guarded-lifecycle.md | 99 ++++++ e2e/guarded-failure-modes.json | 226 +++++++++++++ e2e/guarded-lifecycle.py | 494 ++++++++++++++++++++++++++++ 21 files changed, 2463 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md create mode 100644 PROGRESS.md create mode 100644 ROADMAP.md create mode 100644 VISION.md create mode 100644 crates/core/build.rs create mode 100644 crates/core/src/guarded/journal.rs create mode 100644 crates/core/src/guarded/mod.rs create mode 100644 crates/core/src/guarded/plan.rs create mode 100644 crates/core/src/guarded/snapshot.rs create mode 100644 docs/guarded-lifecycle.md create mode 100644 e2e/guarded-failure-modes.json create mode 100644 e2e/guarded-lifecycle.py diff --git a/.gitignore b/.gitignore index bc3ddf6..f7141bc 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ /npm/rift-darwin-arm64/bin/ /npm/rift-windows-x64/bin/ /npm/rift-snapshot/prebuilds/ + +# Synthetic Python E2E bytecode +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a2074da --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## Unreleased + +- Add exact canonical-path reconciliation and explicit root/leaf trash with full review guards. +- Add durable lifecycle journals, crash recovery, verified finalization, and exact active/history rollback. +- Add shared candidate native ownership locking, journal fences, and synthetic CLI E2E receipts. +- Preserve release 0.0.12 schema, old trash history, row IDs, parents, timestamps, and rowids without migration. diff --git a/Cargo.lock b/Cargo.lock index 5be9863..e178b1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -134,6 +143,35 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -245,6 +283,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -704,6 +752,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2", "tempfile", "thiserror", "toml", @@ -718,6 +767,7 @@ dependencies = [ "clap", "rift", "rusqlite", + "serde_json", "tempfile", "thiserror", ] @@ -829,6 +879,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -965,6 +1026,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "ulid" version = "1.2.1" @@ -1017,6 +1084,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..71ad0bb --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,3 @@ +# Progress + +Native guarded lifecycle code and synthetic CLI E2E coverage are implemented. The existing APFS workspace checks pass outside the test sandbox. Final validation binds separate production and fixture binaries to the same clean local commit. No upstream publication or real cleanup is part of this change. See [the contract and validation procedure](docs/guarded-lifecycle.md). diff --git a/README.md b/README.md index 4e3b1da..0d2e34c 100644 --- a/README.md +++ b/README.md @@ -216,3 +216,10 @@ Results include per-sample timings plus median, minimum, and maximum; `compare` ## License MIT + +### Guarded lifecycle + +For exact reviewed canonical-path reconciliation, root/leaf trash, crash recovery, +finalization, and rollback, use the native `guarded` commands with an explicit +registry. See [the guarded lifecycle contract](docs/guarded-lifecycle.md), including +its ownership gate and synthetic verification procedure. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..f0073b7 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,5 @@ +# Roadmap + +- Complete native exact reconciliation, root/leaf trash, finalization, recovery, and rollback with synthetic CLI verification. +- Review the local patch and production build before any real dispatch. +- Validate the guarded runtime on Linux before Linux use. diff --git a/VISION.md b/VISION.md new file mode 100644 index 0000000..467f22a --- /dev/null +++ b/VISION.md @@ -0,0 +1,3 @@ +# Vision + +Make exact reviewed Rift lifecycle operations native, recoverable, and safe to inspect. Preserve source files, markers, IDs, ancestry, and unrelated registry history. See [the lifecycle contract](docs/guarded-lifecycle.md). diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 9bcf5fe..3b35459 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -8,7 +8,11 @@ license.workspace = true name = "rift" path = "src/main.rs" +[features] +fixture-faults = ["rift/fixture-faults"] + [dependencies] +serde_json.workspace = true clap.workspace = true rift = { path = "../core" } thiserror.workspace = true diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 5b3372b..851c62a 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -84,6 +84,11 @@ impl Shell { #[derive(Subcommand)] enum Command { + /// Exact reviewed native lifecycle; requires an explicit database. + Guarded { + #[command(subcommand)] + action: GuardedCommand, + }, #[command(hide = true)] Rpc, ShellInit { @@ -124,6 +129,84 @@ enum Command { Gc, } +#[derive(Subcommand)] +enum GuardedCommand { + /// Report the exact build commit and compatible base release. + Provenance, + /// Read a request JSON file and emit an immutable dry review plan. + Preflight { request: PathBuf }, + /// Apply the complete reviewed plan JSON file. + Apply { plan: PathBuf }, + /// Resolve an interrupted journal without automatic retirement. + Recover, + /// Restore the exact journal; requires its reviewed SHA-256 hash. + Rollback { + #[arg(long)] + journal_hash: String, + #[arg(long)] + history: Option, + }, + /// Verify committed state and archive its complete rollback history. + Finalize { + #[arg(long)] + journal_hash: String, + }, +} +fn guarded(database: Option, action: GuardedCommand) -> Result<()> { + if matches!(action, GuardedCommand::Provenance) { + println!( + "{}", + serde_json::to_string_pretty(&rift::guarded::provenance()) + .map_err(|e| rift::Error::Guarded(e.to_string()))? + ); + return Ok(()); + } + let database = database.ok_or_else(|| rift::Error::Guarded("--database is required".into()))?; + let json_error = |e: serde_json::Error| rift::Error::Guarded(e.to_string()); + let mut hook_failed = false; + let output = match action { + GuardedCommand::Provenance => unreachable!(), + GuardedCommand::Preflight { request } => { + let request = serde_json::from_slice(&std::fs::read(request)?).map_err(json_error)?; + serde_json::to_string_pretty(&rift::guarded::preflight(database, request)?) + .map_err(json_error)? + } + GuardedCommand::Apply { plan } => { + let plan: rift::guarded::Plan = + serde_json::from_slice(&std::fs::read(plan)?).map_err(json_error)?; + if std::fs::canonicalize(database)? != plan.database { + return Err(rift::Error::Guarded("CLI database differs from plan".into()).into()); + } + let outcome = rift::guarded::apply(&plan)?; + hook_failed = outcome.hook_error.is_some(); + serde_json::to_string_pretty(&outcome).map_err(json_error)? + } + GuardedCommand::Recover => { + serde_json::to_string_pretty(&rift::guarded::recover(database)?).map_err(json_error)? + } + GuardedCommand::Finalize { journal_hash } => { + serde_json::to_string_pretty(&rift::guarded::finalize(database, &journal_hash)?) + .map_err(json_error)? + } + GuardedCommand::Rollback { + journal_hash, + history, + } => serde_json::to_string_pretty(&match history { + Some(history) => rift::guarded::rollback_history(database, history, &journal_hash)?, + None => rift::guarded::rollback(database, &journal_hash)?, + }) + .map_err(json_error)?, + }; + println!("{output}"); + if hook_failed { + return Err(rift::Error::Guarded( + "retirement committed; postremove failed; journal retained".into(), + ) + .into()); + } + Ok(()) +} + fn main() { if let Err(error) = run() { let message = match &error { @@ -153,6 +236,7 @@ fn error_message(error: &rift::Error) -> String { fn run() -> Result<()> { let cli = Cli::parse(); let command = match cli.command { + Command::Guarded { action } => return guarded(cli.database, action), Command::Rpc => { const LIMIT: u64 = 1024 * 1024; let mut input = Vec::new(); @@ -179,6 +263,7 @@ fn run() -> Result<()> { None => Manager::open_default()?, }; match command { + Command::Guarded { .. } => unreachable!(), Command::Rpc => unreachable!(), Command::ShellInit { shell } => { print_shell_init(shell); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 9819e79..a64c9e1 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -4,7 +4,13 @@ version.workspace = true edition.workspace = true license.workspace = true +[features] +# Explicit fixture-only crash/failure injection. Never enable in release packaging. +fixture-faults = [] + [dependencies] +sha2 = "0.10" +tempfile.workspace = true dirs.workspace = true libc.workspace = true rand.workspace = true diff --git a/crates/core/build.rs b/crates/core/build.rs new file mode 100644 index 0000000..3e974f9 --- /dev/null +++ b/crates/core/build.rs @@ -0,0 +1,21 @@ +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=../../.git/HEAD"); + println!("cargo:rerun-if-changed=../../.git/refs"); + println!("cargo:rerun-if-changed=../../.git/index"); + let read = |args: &[&str]| { + Command::new("git") + .args(args) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|value| value.trim().to_owned()) + }; + let commit = read(&["rev-parse", "HEAD"]).unwrap_or_else(|| "unavailable".into()); + let dirty = read(&["status", "--porcelain", "--untracked-files=normal"]) + .is_none_or(|status| !status.is_empty()); + println!("cargo:rustc-env=RIFT_SOURCE_COMMIT={commit}"); + println!("cargo:rustc-env=RIFT_SOURCE_DIRTY={dirty}"); +} diff --git a/crates/core/src/guarded/journal.rs b/crates/core/src/guarded/journal.rs new file mode 100644 index 0000000..6ec5c4d --- /dev/null +++ b/crates/core/src/guarded/journal.rs @@ -0,0 +1,401 @@ +use super::*; +use std::io::Write; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(super) enum Phase { + Prepared, + Committed, + RollingBack, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) enum HookStatus { + Pending, + Skipped, + Succeeded, + Failed(String), +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Journal { + pub version: u32, + pub phase: Phase, + pub plan: Plan, + pub postremove: HookStatus, +} +impl Journal { + pub fn new(plan: Plan) -> Self { + Self { + version: 1, + phase: Phase::Prepared, + postremove: if matches!(plan.request, Request::Trash { hooks: true, .. }) { + HookStatus::Pending + } else { + HookStatus::Skipped + }, + plan, + } + } +} +fn path(j: &Journal) -> PathBuf { + suffix(&j.plan.database, ".guarded-journal") +} +fn bytes(j: &Journal) -> Result> { + serde_json::to_vec_pretty(j).map_err(|e| fail(e.to_string())) +} +fn sync_parent(path: &Path) -> Result<()> { + fs::File::open(path.parent().ok_or_else(|| fail("path has no parent"))?)?.sync_all()?; + Ok(()) +} +pub(super) fn create(j: &Journal) -> Result<()> { + let p = path(j); + let mut temp = tempfile::NamedTempFile::new_in(p.parent().unwrap())?; + temp.write_all(&bytes(j)?)?; + temp.as_file().sync_all()?; + temp.persist_noclobber(&p).map_err(|e| Error::Io(e.error))?; + sync_parent(&p) +} +pub(super) fn save(j: &Journal) -> Result<()> { + let p = path(j); + let mut temp = tempfile::NamedTempFile::new_in(p.parent().unwrap())?; + temp.write_all(&bytes(j)?)?; + temp.as_file().sync_all()?; + temp.persist(&p).map_err(|e| Error::Io(e.error))?; + sync_parent(&p) +} + +// Atomic no-replace rename: never overwrite a destination even if it appears +// after validation. No fallback to deleting or ordinary overwriting rename. +fn rename_exact(from: &Path, to: &Path) -> Result<()> { + use std::ffi::CString; + let src = CString::new(from.as_os_str().as_encoded_bytes()).map_err(|e| fail(e.to_string()))?; + let dst = CString::new(to.as_os_str().as_encoded_bytes()).map_err(|e| fail(e.to_string()))?; + #[cfg(target_os = "macos")] + let rc = unsafe { libc::renamex_np(src.as_ptr(), dst.as_ptr(), libc::RENAME_EXCL) }; + #[cfg(target_os = "linux")] + let rc = unsafe { + libc::renameat2( + libc::AT_FDCWD, + src.as_ptr(), + libc::AT_FDCWD, + dst.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let rc = -1; + if rc != 0 { + return Err(std::io::Error::last_os_error().into()); + } + sync_parent(from)?; + if from.parent() != to.parent() { + sync_parent(to)?; + } + Ok(()) +} +fn moves(plan: &Plan) -> Vec<(&Guard, &Path)> { + match &plan.request { + Request::Reconcile { .. } => Vec::new(), + Request::Trash { destinations, .. } => plan + .guards + .iter() + .map(|g| { + ( + g, + destinations + .iter() + .find(|d| d.id == g.id) + .unwrap() + .trash + .as_path(), + ) + }) + .collect(), + } +} +pub(super) fn move_forward(j: &mut Journal) -> Result<()> { + for (g, to) in moves(&j.plan) { + snapshot::moved_guard(g, &g.canonical)?; + checkpoint("rename_failure")?; + rename_exact(&g.canonical, to)?; + snapshot::moved_guard(g, to)?; + checkpoint("after_each_rename")?; + } + Ok(()) +} +fn locations(j: &Journal) -> Result> { + moves(&j.plan) + .iter() + .map(|(g, to)| { + let original = fs::symlink_metadata(&g.canonical).is_ok(); + let trash = fs::symlink_metadata(to).is_ok(); + if original == trash { + return Err(fail( + "ambiguous recovery: original/trash missing or occupied", + )); + } + snapshot::moved_guard(g, if original { &g.canonical } else { to })?; + Ok(trash) + }) + .collect() +} +pub(super) fn validate_locations(j: &Journal) -> Result<()> { + if locations(j)?.iter().any(|m| !*m) { + return Err(fail("retired directory returned outside rollback")); + } + if matches!(j.plan.request, Request::Reconcile { .. }) { + validate_reconciled(j)?; + } + Ok(()) +} +fn move_back(j: &Journal) -> Result<()> { + // Prevalidate the entire closure before any rollback rename. + let moved = locations(j)?; + for ((g, to), is_moved) in moves(&j.plan).iter().zip(moved).rev() { + if is_moved { + snapshot::moved_guard(g, to)?; + rename_exact(to, &g.canonical)?; + snapshot::moved_guard(g, &g.canonical)?; + checkpoint("during_rollback")?; + } + } + Ok(()) +} +fn finish(j: &Journal) -> Result { + let p = path(j); + let data = fs::read(&p)?; + let archive = suffix( + &j.plan.database, + &format!(".guarded-history-{}", snapshot::hash(&data)), + ); + rename_exact(&p, &archive)?; + Ok(archive) +} + +pub(super) fn recover( + database: &Path, + rollback: bool, + expected_hash: Option<&str>, +) -> Result { + let database = fs::canonicalize(database)?; + let _lock = WriterLock::acquire(&database, false)?; + recover_locked(&database, rollback, expected_hash) +} +fn recover_locked(database: &Path, rollback: bool, expected_hash: Option<&str>) -> Result { + let p = suffix(database, ".guarded-journal"); + if !fs::symlink_metadata(&p)?.file_type().is_file() { + return Err(fail("journal must be a regular file")); + } + let data = fs::read(&p)?; + if let Some(hash) = expected_hash + && hash != snapshot::hash(&data) + { + return Err(fail("stale reviewed rollback journal hash")); + } + let mut j: Journal = serde_json::from_slice(&data).map_err(|e| fail(e.to_string()))?; + if j.version != 1 + || j.plan.database != database + || snapshot::identity(database)? != j.plan.database_identity + { + return Err(fail("journal database identity differs")); + } + validate(&j.plan, false)?; + // Request validation at this stage must not require original directories to + // exist: those may already have moved. Validate trusted saved state below. + let (dry_state, dry_hashes) = snapshot::copied_state(database)?; + let after = expected_after(&j.plan); + if dry_state != j.plan.before && dry_state != after { + return Err(fail("stale registry recovery/rollback snapshot")); + } + let dry_moved = locations(&j)?; + if dry_state == after + && !rollback + && j.phase != Phase::RollingBack + && dry_moved.iter().any(|m| !*m) + { + return Err(fail("committed registry has unretired directories")); + } + if matches!(j.plan.request, Request::Reconcile { .. }) { + validate_reconciled(&j)?; + } + if snapshot::hashes(&snapshot::database_bytes(database)?) != dry_hashes { + return Err(fail("registry changed during recovery preflight")); + } + let mut db = open_existing(database)?; + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = snapshot::state(&tx)?; + let after = expected_after(&j.plan); + if current != dry_state || (current != j.plan.before && current != after) { + return Err(fail("stale registry recovery/rollback snapshot")); + } + let moved = locations(&j)?; + let committed = current == after; + let mut status = Some(committed); + let transition: Result = (|| { + if committed && !rollback && j.phase != Phase::RollingBack { + if moved.iter().any(|m| !*m) { + return Err(fail("committed registry has unretired directories")); + } + // Reconciliation also needs current marker, alias and file guards. + if matches!(j.plan.request, Request::Reconcile { .. }) { + validate_reconciled(&j)?; + } + tx.commit()?; + j.phase = Phase::Committed; + save(&j)?; + return Ok(Outcome { + committed: true, + status: match &j.postremove { + HookStatus::Pending => "committed_hooks_unconfirmed", + HookStatus::Failed(_) => "committed_hook_failed", + _ => "committed", + } + .into(), + journal: Some(p.clone()), + hook_error: match &j.postremove { + HookStatus::Pending => { + Some("postremove completion is unconfirmed; hooks are not replayed".into()) + } + HookStatus::Failed(message) => Some(message.clone()), + _ => None, + }, + }); + } + if rollback || j.phase == Phase::RollingBack { + if matches!(j.plan.request, Request::Reconcile { .. }) { + validate_reconciled(&j)?; + } + j.phase = Phase::RollingBack; + save(&j)?; + } + move_back(&j)?; + if current != j.plan.before { + set_rows(&tx, ¤t, &j.plan.before)?; + } + if snapshot::state(&tx)? != j.plan.before { + return Err(fail("rollback registry invariant failed")); + } + checkpoint("rollback_commit_failure")?; + status = None; + tx.commit()?; + status = Some(false); + checkpoint("after_rollback_commit")?; + let archive = finish(&j)?; + Ok(Outcome { + committed: false, + status: "restored".into(), + journal: Some(archive), + hook_error: None, + }) + })(); + transition.map_err(|error| Error::GuardedInterrupted { + committed: status, + journal: p, + message: error.to_string(), + }) +} +fn validate_reconciled(j: &Journal) -> Result<()> { + for g in &j.plan.guards { + let row = j + .plan + .before + .rows + .iter() + .find(|r| r.id == g.id) + .ok_or_else(|| fail("unknown journal guard"))?; + if snapshot::guard(row)? != *g { + return Err(fail("rollback alias, directory, marker or files changed")); + } + } + Ok(()) +} + +fn reviewed_journal(database: &Path, path: &Path, expected_hash: &str) -> Result { + if !fs::symlink_metadata(path)?.file_type().is_file() { + return Err(fail("history/journal must be a regular file")); + } + let data = fs::read(path)?; + if snapshot::hash(&data) != expected_hash { + return Err(fail("stale reviewed journal hash")); + } + let j: Journal = serde_json::from_slice(&data).map_err(|e| fail(e.to_string()))?; + if j.version != 1 + || j.plan.database != database + || snapshot::identity(database)? != j.plan.database_identity + { + return Err(fail("journal database identity differs")); + } + validate(&j.plan, false)?; + if matches!(j.postremove, HookStatus::Pending) { + return Err(fail( + "postremove completion is unconfirmed; rollback remains available", + )); + } + if j.phase != Phase::Committed { + return Err(fail( + "only a completed committed journal can be finalized or restored from history", + )); + } + let (current, hashes) = snapshot::copied_state(database)?; + if current != expected_after(&j.plan) { + return Err(fail("stale committed registry snapshot")); + } + validate_locations(&j)?; + if snapshot::hashes(&snapshot::database_bytes(database)?) != hashes { + return Err(fail("registry changed during journal review")); + } + Ok(j) +} + +pub(super) fn finalize(database: &Path, expected_hash: &str) -> Result { + let database = fs::canonicalize(database)?; + let _lock = WriterLock::acquire(&database, false)?; + let p = suffix(&database, ".guarded-journal"); + let j = reviewed_journal(&database, &p, expected_hash)?; + let mut db = open_existing(&database)?; + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + if snapshot::state(&tx)? != expected_after(&j.plan) { + return Err(fail("registry changed before finalization")); + } + validate_locations(&j)?; + let archive = finish(&j).map_err(|e| Error::GuardedInterrupted { + committed: Some(true), + journal: p.clone(), + message: e.to_string(), + })?; + checkpoint("after_finalize_archive")?; + tx.commit().map_err(|e| Error::GuardedInterrupted { + committed: Some(true), + journal: archive.clone(), + message: e.to_string(), + })?; + Ok(Outcome { + committed: true, + status: "finalized".into(), + journal: Some(archive), + hook_error: None, + }) +} + +pub(super) fn rollback_history( + database: &Path, + history: &Path, + expected_hash: &str, +) -> Result { + let database = fs::canonicalize(database)?; + let _lock = WriterLock::acquire(&database, false)?; + reject_journal(&database)?; + let exact = suffix(&database, &format!(".guarded-history-{expected_hash}")); + if history != exact { + return Err(fail( + "history path is not the exact adjacent reviewed archive", + )); + } + let mut j = reviewed_journal(&database, history, expected_hash)?; + j.phase = Phase::RollingBack; + // Publish rollback intent before the first reverse rename; preserve the + // original committed history unchanged for audit and crash recovery. + create(&j)?; + recover_locked(&database, true, None) +} diff --git a/crates/core/src/guarded/mod.rs b/crates/core/src/guarded/mod.rs new file mode 100644 index 0000000..68a8d39 --- /dev/null +++ b/crates/core/src/guarded/mod.rs @@ -0,0 +1,404 @@ +//! Exact reviewed lifecycle operations. No Manager is opened for dry preflight. +//! Supported on Unix. Plans and journals are review artifacts, not authorization +//! tokens; callers must review the whole plan and restrict access to these files. +mod journal; +mod plan; +mod snapshot; +use crate::{Error, Result, config, hook, id::RiftId}; +pub use plan::{Destination, Kind, Outcome, PathChange, Plan, Provenance, Request, provenance}; +use plan::{expected_after, selected, validate}; +use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; +use serde::{Deserialize, Serialize}; +pub use snapshot::{Guard, Identity, PathIdentity, Row, State}; +#[cfg(unix)] +use std::os::fd::AsRawFd; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +fn fail(message: impl Into) -> Error { + Error::Guarded(message.into()) +} +fn suffix(path: &Path, text: &str) -> PathBuf { + let mut name = path.as_os_str().to_os_string(); + name.push(text); + PathBuf::from(name) +} + +/// Advisory native writer lock on the registry parent directory. This avoids +/// macOS SQLite's database-inode flock locking style and creates no lock file. +/// Registries in one directory are serialized; SQLite BEGIN IMMEDIATE excludes +/// non-cooperating SQL writers during moves. +#[cfg(unix)] +pub(crate) struct WriterLock { + _file: fs::File, +} +#[cfg(unix)] +impl WriterLock { + pub(crate) fn acquire(database: &Path, create: bool) -> Result { + let parent = database + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let file = fs::File::open(parent)?; + let mut acquired = false; + // A concurrent fork can briefly inherit a CLOEXEC descriptor until exec. + // Bound the wait; never assume a caller will eventually release ownership. + for attempt in 0..25 { + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + acquired = true; + break; + } + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::WouldBlock { + return Err(error.into()); + } + if attempt < 24 { + std::thread::sleep(std::time::Duration::from_millis(2)); + } + } + if !acquired { + return Err(fail( + "another native caller holds the registry directory lock", + )); + } + if fs::metadata(parent)?.ino() != file.metadata()?.ino() + || fs::metadata(parent)?.dev() != file.metadata()?.dev() + { + return Err(fail("registry directory changed during lock acquisition")); + } + match fs::symlink_metadata(database) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(fail("database cannot be a symlink")); + } + Err(e) if !create || e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()), + _ => {} + } + Ok(Self { _file: file }) + } +} +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; +#[cfg(not(unix))] +pub(crate) struct WriterLock; +#[cfg(not(unix))] +impl WriterLock { + pub(crate) fn acquire(_: &Path, _: bool) -> Result { + Ok(Self) + } +} + +pub(crate) fn reject_journal(database: &Path) -> Result<()> { + if fs::symlink_metadata(suffix(database, ".guarded-journal")).is_ok() { + return Err(fail( + "guarded journal exists; use guarded recovery or rollback first", + )); + } + Ok(()) +} + +/// Produces a dry review plan from a stable copied DB/WAL/SHM, with no source +/// SQLite connection, schema change, hook, marker write or lock acquisition. +pub fn preflight(database: impl AsRef, request: Request) -> Result { + let database = fs::canonicalize(database)?; + reject_journal(&database)?; + let database_identity = snapshot::identity(&database)?; + let (before, database_hashes) = snapshot::copied_state(&database)?; + let ids = selected(&request); + let guards = ids + .iter() + .map(|id| { + snapshot::guard( + before + .rows + .iter() + .find(|r| &r.id == id) + .ok_or_else(|| fail("unknown selected ID"))?, + ) + }) + .collect::>>()?; + let plan = Plan { + version: 1, + provenance: provenance(), + database, + database_identity, + database_hashes, + before, + guards, + request, + }; + validate(&plan, true)?; + if snapshot::hashes(&snapshot::database_bytes(&plan.database)?) != plan.database_hashes { + return Err(fail("database changed during filesystem preflight")); + } + revalidate_files(&plan)?; + Ok(plan) +} +fn revalidate_files(plan: &Plan) -> Result<()> { + for g in &plan.guards { + let row = plan.before.rows.iter().find(|r| r.id == g.id).unwrap(); + if snapshot::guard(row)? != *g { + return Err(fail( + "filesystem, marker, canonical path or alias guard changed", + )); + } + } + Ok(()) +} +fn open_existing(path: &Path) -> Result { + let db = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + db.busy_timeout(std::time::Duration::ZERO)?; + db.execute_batch("PRAGMA foreign_keys=ON; PRAGMA synchronous=FULL;")?; + Ok(db) +} +fn set_rows(db: &Connection, from: &State, to: &State) -> Result<()> { + // Disable cascading effects by deleting descendants first. Parent IDs and + // timestamps are restored from complete history, never regenerated. + let removed: Vec<_> = from + .rows + .iter() + .filter(|r| !to.rows.iter().any(|t| t.id == r.id)) + .collect(); + let mut pending = removed.clone(); + while !pending.is_empty() { + let leaves: Vec<_> = pending + .iter() + .filter(|r| !pending.iter().any(|c| c.parent_id.as_ref() == Some(&r.id))) + .cloned() + .collect(); + if leaves.is_empty() { + return Err(fail("cyclic registry ancestry")); + } + for row in leaves { + db.execute("DELETE FROM rift WHERE id=?1", [&row.id])?; + pending.retain(|r| r.id != row.id); + } + } + let mut pending: Vec<_> = to + .rows + .iter() + .filter(|r| !from.rows.iter().any(|t| t.id == r.id)) + .collect(); + while !pending.is_empty() { + let ready: Vec<_> = pending + .iter() + .filter(|r| !pending.iter().any(|p| Some(&p.id) == r.parent_id.as_ref())) + .cloned() + .collect(); + if ready.is_empty() { + return Err(fail("cyclic rollback ancestry")); + } + for row in ready { + db.execute( + "INSERT INTO rift(rowid,id,parent_id,path,created_at) VALUES(?1,?2,?3,?4,?5)", + params![ + row.rowid, + row.id, + row.parent_id, + row.path.to_str().ok_or_else(|| fail("non UTF-8 path"))?, + row.created_at + ], + )?; + pending.retain(|r| r.id != row.id); + } + } + for row in &to.rows { + if let Some(old) = from.rows.iter().find(|r| r.id == row.id) + && old.path != row.path + { + db.execute( + "UPDATE rift SET path=?1 WHERE id=?2", + params![ + row.path.to_str().ok_or_else(|| fail("non UTF-8 path"))?, + row.id + ], + )?; + } + } + Ok(()) +} + +fn hooks(plan: &Plan, name: &str) -> Result<()> { + if let Request::Trash { hooks: true, .. } = &plan.request { + for g in &plan.guards { + let row = plan.before.rows.iter().find(|r| r.id == g.id).unwrap(); + let current = if name == "preremove" { + g.canonical.clone() + } else { + match &plan.request { + Request::Trash { destinations, .. } => destinations + .iter() + .find(|d| d.id == g.id) + .unwrap() + .trash + .clone(), + _ => unreachable!(), + } + }; + let config = config::Config::load(¤t)?; + let steps = if name == "preremove" { + config.preremove() + } else { + config.postremove() + }; + hook::run( + name, + steps, + ¤t, + &g.canonical, + ¤t, + &RiftId::from_stored(g.id.clone()), + &RiftId::from_stored(row.parent_id.clone().unwrap_or_else(|| g.id.clone())), + )?; + } + } + Ok(()) +} + +/// Apply exactly this plan. A successful retirement keeps its journal until +/// rollback. Native Manager writers remain blocked while the journal exists. +pub fn apply(plan: &Plan) -> Result { + let _lock = WriterLock::acquire(&plan.database, false)?; + reject_journal(&plan.database)?; + if snapshot::identity(&plan.database)? != plan.database_identity + || snapshot::hashes(&snapshot::database_bytes(&plan.database)?) != plan.database_hashes + { + return Err(fail("stale database identity or byte snapshot")); + } + validate(plan, true)?; + revalidate_files(plan)?; + if snapshot::copied_state(&plan.database)?.0 != plan.before { + return Err(fail( + "reviewed registry state differs from immutable snapshot", + )); + } + hooks(plan, "preremove")?; + // Hooks can invoke outside tools. Recheck bytes, rows and all filesystem guards. + if snapshot::hashes(&snapshot::database_bytes(&plan.database)?) != plan.database_hashes { + return Err(fail("preremove changed database")); + } + validate(plan, true)?; + revalidate_files(plan)?; + let mut db = open_existing(&plan.database)?; + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + if snapshot::state(&tx)? != plan.before { + return Err(fail("registry changed before writer transaction")); + } + revalidate_files(plan)?; + let mut journal = journal::Journal::new(plan.clone()); + let mut committed = Some(false); + let transition: Result<()> = (|| { + checkpoint("journal_publish_failure")?; + journal::create(&journal)?; + checkpoint("before_rename")?; + journal::move_forward(&mut journal)?; + checkpoint("after_rename")?; + set_rows(&tx, &plan.before, &expected_after(plan))?; + checkpoint("database_failure")?; + if snapshot::state(&tx)? != expected_after(plan) { + return Err(fail( + "registry transition invariant failed; recover journal", + )); + } + checkpoint("before_commit")?; + committed = None; + tx.commit()?; + committed = Some(true); + checkpoint("after_commit")?; + journal.phase = journal::Phase::Committed; + journal::save(&journal)?; + Ok(()) + })(); + if let Err(error) = transition { + return Err(Error::GuardedInterrupted { + committed, + journal: suffix(&plan.database, ".guarded-journal"), + message: error.to_string(), + }); + } + let mut hook_error = hooks(plan, "postremove").err().map(|e| e.to_string()); + if let Err(error) = journal::validate_locations(&journal).and_then(|_| { + if snapshot::state(&db)? != expected_after(plan) { + return Err(fail("postremove changed registry state")); + } + Ok(()) + }) { + hook_error = Some(format!("{}; {error}", hook_error.unwrap_or_default())); + } + journal.postremove = if let Some(message) = &hook_error { + journal::HookStatus::Failed(message.clone()) + } else if matches!(plan.request, Request::Trash { hooks: true, .. }) { + journal::HookStatus::Succeeded + } else { + journal::HookStatus::Skipped + }; + journal::save(&journal).map_err(|error| Error::GuardedInterrupted { + committed: Some(true), + journal: suffix(&plan.database, ".guarded-journal"), + message: error.to_string(), + })?; + Ok(Outcome { + committed: true, + status: if hook_error.is_some() { + "committed_hook_failed" + } else { + "committed" + } + .into(), + journal: Some(suffix(&plan.database, ".guarded-journal")), + hook_error, + }) +} + +/// Resolve an interrupted operation by comparing complete old/new registry +/// states, then return moved directories to their original locations if the +/// registry did not commit. Committed operations retain the journal for rollback. +pub fn recover(database: impl AsRef) -> Result { + journal::recover(database.as_ref(), false, None) +} +/// Roll back only the exact journal hash and current full registry/filesystem +/// snapshot. Occupied destinations and changed files/markers fail closed. +pub fn rollback(database: impl AsRef, expected_journal_hash: &str) -> Result { + journal::recover(database.as_ref(), true, Some(expected_journal_hash)) +} + +/// Verify a completed operation and durably archive its full recovery history. +/// Pending journals cannot be finalized. Archived history remains rollbackable. +pub fn finalize(database: impl AsRef, expected_journal_hash: &str) -> Result { + journal::finalize(database.as_ref(), expected_journal_hash) +} +/// Start exact rollback from a reviewed adjacent committed history archive. +pub fn rollback_history( + database: impl AsRef, + history: impl AsRef, + expected_journal_hash: &str, +) -> Result { + journal::rollback_history(database.as_ref(), history.as_ref(), expected_journal_hash) +} + +#[cfg(feature = "fixture-faults")] +fn checkpoint(name: &str) -> Result<()> { + if std::env::var("RIFT_FIXTURE_FAULT").ok().as_deref() == Some(name) { + if name == "snapshot_pause" { + std::thread::sleep(std::time::Duration::from_millis(400)); + return Ok(()); + } + if name == "database_failure" + || name == "rename_failure" + || name == "rollback_commit_failure" + || name == "journal_publish_failure" + { + return Err(fail(format!("fixture fault: {name}"))); + } + std::process::exit(86); + } + Ok(()) +} +#[cfg(not(feature = "fixture-faults"))] +fn checkpoint(_: &str) -> Result<()> { + Ok(()) +} diff --git a/crates/core/src/guarded/plan.rs b/crates/core/src/guarded/plan.rs new file mode 100644 index 0000000..7b2ad3d --- /dev/null +++ b/crates/core/src/guarded/plan.rs @@ -0,0 +1,280 @@ +use super::*; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Provenance { + pub source_commit: String, + pub source_dirty: bool, + pub base_release: String, + pub base_release_commit: String, + pub version: String, + pub fixture_faults: bool, +} +pub fn provenance() -> Provenance { + Provenance { + source_commit: env!("RIFT_SOURCE_COMMIT").into(), + source_dirty: env!("RIFT_SOURCE_DIRTY") == "true", + base_release: "0.0.12".into(), + base_release_commit: "6b15e6a4dca5e95362324d5bd1c20134034d2aea".into(), + version: env!("CARGO_PKG_VERSION").into(), + fixture_faults: cfg!(feature = "fixture-faults"), + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PathChange { + pub id: String, + pub canonical: PathBuf, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Destination { + pub id: String, + pub trash: PathBuf, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + Root, + Leaf, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +pub enum Request { + Reconcile { + paths: Vec, + }, + Trash { + kind: Kind, + id: String, + closure: Vec, + destinations: Vec, + hooks: bool, + }, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Plan { + pub version: u32, + pub provenance: Provenance, + pub database: PathBuf, + pub database_identity: Identity, + pub database_hashes: Vec>, + pub before: State, + pub guards: Vec, + pub request: Request, +} +#[derive(Debug, Serialize, Deserialize)] +pub struct Outcome { + pub committed: bool, + pub status: String, + pub journal: Option, + pub hook_error: Option, +} + +pub(super) fn selected(request: &Request) -> Vec { + match request { + Request::Reconcile { paths } => paths.iter().map(|p| p.id.clone()).collect(), + Request::Trash { closure, .. } => closure.clone(), + } +} +fn unique(ids: &[String]) -> bool { + let set: std::collections::BTreeSet<_> = ids.iter().collect(); + set.len() == ids.len() +} +pub(super) fn validate(plan: &Plan, live: bool) -> Result<()> { + if plan.provenance != provenance() { + return Err(fail("plan was reviewed for a different native build")); + } + if plan.version != 1 { + return Err(fail("unsupported plan version")); + } + for row in &plan.before.rows { + let mut chain = std::collections::BTreeSet::new(); + let mut current = Some(row.id.as_str()); + while let Some(id) = current { + if !chain.insert(id) { + return Err(fail("cyclic registry ancestry")); + } + let r = plan + .before + .rows + .iter() + .find(|r| r.id == id) + .ok_or_else(|| fail("missing registry parent"))?; + current = r.parent_id.as_deref(); + } + } + let ids = selected(&plan.request); + if ids.is_empty() || !unique(&ids) { + return Err(fail("selection must be explicit, nonempty and unique")); + } + if plan.guards.iter().map(|g| g.id.clone()).collect::>() != ids { + return Err(fail("guard selection differs")); + } + for g in &plan.guards { + let row = plan + .before + .rows + .iter() + .find(|r| r.id == g.id) + .ok_or_else(|| fail("unknown reviewed ID"))?; + if g.stored != row.path { + return Err(fail("stored path guard differs")); + } + } + match &plan.request { + Request::Reconcile { paths } => { + let targets: std::collections::BTreeSet<_> = + paths.iter().map(|p| &p.canonical).collect(); + if targets.len() != paths.len() { + return Err(fail("duplicate canonical reconciliation target")); + } + for p in paths { + let g = plan.guards.iter().find(|g| g.id == p.id).unwrap(); + if p.canonical == g.stored + || p.canonical != g.canonical + || !p.canonical.is_absolute() + { + return Err(fail("reconcile target is not exact canonical path")); + } + if plan + .before + .rows + .iter() + .any(|r| r.id != p.id && r.path == p.canonical) + { + return Err(fail("canonical path belongs to another row")); + } + } + } + Request::Trash { + kind, + id, + closure, + destinations, + .. + } => { + let root = plan + .before + .rows + .iter() + .find(|r| &r.id == id) + .ok_or_else(|| fail("unknown target ID"))?; + if (*kind == Kind::Root) != root.parent_id.is_none() { + return Err(fail("ROOT/LEAF kind differs from registry ancestry")); + } + let mut descendants = vec![id.clone()]; + loop { + let next: Vec<_> = plan + .before + .rows + .iter() + .filter(|r| { + r.parent_id + .as_ref() + .is_some_and(|p| descendants.contains(p)) + && !descendants.contains(&r.id) + }) + .map(|r| r.id.clone()) + .collect(); + if next.is_empty() { + break; + } + descendants.extend(next); + } + descendants.sort(); + let mut exact = closure.clone(); + exact.sort(); + if descendants != exact { + return Err(fail( + "explicit closure differs from exact registered child closure", + )); + } + let dest_ids: Vec<_> = destinations.iter().map(|d| d.id.clone()).collect(); + let mut sorted = dest_ids.clone(); + sorted.sort(); + if !unique(&dest_ids) || sorted != exact { + return Err(fail("destinations differ from exact closure")); + } + for g in &plan.guards { + // Trash only canonical stored directories; reconciliation is a separate review. + if g.stored != g.canonical + || g.alias != g.directory + || (live && fs::symlink_metadata(&g.stored)?.file_type().is_symlink()) + { + return Err(fail("reconcile aliases before exact trash")); + } + if plan + .guards + .iter() + .any(|other| other.id != g.id && g.canonical.starts_with(&other.canonical)) + { + return Err(fail( + "physically nested selected directories are unsupported", + )); + } + for (_, old_path, _) in &plan.before.trash { + let old = if live { + fs::canonicalize(old_path).unwrap_or_else(|_| PathBuf::from(old_path)) + } else { + PathBuf::from(old_path) + }; + if old.starts_with(&g.canonical) { + return Err(fail("old trash history lies inside selected directory")); + } + } + if plan.database.starts_with(&g.canonical) { + return Err(fail("registry must be outside selected directories")); + } + for row in &plan.before.rows { + if !closure.contains(&row.id) { + let actual = if live { + fs::canonicalize(&row.path).unwrap_or_else(|_| row.path.clone()) + } else { + row.path.clone() + }; + if actual.starts_with(&g.canonical) { + return Err(fail( + "unrelated managed directory lies inside selected directory", + )); + } + } + } + let dest = &destinations.iter().find(|d| d.id == g.id).unwrap().trash; + if dest.parent() != g.canonical.parent() + || dest.file_name().and_then(|s| s.to_str()) + != Some(format!(".rift-trash-{}", g.id).as_str()) + { + return Err(fail("trash must be explicit adjacent .rift-trash-ID")); + } + if live && fs::symlink_metadata(dest).is_ok() { + return Err(fail("trash destination is occupied")); + } + if plan + .before + .rows + .iter() + .any(|r| r.path.starts_with(dest) || dest.starts_with(&r.path)) + { + return Err(fail("trash overlaps a managed path")); + } + } + } + } + Ok(()) +} + +pub(super) fn expected_after(plan: &Plan) -> State { + let mut after = plan.before.clone(); + match &plan.request { + Request::Reconcile { paths } => { + for p in paths { + after.rows.iter_mut().find(|r| r.id == p.id).unwrap().path = p.canonical.clone(); + } + } + Request::Trash { closure, .. } => after.rows.retain(|r| !closure.contains(&r.id)), + } + after +} diff --git a/crates/core/src/guarded/snapshot.rs b/crates/core/src/guarded/snapshot.rs new file mode 100644 index 0000000..991889d --- /dev/null +++ b/crates/core/src/guarded/snapshot.rs @@ -0,0 +1,321 @@ +use super::*; +use sha2::{Digest, Sha256}; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Row { + pub rowid: i64, + pub id: String, + pub parent_id: Option, + pub path: PathBuf, + pub created_at: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct State { + pub rows: Vec, + pub trash: Vec<(String, String, i64)>, + pub schema: Vec<(String, String, Option)>, + pub auxiliary_tables: Vec<(String, String)>, + pub user_version: i64, + pub application_id: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Identity { + pub device: u64, + pub inode: u64, + pub mode: u32, + pub birth: Option<(i64, i64)>, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PathIdentity { + pub path: PathBuf, + pub identity: Identity, + pub link: Option, + pub link_change_time: Option<(i64, i64)>, +} +fn path_identity(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path)?; + #[cfg(unix)] + let link_change_time = if metadata.file_type().is_symlink() { + Some((metadata.ctime(), metadata.ctime_nsec())) + } else { + None + }; + #[cfg(not(unix))] + let link_change_time = None; + Ok(PathIdentity { + link_change_time, + path: path.to_path_buf(), + identity: identity(path)?, + link: if metadata.file_type().is_symlink() { + Some(fs::read_link(path)?) + } else { + None + }, + }) +} +fn path_chain(path: &Path) -> Result> { + let mut chain = path.ancestors().collect::>(); + chain.reverse(); + chain.into_iter().map(path_identity).collect() +} +pub(super) fn verify_ancestors(g: &Guard) -> Result<()> { + for entry in g.path_chain.iter().filter(|entry| entry.path != g.stored) { + if path_identity(&entry.path)? != *entry { + return Err(fail("stored path ancestor or alias identity changed")); + } + } + Ok(()) +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Guard { + pub id: String, + pub stored: PathBuf, + pub canonical: PathBuf, + pub alias: Identity, + pub path_chain: Vec, + pub directory: Identity, + pub marker_hash: String, + pub tree_hash: String, +} + +pub(super) fn hash(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(unix)] +pub(super) fn identity(path: &Path) -> Result { + let meta = fs::symlink_metadata(path)?; + #[cfg(target_os = "macos")] + let birth = { + use std::os::macos::fs::MetadataExt; + Some((meta.st_birthtime(), meta.st_birthtime_nsec())) + }; + #[cfg(not(target_os = "macos"))] + let birth = None; + Ok(Identity { + birth, + device: meta.dev(), + inode: meta.ino(), + mode: meta.mode(), + }) +} + +// Every entry is length-framed, so different names/contents cannot have the same +// encoding. Symlinks are hashed as links; traversal never follows them. +#[cfg(unix)] +pub(super) fn tree_hash(path: &Path) -> Result { + let mut digest = Sha256::new(); + let mut entries = walkdir::WalkDir::new(path) + .follow_links(false) + .into_iter() + .collect::, _>>()?; + entries.sort_by(|a, b| a.path().cmp(b.path())); + for entry in entries { + let relative = entry + .path() + .strip_prefix(path) + .map_err(|e| fail(e.to_string()))?; + let name = relative.as_os_str().as_encoded_bytes(); + digest.update((name.len() as u64).to_le_bytes()); + digest.update(name); + let meta = fs::symlink_metadata(entry.path())?; + digest.update(meta.mode().to_le_bytes()); + // Preserve filesystem identity as well as bytes across rename/rollback. + digest.update(meta.dev().to_le_bytes()); + digest.update(meta.ino().to_le_bytes()); + let data = if meta.is_file() { + fs::read(entry.path())? + } else if meta.file_type().is_symlink() { + fs::read_link(entry.path())? + .as_os_str() + .as_encoded_bytes() + .to_vec() + } else if meta.is_dir() { + Vec::new() + } else { + return Err(fail("unsupported entry in guarded directory")); + }; + digest.update((data.len() as u64).to_le_bytes()); + digest.update(data); + } + Ok(format!("{:x}", digest.finalize())) +} + +#[cfg(not(unix))] +pub(super) fn identity(_: &Path) -> Result { + Err(fail( + "guarded lifecycle requires Unix filesystem identity and locking", + )) +} +#[cfg(not(unix))] +pub(super) fn tree_hash(_: &Path) -> Result { + Err(fail( + "guarded lifecycle requires Unix filesystem identity and locking", + )) +} + +pub(super) fn guard(row: &Row) -> Result { + let canonical = fs::canonicalize(&row.path)?; + let marker_path = canonical.join(".rift"); + if !fs::symlink_metadata(&marker_path)?.file_type().is_file() { + return Err(fail("marker must be a regular file")); + } + let marker = fs::read(&marker_path)?; + if std::str::from_utf8(&marker).ok().map(str::trim) != Some(row.id.as_str()) { + return Err(fail("marker ID does not match reviewed row")); + } + if !canonical.is_dir() { + return Err(fail("guarded path is not a directory")); + } + Ok(Guard { + id: row.id.clone(), + stored: row.path.clone(), + alias: identity(&row.path)?, + path_chain: path_chain(&row.path)?, + directory: identity(&canonical)?, + marker_hash: hash(&marker), + tree_hash: tree_hash(&canonical)?, + canonical, + }) +} + +pub(super) fn moved_guard(g: &Guard, path: &Path) -> Result<()> { + verify_ancestors(g)?; + if identity(path)? != g.directory + || tree_hash(path)? != g.tree_hash + || hash(&fs::read(path.join(".rift"))?) != g.marker_hash + { + return Err(fail("moved directory identity, marker or files changed")); + } + Ok(()) +} + +pub(super) fn state(db: &Connection) -> Result { + let mut info = db.prepare("PRAGMA table_info(rift)")?; + let fields = info + .query_map([], |r| r.get::<_, String>(1))? + .collect::, _>>()?; + if fields != ["id", "parent_id", "path", "created_at"] { + return Err(fail( + "unsupported active registry fields; refusing lossy history", + )); + } + + let mut stmt = db.prepare("SELECT rowid,id,parent_id,path,created_at FROM rift ORDER BY id")?; + let rows = stmt + .query_map([], |r| { + Ok(Row { + rowid: r.get(0)?, + id: r.get(1)?, + parent_id: r.get(2)?, + path: PathBuf::from(r.get::<_, String>(3)?), + created_at: r.get(4)?, + }) + })? + .collect::, _>>()?; + let mut stmt = db.prepare("SELECT id,path,removed_at FROM trash ORDER BY id")?; + let trash = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? + .collect::, _>>()?; + let mut stmt = db.prepare("SELECT type,name,sql FROM sqlite_master ORDER BY type,name")?; + let schema: Vec<(String, String, Option)> = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? + .collect::, _>>()?; + let mut auxiliary_tables = Vec::new(); + for (kind, name, _) in &schema { + if kind != "table" || name == "rift" { + continue; + } + let query = format!("SELECT * FROM \"{}\"", name.replace('"', "\"\"")); + let mut statement = db.prepare(&query)?; + let count = statement.column_count(); + let values = statement + .query_map([], |row| { + let mut encoded = Vec::new(); + for index in 0..count { + use rusqlite::types::ValueRef; + let (tag, bytes) = match row.get_ref(index)? { + ValueRef::Null => (0, Vec::new()), + ValueRef::Integer(value) => (1, value.to_le_bytes().to_vec()), + ValueRef::Real(value) => (2, value.to_bits().to_le_bytes().to_vec()), + ValueRef::Text(value) => (3, value.to_vec()), + ValueRef::Blob(value) => (4, value.to_vec()), + }; + encoded.push(tag); + encoded.extend((bytes.len() as u64).to_le_bytes()); + encoded.extend(bytes); + } + Ok(encoded) + })? + .collect::, _>>()?; + let mut values = values; + values.sort(); + let mut digest = Sha256::new(); + digest.update((values.len() as u64).to_le_bytes()); + for row in values { + digest.update((row.len() as u64).to_le_bytes()); + digest.update(row); + } + auxiliary_tables.push((name.clone(), format!("{:x}", digest.finalize()))); + } + let user_version = db.query_row("PRAGMA user_version", [], |r| r.get(0))?; + let application_id = db.query_row("PRAGMA application_id", [], |r| r.get(0))?; + Ok(State { + rows, + trash, + schema, + auxiliary_tables, + user_version, + application_id, + }) +} + +pub(super) fn database_bytes(path: &Path) -> Result>>> { + [ + path.to_path_buf(), + suffix(path, "-wal"), + suffix(path, "-shm"), + ] + .iter() + .map(|p| match fs::read(p) { + Ok(data) => Ok(Some(data)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound && p != path => Ok(None), + Err(e) => Err(e.into()), + }) + .collect() +} + +pub(super) fn hashes(bytes: &[Option>]) -> Vec> { + bytes.iter().map(|b| b.as_ref().map(|b| hash(b))).collect() +} + +// Read the main DB and live WAL twice. Never open SQLite on the source, never +// create source SHM, and never use immutable=1 (which can ignore live WAL). +pub(super) fn copied_state(path: &Path) -> Result<(State, Vec>)> { + let first = database_bytes(path)?; + checkpoint("snapshot_pause")?; + let temp = tempfile::TempDir::new()?; + let copy = temp.path().join("snapshot.sqlite"); + for (index, extension) in ["", "-wal", "-shm"].iter().enumerate() { + if let Some(bytes) = &first[index] { + fs::write(suffix(©, extension), bytes)?; + } + } + let db = Connection::open(©)?; + let state = state(&db)?; + if database_bytes(path)? != first { + return Err(fail("database changed during dry snapshot")); + } + Ok((state, hashes(&first))) +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 2e4b878..8686fa4 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,6 +1,7 @@ mod config; mod filter; mod git; +pub mod guarded; mod hook; mod id; mod marker; @@ -26,6 +27,16 @@ pub type Result = std::result::Result; #[derive(Debug, Error)] pub enum Error { + #[error( + "guarded lifecycle interrupted (committed={committed:?}, journal={journal}): {message}" + )] + GuardedInterrupted { + committed: Option, + journal: PathBuf, + message: String, + }, + #[error("guarded lifecycle: {0}")] + Guarded(String), #[error("{0}")] Io(#[from] std::io::Error), #[error("{operation} failed for {path}: {source}")] diff --git a/crates/core/src/registry.rs b/crates/core/src/registry.rs index 7e653ed..4015f82 100644 --- a/crates/core/src/registry.rs +++ b/crates/core/src/registry.rs @@ -39,10 +39,14 @@ impl SubtreeScope { pub(crate) struct Registry { database: Connection, + _writer_lock: crate::guarded::WriterLock, } impl Registry { pub(crate) fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + let writer_lock = crate::guarded::WriterLock::acquire(path, true)?; + crate::guarded::reject_journal(path)?; let database = Connection::open(path)?; database.execute_batch( "PRAGMA busy_timeout = 2000; @@ -61,7 +65,10 @@ impl Registry { removed_at INTEGER NOT NULL );", )?; - Ok(Self { database }) + Ok(Self { + database, + _writer_lock: writer_lock, + }) } pub(crate) fn insert_root(&self, id: &RiftId, path: &Path) -> Result<()> { diff --git a/crates/core/src/rpc.rs b/crates/core/src/rpc.rs index e77bdad..32e4938 100644 --- a/crates/core/src/rpc.rs +++ b/crates/core/src/rpc.rs @@ -82,6 +82,7 @@ impl Failure { impl From for Failure { fn from(error: Error) -> Self { let (code, path) = match &error { + Error::Guarded(_) | Error::GuardedInterrupted { .. } => ("guarded_lifecycle", None), Error::Io(_) => ("io", None), Error::IoAt { path, .. } => ("io", Some(path.clone())), Error::Database(_) => ("database", None), diff --git a/docs/guarded-lifecycle.md b/docs/guarded-lifecycle.md new file mode 100644 index 0000000..3767d8b --- /dev/null +++ b/docs/guarded-lifecycle.md @@ -0,0 +1,99 @@ +# Guarded native lifecycle + +Use this API to review exact canonical paths, move an exact registered closure to adjacent trash, and restore it. The API is in `rift::guarded`. The CLI requires an explicit database. It does not use the default registry. + +## Ownership gate + +Before an apply, recovery, finalization, or rollback, stop all old native callers and all outside tools that can change the database or selected directories. Keep them stopped until the active journal is resolved or finalized. Release 0.0.12 does not use the new native lock or journal fence. Schema compatibility does not give old callers safe concurrent filesystem access. + +Candidate `Manager` callers share an exclusive lock on the registry parent directory. They refuse to open a registry with an active guarded journal. Registries in the same directory are serialized. The lock has a short bounded wait. SQLite `BEGIN IMMEDIATE` excludes competing SQL writers during filesystem and registry transitions. These controls do not stop an outside process from changing files. Guards detect such changes and refuse to overwrite them. Do not treat detection as exclusive ownership. + +## Commands + +```sh +rift guarded provenance +rift --database /tmp/synthetic/registry.sqlite guarded preflight request.json > plan.json +rift --database /tmp/synthetic/registry.sqlite guarded apply plan.json +rift --database /tmp/synthetic/registry.sqlite guarded recover +rift --database /tmp/synthetic/registry.sqlite guarded finalize --journal-hash REVIEWED_SHA256 +rift --database /tmp/synthetic/registry.sqlite guarded rollback --journal-hash REVIEWED_SHA256 +rift --database /tmp/synthetic/registry.sqlite guarded rollback --history EXACT_HISTORY_PATH --journal-hash REVIEWED_SHA256 +``` + +Read and review the complete plan before apply. Read and hash the current journal before finalize or rollback. A hash refers to its exact current bytes. Protect plans and journals from edits. They are review artifacts, not signed authorization tokens. + +A reconciliation request changes only these row paths: + +```json +{ + "operation": "reconcile", + "paths": [{"id": "SYNTHETIC-ROOT", "canonical": "/tmp/synthetic/current-source"}] +} +``` + +The old stored path can be a symlink to the reviewed directory. The directory, marker, Rift ID, parent links, timestamps, rowid, old alias, and unrelated rows stay intact. Reconciliation does not rename a directory or change an alias. A target must be the exact actual canonical path. Duplicate targets and paths owned by another row are rejected. + +An exact root trash request has this form: + +```json +{ + "operation": "trash", + "kind": "root", + "id": "SYNTHETIC-ROOT", + "closure": ["SYNTHETIC-ROOT", "SYNTHETIC-CHILD"], + "destinations": [ + {"id": "SYNTHETIC-ROOT", "trash": "/tmp/synthetic/.rift-trash-SYNTHETIC-ROOT"}, + {"id": "SYNTHETIC-CHILD", "trash": "/tmp/synthetic/clones/.rift-trash-SYNTHETIC-CHILD"} + ], + "hooks": true +} +``` + +`root` requires no parent. `leaf` requires a parent. A leaf with children requires the exact explicit child closure. The closure includes the selected ID and every registered descendant. The CLI does not select a wildcard subtree. Each destination must be an absent adjacent `.rift-trash-ID` path. Reconcile stored aliases in a separate reviewed operation before trash. A root is moved with its marker; it is never only unregistered. + +Physically nested selected directories are rejected. Unrelated active directories and old trash objects inside a selected directory are rejected. The registry must be outside the selected directories. No source Git operation, directory deletion, force option, old root unregister, marker bypass, or GC is used. + +## Preflight and plan + +`preflight(database, request)` returns a versioned `Plan`. It contains the build provenance, exact database identity and DB/WAL/SHM hashes, complete active rows with rowids and all stored fields, old trash rows, schema, auxiliary-table fingerprints, database header versions, and selected filesystem guards. Guards include stored and canonical paths, directory identity, ancestor and symlink identities, marker ID/hash, and the full directory tree hash. + +Dry preflight reads source files and opens SQLite only on a temporary byte copy of the DB, live WAL, and SHM. It compares source bytes again after the copy and filesystem review. It does not open a writable `Manager`, create source locks or directories, change schema, run hooks, or write markers. It does not use `immutable=1`. An unstable copy fails. Temporary inspection files are outside the source registry and selected directories. + +`apply(plan)` requires the same native build provenance and exact snapshot. It rechecks the full logical state before opening source SQLite. Unknown auxiliary table data and old trash rows are preserved. Extra active-row fields are rejected to prevent lossy history. The current release fields are supported without migration. + +## Mutation and crash order + +1. Acquire the native directory lock. Reject an active journal. Check the exact plan, DB/WAL/SHM bytes, logical state, and filesystem guards. +2. Run preremove hooks if requested. Recheck database bytes and all guards. A hook failure causes no retirement. +3. Open the existing registry without schema creation. Start `BEGIN IMMEDIATE`. Recheck registry and filesystem state. +4. Publish the full prepared journal atomically with exclusive creation. Sync the journal and its parent directory before any rename. +5. Rename each exact directory without replacement. Sync its parent and verify the moved identity, marker, and files. +6. Change only the reviewed active rows. Preserve all old trash rows and auxiliary data. Verify the complete expected registry state. Commit with SQLite full synchronous mode. +7. Mark the journal committed. Run postremove hooks in the moved directories. Recheck moved files and registry state, then persist hook status. A postremove failure returns `committed=true`, a failure status and message, and a nonzero CLI exit. It does not roll back the retirement. + +The active journal blocks candidate `Manager` writers until recovery, rollback, or explicit finalization. A crash can leave any subset of the exact directories moved. Recovery compares the complete old and new registry states and each original/trash location. If the DB is old, recovery restores moved directories. If the DB is new, recovery reports committed and retains evidence. It never completes an uncommitted retirement. An ambiguous location or changed state is rejected. Hooks are not replayed. If hook completion is unconfirmed after a crash, rollback is available and finalize is rejected. + +Interrupted API errors include `committed: Some(false)`, `Some(true)`, or `None` if SQLite commit returned an error and status requires recovery, plus the journal path. A crash has no return value; use recovery to determine the committed state. + +## Finalize and rollback + +`finalize(database, journal_hash)` accepts only a completed committed journal with confirmed or skipped hook completion. It verifies the full committed registry and moved filesystem again under the native and SQLite writer locks. It atomically archives the journal to the adjacent `.guarded-history-SHA256` path and syncs the parent. It deletes no history or trash. It then permits normal candidate callers and a new dry plan. This supports reconcile → finalize → trash → finalize. + +`rollback(database, journal_hash)` restores an active journal. `rollback_history(database, history_path, journal_hash)` restores an exact adjacent committed history archive. History rollback first requires the current full state to equal the archived committed state. It publishes a durable rolling-back active journal before any reverse rename. The original committed history stays unchanged. + +Rollback verifies the full closure before moving anything. It refuses occupied original destinations, stale row/schema/auxiliary-table state, changed aliases, marker or file changes, and wrong history paths or hashes. It restores original Rift IDs, parent IDs, paths, timestamps, rowids, markers, and files. A reverse rename or DB commit failure retains recoverable intent. Recovery can finish interrupted rollback. Completed restoration archives its journal without overwriting the original history. + +History rollback is intentionally strict: a later unrelated registry write makes that history stale. Use the original build to recover or roll back its journals. No history pruning or trash deletion command is provided. Current native rename support is Linux and macOS; APFS validation is available in this change. Linux runtime validation remains required before Linux use. + +## Repeatable synthetic verification + +Failure modes are listed in `e2e/guarded-failure-modes.json`. The CLI E2E runner accepts only explicit binaries and creates new synthetic temporary sources and registries: + +```sh +cargo build --locked -p rift-cli --features fixture-faults --target-dir target/fixture +python3 e2e/guarded-lifecycle.py --binary target/fixture/debug/rift \ + --legacy-binary /tmp/public-release/rift --legacy-binary-sha256 VERIFIED_PUBLIC_RELEASE_HASH \ + --out /tmp/new-fixture-receipt +``` + +The release binary must be the verified public 0.0.12 artifact. The runner records scenario results, command output, binary provenance/hashes, before/after DB/WAL/SHM hashes, row/schema state, filesystem hashes, and failure-mode mappings. Keep receipts outside the repository. The `fixture-faults` feature provides explicit synthetic crash/failure checkpoints; it is off in the production build. Build the production candidate with `cargo build --locked -p rift-cli --release` from the clean reviewed commit. `guarded provenance` reports the exact source commit, dirty state, base release, and feature state. Never dispatch a fixture binary against a real registry. diff --git a/e2e/guarded-failure-modes.json b/e2e/guarded-failure-modes.json new file mode 100644 index 0000000..8b45adf --- /dev/null +++ b/e2e/guarded-failure-modes.json @@ -0,0 +1,226 @@ +{ + "version": 1, + "fixture_only": true, + "failure_modes": [ + { + "id": "dry_db_wal_shm_mutation", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "live_wal_ignored", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "unstable_database_copy", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "stale_database_snapshot", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "marker_id_mismatch", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "marker_hash_mismatch", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "unexpected_row_id", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "unexpected_parent", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "unexpected_children", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "stored_path_mismatch", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "canonical_path_mismatch", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "alias_identity_changed", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "directory_identity_changed", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "root_kind_mismatch", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "leaf_kind_mismatch", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "closure_incomplete", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "unrelated_ancestry_retired", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "protected_ancestry_retired", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "preremove_failure", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "preremove_state_change", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "postremove_failure_committed", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "rename_destination_occupied", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "rename_failure", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "database_failure", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "crash_before_rename", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "crash_after_rename", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "crash_before_commit", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "crash_after_commit", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "crash_during_rollback", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "rollback_destination_occupied", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "rollback_stale_rows", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "rollback_marker_or_files_changed", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "concurrent_native_writer", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "sqlite_writer_contention", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "legacy_native_writer_during_journal", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "source_git_mutation", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "postremove_state_change", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "unknown_tables_stale", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "root_old_trash_nested", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "alias_ancestor_identity_changed", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "legacy_noncooperative_writer_stale", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "rowid_restoration", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "journal_publish_failure", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "rename_partial_failure", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "rollback_commit_failure", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "crash_after_rollback_commit", + "expected": "reject without retirement, or retain durable journal with exact committed status for recovery" + }, + { + "id": "finalize_uncommitted", + "expected": "reject without mutation, or retain durable exact history with committed status" + }, + { + "id": "finalize_stale_registry", + "expected": "reject without mutation, or retain durable exact history with committed status" + }, + { + "id": "finalize_changed_files", + "expected": "reject without mutation, or retain durable exact history with committed status" + }, + { + "id": "history_rollback_stale_state", + "expected": "reject without mutation, or retain durable exact history with committed status" + }, + { + "id": "history_rollback_wrong_path", + "expected": "reject without mutation, or retain durable exact history with committed status" + }, + { + "id": "finalize_hash_mismatch", + "expected": "reject without mutation, or retain durable exact history with committed status" + }, + { + "id": "crash_after_finalize_archive", + "expected": "reject without mutation, or retain durable exact history with committed status" + }, + { + "id": "crash_before_postremove", + "expected": "report committed with unconfirmed hook completion; no replay; reject finalize; permit exact rollback" + }, + { + "id": "postremove_unconfirmed_finalize", + "expected": "report committed with unconfirmed hook completion; no replay; reject finalize; permit exact rollback" + } + ] +} diff --git a/e2e/guarded-lifecycle.py b/e2e/guarded-lifecycle.py new file mode 100644 index 0000000..beac9cc --- /dev/null +++ b/e2e/guarded-lifecycle.py @@ -0,0 +1,494 @@ +#!/usr/bin/env python3 +"""Synthetic native CLI E2E. All fixture SQL is limited to newly created temp DBs. +No production database/path defaults exist. The release binary is a generic input. +""" +import argparse, fcntl, hashlib, json, os, pathlib, shutil, sqlite3, subprocess, tempfile, time, traceback + +parser = argparse.ArgumentParser() +parser.add_argument('--binary', required=True) +parser.add_argument('--legacy-binary', required=True) +parser.add_argument('--legacy-binary-sha256', required=True) +parser.add_argument('--out', required=True) +parser.add_argument('--production', action='store_true') +parser.add_argument('--expected-source-commit') +args = parser.parse_args() +BINARY = str(pathlib.Path(args.binary).resolve()) +LEGACY = str(pathlib.Path(args.legacy_binary).resolve()) +OUT = pathlib.Path(args.out).resolve() +OUT.mkdir(parents=True, exist_ok=False) +BASE = pathlib.Path(tempfile.mkdtemp(prefix='rift-guarded-fixture-')).resolve() +RESULTS = [] +COMMANDS = [] +PROVENANCE=json.loads(subprocess.run([BINARY,'guarded','provenance'],capture_output=True,text=True,check=True).stdout) +if args.expected_source_commit: + assert PROVENANCE['source_commit']==args.expected_source_commit and not PROVENANCE['source_dirty'], 'source provenance differs' +assert PROVENANCE['fixture_faults'] != args.production, 'incorrect fixture/production binary' +FAULT_SCENARIOS={ + 'fault-'+name for name in ('rename_failure','database_failure','before_rename','after_each_rename','after_rename','before_commit','after_commit') +} | {'crash-during-rollback','noncooperative-legacy-writer-stale-recovery','noncooperative-filesystem-stale-recovery','unstable-live-database-copy','rollback-db-commit-failure','crash-after-rollback-commit','journal-publish-failure','finalize-rejects-uncommitted-journal','crash-after-finalize-archive','crash-before-postremove-no-replay-reject-finalize'} + +def sha(data): return hashlib.sha256(data).hexdigest() +def file_hash(path): return sha(path.read_bytes()) if path.is_file() else None +assert file_hash(pathlib.Path(LEGACY))==args.legacy_binary_sha256, 'legacy artifact hash differs' + +def manifest(path): + result = {} + if not path.exists(): return result + for p in sorted(path.rglob('*')): + rel = str(p.relative_to(path)) + stat = p.lstat() + if p.is_symlink(): result[rel] = {'link': os.readlink(p), 'inode': stat.st_ino} + elif p.is_file(): result[rel] = {'sha256': file_hash(p), 'inode': stat.st_ino, 'mode': stat.st_mode} + elif p.is_dir(): result[rel] = {'directory': True, 'inode': stat.st_ino} + return result + +def physical(db): + return {suffix: file_hash(pathlib.Path(str(db)+suffix)) for suffix in ('', '-wal', '-shm')} + +def rows(db): + # Copy live bytes to inspect without touching source SHM or taking source locks. + with tempfile.TemporaryDirectory(prefix='rift-e2e-read-') as tmp: + copy = pathlib.Path(tmp)/'copy.sqlite' + for suffix in ('', '-wal', '-shm'): + src=pathlib.Path(str(db)+suffix) + if src.exists(): shutil.copyfile(src, str(copy)+suffix) + con=sqlite3.connect(copy) + state={table: con.execute(f'SELECT * FROM {table} ORDER BY id').fetchall() for table in ('rift','trash')} + state['rowids']=con.execute('SELECT id,rowid FROM rift ORDER BY id').fetchall() + state['schema']=con.execute('SELECT type,name,sql FROM sqlite_master ORDER BY type,name').fetchall() + con.close() + return state + +def call(db, *cmd, binary=BINARY, fault=None, expect=0): + argv=[binary,'--database',str(db),*map(str,cmd)] + env=os.environ.copy() + env.pop('RIFT_FIXTURE_FAULT',None) + if fault: env['RIFT_FIXTURE_FAULT']=fault + p=subprocess.run(argv,env=env,capture_output=True,text=True,timeout=20,cwd=BASE) + COMMANDS.append({'argv':argv,'fault':fault,'exit':p.returncode,'stdout':p.stdout,'stderr':p.stderr}) + if expect is not None: assert p.returncode==expect,(argv,p.returncode,p.stderr,p.stdout) + return p + +class Fixture: + def __init__(self,name,legacy=False): + self.home=BASE/name; self.home.mkdir() + self.db=self.home/'fixture.sqlite'; self.root=self.home/'root'; self.root.mkdir() + (self.root/'data.txt').write_text('synthetic source bytes\n') + subprocess.run(['git','init','-q',str(self.root)],check=True,capture_output=True) + subprocess.run(['git','-C',str(self.root),'config','user.email','fixture@example.invalid'],check=True) + subprocess.run(['git','-C',str(self.root),'config','user.name','Synthetic Fixture'],check=True) + subprocess.run(['git','-C',str(self.root),'add','data.txt'],check=True) + subprocess.run(['git','-C',str(self.root),'commit','-qm','synthetic fixture'],check=True) + b=LEGACY if legacy else BINARY + call(self.db,'init','--here',self.root,binary=b) + self.child=pathlib.Path(call(self.db,'create',self.root,'--name','child','--into',self.home/'clones','--no-hooks',binary=b).stdout.strip()) + self.sibling=pathlib.Path(call(self.db,'create',self.root,'--name','sibling','--into',self.home/'clones','--no-hooks',binary=b).stdout.strip()) + self.grandchild=pathlib.Path(call(self.db,'create',self.child,'--name','grandchild','--into',self.home/'clones','--no-hooks',binary=b).stdout.strip()) + self.ids={p.name:(p/'.rift').read_text().strip() for p in (self.root,self.child,self.sibling,self.grandchild)} + self.original=rows(self.db) + self.original_files={str(p):manifest(p) for p in (self.root,self.child,self.sibling,self.grandchild)} + self.before_physical=physical(self.db) + def path_for(self,id): return next(pathlib.Path(r[2]) for r in rows(self.db)['rift'] if r[0]==id) + def request(self,target='root',hooks=False,closure=None,kind=None): + id=self.ids[target] + if closure is None: closure=[self.ids[n] for n in (['root','child','sibling','grandchild'] if target=='root' else ['child','grandchild'] if target=='child' else [target])] + return {'operation':'trash','kind':kind or ('root' if target=='root' else 'leaf'),'id':id,'closure':closure,'destinations':[{'id':i,'trash':str(self.path_for(i).parent/('.rift-trash-'+i))} for i in closure], 'hooks':hooks} + def plan(self,request): + req=self.home/'request.json'; req.write_text(json.dumps(request)) + before=physical(self.db); before_files=manifest(self.home) + p=call(self.db,'guarded','preflight',req) + assert before==physical(self.db), 'dry preflight changed DB/WAL/SHM' + assert before_files==manifest(self.home), 'dry preflight changed fixture files/locks' + plan=self.home/'plan.json'; plan.write_text(p.stdout) + return plan + def apply(self,plan,fault=None,expect=0): return call(self.db,'guarded','apply',plan,fault=fault,expect=expect) + def journal(self): return pathlib.Path(str(self.db)+'.guarded-journal') + def rollback(self,expect=0,fault=None,hash=None): + return call(self.db,'guarded','rollback','--journal-hash',hash or file_hash(self.journal()),expect=expect,fault=fault) + def restored(self): + assert rows(self.db)==self.original,'all row fields/schema/history not restored' + for path,original in self.original_files.items(): assert manifest(pathlib.Path(path))==original,'files/markers/Git changed' + def receipt(self): + return {'fixture':str(self.home),'before_db_wal_shm':self.before_physical,'after_db_wal_shm':physical(self.db),'before_state':self.original,'after_state':rows(self.db),'before_files':self.original_files,'after_files':{p:manifest(pathlib.Path(p)) for p in self.original_files},'all_files':manifest(self.home)} + +CURRENT=None + +def scenario(name,modes,fn): + global CURRENT + CURRENT=None + start=len(COMMANDS) + skipped=args.production and name in FAULT_SCENARIOS + try: + if not skipped: fn() + result={'scenario':name,'passed':True,'failure_modes':modes} + except Exception as error: + result={'scenario':name,'passed':False,'error':str(error),'traceback':traceback.format_exc(),'failure_modes':modes} + result['skipped']=skipped + if skipped: result['skip_reason']='explicit fixture-only fault injection' + if CURRENT: result.update(CURRENT.receipt()) + result['commands']=COMMANDS[start:] + path=OUT/(name+'.json'); path.write_text(json.dumps(result,indent=2)+'\n') + RESULTS.append({'scenario':name,'passed':result['passed'],'skipped':skipped,'path':str(path),'sha256':file_hash(path),'failure_modes':modes}) + print(name, 'SKIP' if skipped else 'PASS' if result['passed'] else 'FAIL',flush=True) + +def fixture(name,legacy=False): + global CURRENT + CURRENT=Fixture(name,legacy); return CURRENT + +def success(target='root',legacy=False): + f=fixture('success-'+target+('-legacy' if legacy else ''),legacy) + if legacy: + # True release creates a trash row; preserve it and unknown extra schema. + old=f.home/'old'; old.mkdir(); (old/'bytes').write_text('old trash synthetic\n') + call(f.db,'init','--here',old,binary=LEGACY) + old_child=pathlib.Path(call(f.db,'create',old,'--name','old-child','--into',f.home/'old-clones','--no-hooks',binary=LEGACY).stdout.strip()) + call(f.db,'remove',old_child,'--no-hooks',binary=LEGACY) + with sqlite3.connect(f.db) as db: + db.execute('CREATE TABLE legacy_extra (value TEXT)') + db.execute("INSERT INTO legacy_extra VALUES ('synthetic unchanged')") + f.original=rows(f.db) + old_trash=f.original['trash']; assert old_trash,'true 0.0.12 trash fixture missing' + plan=f.plan(f.request(target)); before=rows(f.db) + outcome=json.loads(f.apply(plan).stdout); assert outcome['committed'] is True + request=json.loads(plan.read_text())['request']; selected=request['closure'] + after=rows(f.db) + assert after['rift']==[r for r in before['rift'] if r[0] not in selected] + assert after['trash']==before['trash'] and after['schema']==before['schema'] + for d in request['destinations']: + original=next(pathlib.Path(r[2]) for r in before['rift'] if r[0]==d['id']) + assert not original.exists() and (pathlib.Path(d['trash'])/'.rift').read_text().strip()==d['id'] + assert call(f.db,'list',f.root,expect=None).returncode!=0,'legacy native writer was not fenced by journal' + f.rollback(); f.restored() + if legacy: + with sqlite3.connect(f.db) as db: assert db.execute('SELECT * FROM legacy_extra').fetchall()==[('synthetic unchanged',)] + assert call(f.db,'list',f.root,binary=LEGACY).returncode==0 + +scenario('root-trash-rollback',['rowid_restoration','source_git_mutation','unrelated_ancestry_retired'],lambda:success('root')) +scenario('child-closure-rollback',['protected_ancestry_retired'],lambda:success('child')) +scenario('leaf-trash-rollback',[],lambda:success('sibling')) +scenario('release-0-0-12-compatibility',[],lambda:success('child',True)) + +def alias(): + f=fixture('alias'); actual=f.home/'actual-root'; f.root.rename(actual); f.root.symlink_to(actual,target_is_directory=True) + plan=f.plan({'operation':'reconcile','paths':[{'id':f.ids['root'],'canonical':str(actual)}]}) + before=rows(f.db); link=os.readlink(f.root); identity=f.root.lstat().st_ino + f.apply(plan); after=rows(f.db) + assert len(before['rift'])==len(after['rift']) and before['trash']==after['trash'] and before['schema']==after['schema'] + for old,new in zip(before['rift'],after['rift']): assert new==tuple(str(actual) if n==2 and old[0]==f.ids['root'] else v for n,v in enumerate(old)) + assert f.root.lstat().st_ino==identity and os.readlink(f.root)==link + f.rollback(); assert rows(f.db)==before and os.readlink(f.root)==link + assert manifest(actual)==f.original_files[str(f.root)] +scenario('old-symlink-alias-reconcile',['stored_path_mismatch','canonical_path_mismatch'],alias) + +def reject_request(name,mutate): + f=fixture(name); request=f.request('child'); mutate(f,request) + p=f.home/'bad-request.json';p.write_text(json.dumps(request)); before=physical(f.db); files=manifest(f.home) + assert call(f.db,'guarded','preflight',p,expect=None).returncode!=0 + assert before==physical(f.db) and files==manifest(f.home) + assert rows(f.db)==f.original +scenario('incomplete-child-closure',['closure_incomplete','unexpected_children'],lambda:reject_request('closure',lambda f,r:r['closure'].pop())) +scenario('root-kind-mismatch',['root_kind_mismatch'],lambda:reject_request('rootkind',lambda f,r:r.update(kind='root'))) +scenario('leaf-kind-mismatch',['leaf_kind_mismatch'],lambda:reject_request('leafkind',lambda f,r:r.update(id=f.ids['root']))) +scenario('unexpected-id',['unexpected_row_id'],lambda:reject_request('unknownid',lambda f,r:r.update(id='SYNTHETIC-UNKNOWN'))) +scenario('occupied-trash',['rename_destination_occupied'],lambda:reject_request('occupied',lambda f,r:pathlib.Path(r['destinations'][0]['trash']).mkdir())) + +def sqlite_change(db): + with sqlite3.connect(db) as con: con.execute("INSERT INTO trash VALUES ('SYNTHETIC-OLD-TRASH','/tmp/synthetic-missing-trash',123)") + +def stale(name,mutate): + f=fixture(name); plan=f.plan(f.request('child')); mutate(f,plan) + before=physical(f.db); state=rows(f.db); files=manifest(f.home) + assert f.apply(plan,expect=None).returncode!=0 + assert before==physical(f.db) and files==manifest(f.home) and rows(f.db)==state + assert not f.journal().exists() +scenario('stale-marker-id',['marker_id_mismatch'],lambda:stale('markerid',lambda f,p:(f.child/'.rift').write_text('SYNTHETIC-WRONG\n'))) +scenario('stale-marker-hash',['marker_hash_mismatch'],lambda:stale('markerhash',lambda f,p:(f.child/'.rift').write_text(f.ids['child']+'\n\n'))) +scenario('stale-tree-files',['directory_identity_changed'],lambda:stale('treebytes',lambda f,p:(f.child/'data.txt').write_text('changed synthetic bytes'))) + +def change_plan(f,p,key,value): + plan=json.loads(p.read_text()); plan['before']['rows'][0][key]=value; p.write_text(json.dumps(plan)) +scenario('unexpected-parent',['unexpected_parent'],lambda:stale('parent',lambda f,p:change_plan(f,p,'parent_id','SYNTHETIC-WRONG'))) +scenario('stale-db-byte-snapshot',['stale_database_snapshot'],lambda:stale('dbsnapshot',lambda f,p:sqlite_change(f.db))) + +def alias_changed(): + f=fixture('aliaschanged'); actual=f.home/'actual'; f.root.rename(actual); f.root.symlink_to(actual,target_is_directory=True) + plan=f.plan({'operation':'reconcile','paths':[{'id':f.ids['root'],'canonical':str(actual)}]}) + f.root.unlink(); replacement=f.home/'replacement'; shutil.copytree(actual,replacement); f.root.symlink_to(replacement,target_is_directory=True) + before=physical(f.db); assert f.apply(plan,expect=None).returncode!=0; assert physical(f.db)==before +scenario('alias-identity-change',['alias_identity_changed'],alias_changed) + +def hook_case(name,command,post=False): + f=fixture(name) + (f.child/'.rift.toml').write_text('version = 1\n[[hooks.'+('postremove' if post else 'preremove')+']]\nrun = '+json.dumps(command)+'\n') + f.original_files[str(f.child)]=manifest(f.child) + plan=f.plan(f.request('child',hooks=True)) + p=f.apply(plan,expect=None) + if post: + outcome=json.loads(p.stdout); assert outcome['committed'] and outcome['hook_error'] and outcome['status']=='committed_hook_failed' + assert f.journal().exists() and not f.child.exists(); f.rollback(); f.restored() + else: + assert p.returncode!=0 and rows(f.db)==f.original and f.child.exists() and not f.journal().exists() +scenario('preremove-hook-failure',['preremove_failure'],lambda:hook_case('prehook','exit 7')) +scenario('preremove-hook-state-change',['preremove_state_change'],lambda:hook_case('prechange','printf changed >> data.txt')) +scenario('postremove-hook-failure',['postremove_failure_committed'],lambda:hook_case('posthook','exit 9',True)) + +for fault in ('rename_failure','database_failure','before_rename','after_each_rename','after_rename','before_commit','after_commit'): + def crash(fault=fault): + f=fixture('fault-'+fault); plan=f.plan(f.request('root')) + p=f.apply(plan,fault=fault,expect=None); assert p.returncode!=0 and f.journal().exists() + outcome=json.loads(call(f.db,'guarded','recover').stdout) + if fault=='after_commit': + assert outcome['committed'] and not f.root.exists(); f.rollback() + else: assert outcome['committed'] is False + f.restored() + scenario('fault-'+fault,[{'before_rename':'crash_before_rename','after_each_rename':'rename_partial_failure','after_rename':'crash_after_rename','before_commit':'crash_before_commit','after_commit':'crash_after_commit','database_failure':'database_failure','rename_failure':'rename_failure'}[fault]],crash) + +def interrupted_rollback(): + f=fixture('rollbackcrash'); plan=f.plan(f.request('root')); f.apply(plan) + assert f.rollback(fault='during_rollback',expect=None).returncode==86 + outcome=json.loads(call(f.db,'guarded','recover').stdout); assert not outcome['committed']; f.restored() +scenario('crash-during-rollback',['crash_during_rollback'],interrupted_rollback) + +def reject_rollback(name,mutate): + f=fixture(name); plan=f.plan(f.request('child')); f.apply(plan); mutate(f) + before=rows(f.db); files=manifest(f.home) + assert f.rollback(expect=None).returncode!=0 + assert before==rows(f.db) and files==manifest(f.home) +scenario('rollback-occupied-original',['rollback_destination_occupied'],lambda:reject_rollback('rollbackoccupied',lambda f:f.child.mkdir())) +scenario('rollback-stale-registry',['rollback_stale_rows'],lambda:reject_rollback('rollbackstale',lambda f:sqlite_change(f.db))) +scenario('rollback-changed-marker',['rollback_marker_or_files_changed'],lambda:reject_rollback('rollbackmarker',lambda f:(f.child.parent/('.rift-trash-'+f.ids['child'])/'.rift').write_text('SYNTHETIC-WRONG\n'))) + +def stale_hash(): + f=fixture('stalehash'); f.apply(f.plan(f.request('child'))); before=manifest(f.home) + assert f.rollback(hash='0'*64,expect=None).returncode!=0; assert before==manifest(f.home) + f.rollback(); f.restored() +scenario('rollback-stale-journal-hash',['rollback_stale_rows'],stale_hash) + +def native_lock(): + f=fixture('nativelock'); plan=f.plan(f.request('child')); before=physical(f.db) + lock=os.open(f.db.parent,os.O_RDONLY) + try: + fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB) + assert f.apply(plan,expect=None).returncode!=0 + assert call(f.db,'list',f.root,expect=None).returncode!=0 + finally: os.close(lock) + assert physical(f.db)==before; f.apply(plan); f.rollback(); f.restored() +scenario('native-caller-lock',['concurrent_native_writer'],native_lock) + +def db_lock(): + import sys + f=fixture('dbcontention') + holder=subprocess.Popen([sys.executable,'-c',"import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute('BEGIN IMMEDIATE'); print('locked',flush=True); sys.stdin.readline(); c.rollback()",str(f.db)],stdin=subprocess.PIPE,stdout=subprocess.PIPE,text=True) + try: + assert holder.stdout.readline().strip()=='locked' + plan=f.plan(f.request('child')); p=f.apply(plan,expect=None) + assert p.returncode!=0 and not f.journal().exists() + finally: + holder.communicate('release\n',timeout=10) + assert rows(f.db)==f.original + +scenario('sqlite-writer-contention',['sqlite_writer_contention'],db_lock) + +def journal_gate(): + f=fixture('journalgate'); f.apply(f.plan(f.request('child'))); before=rows(f.db) + assert call(f.db,'gc',expect=None).returncode!=0 + assert call(f.db,'create',f.root,'--name','forbidden','--into',f.home/'clones','--no-hooks',expect=None).returncode!=0 + assert before==rows(f.db); f.rollback(); f.restored() +scenario('legacy-manager-journal-gate',['legacy_native_writer_during_journal'],journal_gate) + +def live_wal(): + f=fixture('livewal'); con=sqlite3.connect(f.db); con.execute('PRAGMA journal_mode=WAL'); con.execute('PRAGMA wal_autocheckpoint=0') + con.execute("INSERT INTO trash VALUES ('SYNTHETIC-LIVE-WAL','/tmp/synthetic-live-wal',456)"); con.commit() + assert pathlib.Path(str(f.db)+'-wal').stat().st_size>0 + before=physical(f.db); plan=f.plan(f.request('child')); data=json.loads(plan.read_text()) + assert any(r[0]=='SYNTHETIC-LIVE-WAL' for r in data['before']['trash']) + assert before==physical(f.db); con.close() +scenario('immutable-live-wal-preflight',['dry_db_wal_shm_mutation','live_wal_ignored'],live_wal) + +def reject_plan_field(name,field,value): + f=fixture(name); plan=f.plan(f.request('child')); data=json.loads(plan.read_text()); data['guards'][0][field]=value; plan.write_text(json.dumps(data)) + before=physical(f.db); files=manifest(f.home) + assert f.apply(plan,expect=None).returncode!=0 and before==physical(f.db) and files==manifest(f.home) +scenario('stored-path-guard-mismatch',['stored_path_mismatch'],lambda:reject_plan_field('storedpath','stored','/tmp/synthetic-unreviewed')) +scenario('canonical-path-guard-mismatch',['canonical_path_mismatch'],lambda:reject_plan_field('canonicalpath','canonical','/tmp/synthetic-unreviewed')) + +def unexpected_child(): + f=fixture('newchild'); plan=f.plan(f.request('child')) + call(f.db,'create',f.child,'--name','unreviewed-child','--into',f.home/'clones','--no-hooks') + before=physical(f.db); state=rows(f.db); files=manifest(f.home) + assert f.apply(plan,expect=None).returncode!=0 + assert physical(f.db)==before and rows(f.db)==state and manifest(f.home)==files +scenario('unreviewed-new-child',['unexpected_children'],unexpected_child) + +def protected_nested(): + f=fixture('protectednested'); nested=f.child/'unrelated-sibling'; f.sibling.rename(nested) + with sqlite3.connect(f.db) as con: con.execute('UPDATE rift SET path=? WHERE id=?',(str(nested),f.ids['sibling'])) + request=f.request('child'); req=f.home/'request.json'; req.write_text(json.dumps(request)); before=physical(f.db); files=manifest(f.home) + assert call(f.db,'guarded','preflight',req,expect=None).returncode!=0 + assert before==physical(f.db) and files==manifest(f.home) +scenario('protected-unrelated-physical-ancestry',['unrelated_ancestry_retired','protected_ancestry_retired'],protected_nested) + +def old_nested_trash(): + f=fixture('oldnestedtrash'); old=f.root/'old-trash'; old.mkdir(); (old/'synthetic').write_text('preserve old trash') + with sqlite3.connect(f.db) as con: con.execute('INSERT INTO trash VALUES (?,?,?)',('SYNTHETIC-OLD-NESTED',str(old),789)) + request=f.request('root'); req=f.home/'request.json';req.write_text(json.dumps(request)); before=physical(f.db);files=manifest(f.home) + assert call(f.db,'guarded','preflight',req,expect=None).returncode!=0 + assert before==physical(f.db) and files==manifest(f.home) +scenario('old-trash-objects-protected',['root_old_trash_nested'],old_nested_trash) + +def legacy_stale(): + f=fixture('legacystale',True); plan=f.plan(f.request('child')) + assert f.apply(plan,fault='before_rename',expect=None).returncode==86 + call(f.db,'create',f.root,'--name','legacy-unreviewed','--into',f.home/'clones','--no-hooks',binary=LEGACY) + before=physical(f.db); state=rows(f.db); files=manifest(f.home) + assert call(f.db,'guarded','recover',expect=None).returncode!=0 + assert before==physical(f.db) and state==rows(f.db) and files==manifest(f.home) +scenario('noncooperative-legacy-writer-stale-recovery',['legacy_noncooperative_writer_stale','stale_database_snapshot','rollback_stale_rows'],legacy_stale) + +def legacy_file_stale(): + f=fixture('legacyfilestale',True); plan=f.plan(f.request('child')) + assert f.apply(plan,fault='after_rename',expect=None).returncode==86 + trash=f.child.parent/('.rift-trash-'+f.ids['child']); (trash/'data.txt').write_text('outside writer changed files') + before=physical(f.db); files=manifest(f.home) + assert call(f.db,'guarded','recover',expect=None).returncode!=0 + assert before==physical(f.db) and files==manifest(f.home) +scenario('noncooperative-filesystem-stale-recovery',['rollback_marker_or_files_changed'],legacy_file_stale) + +def unstable_snapshot(): + import sys + f=fixture('unstablesnapshot'); request=f.request('child'); req=f.home/'request.json';req.write_text(json.dumps(request)) + writer=subprocess.Popen([sys.executable,'-c',"import sqlite3,sys,time; time.sleep(.15); c=sqlite3.connect(sys.argv[1]); c.execute(\"INSERT INTO trash VALUES ('SYNTHETIC-RACE','/tmp/synthetic-race',901)\"); c.commit()",str(f.db)]) + try: + p=call(f.db,'guarded','preflight',req,fault='snapshot_pause',expect=None) + assert p.returncode!=0 and 'changed during dry snapshot' in p.stderr + finally: writer.wait(timeout=10) + assert f.child.exists() and not f.journal().exists() +scenario('unstable-live-database-copy',['unstable_database_copy'],unstable_snapshot) + + +def rollback_fault(fault): + f=fixture('rollbackfault-'+fault); f.apply(f.plan(f.request('root'))) + assert f.rollback(fault=fault,expect=None).returncode!=0 + outcome=json.loads(call(f.db,'guarded','recover').stdout) + assert not outcome['committed']; f.restored() +scenario('rollback-db-commit-failure',['rollback_commit_failure'],lambda:rollback_fault('rollback_commit_failure')) +scenario('crash-after-rollback-commit',['crash_after_rollback_commit'],lambda:rollback_fault('after_rollback_commit')) + +def journal_publish(): + f=fixture('journalpublish');plan=f.plan(f.request('root')) + assert f.apply(plan,fault='journal_publish_failure',expect=None).returncode!=0 and not f.journal().exists() + f.restored() +scenario('journal-publish-failure',['journal_publish_failure'],journal_publish) + +def post_change(): + f=fixture('postchange');(f.child/'.rift.toml').write_text('version=1\n[[hooks.postremove]]\nrun="printf changed >> data.txt"\n') + plan=f.plan(f.request('child',hooks=True));p=f.apply(plan,expect=None);outcome=json.loads(p.stdout) + assert p.returncode!=0 and outcome['committed'] and outcome['status']=='committed_hook_failed' + before=physical(f.db);files=manifest(f.home) + assert f.rollback(expect=None).returncode!=0 and physical(f.db)==before and manifest(f.home)==files +scenario('postremove-state-change-committed',['postremove_state_change'],post_change) + +def unknown_stale(): + f=fixture('unknownstale') + with sqlite3.connect(f.db) as con: + con.execute('CREATE TABLE old_unknown (value BLOB)');con.execute('INSERT INTO old_unknown VALUES (?)',(b'synthetic-old',)) + f.apply(f.plan(f.request('child'))) + with sqlite3.connect(f.db) as con:con.execute('UPDATE old_unknown SET value=?',(b'synthetic-unreviewed',)) + before=physical(f.db);files=manifest(f.home) + assert f.rollback(expect=None).returncode!=0 and before==physical(f.db) and files==manifest(f.home) +scenario('unknown-table-stale-rollback',['unknown_tables_stale'],unknown_stale) + +def ancestor_alias(): + f=fixture('ancestoralias'); actual_container=f.home/'actual-container';actual_container.mkdir(); actual=actual_container/'root'; f.root.rename(actual) + old_container=f.home/'old-container'; old_container.symlink_to(actual_container,target_is_directory=True); stored=old_container/'root' + with sqlite3.connect(f.db) as con:con.execute('UPDATE rift SET path=? WHERE id=?',(str(stored),f.ids['root'])) + plan=f.plan({'operation':'reconcile','paths':[{'id':f.ids['root'],'canonical':str(actual)}]}) + old_container.unlink();old_container.symlink_to(actual_container,target_is_directory=True) + before=physical(f.db);files=manifest(f.home) + assert f.apply(plan,expect=None).returncode!=0 and physical(f.db)==before and manifest(f.home)==files +scenario('ancestor-alias-identity-changed',['alias_ancestor_identity_changed'],ancestor_alias) + + +def finalize(f,hash=None,expect=0,fault=None): + return call(f.db,'guarded','finalize','--journal-hash',hash or file_hash(f.journal()),expect=expect,fault=fault) +def history_rollback(f,path,expect=0): + return call(f.db,'guarded','rollback','--journal-hash',file_hash(path),'--history',path,expect=expect) + +def sequential(): + f=fixture('sequential'); actual=f.home/'actual-root'; f.root.rename(actual); f.root.symlink_to(actual,target_is_directory=True) + original=rows(f.db);alias=os.readlink(f.root) + f.apply(f.plan({'operation':'reconcile','paths':[{'id':f.ids['root'],'canonical':str(actual)}]})) + history_a=pathlib.Path(json.loads(finalize(f).stdout)['journal']);assert history_a.exists() and not f.journal().exists() + after_reconcile=rows(f.db) + call(f.db,'list',actual) + f.apply(f.plan(f.request('child'))) + history_b=pathlib.Path(json.loads(finalize(f).stdout)['journal']);assert history_b.exists() and not f.journal().exists() + call(f.db,'list',actual) + assert os.readlink(f.root)==alias and history_a.exists() + history_rollback(f,history_b);assert rows(f.db)==after_reconcile + history_rollback(f,history_a);assert rows(f.db)==original + assert history_a.exists() and history_b.exists() and os.readlink(f.root)==alias + assert manifest(actual)==f.original_files[str(f.root)] +scenario('reconcile-finalize-trash-finalize-retained-caller-history-rollback',[],sequential) + +def completed_root(): + f=fixture('completedroot');retained=f.home/'retained-root';retained.mkdir();(retained/'synthetic.txt').write_text('retained unrelated bytes') + call(f.db,'init','--here',retained);f.original=rows(f.db);retained_files=manifest(retained) + f.apply(f.plan(f.request('root')));history=pathlib.Path(json.loads(finalize(f).stdout)['journal']) + call(f.db,'list',retained);assert manifest(retained)==retained_files + history_rollback(f,history);f.restored();assert manifest(retained)==retained_files +scenario('root-finalize-retained-native-caller-exact-history-rollback',[],completed_root) + +def finalized_stale(): + f=fixture('historystale');f.apply(f.plan(f.request('child')));history=pathlib.Path(json.loads(finalize(f).stdout)['journal']) + call(f.db,'create',f.root,'--name','retained-new-child','--into',f.home/'clones','--no-hooks') + before=physical(f.db);files=manifest(f.home) + assert history_rollback(f,history,expect=None).returncode!=0 and before==physical(f.db) and files==manifest(f.home) + assert not f.journal().exists() and history.exists() +scenario('history-rollback-rejects-fresh-unrelated-native-state',['history_rollback_stale_state'],finalized_stale) + +def reject_finalize(name,mutate,fault=None): + f=fixture(name);f.apply(f.plan(f.request('child')),fault=fault,expect=None);mutate(f) + before=physical(f.db);files=manifest(f.home) + assert finalize(f,expect=None).returncode!=0 and before==physical(f.db) and files==manifest(f.home) +scenario('finalize-rejects-uncommitted-journal',['finalize_uncommitted'],lambda:reject_finalize('finalizepending',lambda f:None,'before_rename')) +scenario('finalize-rejects-stale-registry',['finalize_stale_registry'],lambda:reject_finalize('finalizestale',lambda f:sqlite_change(f.db))) +scenario('finalize-rejects-changed-files',['finalize_changed_files'],lambda:reject_finalize('finalizefiles',lambda f:(f.child.parent/('.rift-trash-'+f.ids['child'])/'data.txt').write_text('outside change'))) + +def finalize_hash(): + f=fixture('finalizehash');f.apply(f.plan(f.request('child')));before=manifest(f.home) + assert finalize(f,hash='0'*64,expect=None).returncode!=0 and before==manifest(f.home) + history=pathlib.Path(json.loads(finalize(f).stdout)['journal']);wrong=f.home/'wrong-history';wrong.write_bytes(history.read_bytes());before=manifest(f.home) + assert history_rollback(f,wrong,expect=None).returncode!=0 and before==manifest(f.home) +scenario('finalize-and-history-hash-path-guards',['finalize_hash_mismatch','history_rollback_wrong_path'],finalize_hash) + +def finalize_crash(): + f=fixture('finalizecrash');f.apply(f.plan(f.request('child')));original_hash=file_hash(f.journal()) + assert finalize(f,fault='after_finalize_archive',expect=None).returncode==86 + history=pathlib.Path(str(f.db)+'.guarded-history-'+original_hash) + assert not f.journal().exists() and history.exists() + call(f.db,'list',f.root);history_rollback(f,history);f.restored() +scenario('crash-after-finalize-archive',['crash_after_finalize_archive'],finalize_crash) + + +def hook_crash(): + f=fixture('hookcrash');(f.child/'.rift.toml').write_text('version=1\n[[hooks.postremove]]\nrun="printf fired > hook-fired.txt"\n') + f.original_files[str(f.child)]=manifest(f.child) + assert f.apply(f.plan(f.request('child',hooks=True)),fault='after_commit',expect=None).returncode==86 + outcome=json.loads(call(f.db,'guarded','recover').stdout) + assert outcome['committed'] and outcome['hook_error'] and outcome['status']=='committed_hooks_unconfirmed' + trash=f.child.parent/('.rift-trash-'+f.ids['child']);assert not (trash/'hook-fired.txt').exists() + before=physical(f.db);files=manifest(f.home) + assert finalize(f,expect=None).returncode!=0 and before==physical(f.db) and files==manifest(f.home) + f.rollback();f.restored() +scenario('crash-before-postremove-no-replay-reject-finalize',['crash_before_postremove','postremove_unconfirmed_finalize'],hook_crash) + +def replace_directory(f,p): + backup=f.home/'synthetic-original-child';f.child.rename(backup);shutil.copytree(backup,f.child) +scenario('changed-directory-inode',['directory_identity_changed'],lambda:stale('directoryinode',replace_directory)) + +provenance=json.loads(subprocess.run([BINARY,'guarded','provenance'],capture_output=True,text=True,check=True).stdout) +receipt={'provenance':provenance,'production':args.production,'executed_scenarios':sum(not r['skipped'] for r in RESULTS),'version':1,'synthetic_only':True,'fixture_root':str(BASE),'binary':{'path':BINARY,'sha256':file_hash(pathlib.Path(BINARY))},'legacy_binary':{'path':LEGACY,'sha256':file_hash(pathlib.Path(LEGACY)),'release':'0.0.12','source_commit':'6b15e6a4dca5e95362324d5bd1c20134034d2aea'},'results':RESULTS,'passed':all(r['passed'] for r in RESULTS),'failure_modes_file_sha256':file_hash(pathlib.Path(__file__).with_name('guarded-failure-modes.json'))} +(OUT/'receipt.json').write_text(json.dumps(receipt,indent=2)+'\n') +print(json.dumps({'passed':receipt['passed'],'scenarios':len(RESULTS),'receipt':str(OUT/'receipt.json')}),flush=True) +raise SystemExit(0 if receipt['passed'] else 1)