diff --git a/frontend/src-tauri/src/extension_transaction.rs b/frontend/src-tauri/src/extension_transaction.rs index 78ac7c7..c2ef1c4 100644 --- a/frontend/src-tauri/src/extension_transaction.rs +++ b/frontend/src-tauri/src/extension_transaction.rs @@ -213,6 +213,12 @@ pub fn recover(db: &mut Connection) -> Result { #[cfg(test)] mod tests { use super::*; + use std::{ + path::Path, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, + }; fn open(path: &std::path::Path) -> Connection { let db = Connection::open(path).unwrap(); db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;") @@ -232,6 +238,41 @@ mod tests { expected_revision: previous, } } + fn seeded(path: &Path) -> (Vec, Vec) { + let mut db = open(path); + let old = vec![change('a', 1, None), change('b', 1, None)]; + let id = uuid::Uuid::new_v4().to_string(); + switch(&mut db, &id, &old).unwrap(); + finish(&mut db, &id, true).unwrap(); + let next = old + .iter() + .map(|current| { + change( + current.target.slot.chars().next().unwrap(), + 2, + Some(active(&db, ¤t.target.slot).unwrap().unwrap().revision), + ) + }) + .collect(); + (old, next) + } + fn assert_complete_generation(db: &Connection) { + let active: Vec<_> = ['a', 'b'] + .into_iter() + .map(|slot| active(db, &slot.to_string().repeat(64)).unwrap().unwrap()) + .collect(); + let versions: BTreeSet<_> = active + .iter() + .map(|item| item.target.configuration["version"].as_u64().unwrap()) + .collect(); + assert_eq!(versions.len(), 1, "package/config generations were mixed"); + let version = *versions.first().unwrap(); + assert!(matches!(version, 1 | 2)); + for item in active { + assert_eq!(item.target.package_key, format!("{version:x}").repeat(64)); + assert!(item.pending_operation.is_none()); + } + } #[test] fn group_switch_crashes_recover_matching_packages_and_configuration() { for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] { @@ -275,6 +316,176 @@ mod tests { } } #[test] + #[ignore = "parent acceptance oracle hard-terminates this helper at a durable boundary"] + fn power_cut_worker() { + let Some(path) = std::env::var_os("OPENNEXUS_D03_DATABASE") else { + return; + }; + let boundary = std::env::var("OPENNEXUS_D03_BOUNDARY").unwrap(); + let marker = std::path::PathBuf::from(std::env::var_os("OPENNEXUS_D03_MARKER").unwrap()); + let mut db = open(Path::new(&path)); + let old: Vec<_> = ['a', 'b'] + .into_iter() + .map(|slot| { + let current = active(&db, &slot.to_string().repeat(64)).unwrap().unwrap(); + Change { + target: current.target.clone(), + expected_revision: Some(current.revision), + } + }) + .collect(); + let next: Vec<_> = old + .iter() + .map(|current| { + change( + current.target.slot.chars().next().unwrap(), + 2, + current.expected_revision.clone(), + ) + }) + .collect(); + let operation = uuid::Uuid::new_v4().to_string(); + let _ = switch_inner(&mut db, &operation, &next, |at| { + if at == boundary { + let file = std::fs::File::create(&marker).unwrap(); + file.sync_all().unwrap(); + loop { + thread::sleep(Duration::from_secs(60)); + } + } + Ok(()) + }); + panic!("power-cut helper passed the requested boundary"); + } + #[test] + fn group_switch_survives_hard_termination_twenty_times_per_boundary() { + for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] { + for round in 0..20 { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state.sqlite3"); + seeded(&path); + let marker = temp.path().join(format!("{boundary}-{round}.ready")); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--ignored", + "--exact", + "extension_transaction::tests::power_cut_worker", + "--nocapture", + ]) + .env("OPENNEXUS_D03_DATABASE", &path) + .env("OPENNEXUS_D03_BOUNDARY", boundary) + .env("OPENNEXUS_D03_MARKER", &marker) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let started = Instant::now(); + while !marker.is_file() { + assert!( + child.try_wait().unwrap().is_none(), + "helper exited before {boundary}" + ); + assert!( + started.elapsed() < Duration::from_secs(10), + "helper did not reach {boundary}" + ); + thread::sleep(Duration::from_millis(5)); + } + child.kill().unwrap(); + assert!(!child.wait().unwrap().success()); + let mut db = open(&path); + recover(&mut db).unwrap(); + assert_complete_generation(&db); + assert_eq!(recover(&mut db).unwrap(), 0); + } + } + } + #[test] + fn disk_full_and_configuration_migration_failures_cover_every_boundary() { + let mapped: HostError = rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_FULL), + None, + ) + .into(); + assert_eq!(mapped.code, "QUOTA_EXCEEDED"); + for failure in ["QUOTA_EXCEEDED", "EXTENSION_CONFIG_MIGRATION_FAILED"] { + for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] { + for _ in 0..20 { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state.sqlite3"); + let (_, next) = seeded(&path); + let mut db = open(&path); + let update = uuid::Uuid::new_v4().to_string(); + let error = switch_inner(&mut db, &update, &next, |at| { + if at == boundary { + Err(HostError::new(failure)) + } else { + Ok(()) + } + }) + .unwrap_err(); + assert_eq!(error.code, failure); + drop(db); + let mut db = open(&path); + recover(&mut db).unwrap(); + assert_complete_generation(&db); + assert_eq!(recover(&mut db).unwrap(), 0); + } + } + } + } + #[test] + fn failed_switch_cannot_expand_an_existing_execution_permit() { + use crate::extension_permit::{Authority, Claims, Environment, ExecutionKind}; + use std::collections::BTreeMap; + + let authority = Authority::default(); + let mut claims = Claims { + kind: ExecutionKind::Mcp, + source: "https://catalog.example/".into(), + namespace: "examples".into(), + package_id: "note-reviewer".into(), + version: "1.0.0".into(), + archive_sha256: "a".repeat(64), + tree_sha256: "b".repeat(64), + signer_sha256: "c".repeat(64), + entry: "entry.exe".into(), + arguments: vec!["--stdio".into()], + environment: BTreeMap::from([( + "MODE".into(), + Environment::Literal("production".into()), + )]), + permissions: BTreeSet::from(["notes.read".into()]), + vault_id: uuid::Uuid::new_v4().to_string(), + platform: "windows".into(), + policy_version: "1".into(), + expires_at_ms: 10_000, + }; + let permit = authority.issue(&claims, 1).unwrap(); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state.sqlite3"); + let (_, next) = seeded(&path); + let mut db = open(&path); + let error = switch_inner(&mut db, &uuid::Uuid::new_v4().to_string(), &next, |at| { + if at == "switch_committed" { + Err(HostError::new("EXTENSION_CONFIG_MIGRATION_FAILED")) + } else { + Ok(()) + } + }) + .unwrap_err(); + assert_eq!(error.code, "EXTENSION_CONFIG_MIGRATION_FAILED"); + recover(&mut db).unwrap(); + authority.verify(&permit, &claims, 2).unwrap(); + claims.permissions.insert("notes.write".into()); + assert_eq!( + authority.verify(&permit, &claims, 2).unwrap_err().code, + "PERMISSION_CHANGED" + ); + assert_complete_generation(&db); + } + #[test] fn cas_busy_replay_and_failed_first_install() { let mut db = Connection::open_in_memory().unwrap(); schema(&db).unwrap(); diff --git a/frontend/src-tauri/src/workspace.rs b/frontend/src-tauri/src/workspace.rs index a188800..2e51e31 100644 --- a/frontend/src-tauri/src/workspace.rs +++ b/frontend/src-tauri/src/workspace.rs @@ -33,7 +33,19 @@ impl From for HostError { } } impl From for HostError { - fn from(_: rusqlite::Error) -> Self { + fn from(error: rusqlite::Error) -> Self { + if matches!( + error, + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: rusqlite::ErrorCode::DiskFull, + .. + }, + _ + ) + ) { + return Self::new("QUOTA_EXCEEDED"); + } Self::new("DATABASE_ERROR") } } diff --git a/scripts/acceptance_cases/d03_extension_transactions.py b/scripts/acceptance_cases/d03_extension_transactions.py new file mode 100644 index 0000000..c4359d0 --- /dev/null +++ b/scripts/acceptance_cases/d03_extension_transactions.py @@ -0,0 +1,149 @@ +"""D-03 crash, storage, configuration, dependency, and permission acceptance.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "frontend" / "src-tauri" / "Cargo.toml" +TESTS = ( + ( + "extension_transaction::tests::group_switch_survives_hard_termination_twenty_times_per_boundary", + False, + ), + ( + "extension_transaction::tests::disk_full_and_configuration_migration_failures_cover_every_boundary", + False, + ), + ( + "extension_transaction::tests::failed_switch_cannot_expand_an_existing_execution_permit", + False, + ), + ( + "extension_dependencies::tests::cycles_missing_versions_and_incompatible_platforms_fail", + False, + ), + ( + "extension_store::tests::prepared_switch_rechecks_content_vault_binding_and_recovers_on_open", + False, + ), +) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def run_exact(cargo: str, test: str, ignored: bool) -> bool: + arguments = [ + cargo, + "test", + "--manifest-path", + str(MANIFEST), + "--locked", + "--features", + "desktop", + "--lib", + test, + "--", + ] + if ignored: + arguments.append("--ignored") + arguments.extend(("--exact", "--nocapture", "--test-threads=1")) + completed = subprocess.run( + arguments, + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + print(completed.stdout, end="") + print(completed.stderr, end="") + return completed.returncode == 0 and "1 passed; 0 failed" in completed.stdout + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--config", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + case_id = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", "") + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + assertions = [ + { + "name": "three durable switch boundaries survive 20 actual hard terminations each", + "evidence": "60 child test processes were killed only after the requested SQLite boundary", + }, + { + "name": "disk-full and configuration-migration failures cover every boundary for 20 rounds", + "evidence": "120 injected failures reopened and recovered to one complete package/config generation", + }, + { + "name": "dependency cycle, missing package, and version conflict fail during immutable planning", + "evidence": "three exact dependency error codes are returned before any active pointer exists", + }, + { + "name": "failed installation cannot expand a previously issued execution permit", + "evidence": "old permit remains bound to notes.read and rejects added notes.write as PERMISSION_CHANGED", + }, + { + "name": "prepared switch rejects invalid configuration and recovers pending state on reopen", + "evidence": "configuration rejection creates zero transactions; reopen rolls pending switch back idempotently", + }, + ] + cargo = shutil.which("cargo") + passed = case_id == "D-03" and cargo is not None and os.name == "nt" + if passed: + passed = all(run_exact(cargo, test, ignored) for test, ignored in TESTS) + status = "PASSED" if passed else "FAILED" + for assertion in assertions: + assertion["status"] = status + files = [] + for relative in ( + "frontend/src-tauri/src/extension_transaction.rs", + "frontend/src-tauri/src/extension_dependencies.rs", + "frontend/src-tauri/src/extension_store.rs", + "frontend/src-tauri/src/extension_permit.rs", + "frontend/src-tauri/src/workspace.rs", + "scripts/acceptance_cases/d03_extension_transactions.py", + ): + files.append({"path": relative, "sha256": sha256(ROOT / relative)}) + payload = { + "schema": 1, + "case_id": case_id, + "status": status, + "reason": "" if passed else "A D-03 exact transaction oracle failed.", + "assertions": assertions, + "metrics": { + "power_cut_rounds": 60 if passed else 0, + "disk_full_rounds": 60 if passed else 0, + "configuration_failure_rounds": 60 if passed else 0, + "dependency_preflight_rejections": 3 if passed else 0, + "permission_expansions": 0, + "peak_rss_bytes": None, + "max_process_count": None, + "denied_access_count": None, + }, + "files": files, + "revisions": [ + {"scope": "transaction boundaries", "values": ["journal_recorded", "pointer_recorded", "switch_committed"]}, + {"scope": "recovered generations", "values": ["complete-old", "complete-new"]}, + ], + } + output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/phase3_acceptance.py b/scripts/phase3_acceptance.py index 615d63a..531160e 100644 --- a/scripts/phase3_acceptance.py +++ b/scripts/phase3_acceptance.py @@ -68,6 +68,18 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = { "timeout_seconds": 900, "required_metrics": (), }, + "D-03": { + "driver": "scripts/acceptance_cases/d03_extension_transactions.py", + "timeout_seconds": 900, + "required_metrics": ( + "power_cut_rounds", + "disk_full_rounds", + "configuration_failure_rounds", + "dependency_preflight_rejections", + "permission_expansions", + ), + "platform_profiles": ("windows-11-x64",), + }, "S-01": { "driver": "scripts/acceptance_cases/s01_sync_client.py", "timeout_seconds": 900,