From 923e5ed7435d2befbabf776bdb0e6d457733bdfe Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:38:51 -0600 Subject: [PATCH 01/19] feat(api): delegate durable lifecycle to storage admission --- crates/graphforge-api/src/lib.rs | 52 ++++++++++++++++++------- crates/graphforge-api/src/repository.rs | 51 +++++++++++++++++++++--- 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/crates/graphforge-api/src/lib.rs b/crates/graphforge-api/src/lib.rs index 7488aa50..2a6bf652 100644 --- a/crates/graphforge-api/src/lib.rs +++ b/crates/graphforge-api/src/lib.rs @@ -22,7 +22,7 @@ #![forbid(unsafe_code)] use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; @@ -451,9 +451,11 @@ impl std::fmt::Debug for GraphForge { impl GraphForge { /// Create a new in-memory (`None`) or Parquet-backed (`Some(path)`) instance. /// - /// For a persistent instance, the directory must exist. Ontology authority - /// and enforcement mode are resolved from the committed workspace ontology - /// and configuration participants in the selected project generation. + /// For a persistent instance, the directory may be absent when its parent + /// exists; storage admission owns creation of the final project directory. + /// Ontology authority and enforcement mode are resolved from the committed + /// workspace ontology and configuration participants in the selected + /// project generation. /// Loose `graphforge.yaml` or `ontology.yaml` files are not authority and /// are not loaded implicitly. An existing runtime-catalog participant seeds /// the runtime catalog. @@ -461,10 +463,10 @@ impl GraphForge { /// An in-memory instance is exploratory and backed by a temp directory. /// /// # Errors - /// Returns [`GfError::Storage`] if `path` does not exist or the temp dir - /// cannot be created, [`GfError::Validation`] for malformed committed - /// workspace records, and [`GfError::Ontology`] if the adopted ontology - /// cannot be decoded or compiled. + /// Returns [`GfError::Storage`] if the persistent path's parent does not + /// exist or the temp dir cannot be created, [`GfError::Validation`] for + /// malformed committed workspace records, and [`GfError::Ontology`] if the + /// adopted ontology cannot be decoded or compiled. /// Opening a persistent project can also return structured knowledge, /// provenance, or publication errors while reconciling an interrupted /// recorded algorithm run. @@ -490,7 +492,7 @@ impl GraphForge { let tmp = tempfile::TempDir::new() .map_err(|e| GfError::Storage(format!("failed to create temp dir: {e}")))?; let (resolved_generation, project_open_recovery) = - graphforge_storage::open_or_initialize_project_with_recovery(tmp.path())?; + graphforge_storage::open_or_initialize_ephemeral_project_with_recovery(tmp.path())?; let generation_uuid = resolved_generation.generation_uuid(); let (ontology_mode, ontology, ontology_document) = load_workspace_ontology(&resolved_generation)?; @@ -561,10 +563,14 @@ impl GraphForge { options: GraphForgeOptions, resource_policy: resource_policy::NormalizedResourcePolicy, ) -> Result { - if !dir.exists() { + let parent = dir + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + if !parent.is_dir() { return Err(GfError::Storage(format!( - "path does not exist: {}", - dir.display() + "project parent does not exist or is not a directory: {}", + parent.display() ))); } @@ -5891,6 +5897,25 @@ mod tests { ); } + #[test] + fn persistent_open_creates_an_absent_final_target_through_storage() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().canonicalize().unwrap().join("project"); + assert!(!root.exists()); + + let first = GraphForge::new(root.to_str()).expect("admit and create v1 project"); + let generation_uuid = first.resolved_generation.generation_uuid(); + assert_eq!(first.path(), Some(root.as_path())); + assert!(root.is_dir()); + drop(first); + + let reopened = GraphForge::new(root.to_str()).expect("reopen admitted project"); + assert_eq!( + reopened.resolved_generation.generation_uuid(), + generation_uuid + ); + } + #[test] fn persistent_open_rejects_pre_v1_without_mutation() { let root = tempfile::tempdir().unwrap(); @@ -5907,7 +5932,8 @@ mod tests { #[test] fn graphforge_new_bad_path() { - let result = GraphForge::new(Some("/nonexistent/path/xyz")); + let parent = tempfile::tempdir().unwrap(); + let result = GraphForge::new(parent.path().join("missing/project").to_str()); assert!(matches!(result, Err(GfError::Storage(_)))); } diff --git a/crates/graphforge-api/src/repository.rs b/crates/graphforge-api/src/repository.rs index ba33608a..452bcbb0 100644 --- a/crates/graphforge-api/src/repository.rs +++ b/crates/graphforge-api/src/repository.rs @@ -357,14 +357,13 @@ impl RepositoryContext { true }; let ignore_changed = Self::write_gitignore(ignore)?; - fs::create_dir_all(&self.state_path) - .map_err(|error| GfError::Storage(error.to_string()))?; let state = self .state_path .to_str() .ok_or_else(|| validation("project path must be valid UTF-8"))?; - // GraphForge::new creates or reopens the v1 container. Opening it a second - // time proves that the published project can be resolved immediately. + // Storage admission creates or reopens the v1 container. Repository + // setup must not create the final state target before that gate. Opening + // it a second time proves that the published project resolves immediately. super::GraphForge::new(Some(state))?; super::GraphForge::new(Some(state))?; Ok(RepositoryInitReceipt { @@ -1109,7 +1108,17 @@ impl RepositoryContext { } let removed = if target.exists() { reject_symlink_components(&self.root, &target)?; - fs::remove_dir_all(&target).map_err(|error| GfError::Storage(error.to_string()))?; + let admission = storage::filesystem_admission::admit_project_lifecycle( + &target, + storage::filesystem_admission::ProjectLifecycleMode::Durable, + storage::filesystem_admission::ProjectRootRequirement::Existing, + )?; + if admission.root() != target { + return Err(validation( + "admitted repository state does not match the contained remove target", + )); + } + admission.remove_project_root()?; true } else { false @@ -3052,6 +3061,19 @@ mod tests { assert!(root.path().join(".graphforge/graphforge.yaml").is_file()); } + #[test] + fn repository_init_delegates_state_target_creation_to_storage_admission() { + let root = tempdir().unwrap(); + let mut context = RepositoryContext::discover(root.path()).unwrap(); + fs::create_dir_all(root.path().join(".graphforge/hop")).unwrap(); + context.state_path = root.path().join(".graphforge/hop/../state"); + + let error = context.init_without_skills().unwrap_err(); + + assert_eq!(error.code(), "GF_UNSUPPORTED_FILESYSTEM"); + assert!(!root.path().join(".graphforge/state").exists()); + } + #[test] fn containment_and_symlinks_fail_closed() { let root = tempdir().unwrap(); @@ -3094,6 +3116,25 @@ mod tests { assert!(!root.path().join(".graphforge/state").exists()); } + #[test] + fn remove_rejects_traversal_without_deleting_the_project() { + let root = tempdir().unwrap(); + let context = RepositoryContext::discover(root.path()).unwrap(); + context.init_without_skills().unwrap(); + let current = fs::read(context.state_path.join(storage::CURRENT_FILE)).unwrap(); + fs::create_dir(root.path().join("hop")).unwrap(); + let mut traversed = context.clone(); + traversed.root = context.root.join("hop/.."); + + let error = traversed.remove(true).unwrap_err(); + + assert_eq!(error.code(), "GF_UNSUPPORTED_FILESYSTEM"); + assert_eq!( + fs::read(context.state_path.join(storage::CURRENT_FILE)).unwrap(), + current + ); + } + #[test] fn discovery_uses_the_nearest_git_worktree_and_rejects_tracked_data() { let root = tempdir().unwrap(); From feff3f57f2b3216c142299903dc9dee298092972 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:15:54 -0600 Subject: [PATCH 02/19] test(bindings): cover filesystem admission parity --- .../tests/checkpoints.test.mjs | 37 +++++++++++ .../tests/core.test.mjs | 33 +++++++++- crates/graphforge-bindings-py/tests/smoke.py | 29 +++++++++ .../tests/filesystem_admission.rs | 64 +++++++++++++++++++ 4 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 crates/graphforge-cli/tests/filesystem_admission.rs diff --git a/crates/graphforge-bindings-node/tests/checkpoints.test.mjs b/crates/graphforge-bindings-node/tests/checkpoints.test.mjs index 876e79d4..2a281723 100644 --- a/crates/graphforge-bindings-node/tests/checkpoints.test.mjs +++ b/crates/graphforge-bindings-node/tests/checkpoints.test.mjs @@ -1,7 +1,17 @@ // Thin native checkpoint surface acceptance (#2480). import assert from "node:assert/strict"; +import { + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, +} from "node:fs"; import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { test } from "node:test"; import { tableFromIPC } from "apache-arrow"; @@ -155,3 +165,30 @@ test("checkpoint pagination and diff cancellation use the shared adapter", async ); }); }); + +test( + "async checkpoint preserves filesystem admission code after root substitution", + { skip: process.platform === "win32" }, + async () => { + const fixture = mkdtempSync(join(tmpdir(), "gf-node-checkpoint-admission-")); + const parent = realpathSync(fixture); + const project = join(parent, "project"); + const moved = join(parent, "project-moved"); + const forge = new GraphForge(project); + try { + await forge.checkpoint({ name: "Before", idempotencyKey: operation(20) }); + const currentBefore = readFileSync(join(project, "CURRENT")); + renameSync(project, moved); + symlinkSync(moved, project, "dir"); + + await assert.rejects( + forge.checkpoint({ name: "Rejected", idempotencyKey: operation(21) }), + (error) => error.code === "GF_UNSUPPORTED_FILESYSTEM", + ); + assert.deepEqual(readFileSync(join(moved, "CURRENT")), currentBefore); + } finally { + forge.close(); + rmSync(fixture, { recursive: true, force: true }); + } + }, +); diff --git a/crates/graphforge-bindings-node/tests/core.test.mjs b/crates/graphforge-bindings-node/tests/core.test.mjs index 6d180151..cd7746af 100644 --- a/crates/graphforge-bindings-node/tests/core.test.mjs +++ b/crates/graphforge-bindings-node/tests/core.test.mjs @@ -7,11 +7,13 @@ import { mkdirSync, mkdtempSync, readFileSync, + readdirSync, + realpathSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, sep } from "node:path"; import { tableFromIPC } from "apache-arrow"; import { GraphForge, NodeHandle, version } from "../index.js"; @@ -106,6 +108,34 @@ function checkPreV1ProjectError() { } } +function checkProjectTraversalAdmissionError() { + const fixture = mkdtempSync(join(tmpdir(), "gf-node-admission-")); + const parent = realpathSync(fixture); + const project = join(parent, "project"); + try { + const forge = new GraphForge(project); + forge.close(); + mkdirSync(join(parent, "hop")); + const currentBefore = readFileSync(join(project, "CURRENT")); + const generationsBefore = readdirSync(join(project, "generations")).sort(); + const parentBefore = readdirSync(parent).sort(); + + const traversal = `${join(parent, "hop")}${sep}..${sep}project`; + assert.throws( + () => new GraphForge(traversal), + (error) => error.code === "GF_UNSUPPORTED_FILESYSTEM", + ); + assert.deepEqual(readFileSync(join(project, "CURRENT")), currentBefore); + assert.deepEqual( + readdirSync(join(project, "generations")).sort(), + generationsBefore, + ); + assert.deepEqual(readdirSync(parent).sort(), parentBefore); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +} + function checkInspectionSurface() { const forge = new GraphForge(); forge.execute( @@ -250,6 +280,7 @@ test("explain", checkExplain); test("load ontology", checkLoadOntology); test("parse error", checkParseError); test("pre-v1 project error", checkPreV1ProjectError); +test("project traversal admission error", checkProjectTraversalAdmissionError); test("find", checkFind); test("graph inspection surface", checkInspectionSurface); test( diff --git a/crates/graphforge-bindings-py/tests/smoke.py b/crates/graphforge-bindings-py/tests/smoke.py index 4a4e5549..70181d70 100644 --- a/crates/graphforge-bindings-py/tests/smoke.py +++ b/crates/graphforge-bindings-py/tests/smoke.py @@ -66,6 +66,35 @@ def check_exception_hierarchy() -> None: else: raise SystemExit("expected unsupported-format error for pre-v1 root") + # Durable project admission rejects lexical traversal before reopening or + # mutating an otherwise valid project, and preserves the Rust error code. + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory).resolve() + project = parent / "project" + forge = g.GraphForge(str(project)) + forge.close() + (parent / "hop").mkdir() + current_before = (project / "CURRENT").read_bytes() + generations_before = sorted( + path.name for path in (project / "generations").iterdir() + ) + parent_before = sorted(path.name for path in parent.iterdir()) + + traversal = parent / "hop" / ".." / "project" + try: + g.GraphForge(str(traversal)) + except g.StorageError as exc: + assert exc.code == "GF_UNSUPPORTED_FILESYSTEM", exc.code + else: + raise SystemExit("expected unsupported-filesystem error for project traversal") + + assert (project / "CURRENT").read_bytes() == current_before + assert ( + sorted(path.name for path in (project / "generations").iterdir()) + == generations_before + ) + assert sorted(path.name for path in parent.iterdir()) == parent_before + def check_execute() -> None: # #586 — execute returns a real pyarrow.Table carrying the result metadata. diff --git a/crates/graphforge-cli/tests/filesystem_admission.rs b/crates/graphforge-cli/tests/filesystem_admission.rs new file mode 100644 index 00000000..34194946 --- /dev/null +++ b/crates/graphforge-cli/tests/filesystem_admission.rs @@ -0,0 +1,64 @@ +//! CLI parity for durable project filesystem admission. + +use std::collections::BTreeSet; +use std::fs; +use std::process::{Command, Output}; + +use serde_json::Value; +use tempfile::TempDir; + +fn gf(project: &std::path::Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_gf")) + .arg("--project") + .arg(project) + .args(args) + .output() + .expect("run same-build gf binary") +} + +fn names(path: &std::path::Path) -> BTreeSet { + fs::read_dir(path) + .expect("read directory snapshot") + .map(|entry| entry.expect("read directory entry").file_name()) + .collect() +} + +#[test] +fn traversal_is_typed_in_text_and_json_without_project_mutation() { + let fixture = TempDir::new().expect("temporary admission parent"); + let parent = fixture + .path() + .canonicalize() + .expect("canonical fixture parent"); + let project = parent.join("project"); + let initialized = gf(&project, &["checkpoint", "list"]); + assert!( + initialized.status.success(), + "initial project open failed: {}", + String::from_utf8_lossy(&initialized.stderr) + ); + + fs::create_dir(parent.join("hop")).expect("create traversal hop"); + let traversal = parent.join("hop").join("..").join("project"); + let current_before = fs::read(project.join("CURRENT")).expect("read CURRENT"); + let generations_before = names(&project.join("generations")); + let parent_before = names(&parent); + + let text = gf(&traversal, &["checkpoint", "list"]); + assert_eq!(text.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&text.stderr).starts_with("GF_UNSUPPORTED_FILESYSTEM:"), + "unexpected text error: {}", + String::from_utf8_lossy(&text.stderr) + ); + + let json = gf(&traversal, &["--json", "checkpoint", "list"]); + assert_eq!(json.status.code(), Some(1)); + let error: Value = serde_json::from_slice(&json.stderr).expect("JSON error payload"); + assert_eq!(error["error"]["code"], "GF_UNSUPPORTED_FILESYSTEM"); + assert_eq!(error["error"]["details"]["kind"], "project"); + + assert_eq!(fs::read(project.join("CURRENT")).unwrap(), current_before); + assert_eq!(names(&project.join("generations")), generations_before); + assert_eq!(names(&parent), parent_before); +} From 19326f337199a9aaca2c2ed47d36506349c82109 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:21:38 -0600 Subject: [PATCH 03/19] test(api): certify concurrent project admission --- crates/graphforge-api/src/lib.rs | 164 ++++++++++++++++++++++++ crates/graphforge-api/src/repository.rs | 97 +++++++++++++- 2 files changed, 260 insertions(+), 1 deletion(-) diff --git a/crates/graphforge-api/src/lib.rs b/crates/graphforge-api/src/lib.rs index 2a6bf652..39ac9453 100644 --- a/crates/graphforge-api/src/lib.rs +++ b/crates/graphforge-api/src/lib.rs @@ -3786,6 +3786,80 @@ mod tests { }; use arrow::datatypes::DataType; use std::collections::HashSet; + use std::io::Read as _; + use std::process::{Child, Command, Stdio}; + use std::time::{Duration, Instant}; + + const ABSENT_TARGET_CHILD: &str = "tests::absent_target_open_child"; + const ABSENT_TARGET_COOKIE: &str = "graphforge-absent-target-open-v1"; + const ABSENT_TARGET_DEADLINE: Duration = Duration::from_secs(10); + + fn spawn_absent_target_child(parent: &Path, child_id: &str) -> Child { + Command::new(std::env::current_exe().expect("absent-target current test executable")) + .args(["--exact", ABSENT_TARGET_CHILD, "--nocapture"]) + .env("GF_ABSENT_TARGET_COOKIE", ABSENT_TARGET_COOKIE) + .env("GF_ABSENT_TARGET_PARENT", parent) + .env("GF_ABSENT_TARGET_CHILD_ID", child_id) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|error| panic!("absent-target child={child_id} spawn error={error}")) + } + + fn wait_for_paths(paths: &[PathBuf], phase: &str) { + let deadline = Instant::now() + ABSENT_TARGET_DEADLINE; + while paths.iter().any(|path| !path.is_file()) { + assert!( + Instant::now() < deadline, + "phase={phase} timed out waiting for subprocess barrier" + ); + std::thread::yield_now(); + } + } + + fn wait_for_absent_target_child(mut child: Child, child_id: &str) -> uuid::Uuid { + let deadline = Instant::now() + ABSENT_TARGET_DEADLINE; + let status = loop { + if let Some(status) = child.try_wait().expect("absent-target child try_wait") { + break status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("absent-target child={child_id} timed out"); + } + std::thread::yield_now(); + }; + let mut stdout = String::new(); + child + .stdout + .take() + .expect("absent-target child stdout") + .read_to_string(&mut stdout) + .expect("read absent-target child stdout"); + let mut stderr = String::new(); + child + .stderr + .take() + .expect("absent-target child stderr") + .read_to_string(&mut stderr) + .expect("read absent-target child stderr"); + assert!( + status.success(), + "absent-target child={child_id} failed: status={status} stdout={stdout:?} stderr={stderr:?}" + ); + let generation = stdout + .lines() + .find_map(|line| line.strip_prefix("GF_ABSENT_TARGET_UUID ")) + .unwrap_or_else(|| { + panic!( + "absent-target child={child_id} omitted generation marker: stdout={stdout:?}" + ) + }); + uuid::Uuid::parse_str(generation).unwrap_or_else(|error| { + panic!("absent-target child={child_id} invalid generation={generation:?}: {error}") + }) + } #[test] fn facade_debug_empty_batch_and_procedure_width_contracts_are_exact() { @@ -5916,6 +5990,96 @@ mod tests { ); } + #[test] + fn absent_target_open_child() { + if std::env::var("GF_ABSENT_TARGET_COOKIE").as_deref() != Ok(ABSENT_TARGET_COOKIE) { + return; + } + let parent = PathBuf::from( + std::env::var_os("GF_ABSENT_TARGET_PARENT") + .expect("absent-target child canonical parent"), + ); + let child_id = + std::env::var("GF_ABSENT_TARGET_CHILD_ID").expect("absent-target child identifier"); + std::fs::write(parent.join(format!("ready-{child_id}")), b"ready\n") + .expect("absent-target child publish readiness"); + wait_for_paths(&[parent.join("go")], "child-open-release"); + + let root = parent.join("project"); + let graph = GraphForge::new(root.to_str()).expect("absent-target child open project"); + println!( + "GF_ABSENT_TARGET_UUID {}", + graph.resolved_generation.generation_uuid() + ); + } + + #[test] + fn concurrent_processes_open_one_absent_target_generation() { + let fixture = tempfile::tempdir().expect("absent-target parent fixture"); + let parent = fixture.path().canonicalize().unwrap(); + let root = parent.join("project"); + assert!(!root.exists()); + + let first = spawn_absent_target_child(&parent, "first"); + let second = spawn_absent_target_child(&parent, "second"); + wait_for_paths( + &[parent.join("ready-first"), parent.join("ready-second")], + "children-ready", + ); + std::fs::write(parent.join("go"), b"open\n").expect("release absent-target children"); + + let first_uuid = wait_for_absent_target_child(first, "first"); + let second_uuid = wait_for_absent_target_child(second, "second"); + assert_eq!(first_uuid, second_uuid); + + let current_before_reopen = std::fs::read(root.join(graphforge_storage::CURRENT_FILE)) + .expect("read CURRENT after concurrent admission"); + let current_record: serde_json::Value = + serde_json::from_slice(¤t_before_reopen).expect("CURRENT is canonical JSON"); + assert_eq!( + current_record["generation_uuid"].as_str(), + Some(first_uuid.hyphenated().to_string().as_str()) + ); + let generations = std::fs::read_dir(root.join("generations")) + .expect("read admitted generations") + .map(|entry| { + entry + .expect("read admitted generation entry") + .file_name() + .into_string() + .expect("generation UUID is UTF-8") + }) + .collect::>(); + assert_eq!(generations, [first_uuid.to_string()]); + + let admission_locks = std::fs::read_dir(&parent) + .expect("read canonical parent") + .filter_map(|entry| { + let entry = entry.expect("read canonical parent entry"); + let name = entry.file_name().into_string().ok()?; + (name.starts_with(".graphforge-admission-") && name.ends_with(".lock")) + .then_some(entry.path()) + }) + .collect::>(); + assert_eq!(admission_locks.len(), 1); + assert!( + std::fs::symlink_metadata(&admission_locks[0]) + .expect("inspect persistent admission lock") + .file_type() + .is_file() + ); + + let reopened = GraphForge::new(root.to_str()).expect("reopen concurrently admitted root"); + assert_eq!(reopened.resolved_generation.generation_uuid(), first_uuid); + drop(reopened); + assert_eq!( + std::fs::read(root.join(graphforge_storage::CURRENT_FILE)) + .expect("read stable CURRENT after reopen"), + current_before_reopen + ); + assert!(admission_locks[0].is_file()); + } + #[test] fn persistent_open_rejects_pre_v1_without_mutation() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/graphforge-api/src/repository.rs b/crates/graphforge-api/src/repository.rs index 452bcbb0..b0b0ac31 100644 --- a/crates/graphforge-api/src/repository.rs +++ b/crates/graphforge-api/src/repository.rs @@ -18,8 +18,11 @@ const CONFIG: &str = ".graphforge/graphforge.yaml"; const IGNORE_START: &str = "# graphforge: managed data (do not edit)"; const IGNORE_END: &str = "# graphforge: end managed data"; const MAX_PORTABLE_INTEGER: u64 = 9_007_199_254_740_991; -const IGNORE_LINES: [&str; 3] = [ +/// Four repository-local runtime patterns managed as one idempotent block. +/// The admission lock is a persistent sibling of `state`, not part of it. +const IGNORE_LINES: [&str; 4] = [ "/.graphforge/state/", + "/.graphforge/.graphforge-admission-*.lock", "/.graphforge/imports/", "/.graphforge/exports/", ]; @@ -3055,12 +3058,104 @@ mod tests { assert!(!context.init().unwrap().ignore_changed); let ignore = fs::read_to_string(root.path().join(".gitignore")).unwrap(); assert!(ignore.starts_with("target/\n")); + assert_eq!(IGNORE_LINES.len(), 4); for line in IGNORE_LINES { assert_eq!(ignore.matches(line).count(), 1); } assert!(root.path().join(".graphforge/graphforge.yaml").is_file()); } + #[test] + fn admission_lock_persists_without_dirtying_git_after_init_or_remove() { + let root = tempdir().unwrap(); + assert!( + Command::new("git") + .arg("init") + .arg("-q") + .arg(root.path()) + .status() + .unwrap() + .success() + ); + let context = RepositoryContext::discover(root.path()).unwrap(); + context.init_without_skills().unwrap(); + + let admission_locks = || { + fs::read_dir(root.path().join(".graphforge")) + .unwrap() + .filter_map(|entry| { + let entry = entry.unwrap(); + let name = entry.file_name().into_string().ok()?; + (name.starts_with(".graphforge-admission-") && name.ends_with(".lock")) + .then_some(entry.path()) + }) + .collect::>() + }; + let locks = admission_locks(); + assert_eq!(locks.len(), 1); + let lock = locks[0].clone(); + assert!(lock.is_file()); + let ignored = Command::new("git") + .arg("-C") + .arg(root.path()) + .args(["check-ignore", "-v"]) + .arg(&lock) + .output() + .unwrap(); + assert!(ignored.status.success()); + assert!( + String::from_utf8(ignored.stdout) + .unwrap() + .contains("/.graphforge/.graphforge-admission-*.lock") + ); + + assert!( + Command::new("git") + .arg("-C") + .arg(root.path()) + .args(["add", "-A"]) + .status() + .unwrap() + .success() + ); + assert!( + Command::new("git") + .arg("-C") + .arg(root.path()) + .args([ + "-c", + "user.name=GraphForge Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-qm", + "fixture", + ]) + .status() + .unwrap() + .success() + ); + let git_status = || { + Command::new("git") + .arg("-C") + .arg(root.path()) + .args(["status", "--porcelain=v1"]) + .output() + .unwrap() + .stdout + }; + assert!(git_status().is_empty()); + + assert!(!context.init_without_skills().unwrap().ignore_changed); + assert_eq!(admission_locks(), [lock.clone()]); + assert!(git_status().is_empty()); + + assert!(context.remove(true).unwrap().removed); + assert!(!context.state_path.exists()); + assert_eq!(admission_locks(), [lock]); + assert!(git_status().is_empty()); + } + #[test] fn repository_init_delegates_state_target_creation_to_storage_admission() { let root = tempdir().unwrap(); From c95f8bb9ec1959942661386291a36992c050f9e1 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:23:04 -0600 Subject: [PATCH 04/19] feat(storage): gate project lifecycle admission (#780) --- .../src/filesystem_admission.rs | 851 +++++++++++++++++- 1 file changed, 850 insertions(+), 1 deletion(-) diff --git a/crates/graphforge-storage/src/filesystem_admission.rs b/crates/graphforge-storage/src/filesystem_admission.rs index a13f6488..8d9e5285 100644 --- a/crates/graphforge-storage/src/filesystem_admission.rs +++ b/crates/graphforge-storage/src/filesystem_admission.rs @@ -40,6 +40,526 @@ pub struct FilesystemAdmissionEvidence { pub elapsed_ms: u64, } +/// Whether a project lifecycle requires the durable-filesystem contract. +/// +/// Ephemeral mode is an explicit escape hatch for in-memory instances whose +/// temporary workspace is not presented as durable project storage. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectLifecycleMode { + /// Probe and retain the supported durable-filesystem identity. + Durable, + /// Skip durability probing while retaining link and identity checks. + Ephemeral, +} + +/// Whether lifecycle admission may create an absent final project directory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectRootRequirement { + /// The final project directory must already exist. + Existing, + /// Create the final project directory after successful durable probing. + CreateIfMissing, +} + +/// Short-lived, storage-owned admission guard for one project lifecycle. +/// +/// Durable guards own the persistent parent-scoped creation lock until drop. +/// The lock file itself is intentionally never unlinked. Both durable and +/// ephemeral guards retain opened parent/root identities so callers can +/// revalidate the namespace immediately before mutation. +#[derive(Debug)] +pub struct ProjectLifecycleAdmission { + mode: ProjectLifecycleMode, + root: PathBuf, + parent: LifecycleDirectory, + project: LifecycleDirectory, + lifecycle_lock: Option, + evidence: Option, + created_root: bool, +} + +/// Retained identity for an admitted project root without a lifecycle lock. +/// +/// This token is suitable for optimistic work that must not serialize every +/// stager. Call [`Self::readmit`] before durable publication to reacquire the +/// lifecycle lock and prove the namespace still names the retained root. +#[derive(Debug)] +pub struct ProjectRootIdentity { + root: PathBuf, + parent: LifecycleDirectory, + project: LifecycleDirectory, +} + +impl ProjectLifecycleAdmission { + /// Canonical project-root path admitted by this guard. + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + /// Lifecycle mode selected by the caller. + #[must_use] + pub const fn mode(&self) -> ProjectLifecycleMode { + self.mode + } + + /// Native durability evidence, present only for durable admission. + #[must_use] + pub fn evidence(&self) -> Option<&FilesystemAdmissionEvidence> { + self.evidence.as_ref() + } + + /// Whether this admission created the previously absent final directory. + #[must_use] + pub const fn created_root(&self) -> bool { + self.created_root + } + + /// Revalidate the retained parent, persistent lock, and project identity. + /// + /// # Errors + /// Returns `GF_UNSUPPORTED_FILESYSTEM` when any named object no longer + /// resolves to the exact opened identity retained by this guard. + pub fn revalidate_identity(&self) -> Result<(), GfError> { + self.parent + .revalidate("IDENTITY", "parent_identity_changed")?; + if let Some(lock) = &self.lifecycle_lock { + lock.revalidate()?; + } + self.project + .revalidate("IDENTITY", "project_identity_changed")?; + if self.project.identity.volume_serial != self.parent.identity.volume_serial { + return Err(unsupported("IDENTITY", "project_cross_volume")); + } + Ok(()) + } + + /// Retain parent/root identity while releasing the lifecycle lock. + /// + /// # Errors + /// Returns `GF_UNSUPPORTED_FILESYSTEM` if namespace identity changed + /// before the lock-release transition. + pub fn into_identity(self) -> Result { + self.revalidate_identity()?; + let Self { + root, + parent, + project, + lifecycle_lock, + .. + } = self; + drop(lifecycle_lock); + let identity = ProjectRootIdentity { + root, + parent, + project, + }; + identity.revalidate_identity()?; + Ok(identity) + } + + /// Remove the exact project root retained by this admission. + /// + /// The lifecycle lock remains held while the root identity is checked and + /// removed. The retained root handle is released only after that check so + /// Windows can delete a directory opened without delete sharing. A second + /// named-identity check immediately before removal prevents deleting a + /// replacement root. + /// + /// # Errors + /// Returns `GF_UNSUPPORTED_FILESYSTEM` if the admitted namespace identity + /// changed or the exact root cannot be removed durably. + pub fn remove_project_root(self) -> Result<(), GfError> { + self.revalidate_identity()?; + let Self { + root, + parent, + project, + lifecycle_lock, + .. + } = self; + let project_identity = project.identity; + drop(project); + + let named = std::fs::symlink_metadata(&root) + .map_err(|_| unsupported("REMOVE", "project_identity_unavailable"))?; + if is_link_or_reparse(&named) + || !named.is_dir() + || graphforge_filesystem::path_identity(&root) + .map_err(|_| unsupported("REMOVE", "project_identity_unavailable"))? + != project_identity + { + return Err(unsupported("REMOVE", "project_identity_changed")); + } + std::fs::remove_dir_all(&root) + .map_err(|_| unsupported("REMOVE", "project_remove_failed"))?; + complete_namespace_barrier(&parent.path) + .map_err(|_| unsupported("REMOVE", "parent_namespace_barrier_failed"))?; + parent.revalidate("REMOVE", "parent_identity_changed")?; + if let Some(lock) = &lifecycle_lock { + lock.revalidate()?; + } + Ok(()) + } +} + +impl ProjectRootIdentity { + /// Canonical project-root path retained by this token. + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + /// Revalidate the retained parent and project identities. + /// + /// # Errors + /// Returns `GF_UNSUPPORTED_FILESYSTEM` when the named root or parent no + /// longer resolves to the exact opened identity retained by this token. + pub fn revalidate_identity(&self) -> Result<(), GfError> { + self.parent + .revalidate("IDENTITY", "parent_identity_changed")?; + self.project + .revalidate("IDENTITY", "project_identity_changed")?; + if self.project.identity.volume_serial != self.parent.identity.volume_serial { + return Err(unsupported("IDENTITY", "project_cross_volume")); + } + Ok(()) + } + + /// Reacquire durable lifecycle admission for the exact retained root. + /// + /// The newly opened parent/root identities must equal this token's + /// identities. A namespace replacement therefore fails closed even when + /// it occurs while the lifecycle lock was intentionally released. + /// + /// # Errors + /// Returns `GF_UNSUPPORTED_FILESYSTEM` if durable readmission fails or the + /// retained namespace identity changed. + pub fn readmit(self) -> Result { + self.revalidate_identity()?; + let admission = admit_project_lifecycle( + &self.root, + ProjectLifecycleMode::Durable, + ProjectRootRequirement::Existing, + )?; + if admission.parent.identity != self.parent.identity + || admission.project.identity != self.project.identity + { + return Err(unsupported("IDENTITY", "project_identity_changed")); + } + admission.revalidate_identity()?; + Ok(admission) + } +} + +/// Admit one project lifecycle before initialization, recovery, or mutation. +/// +/// Durable admission creates and exclusively owns a deterministic lock file in +/// the canonical target parent, runs the native publication probe, and only +/// then creates an absent final project directory. The lock file persists after +/// the guard releases its kernel lock so crash/retry and independent processes +/// always rendezvous on the same inode. +/// +/// # Errors +/// Returns `GF_UNSUPPORTED_FILESYSTEM` before final-root creation when the +/// durable contract, link policy, namespace identity, or target shape cannot +/// be proven. +pub fn admit_project_lifecycle( + proposed_project_root: impl AsRef, + mode: ProjectLifecycleMode, + requirement: ProjectRootRequirement, +) -> Result { + admit_project_lifecycle_inner( + proposed_project_root.as_ref(), + mode, + requirement, + ProbeFault::None, + ) +} + +fn admit_project_lifecycle_inner( + proposed_project_root: &Path, + mode: ProjectLifecycleMode, + requirement: ProjectRootRequirement, + fault: ProbeFault, +) -> Result { + let ephemeral_root = if mode == ProjectLifecycleMode::Ephemeral { + match std::fs::symlink_metadata(proposed_project_root) { + Ok(metadata) if !is_link_or_reparse(&metadata) && metadata.is_dir() => Some( + proposed_project_root + .canonicalize() + .map_err(|_| unsupported("CLASSIFY", "target_unavailable"))?, + ), + _ => None, + } + } else { + None + }; + let proposed_project_root = ephemeral_root.as_deref().unwrap_or(proposed_project_root); + let (parent_path, target_name) = canonical_parent_and_name(proposed_project_root)?; + let parent = LifecycleDirectory::open(&parent_path, "LOCK", "parent_identity_unavailable")?; + let lifecycle_lock = match mode { + ProjectLifecycleMode::Durable => Some(LifecycleLock::acquire(&parent, &target_name)?), + ProjectLifecycleMode::Ephemeral => None, + }; + crate::project_failpoint::hit( + "filesystem_admission.after_lifecycle_lock", + None, + None, + "LIFECYCLE_LOCK", + false, + )?; + parent.revalidate("LOCK", "parent_identity_changed")?; + + let evidence = match mode { + ProjectLifecycleMode::Durable => Some(filesystem_durability_preflight_inner( + proposed_project_root, + fault, + )?), + ProjectLifecycleMode::Ephemeral => None, + }; + crate::project_failpoint::hit( + "filesystem_admission.after_probe", + None, + None, + "PROBE", + false, + )?; + parent.revalidate("IDENTITY", "parent_identity_changed")?; + if let Some(lock) = &lifecycle_lock { + lock.revalidate()?; + } + + let root = parent_path.join(&target_name); + let mut created_root = false; + match std::fs::symlink_metadata(&root) { + Ok(metadata) => { + if is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(unsupported("IDENTITY", "target_link_or_special")); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if requirement == ProjectRootRequirement::Existing { + return Err(unsupported("IDENTITY", "target_missing")); + } + graphforge_filesystem::create_private_directory(&root) + .map_err(|_| unsupported("CREATE", "project_directory_create_failed"))?; + complete_namespace_barrier(&parent_path) + .map_err(|_| unsupported("CREATE", "parent_namespace_barrier_failed"))?; + created_root = true; + } + Err(_) => return Err(unsupported("IDENTITY", "target_metadata_unavailable")), + } + + let project = LifecycleDirectory::open(&root, "IDENTITY", "project_identity_unavailable")?; + let admission = ProjectLifecycleAdmission { + mode, + root, + parent, + project, + lifecycle_lock, + evidence, + created_root, + }; + admission.revalidate_identity()?; + crate::project_failpoint::hit( + "filesystem_admission.after_root_identity", + None, + None, + "ROOT_IDENTITY", + false, + )?; + Ok(admission) +} + +#[derive(Debug)] +struct LifecycleDirectory { + path: PathBuf, + handle: File, + identity: graphforge_filesystem::FileIdentity, +} + +impl LifecycleDirectory { + fn open(path: &Path, phase: &'static str, cause: &'static str) -> Result { + let named = std::fs::symlink_metadata(path).map_err(|_| unsupported(phase, cause))?; + if is_link_or_reparse(&named) || !named.is_dir() { + return Err(unsupported(phase, cause)); + } + let handle = open_directory_handle(path).map_err(|_| unsupported(phase, cause))?; + let identity = + graphforge_filesystem::path_identity(path).map_err(|_| unsupported(phase, cause))?; + let opened = handle.metadata().map_err(|_| unsupported(phase, cause))?; + if !opened.is_dir() || file_identity(&handle)? != identity { + return Err(unsupported(phase, cause)); + } + Ok(Self { + path: path.to_path_buf(), + handle, + identity, + }) + } + + fn revalidate(&self, phase: &'static str, cause: &'static str) -> Result<(), GfError> { + let named = std::fs::symlink_metadata(&self.path).map_err(|_| unsupported(phase, cause))?; + let opened = self + .handle + .metadata() + .map_err(|_| unsupported(phase, cause))?; + if is_link_or_reparse(&named) + || !named.is_dir() + || !opened.is_dir() + || graphforge_filesystem::path_identity(&self.path) + .map_err(|_| unsupported(phase, cause))? + != self.identity + || file_identity(&self.handle)? != self.identity + { + return Err(unsupported(phase, cause)); + } + Ok(()) + } +} + +#[derive(Debug)] +struct LifecycleLock { + path: PathBuf, + file: File, + identity: graphforge_filesystem::FileIdentity, + parent_identity: graphforge_filesystem::FileIdentity, +} + +impl LifecycleLock { + fn acquire( + parent: &LifecycleDirectory, + target_name: &std::ffi::OsStr, + ) -> Result { + let name = lifecycle_lock_name(&parent.path, target_name); + let path = parent.path.join(&name); + let file = open_lifecycle_lock_file(parent, &name) + .map_err(|_| unsupported("LOCK", "lifecycle_lock_open_failed"))?; + file.sync_all() + .map_err(|_| unsupported("LOCK", "lifecycle_lock_flush_failed"))?; + complete_namespace_barrier(&parent.path) + .map_err(|_| unsupported("LOCK", "parent_namespace_barrier_failed"))?; + crate::file_lock::lock_exclusive(&file) + .map_err(|_| unsupported("LOCK", "lifecycle_lock_failed"))?; + let identity = graphforge_filesystem::file_identity(&file) + .map_err(|_| unsupported("LOCK", "lifecycle_lock_identity_unavailable"))?; + let lock = Self { + path, + file, + identity, + parent_identity: parent.identity, + }; + lock.revalidate()?; + Ok(lock) + } + + fn revalidate(&self) -> Result<(), GfError> { + let named = std::fs::symlink_metadata(&self.path) + .map_err(|_| unsupported("LOCK", "lifecycle_lock_missing"))?; + let opened = self + .file + .metadata() + .map_err(|_| unsupported("LOCK", "lifecycle_lock_unreadable"))?; + if is_link_or_reparse(&named) + || !named.is_file() + || !opened.is_file() + || graphforge_filesystem::path_link_count(&self.path) + .map_err(|_| unsupported("LOCK", "lifecycle_lock_link_count_unavailable"))? + != 1 + || graphforge_filesystem::file_link_count(&self.file) + .map_err(|_| unsupported("LOCK", "lifecycle_lock_link_count_unavailable"))? + != 1 + || graphforge_filesystem::path_identity(&self.path) + .map_err(|_| unsupported("LOCK", "lifecycle_lock_identity_unavailable"))? + != self.identity + || graphforge_filesystem::file_identity(&self.file) + .map_err(|_| unsupported("LOCK", "lifecycle_lock_identity_unavailable"))? + != self.identity + || self.identity.volume_serial != self.parent_identity.volume_serial + { + return Err(unsupported("LOCK", "lifecycle_lock_identity_changed")); + } + Ok(()) + } +} + +impl Drop for LifecycleLock { + fn drop(&mut self) { + let _ = crate::file_lock::unlock(&self.file); + } +} + +fn lifecycle_lock_name(parent: &Path, target_name: &std::ffi::OsStr) -> String { + let mut digest = Sha256::new(); + digest.update(b"graphforge-project-lifecycle-lock/v1\0"); + digest.update(path_bytes(parent.as_os_str())); + digest.update([0]); + digest.update(path_bytes(target_name)); + let digest: [u8; 32] = digest.finalize().into(); + let mut encoded = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + format!(".graphforge-admission-{encoded}.lock") +} + +#[cfg(unix)] +fn open_lifecycle_lock_file(parent: &LifecycleDirectory, name: &str) -> std::io::Result { + use rustix::fs::{Mode, OFlags, openat}; + + let open_existing = || { + openat( + &parent.handle, + name, + OFlags::RDWR | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map(File::from) + .map_err(std::io::Error::from) + }; + match open_existing() { + Ok(file) => Ok(file), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => match openat( + &parent.handle, + name, + OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::RUSR | Mode::WUSR, + ) { + Ok(file) => Ok(File::from(file)), + Err(error) if error == rustix::io::Errno::EXIST => open_existing(), + Err(error) => Err(std::io::Error::from(error)), + }, + Err(error) => Err(error), + } +} + +#[cfg(windows)] +fn open_lifecycle_lock_file(parent: &LifecycleDirectory, name: &str) -> std::io::Result { + use std::os::windows::fs::OpenOptionsExt as _; + + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_FLAG_WRITE_THROUGH: u32 = 0x8000_0000; + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH) + .open(parent.path.join(name)) +} + +#[cfg(all(not(unix), not(windows)))] +fn open_lifecycle_lock_file(_parent: &LifecycleDirectory, _name: &str) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "persistent lifecycle locks are unsupported", + )) +} + /// Prove that the proposed project location provides GraphForge's required /// local publication primitives. /// @@ -233,13 +753,37 @@ fn reject_ancestor_links(parent: &Path) -> Result<(), GfError> { } let metadata = std::fs::symlink_metadata(¤t) .map_err(|_| unsupported("CLASSIFY", "ancestor_unavailable"))?; - if is_link_or_reparse(&metadata) || !metadata.is_dir() { + if is_link_or_reparse(&metadata) { + if trusted_macos_system_alias(¤t) { + continue; + } + return Err(unsupported("CLASSIFY", "ancestor_link_or_special")); + } + if !metadata.is_dir() { return Err(unsupported("CLASSIFY", "ancestor_link_or_special")); } } Ok(()) } +#[cfg(target_os = "macos")] +fn trusted_macos_system_alias(path: &Path) -> bool { + let expected = if path == Path::new("/var") { + Path::new("/private/var") + } else if path == Path::new("/tmp") { + Path::new("/private/tmp") + } else { + return false; + }; + path.canonicalize() + .is_ok_and(|resolved| resolved == expected) +} + +#[cfg(not(target_os = "macos"))] +const fn trusted_macos_system_alias(_path: &Path) -> bool { + false +} + fn classify_supported_local_volume(parent: &Path) -> Result { classify_supported_local_volume_platform(parent) } @@ -844,6 +1388,311 @@ mod tests { assert_eq!(std::fs::read_dir(parent.path()).unwrap().count(), 0); } + #[test] + fn durable_lifecycle_rejects_before_target_mutation_and_retries_on_persistent_lock() { + let parent = canonical_tempdir(); + let target = parent.path().join("project"); + let target_name = target.file_name().unwrap(); + let lock = parent + .path() + .join(lifecycle_lock_name(parent.path(), target_name)); + + let error = admit_project_lifecycle_inner( + &target, + ProjectLifecycleMode::Durable, + ProjectRootRequirement::CreateIfMissing, + ProbeFault::Classify, + ) + .unwrap_err(); + assert_eq!(error.code(), "GF_UNSUPPORTED_FILESYSTEM"); + assert!( + !target.exists(), + "failed admission must not create the root" + ); + assert!(lock.is_file(), "the rendezvous lock persists after failure"); + assert_eq!(std::fs::read_dir(parent.path()).unwrap().count(), 1); + + let admission = admit_project_lifecycle( + &target, + ProjectLifecycleMode::Durable, + ProjectRootRequirement::CreateIfMissing, + ) + .unwrap(); + assert!(admission.created_root()); + assert!(admission.evidence().is_some()); + admission.revalidate_identity().unwrap(); + drop(admission); + assert!(target.is_dir()); + assert!(lock.is_file(), "unlock must never unlink the lock file"); + } + + #[test] + fn durable_lifecycle_preserves_existing_current_bytes() { + let parent = canonical_tempdir(); + let target = parent.path().join("project"); + graphforge_filesystem::create_private_directory(&target).unwrap(); + let current = target.join("CURRENT"); + let expected = b"existing-current-authority\n"; + std::fs::write(¤t, expected).unwrap(); + + let admission = admit_project_lifecycle( + &target, + ProjectLifecycleMode::Durable, + ProjectRootRequirement::Existing, + ) + .unwrap(); + assert!(!admission.created_root()); + admission.revalidate_identity().unwrap(); + assert_eq!(std::fs::read(¤t).unwrap(), expected); + } + + #[test] + fn concurrent_first_admissions_create_exactly_one_root_and_share_identity() { + use std::sync::{Arc, Barrier}; + + let parent = canonical_tempdir(); + let target = parent.path().join("project"); + let barrier = Arc::new(Barrier::new(3)); + let mut workers = Vec::new(); + for _ in 0..2 { + let target = target.clone(); + let barrier = Arc::clone(&barrier); + workers.push(std::thread::spawn(move || { + barrier.wait(); + let admission = admit_project_lifecycle( + &target, + ProjectLifecycleMode::Durable, + ProjectRootRequirement::CreateIfMissing, + ) + .unwrap(); + admission.revalidate_identity().unwrap(); + (admission.created_root(), admission.project.identity) + })); + } + barrier.wait(); + let results = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect::>(); + assert_eq!(results.iter().filter(|(created, _)| *created).count(), 1); + assert_eq!(results[0].1, results[1].1); + assert_eq!( + graphforge_filesystem::path_identity(&target).unwrap(), + results[0].1 + ); + let lock = parent.path().join(lifecycle_lock_name( + parent.path(), + target.file_name().unwrap(), + )); + assert!(lock.is_file()); + } + + #[test] + fn ephemeral_lifecycle_is_an_explicit_probe_and_lock_bypass() { + let parent = canonical_tempdir(); + let target = parent.path().join("ephemeral"); + let admission = admit_project_lifecycle_inner( + &target, + ProjectLifecycleMode::Ephemeral, + ProjectRootRequirement::CreateIfMissing, + ProbeFault::Classify, + ) + .unwrap(); + assert_eq!(admission.mode(), ProjectLifecycleMode::Ephemeral); + assert!(admission.evidence().is_none()); + assert!(admission.created_root()); + admission.revalidate_identity().unwrap(); + assert_eq!(std::fs::read_dir(parent.path()).unwrap().count(), 1); + } + + const LIFECYCLE_TEST_COOKIE: &str = "graphforge-780-lifecycle-test"; + + #[test] + fn subprocess_lifecycle_admission() { + if std::env::var("GF_780_LIFECYCLE_COOKIE").as_deref() != Ok(LIFECYCLE_TEST_COOKIE) { + return; + } + let root = PathBuf::from(std::env::var_os("GF_780_PROJECT_ROOT").unwrap()); + let admission = admit_project_lifecycle( + root, + ProjectLifecycleMode::Durable, + ProjectRootRequirement::CreateIfMissing, + ) + .unwrap(); + admission.revalidate_identity().unwrap(); + } + + #[test] + fn lifecycle_phase_crashes_retry_to_one_bounded_root_and_lock() { + for phase in [ + "filesystem_admission.after_lifecycle_lock", + "filesystem_admission.after_probe", + "filesystem_admission.after_root_identity", + ] { + let parent = canonical_tempdir(); + let root = parent.path().join("project"); + let lock = parent.path().join(lifecycle_lock_name( + parent.path(), + root.file_name().unwrap(), + )); + let crashed = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "filesystem_admission::tests::subprocess_lifecycle_admission", + "--nocapture", + ]) + .env("GF_780_LIFECYCLE_COOKIE", LIFECYCLE_TEST_COOKIE) + .env("GF_780_PROJECT_ROOT", &root) + .env( + "GRAPHFORGE_PROJECT_FAILPOINTS", + "graphforge-internal-subprocess-v1", + ) + .env("GRAPHFORGE_PROJECT_FAILPOINT", phase) + .status() + .unwrap(); + assert_eq!( + crashed.code(), + Some(crate::project_failpoint::exit_code()), + "{phase}" + ); + assert!(lock.is_file(), "{phase}"); + assert!( + std::fs::read_dir(parent.path()).unwrap().count() <= 2, + "{phase} left unbounded admission artifacts" + ); + + let retry = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "filesystem_admission::tests::subprocess_lifecycle_admission", + "--nocapture", + ]) + .env("GF_780_LIFECYCLE_COOKIE", LIFECYCLE_TEST_COOKIE) + .env("GF_780_PROJECT_ROOT", &root) + .status() + .unwrap(); + assert!(retry.success(), "retry after {phase} failed: {retry}"); + + let admission = admit_project_lifecycle( + &root, + ProjectLifecycleMode::Durable, + ProjectRootRequirement::Existing, + ) + .unwrap(); + admission.revalidate_identity().unwrap(); + assert!(root.is_dir(), "{phase}"); + assert!(lock.is_file(), "{phase}"); + assert_eq!( + std::fs::read_dir(parent.path()).unwrap().count(), + 2, + "{phase} did not converge to exactly one root and one lock" + ); + } + } + + #[test] + fn admitted_root_can_be_removed_without_unlinking_the_lifecycle_lock() { + let parent = canonical_tempdir(); + let root = parent.path().join("project"); + let admission = admit_project_lifecycle( + &root, + ProjectLifecycleMode::Durable, + ProjectRootRequirement::CreateIfMissing, + ) + .unwrap(); + let lock = parent.path().join(lifecycle_lock_name( + parent.path(), + root.file_name().unwrap(), + )); + std::fs::write(root.join("owned"), b"data").unwrap(); + + admission.remove_project_root().unwrap(); + + assert!(!root.exists()); + assert!(lock.is_file()); + assert_eq!(std::fs::read_dir(parent.path()).unwrap().count(), 1); + } + + #[cfg(unix)] + #[test] + fn root_removal_rejects_namespace_substitution_without_deleting_either_tree() { + let parent = canonical_tempdir(); + let root = parent.path().join("project"); + let moved = parent.path().join("moved"); + let admission = admit_project_lifecycle( + &root, + ProjectLifecycleMode::Ephemeral, + ProjectRootRequirement::CreateIfMissing, + ) + .unwrap(); + std::fs::rename(&root, &moved).unwrap(); + graphforge_filesystem::create_private_directory(&root).unwrap(); + + let error = admission.remove_project_root().unwrap_err(); + + assert_eq!(error.code(), "GF_UNSUPPORTED_FILESYSTEM"); + assert!(root.is_dir()); + assert!(moved.is_dir()); + } + + #[test] + fn existing_ephemeral_root_is_canonicalized_before_ancestor_policy() { + let root = tempfile::tempdir().unwrap(); + let admission = admit_project_lifecycle( + root.path(), + ProjectLifecycleMode::Ephemeral, + ProjectRootRequirement::Existing, + ) + .unwrap(); + assert_eq!(admission.root(), root.path().canonicalize().unwrap()); + admission.revalidate_identity().unwrap(); + } + + #[test] + fn identity_token_releases_and_readmits_the_same_durable_root() { + let parent = canonical_tempdir(); + let root = parent.path().join("project"); + let admission = admit_project_lifecycle( + &root, + ProjectLifecycleMode::Durable, + ProjectRootRequirement::CreateIfMissing, + ) + .unwrap(); + let expected = admission.project.identity; + + let identity = admission.into_identity().unwrap(); + identity.revalidate_identity().unwrap(); + assert_eq!(identity.root(), root); + let readmitted = identity.readmit().unwrap(); + + assert_eq!(readmitted.project.identity, expected); + readmitted.revalidate_identity().unwrap(); + } + + #[cfg(unix)] + #[test] + fn identity_token_readmission_rejects_a_replacement_root() { + let parent = canonical_tempdir(); + let root = parent.path().join("project"); + let moved = parent.path().join("moved"); + let identity = admit_project_lifecycle( + &root, + ProjectLifecycleMode::Durable, + ProjectRootRequirement::CreateIfMissing, + ) + .unwrap() + .into_identity() + .unwrap(); + std::fs::rename(&root, &moved).unwrap(); + graphforge_filesystem::create_private_directory(&root).unwrap(); + + let error = identity.readmit().unwrap_err(); + + assert_eq!(error.code(), "GF_UNSUPPORTED_FILESYSTEM"); + assert!(root.is_dir()); + assert!(moved.is_dir()); + } + #[test] fn windows_classifier_accepts_only_fixed_writable_ntfs() { assert_eq!( From d4a50e83120100577caa677f38bf3654dbf4ba2d Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:36:30 -0600 Subject: [PATCH 05/19] feat(storage): route durable publishers through admission (#780) --- .../src/graph_delta_compaction.rs | 18 +++- .../src/graph_delta_journal.rs | 9 ++ crates/graphforge-storage/src/lib.rs | 5 +- .../src/project_checkpoints.rs | 31 +++++-- .../src/project_generation.rs | 41 ++++++++- .../src/project_portable.rs | 28 ++++-- .../src/project_publication.rs | 89 ++++++++++++++++++- .../src/project_recovery.rs | 67 ++++++++++++-- .../src/project_retention.rs | 15 +++- 9 files changed, 270 insertions(+), 33 deletions(-) diff --git a/crates/graphforge-storage/src/graph_delta_compaction.rs b/crates/graphforge-storage/src/graph_delta_compaction.rs index b057e446..e98731df 100644 --- a/crates/graphforge-storage/src/graph_delta_compaction.rs +++ b/crates/graphforge-storage/src/graph_delta_compaction.rs @@ -183,7 +183,13 @@ pub fn preview_graph_delta_compaction( cancel: Option<&AtomicBool>, ) -> Result { let started = Instant::now(); - let prepared = prepare_compaction(container_root.as_ref(), request, cancel)?; + let admission = crate::filesystem_admission::admit_project_lifecycle( + container_root, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + crate::filesystem_admission::ProjectRootRequirement::Existing, + )?; + admission.revalidate_identity()?; + let prepared = prepare_compaction(admission.root(), request, cancel)?; Ok(report_from_prepared( &prepared, true, @@ -205,7 +211,14 @@ pub fn compact_graph_delta( cancel: Option<&AtomicBool>, ) -> Result { let started = Instant::now(); - let root = container_root.as_ref(); + let admission = crate::filesystem_admission::admit_project_lifecycle( + container_root, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + crate::filesystem_admission::ProjectRootRequirement::Existing, + )?; + admission.revalidate_identity()?; + let admitted_root = admission.root().to_owned(); + let root = admitted_root.as_path(); let limits = request.limits.validate()?; if let Some(publication) = published_project_transaction(root, request.transaction_uuid)? { @@ -251,6 +264,7 @@ pub fn compact_graph_delta( }; check_cancel(cancel)?; + drop(admission); let publication = match stage_project_generation_with_graph_tree( root, &generation_request, diff --git a/crates/graphforge-storage/src/graph_delta_journal.rs b/crates/graphforge-storage/src/graph_delta_journal.rs index b7bd920f..8f6f55ca 100644 --- a/crates/graphforge-storage/src/graph_delta_journal.rs +++ b/crates/graphforge-storage/src/graph_delta_journal.rs @@ -714,6 +714,14 @@ pub fn publish_graph_delta( container_root: &Path, request: &GraphDeltaPublishRequest, ) -> Result { + let admission = crate::filesystem_admission::admit_project_lifecycle( + container_root, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + crate::filesystem_admission::ProjectRootRequirement::Existing, + )?; + admission.revalidate_identity()?; + let admitted_root = admission.root().to_owned(); + let container_root = admitted_root.as_path(); for op in &request.operations { if op.payload.expected_kind() != op.kind { return Err(validation( @@ -862,6 +870,7 @@ pub fn publish_graph_delta( participants, }; + drop(admission); let publication = match stage_project_generation_with_graph_tree( container_root, &generation_request, diff --git a/crates/graphforge-storage/src/lib.rs b/crates/graphforge-storage/src/lib.rs index 46ce95ee..dfc30fb2 100644 --- a/crates/graphforge-storage/src/lib.rs +++ b/crates/graphforge-storage/src/lib.rs @@ -62,7 +62,7 @@ pub mod project_generation; pub use project_generation::{ CURRENT_FILE, FORMAT_FILE, PROJECT_FORMAT_BYTES, ProjectCapabilityDescriptor, ProjectParticipantDescriptor, ProjectParticipantSnapshot, ResolvedProjectGeneration, - open_or_initialize_project, resolve_project_generation, + open_or_initialize_ephemeral_project, open_or_initialize_project, resolve_project_generation, }; mod project_failpoint; @@ -93,7 +93,8 @@ pub mod project_recovery; pub use project_recovery::{ DEFAULT_RETAINED_ANCESTORS, MAX_RETAINED_ANCESTORS, ProjectOpenRecoveryEvidence, ProjectOpenRecoveryKind, ProjectRecoveryDeferral, ProjectRecoveryGenerationClass, - ProjectRecoveryReport, open_or_initialize_project_with_recovery, recover_project_on_open, + ProjectRecoveryReport, open_or_initialize_ephemeral_project_with_recovery, + open_or_initialize_project_with_recovery, recover_project_on_open, recover_project_transactions, }; diff --git a/crates/graphforge-storage/src/project_checkpoints.rs b/crates/graphforge-storage/src/project_checkpoints.rs index 92f3e96a..d8d8426a 100644 --- a/crates/graphforge-storage/src/project_checkpoints.rs +++ b/crates/graphforge-storage/src/project_checkpoints.rs @@ -239,7 +239,8 @@ pub fn create_checkpoint( ) -> Result { let name = validate_name(&request.name)?; validate_description(request.description.as_deref())?; - let root = canonical_project_root(container_root.as_ref())?; + let admission = admit_existing_project(container_root.as_ref())?; + let root = canonical_project_root(admission.root())?; let _locks = acquire_mutation_locks(&root)?; let checkpoint_root = checkpoint_root(&root)?; recover_pair(&checkpoint_root)?; @@ -332,7 +333,8 @@ pub fn delete_checkpoint( request: &CheckpointDeleteRequest, ) -> Result { let name = validate_name(&request.name)?; - let root = canonical_project_root(container_root.as_ref())?; + let admission = admit_existing_project(container_root.as_ref())?; + let root = canonical_project_root(admission.root())?; let _locks = acquire_mutation_locks(&root)?; let checkpoint_root = checkpoint_root(&root)?; recover_pair(&checkpoint_root)?; @@ -418,7 +420,8 @@ pub fn delete_checkpoint( pub fn list_checkpoints( container_root: impl AsRef, ) -> Result, GfError> { - let root = canonical_project_root(container_root.as_ref())?; + let admission = admit_existing_project(container_root.as_ref())?; + let root = canonical_project_root(admission.root())?; let checkpoint_root = checkpoint_root(&root)?; let (_checkpoint_lock, registry) = read_registry_for_read(&root, &checkpoint_root)?; Ok(registry.active) @@ -430,7 +433,8 @@ pub fn open_checkpoint_generation( name: &str, ) -> Result<(CheckpointRecord, crate::ResolvedProjectGeneration), GfError> { let name = validate_name(name)?; - let root = canonical_project_root(container_root.as_ref())?; + let admission = admit_existing_project(container_root.as_ref())?; + let root = canonical_project_root(admission.root())?; let checkpoint_root = checkpoint_root(&root)?; let (_checkpoint_lock, registry) = read_registry_for_read(&root, &checkpoint_root)?; let row = registry @@ -478,7 +482,8 @@ where { let requested_name = validate_name(&request.name)?; let requested_reason = validate_reason(&request.reason)?; - let root = canonical_project_root(container_root.as_ref())?; + let admission = admit_existing_project(container_root.as_ref())?; + let root = canonical_project_root(admission.root())?; let transaction_uuid = revert_transaction_uuid(request.operation_uuid); let mut locks = acquire_mutation_locks(&root)?; let checkpoint_root = checkpoint_root(&root)?; @@ -660,6 +665,7 @@ where }) .then_some(source_graph_tree.as_path()); let receipt = match stage_project_generation_with_lock( + admission, root.clone(), writer, prior_current, @@ -885,6 +891,18 @@ fn canonical_project_root(path: &Path) -> Result { std::fs::canonicalize(path).map_err(storage_io) } +fn admit_existing_project( + root: &Path, +) -> Result { + let admission = crate::filesystem_admission::admit_project_lifecycle( + root, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + crate::filesystem_admission::ProjectRootRequirement::Existing, + )?; + admission.revalidate_identity()?; + Ok(admission) +} + fn checkpoint_root(root: &Path) -> Result { ensure_machine_directory(root, Path::new(CHECKPOINTS_DIR)) } @@ -2853,8 +2871,9 @@ mod tests { symlink(&project, &linked).unwrap(); assert_eq!( list_checkpoints(&linked).unwrap_err().code(), - "GF_UNSUPPORTED_PROJECT_FORMAT" + "GF_UNSUPPORTED_FILESYSTEM" ); + assert!(!project.join(CHECKPOINTS_DIR).exists()); } #[test] diff --git a/crates/graphforge-storage/src/project_generation.rs b/crates/graphforge-storage/src/project_generation.rs index 9b4d7d0a..d216be46 100644 --- a/crates/graphforge-storage/src/project_generation.rs +++ b/crates/graphforge-storage/src/project_generation.rs @@ -619,7 +619,46 @@ pub(crate) fn resolve_verified_generation( pub fn open_or_initialize_project( container_root: impl AsRef, ) -> Result { - let root = container_root.as_ref(); + open_or_initialize_project_for_mode( + container_root.as_ref(), + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Open or initialize an explicitly ephemeral project workspace. +/// +/// This retains the lifecycle link and identity checks while skipping the +/// durable-volume capability probe. It is reserved for process-owned temporary +/// workspaces and must not be used for a caller-supplied persistent project. +/// +/// # Errors +/// Returns the same initialization, format, and storage errors as +/// [`open_or_initialize_project`]. +pub fn open_or_initialize_ephemeral_project( + container_root: impl AsRef, +) -> Result { + open_or_initialize_project_for_mode( + container_root.as_ref(), + crate::filesystem_admission::ProjectLifecycleMode::Ephemeral, + ) +} + +pub(crate) fn open_or_initialize_project_for_mode( + root: &Path, + mode: crate::filesystem_admission::ProjectLifecycleMode, +) -> Result { + let admission = crate::filesystem_admission::admit_project_lifecycle( + root, + mode, + crate::filesystem_admission::ProjectRootRequirement::CreateIfMissing, + )?; + admission.revalidate_identity()?; + open_or_initialize_project_admitted(admission.root()) +} + +pub(crate) fn open_or_initialize_project_admitted( + root: &Path, +) -> Result { reject_root_link(root)?; let _root_lock = lock_project_root(root)?; let mut entries = std::fs::read_dir(root).map_err(|error| { diff --git a/crates/graphforge-storage/src/project_portable.rs b/crates/graphforge-storage/src/project_portable.rs index 6d0b41d0..2f867b0f 100644 --- a/crates/graphforge-storage/src/project_portable.rs +++ b/crates/graphforge-storage/src/project_portable.rs @@ -15,10 +15,13 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; +#[cfg(test)] +use crate::open_or_initialize_project; +use crate::project_generation::open_or_initialize_project_admitted; use crate::{ ProjectCapability, ProjectGenerationRequest, ProjectParticipant, ProjectParticipantEncoding, ProjectPublicationReceipt, ProjectStageOutcome, ResolvedProjectGeneration, - open_or_initialize_project, resolve_project_generation, stage_project_generation, + resolve_project_generation, stage_project_generation, }; const MAGIC: &[u8; 16] = b"graphforge-exp\0\n"; @@ -239,12 +242,19 @@ pub fn import_portable_project( ) -> Result { let validated = validate_envelope(envelope, supported_capabilities, limits)?; let target = target.as_ref(); + let admission = crate::filesystem_admission::admit_project_lifecycle( + target, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + crate::filesystem_admission::ProjectRootRequirement::CreateIfMissing, + )?; + admission.revalidate_identity()?; + let target = admission.root(); let existing_parent = prepare_import_target(target)?; let initialized_parent; let _parent = if let Some(parent) = existing_parent { parent } else { - initialized_parent = open_or_initialize_project(target)?; + initialized_parent = open_or_initialize_project_admitted(target)?; initialized_parent }; let request = ProjectGenerationRequest { @@ -253,7 +263,9 @@ pub fn import_portable_project( capabilities: validated.capabilities, participants: validated.participants, }; - let publication = match stage_project_generation(target, &request)? { + let target = target.to_owned(); + drop(admission); + let publication = match stage_project_generation(&target, &request)? { ProjectStageOutcome::AlreadyPublished(receipt) => receipt, ProjectStageOutcome::Staged(staged) => { staged.validate(|_| Ok(()), |_, _| Ok(()))?.publish()? @@ -527,12 +539,10 @@ fn prepare_import_target(target: &Path) -> Result { - std::fs::create_dir(target).map_err(|error| { - GfError::Storage(format!("failed to create portable import target: {error}")) - })?; - Ok(None) - } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Err(project_error( + ProjectErrorCode::UnsupportedFilesystem, + "portable import target was not admitted before target preparation", + )), Err(error) => Err(GfError::Storage(format!( "failed to inspect portable import target: {error}" ))), diff --git a/crates/graphforge-storage/src/project_publication.rs b/crates/graphforge-storage/src/project_publication.rs index effb70b4..d498de9a 100644 --- a/crates/graphforge-storage/src/project_publication.rs +++ b/crates/graphforge-storage/src/project_publication.rs @@ -149,6 +149,7 @@ pub enum ProjectStageOutcome { pub struct StagedProjectGeneration { root: PathBuf, publication_lock: PublicationLock, + admission: StagedAdmission, parent: ResolvedProjectGeneration, transaction_uuid: Uuid, generation_uuid: Uuid, @@ -166,6 +167,45 @@ enum PublicationLock { Optimistic(File), } +enum StagedAdmission { + Exclusive(crate::filesystem_admission::ProjectLifecycleAdmission), + Optimistic(Option), +} + +impl StagedAdmission { + fn revalidate_identity(&self) -> Result<(), GfError> { + match self { + Self::Exclusive(admission) => admission.revalidate_identity(), + Self::Optimistic(Some(identity)) => identity.revalidate_identity(), + Self::Optimistic(None) => Err(project_error( + ProjectErrorCode::PublicationFailed, + "optimistic project identity was already consumed", + )), + } + } + + fn readmit_for_publish( + &mut self, + ) -> Result, GfError> { + match self { + Self::Exclusive(admission) => { + admission.revalidate_identity()?; + Ok(None) + } + Self::Optimistic(identity) => identity + .take() + .ok_or_else(|| { + project_error( + ProjectErrorCode::PublicationFailed, + "optimistic project identity was already consumed", + ) + })? + .readmit() + .map(Some), + } + } +} + impl Drop for PublicationLock { fn drop(&mut self) { // Every error path that abandons a held publication lock must release it. @@ -295,7 +335,13 @@ fn stage_project_generation_inner( // Reject malformed contracts before taking the writer lock so concurrent // readers/writers are never blocked by validation-only failures. validate_request(request)?; - let root = canonical_supported_root(container_root)?; + let admission = crate::filesystem_admission::admit_project_lifecycle( + container_root, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + crate::filesystem_admission::ProjectRootRequirement::Existing, + )?; + admission.revalidate_identity()?; + let root = canonical_supported_root(admission.root())?; let writer_lock = acquire_writer_lock(&root, request)?; project_failpoint::hit( "project.after_writer_lock", @@ -305,7 +351,16 @@ fn stage_project_generation_inner( false, )?; let parent = resolve_project_generation(&root)?; - stage_project_generation_with_lock(root, writer_lock, parent, request, None, graph_tree) + stage_project_generation_inner_with_locks( + StagedAdmission::Exclusive(admission), + root, + PublicationLock::Exclusive(writer_lock), + parent, + request, + None, + None, + graph_tree, + ) } fn stage_project_generation_optimistic_inner( @@ -315,10 +370,18 @@ fn stage_project_generation_optimistic_inner( graph_tree: Option<&Path>, ) -> Result { validate_request(request)?; - let root = canonical_supported_root(container_root)?; + let admission = crate::filesystem_admission::admit_project_lifecycle( + container_root, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + crate::filesystem_admission::ProjectRootRequirement::Existing, + )?; + admission.revalidate_identity()?; + let root = canonical_supported_root(admission.root())?; let transaction_lock = acquire_transaction_lock(&root, request)?; let parent = resolve_project_generation(&root)?; + let identity = admission.into_identity()?; stage_project_generation_inner_with_locks( + StagedAdmission::Optimistic(Some(identity)), root, PublicationLock::Optimistic(transaction_lock), parent, @@ -335,6 +398,7 @@ fn stage_project_generation_optimistic_inner( /// Pass `graph_tree` when the request's `graph`/`files` inventory must be /// staged from a non-parent source (for example a pinned checkpoint generation). pub(crate) fn stage_project_generation_with_lock( + admission: crate::filesystem_admission::ProjectLifecycleAdmission, root: PathBuf, writer_lock: File, parent: ResolvedProjectGeneration, @@ -342,7 +406,9 @@ pub(crate) fn stage_project_generation_with_lock( revert: Option, graph_tree: Option<&Path>, ) -> Result { + admission.revalidate_identity()?; stage_project_generation_inner_with_locks( + StagedAdmission::Exclusive(admission), root, PublicationLock::Exclusive(writer_lock), parent, @@ -353,7 +419,12 @@ pub(crate) fn stage_project_generation_with_lock( ) } +#[expect( + clippy::too_many_arguments, + reason = "the shared staging kernel keeps admission, lock, parent, request, revert, optimistic identity, and graph-tree authority explicit" +)] fn stage_project_generation_inner_with_locks( + admission: StagedAdmission, root: PathBuf, publication_lock: PublicationLock, parent: ResolvedProjectGeneration, @@ -438,6 +509,7 @@ fn stage_project_generation_inner_with_locks( Ok(ProjectStageOutcome::Staged(Box::new( StagedProjectGeneration { + admission, root, publication_lock, parent, @@ -884,6 +956,7 @@ impl StagedProjectGeneration { D: FnOnce(&[StagedParticipant]) -> Result<(), GfError>, C: FnOnce(&ResolvedProjectGeneration, &[StagedParticipant]) -> Result<(), GfError>, { + self.admission.revalidate_identity()?; for participant in &self.participants { verify_participant_file( &self @@ -968,8 +1041,15 @@ impl ValidatedProjectGeneration { /// # Errors /// Returns a stable publication error whose diagnostic states whether the /// commit point was crossed. - pub fn publish(self) -> Result { + pub fn publish(mut self) -> Result { + self.0.admission.revalidate_identity()?; + let lifecycle_admission = self.0.admission.readmit_for_publish()?; let commit_lock = self.prepare_commit_lock()?; + if let Some(admission) = &lifecycle_admission { + admission.revalidate_identity()?; + } else { + self.0.admission.revalidate_identity()?; + } let result = self.publish_inner().map_err(|error| { if matches!(error, GfError::Project { .. }) { error @@ -984,6 +1064,7 @@ impl ValidatedProjectGeneration { } }); drop(commit_lock); + drop(lifecycle_admission); result } diff --git a/crates/graphforge-storage/src/project_recovery.rs b/crates/graphforge-storage/src/project_recovery.rs index f981f7d1..2dd89dbe 100644 --- a/crates/graphforge-storage/src/project_recovery.rs +++ b/crates/graphforge-storage/src/project_recovery.rs @@ -15,7 +15,7 @@ use uuid::Uuid; use crate::project_checkpoints::checkpoint_retention_roots_after_writer_lock; use crate::project_failpoint; use crate::project_generation::{ - CURRENT_FILE, ResolvedProjectGeneration, open_or_initialize_project, + CURRENT_FILE, ResolvedProjectGeneration, open_or_initialize_project_admitted, resolve_project_generation, validated_generation_manifest_sha256, validated_generation_parent, }; use crate::project_publication::{ @@ -188,11 +188,42 @@ impl ProjectOpenRecoveryEvidence { pub fn open_or_initialize_project_with_recovery( container_root: impl AsRef, ) -> Result<(ResolvedProjectGeneration, ProjectOpenRecoveryEvidence), GfError> { - let root = container_root.as_ref(); + open_or_initialize_project_with_recovery_for_mode( + container_root.as_ref(), + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Open or initialize an explicitly ephemeral project and apply the same +/// bounded recovery protocol without requiring a durable-volume probe. +/// +/// # Errors +/// Returns the same initialization and recovery errors as +/// [`open_or_initialize_project_with_recovery`]. +pub fn open_or_initialize_ephemeral_project_with_recovery( + container_root: impl AsRef, +) -> Result<(ResolvedProjectGeneration, ProjectOpenRecoveryEvidence), GfError> { + open_or_initialize_project_with_recovery_for_mode( + container_root.as_ref(), + crate::filesystem_admission::ProjectLifecycleMode::Ephemeral, + ) +} + +fn open_or_initialize_project_with_recovery_for_mode( + root: &Path, + mode: crate::filesystem_admission::ProjectLifecycleMode, +) -> Result<(ResolvedProjectGeneration, ProjectOpenRecoveryEvidence), GfError> { + let admission = crate::filesystem_admission::admit_project_lifecycle( + root, + mode, + crate::filesystem_admission::ProjectRootRequirement::CreateIfMissing, + )?; + admission.revalidate_identity()?; + let root = admission.root(); if root.join(CURRENT_FILE).exists() { - return recover_project_on_open(root); + return recover_project_on_open_admitted(root); } - let resolved = open_or_initialize_project(root)?; + let resolved = open_or_initialize_project_admitted(root)?; Ok(( resolved.clone(), ProjectOpenRecoveryEvidence::initialization(resolved.generation_uuid()), @@ -211,9 +242,20 @@ pub fn open_or_initialize_project_with_recovery( /// ambiguous. Never elects a generation by UUID, time, or directory order. pub fn recover_project_on_open( container_root: impl AsRef, +) -> Result<(ResolvedProjectGeneration, ProjectOpenRecoveryEvidence), GfError> { + let admission = crate::filesystem_admission::admit_project_lifecycle( + container_root, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + crate::filesystem_admission::ProjectRootRequirement::Existing, + )?; + admission.revalidate_identity()?; + recover_project_on_open_admitted(admission.root()) +} + +fn recover_project_on_open_admitted( + root: &Path, ) -> Result<(ResolvedProjectGeneration, ProjectOpenRecoveryEvidence), GfError> { let started = Instant::now(); - let root = container_root.as_ref(); let selected = resolve_project_generation(root).map_err(map_recovery_resolution)?; let work_detected = project_needs_recovery_pass(root)?; if !work_detected { @@ -234,7 +276,7 @@ pub fn recover_project_on_open( )); } - match recover_project_transactions(root) { + match recover_project_transactions_admitted(root) { Ok(report) => { let selected = resolve_project_generation(root).map_err(map_recovery_resolution)?; Ok(( @@ -382,8 +424,17 @@ fn looks_like_atomicwrite_temp(path: &Path) -> bool { pub fn recover_project_transactions( container_root: impl AsRef, ) -> Result { - let selected = - resolve_project_generation(container_root.as_ref()).map_err(map_recovery_resolution)?; + let admission = crate::filesystem_admission::admit_project_lifecycle( + container_root, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + crate::filesystem_admission::ProjectRootRequirement::Existing, + )?; + admission.revalidate_identity()?; + recover_project_transactions_admitted(admission.root()) +} + +fn recover_project_transactions_admitted(root: &Path) -> Result { + let selected = resolve_project_generation(root).map_err(map_recovery_resolution)?; let root = selected.container_root().to_owned(); let writer_lock = acquire_recovery_lock(&root)?; diff --git a/crates/graphforge-storage/src/project_retention.rs b/crates/graphforge-storage/src/project_retention.rs index d1bd7971..9ce41dbc 100644 --- a/crates/graphforge-storage/src/project_retention.rs +++ b/crates/graphforge-storage/src/project_retention.rs @@ -242,9 +242,15 @@ pub fn inspect_project_reachability( limits: ProjectRetentionLimits, ) -> Result { let started = Instant::now(); + let admission = crate::filesystem_admission::admit_project_lifecycle( + container_root, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + crate::filesystem_admission::ProjectRootRequirement::Existing, + )?; + admission.revalidate_identity()?; let policy = policy.validate()?; let limits = limits.validate()?; - let root = container_root.as_ref(); + let root = admission.root(); let writer_lock = acquire_recovery_lock(root)?; let selected = resolve_project_generation(root).map_err(map_recovery_resolution)?; let checkpoint_roots = checkpoint_retention_roots_after_writer_lock(root)?; @@ -340,6 +346,13 @@ fn run_cleanup( dry_run: bool, ) -> Result { let started = Instant::now(); + let admission = crate::filesystem_admission::admit_project_lifecycle( + root, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + crate::filesystem_admission::ProjectRootRequirement::Existing, + )?; + admission.revalidate_identity()?; + let root = admission.root(); let policy = policy.validate()?; let limits = limits.validate()?; let writer_lock = acquire_recovery_lock(root)?; From 8f909f9893f54de2e9eab939348b8acab2e4ab74 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:36:33 -0600 Subject: [PATCH 06/19] docs(storage): freeze lifecycle admission contract (#780) --- docs/adr/0013-project-generation-protocol.md | 32 +++++++++++++------ .../book/architecture/concurrency-recovery.md | 12 +++++++ docs/guides/repository-integration.md | 16 ++++++---- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/docs/adr/0013-project-generation-protocol.md b/docs/adr/0013-project-generation-protocol.md index a7bcf6c2..bab5a754 100644 --- a/docs/adr/0013-project-generation-protocol.md +++ b/docs/adr/0013-project-generation-protocol.md @@ -169,15 +169,29 @@ results. ### Container creation An absent path, or an explicitly supplied empty directory, may become a new -container. Creation performs the filesystem preflight, creates the tree, writes -and flushes `FORMAT`, and completes platform-native namespace barriers for each -created entry bottom-up. For an -absent path, the complete private root is atomically installed from a sibling. -For an explicitly empty existing directory, initialization occurs in place -while holding an exclusive parent-scoped creation lock whose name is the -SHA-256 of the canonical target path. That lock uses the same OS-lock rules as -`writer.lock`; its file name contains no caller text. `CURRENT` does not exist -yet. +container. Before the final root, `FORMAT`, `generations/`, `CURRENT`, or any +project lock is created or changed, GraphForge creates or opens a deterministic +parent-sibling lifecycle lock, flushes its file and parent namespace, and takes +its exclusive kernel lock. The lock name is a domain-separated SHA-256 over the +canonical parent and final target component; it contains no caller text. The +lock file is a persistent rendezvous inode and is never unlinked after unlock. + +While holding that lock GraphForge performs the filesystem preflight. Only a +successful preflight may create an absent final root. Creation flushes the new +root entry, writes and flushes `FORMAT`, and completes platform-native namespace +barriers for each created entry bottom-up. A process death before `CURRENT` +may leave only the persistent sibling lock and a bounded empty or resumable +uninitialized root; the next admitted opener validates that exact shape and +resumes it. `CURRENT` does not exist until one complete first generation is +durable. Concurrent first openers therefore initialize exactly once and all +select the same committed generation. + +Existing projects pass through the same lifecycle admission without changing +the selected `CURRENT` generation. The retained parent/root identities are +revalidated before project-lock mutation. Optimistic stagers may release the +lifecycle lock after retaining those identities, but must reacquire admission +and prove the same root identity before taking the commit writer lock. The +lifecycle lock remains ordered before project writer/checkpoint/recovery locks. Such a container is *uninitialized*, not generation zero. Project APIs return `GF_PROJECT_UNINITIALIZED` until the first complete generation is published. diff --git a/docs/book/architecture/concurrency-recovery.md b/docs/book/architecture/concurrency-recovery.md index fd8d87a1..c802ae75 100644 --- a/docs/book/architecture/concurrency-recovery.md +++ b/docs/book/architecture/concurrency-recovery.md @@ -41,6 +41,18 @@ Network, userspace, removable, cross-device, symlink-mediated, ReFS, and unknown filesystems return `GF_UNSUPPORTED_FILESYSTEM` before the project root or `CURRENT` changes. There is no best-effort durability mode. +Every durable lifecycle enters one Rust-owned parent-scoped admission before +project mutation. A deterministic sibling lock file persists across unlock and +process death; it coordinates absent-root initialization before `FORMAT`, +`generations/`, project locks, or `CURRENT` exist. Existing roots retain opened +parent/root identities. Ordinary publication, recovery, checkpoints and revert, +delta publication and compaction spill, retention cleanup, portable import, and +repository state initialization/removal all use that authority. Optimistic +staging retains identity without serializing peer stagers, then readmits the +same root before acquiring the commit writer lock. Python, Node, and CLI calls +remain thin adapters over these Rust outcomes, including +`GF_UNSUPPORTED_FILESYSTEM`. + Recovery resolves an exact valid `CURRENT` only. Journals and directory scans are advisory cleanup input. Corrupt or ambiguous pointers fail closed as `GF_PROJECT_CORRUPT` without electing a newest generation. diff --git a/docs/guides/repository-integration.md b/docs/guides/repository-integration.md index 1646821c..422192a6 100644 --- a/docs/guides/repository-integration.md +++ b/docs/guides/repository-integration.md @@ -52,13 +52,15 @@ gf --project-dir . remove --yes ``` Commands discover the nearest Git worktree when `--project-dir` is omitted. -`init` preserves existing `.gitignore` content while managing only the three -data exclusions selected by ADR 0016. It refuses to proceed if any of those data -paths are already tracked. `sync` validates only declared definition paths and -digest-addressed sources; it never scans or ingests the repository implicitly. -`remove` requires `--yes` and deletes only `.graphforge/state/`, leaving tracked -definitions, project-local skills, imports, exports, external datasets, and -credentials alone. +`init` preserves existing `.gitignore` content while managing the four runtime +exclusions selected by ADR 0016, including the persistent parent-scoped +admission lock for `.graphforge/state/`. It refuses to proceed if any managed +data path is already tracked. `sync` validates only declared definition paths +and digest-addressed sources; it never scans or ingests the repository +implicitly. `remove` requires `--yes` and deletes only +`.graphforge/state/`; the admission lock remains as the stable crash/retry +rendezvous. Tracked definitions, project-local skills, imports, exports, +external datasets, and credentials are left alone. The stable machine interface is selected with global `--json`. Configuration resolution always emits canonical compact JSON and never resolves secret values. From 94bfb29d66d653a8b1a2090c1f6669dbbbe3338b Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:16:36 -0600 Subject: [PATCH 07/19] fix(storage): preserve writer concurrency under admission --- crates/graphforge-api/src/repository.rs | 12 +--- crates/graphforge-storage/src/lib.rs | 2 +- .../src/project_checkpoints.rs | 3 +- .../src/project_publication.rs | 20 ++++--- .../src/project_recovery.rs | 59 +++++++++++++++++++ 5 files changed, 74 insertions(+), 22 deletions(-) diff --git a/crates/graphforge-api/src/repository.rs b/crates/graphforge-api/src/repository.rs index b0b0ac31..c5913138 100644 --- a/crates/graphforge-api/src/repository.rs +++ b/crates/graphforge-api/src/repository.rs @@ -1111,17 +1111,7 @@ impl RepositoryContext { } let removed = if target.exists() { reject_symlink_components(&self.root, &target)?; - let admission = storage::filesystem_admission::admit_project_lifecycle( - &target, - storage::filesystem_admission::ProjectLifecycleMode::Durable, - storage::filesystem_admission::ProjectRootRequirement::Existing, - )?; - if admission.root() != target { - return Err(validation( - "admitted repository state does not match the contained remove target", - )); - } - admission.remove_project_root()?; + storage::remove_durable_project_root(&target)?; true } else { false diff --git a/crates/graphforge-storage/src/lib.rs b/crates/graphforge-storage/src/lib.rs index dfc30fb2..617c81d7 100644 --- a/crates/graphforge-storage/src/lib.rs +++ b/crates/graphforge-storage/src/lib.rs @@ -95,7 +95,7 @@ pub use project_recovery::{ ProjectOpenRecoveryKind, ProjectRecoveryDeferral, ProjectRecoveryGenerationClass, ProjectRecoveryReport, open_or_initialize_ephemeral_project_with_recovery, open_or_initialize_project_with_recovery, recover_project_on_open, - recover_project_transactions, + recover_project_transactions, remove_durable_project_root, }; pub mod project_retention; diff --git a/crates/graphforge-storage/src/project_checkpoints.rs b/crates/graphforge-storage/src/project_checkpoints.rs index d8d8426a..03c2c7bb 100644 --- a/crates/graphforge-storage/src/project_checkpoints.rs +++ b/crates/graphforge-storage/src/project_checkpoints.rs @@ -652,6 +652,7 @@ where }) .collect::>(); let writer = locks.transfer_writer_for_revert_publication(); + let identity = admission.into_identity()?; // Revert must stage graph bytes from the pinned source generation. Using // the parent's tree (CURRENT) would verify the restored inventory against // post-checkpoint mutations and fail closed with length/digest mismatch. @@ -665,7 +666,7 @@ where }) .then_some(source_graph_tree.as_path()); let receipt = match stage_project_generation_with_lock( - admission, + identity, root.clone(), writer, prior_current, diff --git a/crates/graphforge-storage/src/project_publication.rs b/crates/graphforge-storage/src/project_publication.rs index d498de9a..311374b5 100644 --- a/crates/graphforge-storage/src/project_publication.rs +++ b/crates/graphforge-storage/src/project_publication.rs @@ -168,15 +168,16 @@ enum PublicationLock { } enum StagedAdmission { - Exclusive(crate::filesystem_admission::ProjectLifecycleAdmission), + Exclusive(crate::filesystem_admission::ProjectRootIdentity), Optimistic(Option), } impl StagedAdmission { fn revalidate_identity(&self) -> Result<(), GfError> { match self { - Self::Exclusive(admission) => admission.revalidate_identity(), - Self::Optimistic(Some(identity)) => identity.revalidate_identity(), + Self::Exclusive(identity) | Self::Optimistic(Some(identity)) => { + identity.revalidate_identity() + } Self::Optimistic(None) => Err(project_error( ProjectErrorCode::PublicationFailed, "optimistic project identity was already consumed", @@ -188,8 +189,8 @@ impl StagedAdmission { &mut self, ) -> Result, GfError> { match self { - Self::Exclusive(admission) => { - admission.revalidate_identity()?; + Self::Exclusive(identity) => { + identity.revalidate_identity()?; Ok(None) } Self::Optimistic(identity) => identity @@ -351,8 +352,9 @@ fn stage_project_generation_inner( false, )?; let parent = resolve_project_generation(&root)?; + let identity = admission.into_identity()?; stage_project_generation_inner_with_locks( - StagedAdmission::Exclusive(admission), + StagedAdmission::Exclusive(identity), root, PublicationLock::Exclusive(writer_lock), parent, @@ -398,7 +400,7 @@ fn stage_project_generation_optimistic_inner( /// Pass `graph_tree` when the request's `graph`/`files` inventory must be /// staged from a non-parent source (for example a pinned checkpoint generation). pub(crate) fn stage_project_generation_with_lock( - admission: crate::filesystem_admission::ProjectLifecycleAdmission, + identity: crate::filesystem_admission::ProjectRootIdentity, root: PathBuf, writer_lock: File, parent: ResolvedProjectGeneration, @@ -406,9 +408,9 @@ pub(crate) fn stage_project_generation_with_lock( revert: Option, graph_tree: Option<&Path>, ) -> Result { - admission.revalidate_identity()?; + identity.revalidate_identity()?; stage_project_generation_inner_with_locks( - StagedAdmission::Exclusive(admission), + StagedAdmission::Exclusive(identity), root, PublicationLock::Exclusive(writer_lock), parent, diff --git a/crates/graphforge-storage/src/project_recovery.rs b/crates/graphforge-storage/src/project_recovery.rs index 2dd89dbe..113a974c 100644 --- a/crates/graphforge-storage/src/project_recovery.rs +++ b/crates/graphforge-storage/src/project_recovery.rs @@ -433,6 +433,31 @@ pub fn recover_project_transactions( recover_project_transactions_admitted(admission.root()) } +/// Remove an explicitly selected durable project only while no writer owns the +/// project mutation lock. +/// +/// Lifecycle admission remains held after the nonblocking writer-idle check, +/// so no new admitted mutator can enter between that check and exact-root +/// removal. The writer handle is released before deletion for Windows +/// compatibility; retained lifecycle identity still prevents root substitution. +/// +/// # Errors +/// Returns `GF_WRITER_BUSY` when a live writer exists, or the same typed +/// admission/removal failures as the shared filesystem lifecycle. +pub fn remove_durable_project_root(container_root: impl AsRef) -> Result<(), GfError> { + let admission = crate::filesystem_admission::admit_project_lifecycle( + container_root, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + crate::filesystem_admission::ProjectRootRequirement::Existing, + )?; + admission.revalidate_identity()?; + let writer_lock = acquire_recovery_lock(admission.root())?; + crate::file_lock::unlock(&writer_lock).map_err(storage_io)?; + drop(writer_lock); + admission.revalidate_identity()?; + admission.remove_project_root() +} + fn recover_project_transactions_admitted(root: &Path) -> Result { let selected = resolve_project_generation(root).map_err(map_recovery_resolution)?; let root = selected.container_root().to_owned(); @@ -1526,6 +1551,40 @@ mod tests { assert_eq!(error.code(), "GF_WRITER_BUSY"); } + #[test] + fn live_writer_lock_blocks_project_removal_until_the_writer_is_idle() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("project"); + open_or_initialize_project(&root).unwrap(); + let lock_dir = ensure_machine_directory(&root, Path::new(LOCKS_DIR)).unwrap(); + let lock = open_regular_lock(&lock_dir.join(WRITER_LOCK_FILE)).unwrap(); + crate::file_lock::lock_exclusive(&lock).unwrap(); + + let error = remove_durable_project_root(&root).unwrap_err(); + + assert_eq!(error.code(), "GF_WRITER_BUSY"); + assert!(root.exists()); + crate::file_lock::unlock(&lock).unwrap(); + drop(lock); + + remove_durable_project_root(&root).unwrap(); + assert!(!root.exists()); + assert_eq!( + std::fs::read_dir(parent.path()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".graphforge-admission-") + }) + .count(), + 1, + "the persistent parent-scoped admission lock must survive removal" + ); + } + #[test] fn corrupt_knowledge_bytes_do_not_block_graph_only_reopen_or_recovery() { let root = tempfile::tempdir().unwrap(); From 8c43083a75c8ebea698366353589a89bb92cd097 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:51:43 -0600 Subject: [PATCH 08/19] fix(storage): anchor lifecycle admission traversal (#780) --- .../src/filesystem_admission.rs | 734 ++++++++++++++---- 1 file changed, 590 insertions(+), 144 deletions(-) diff --git a/crates/graphforge-storage/src/filesystem_admission.rs b/crates/graphforge-storage/src/filesystem_admission.rs index 8d9e5285..37187b4e 100644 --- a/crates/graphforge-storage/src/filesystem_admission.rs +++ b/crates/graphforge-storage/src/filesystem_admission.rs @@ -2,7 +2,10 @@ //! //! The probe is deliberately independent of project contents. It operates in //! one private sibling below the canonical target parent and proves the same -//! replacement primitive used by durable publication. +//! replacement primitive used by durable publication. Resolution retains +//! every opened ancestor and uses handle-relative, no-follow traversal on +//! Unix; Windows holds non-delete-shared ancestor handles while opening each +//! reparse point itself. //! //! This admission check is a capability probe, not a sandbox boundary against //! another process already running as the same OS principal. Private directory @@ -85,6 +88,7 @@ pub struct ProjectLifecycleAdmission { /// lifecycle lock and prove the namespace still names the retained root. #[derive(Debug)] pub struct ProjectRootIdentity { + mode: ProjectLifecycleMode, root: PathBuf, parent: LifecycleDirectory, project: LifecycleDirectory, @@ -142,6 +146,7 @@ impl ProjectLifecycleAdmission { pub fn into_identity(self) -> Result { self.revalidate_identity()?; let Self { + mode, root, parent, project, @@ -150,6 +155,7 @@ impl ProjectLifecycleAdmission { } = self; drop(lifecycle_lock); let identity = ProjectRootIdentity { + mode, root, parent, project, @@ -226,22 +232,20 @@ impl ProjectRootIdentity { Ok(()) } - /// Reacquire durable lifecycle admission for the exact retained root. + /// Reacquire lifecycle admission in the token's original mode for the + /// exact retained root. /// /// The newly opened parent/root identities must equal this token's /// identities. A namespace replacement therefore fails closed even when /// it occurs while the lifecycle lock was intentionally released. /// /// # Errors - /// Returns `GF_UNSUPPORTED_FILESYSTEM` if durable readmission fails or the - /// retained namespace identity changed. + /// Returns `GF_UNSUPPORTED_FILESYSTEM` if readmission fails or the retained + /// namespace identity changed. pub fn readmit(self) -> Result { self.revalidate_identity()?; - let admission = admit_project_lifecycle( - &self.root, - ProjectLifecycleMode::Durable, - ProjectRootRequirement::Existing, - )?; + let admission = + admit_project_lifecycle(&self.root, self.mode, ProjectRootRequirement::Existing)?; if admission.parent.identity != self.parent.identity || admission.project.identity != self.project.identity { @@ -283,21 +287,11 @@ fn admit_project_lifecycle_inner( requirement: ProjectRootRequirement, fault: ProbeFault, ) -> Result { - let ephemeral_root = if mode == ProjectLifecycleMode::Ephemeral { - match std::fs::symlink_metadata(proposed_project_root) { - Ok(metadata) if !is_link_or_reparse(&metadata) && metadata.is_dir() => Some( - proposed_project_root - .canonicalize() - .map_err(|_| unsupported("CLASSIFY", "target_unavailable"))?, - ), - _ => None, - } - } else { - None - }; - let proposed_project_root = ephemeral_root.as_deref().unwrap_or(proposed_project_root); - let (parent_path, target_name) = canonical_parent_and_name(proposed_project_root)?; - let parent = LifecycleDirectory::open(&parent_path, "LOCK", "parent_identity_unavailable")?; + let ResolvedProjectPath { + parent, + target_name, + root, + } = resolve_project_path(proposed_project_root)?; let lifecycle_lock = match mode { ProjectLifecycleMode::Durable => Some(LifecycleLock::acquire(&parent, &target_name)?), ProjectLifecycleMode::Ephemeral => None, @@ -312,8 +306,9 @@ fn admit_project_lifecycle_inner( parent.revalidate("LOCK", "parent_identity_changed")?; let evidence = match mode { - ProjectLifecycleMode::Durable => Some(filesystem_durability_preflight_inner( - proposed_project_root, + ProjectLifecycleMode::Durable => Some(filesystem_durability_preflight_resolved( + &parent, + &target_name, fault, )?), ProjectLifecycleMode::Ephemeral => None, @@ -330,9 +325,8 @@ fn admit_project_lifecycle_inner( lock.revalidate()?; } - let root = parent_path.join(&target_name); let mut created_root = false; - match std::fs::symlink_metadata(&root) { + match child_metadata(&parent, &target_name) { Ok(metadata) => { if is_link_or_reparse(&metadata) || !metadata.is_dir() { return Err(unsupported("IDENTITY", "target_link_or_special")); @@ -342,16 +336,22 @@ fn admit_project_lifecycle_inner( if requirement == ProjectRootRequirement::Existing { return Err(unsupported("IDENTITY", "target_missing")); } - graphforge_filesystem::create_private_directory(&root) + create_private_child_directory(&parent, &target_name, &root) .map_err(|_| unsupported("CREATE", "project_directory_create_failed"))?; - complete_namespace_barrier(&parent_path) + complete_namespace_barrier_handle(&parent) .map_err(|_| unsupported("CREATE", "parent_namespace_barrier_failed"))?; created_root = true; } Err(_) => return Err(unsupported("IDENTITY", "target_metadata_unavailable")), } - let project = LifecycleDirectory::open(&root, "IDENTITY", "project_identity_unavailable")?; + let project = LifecycleDirectory::open_child( + &parent, + &target_name, + &root, + "IDENTITY", + "project_identity_unavailable", + )?; let admission = ProjectLifecycleAdmission { mode, root, @@ -377,9 +377,38 @@ struct LifecycleDirectory { path: PathBuf, handle: File, identity: graphforge_filesystem::FileIdentity, + ancestors: Vec, +} + +#[derive(Debug)] +struct RetainedDirectory { + path: PathBuf, + handle: File, + identity: graphforge_filesystem::FileIdentity, } impl LifecycleDirectory { + fn from_handle( + path: PathBuf, + handle: File, + phase: &'static str, + cause: &'static str, + ) -> Result { + let opened = handle.metadata().map_err(|_| unsupported(phase, cause))?; + let identity = file_identity(&handle)?; + if !opened.is_dir() { + return Err(unsupported(phase, cause)); + } + let directory = Self { + path, + handle, + identity, + ancestors: Vec::new(), + }; + directory.revalidate(phase, cause)?; + Ok(directory) + } + fn open(path: &Path, phase: &'static str, cause: &'static str) -> Result { let named = std::fs::symlink_metadata(path).map_err(|_| unsupported(phase, cause))?; if is_link_or_reparse(&named) || !named.is_dir() { @@ -392,10 +421,89 @@ impl LifecycleDirectory { if !opened.is_dir() || file_identity(&handle)? != identity { return Err(unsupported(phase, cause)); } - Ok(Self { + Self::from_handle(path.to_path_buf(), handle, phase, cause) + } + + fn open_child( + parent: &Self, + name: &std::ffi::OsStr, + path: &Path, + phase: &'static str, + cause: &'static str, + ) -> Result { + let handle = open_child_directory_handle(parent, name, path) + .map_err(|_| unsupported(phase, cause))?; + let opened = handle.metadata().map_err(|_| unsupported(phase, cause))?; + let identity = file_identity(&handle)?; + if !opened.is_dir() { + return Err(unsupported(phase, cause)); + } + let directory = Self { path: path.to_path_buf(), handle, identity, + ancestors: parent.retained_ancestry(phase, cause)?, + }; + directory.revalidate(phase, cause)?; + parent.revalidate(phase, "ancestor_identity_changed")?; + if directory.identity.volume_serial != parent.identity.volume_serial { + return Err(unsupported(phase, "ancestor_cross_volume")); + } + Ok(directory) + } + + fn revalidate(&self, phase: &'static str, cause: &'static str) -> Result<(), GfError> { + for ancestor in &self.ancestors { + ancestor.revalidate(phase, "ancestor_identity_changed")?; + } + let named = std::fs::symlink_metadata(&self.path).map_err(|_| unsupported(phase, cause))?; + let opened = self + .handle + .metadata() + .map_err(|_| unsupported(phase, cause))?; + if is_link_or_reparse(&named) + || !named.is_dir() + || !opened.is_dir() + || graphforge_filesystem::path_identity(&self.path) + .map_err(|_| unsupported(phase, cause))? + != self.identity + || file_identity(&self.handle)? != self.identity + { + return Err(unsupported(phase, cause)); + } + Ok(()) + } + + fn retained_ancestry( + &self, + phase: &'static str, + cause: &'static str, + ) -> Result, GfError> { + let mut retained = Vec::with_capacity(self.ancestors.len() + 1); + for ancestor in &self.ancestors { + retained.push(ancestor.try_clone(phase, cause)?); + } + retained.push(RetainedDirectory { + path: self.path.clone(), + handle: self + .handle + .try_clone() + .map_err(|_| unsupported(phase, cause))?, + identity: self.identity, + }); + Ok(retained) + } +} + +impl RetainedDirectory { + fn try_clone(&self, phase: &'static str, cause: &'static str) -> Result { + Ok(Self { + path: self.path.clone(), + handle: self + .handle + .try_clone() + .map_err(|_| unsupported(phase, cause))?, + identity: self.identity, }) } @@ -419,6 +527,13 @@ impl LifecycleDirectory { } } +#[derive(Debug)] +struct ResolvedProjectPath { + parent: LifecycleDirectory, + target_name: std::ffi::OsString, + root: PathBuf, +} + #[derive(Debug)] struct LifecycleLock { path: PathBuf, @@ -564,9 +679,10 @@ fn open_lifecycle_lock_file(_parent: &LifecycleDirectory, _name: &str) -> std::i /// local publication primitives. /// /// The target itself is never created or mutated. The nearest parent must -/// already exist. Caller-controlled final-component links are rejected; an -/// ancestor aliases and traversal components are rejected before the canonical -/// parent is opened. +/// already exist. Caller-controlled final-component links, ancestor aliases, +/// and traversal components are rejected while the parent is opened one +/// component at a time. The fixed macOS `/var` and `/tmp` system aliases are +/// normalized to `/private/var` and `/private/tmp` first. /// /// # Errors /// Every inability to prove the contract returns @@ -581,45 +697,66 @@ pub fn filesystem_durability_preflight( fn filesystem_durability_preflight_inner( proposed_project_root: &Path, fault: ProbeFault, +) -> Result { + let ResolvedProjectPath { + parent, + target_name, + .. + } = resolve_project_path(proposed_project_root)?; + filesystem_durability_preflight_resolved(&parent, &target_name, fault) +} + +fn filesystem_durability_preflight_resolved( + parent: &LifecycleDirectory, + target_name: &std::ffi::OsStr, + fault: ProbeFault, ) -> Result { let started = Instant::now(); - let (parent, target_name) = canonical_parent_and_name(proposed_project_root)?; - let _probe_lock = lock_probe_parent(&parent, &target_name)?; - let parent_metadata = std::fs::metadata(&parent) + let _probe_lock = lock_probe_parent(parent, target_name)?; + let parent_metadata = parent + .handle + .metadata() .map_err(|_| unsupported("CLASSIFY", "parent_metadata_unavailable"))?; if !parent_metadata.is_dir() { return Err(unsupported("CLASSIFY", "parent_not_directory")); } - if let Ok(target_metadata) = std::fs::symlink_metadata(parent.join(&target_name)) { + if let Ok(target_metadata) = child_metadata(parent, target_name) { if is_link_or_reparse(&target_metadata) || !target_metadata.is_dir() { return Err(unsupported("CLASSIFY", "target_link_or_special")); } - if !same_volume_paths(&parent, &parent.join(&target_name))? { + let target = LifecycleDirectory::open_child( + parent, + target_name, + &parent.path.join(target_name), + "CLASSIFY", + "target_identity_unavailable", + )?; + if target.identity.volume_serial != parent.identity.volume_serial { return Err(unsupported("CLASSIFY", "target_cross_volume")); } } hit(fault, ProbeFault::Classify, "CLASSIFY")?; - let filesystem_class = classify_supported_local_volume(&parent)?; + let filesystem_class = classify_supported_local_volume(parent)?; - let probe_name = stable_probe_name(&parent, &target_name); - let probe_root = parent.join(&probe_name); - if probe_root.exists() { - let stale = open_probe_directory(&probe_root)?; - cleanup_probe(&parent, stale, ProbeFault::None)?; + let probe_name = stable_probe_name(&parent.path, target_name); + let probe_root = parent.path.join(&probe_name); + if child_metadata(parent, std::ffi::OsStr::new(&probe_name)).is_ok() { + let stale = open_probe_directory(parent, &probe_name, &probe_root)?; + cleanup_probe(parent, stale, ProbeFault::None)?; } - let probe = match create_private_probe_directory(&parent, &probe_name, &probe_root) { + let probe = match create_private_probe_directory(parent, &probe_name, &probe_root) { Ok(probe) => probe, Err(create_error) => { - if probe_root.exists() { - let partial = open_probe_directory(&probe_root)?; - cleanup_probe(&parent, partial, ProbeFault::None)?; + if child_metadata(parent, std::ffi::OsStr::new(&probe_name)).is_ok() { + let partial = open_probe_directory(parent, &probe_name, &probe_root)?; + cleanup_probe(parent, partial, ProbeFault::None)?; } return Err(create_error); } }; - let probe_result = run_probe(&parent, &probe, fault); - let cleanup_result = cleanup_probe(&parent, probe, fault); + let probe_result = run_probe(parent, &probe, fault); + let cleanup_result = cleanup_probe(parent, probe, fault); probe_result?; cleanup_result?; @@ -677,10 +814,13 @@ impl Drop for ProbeParentLock { #[cfg(unix)] fn lock_probe_parent( - parent: &Path, + parent: &LifecycleDirectory, _target_name: &std::ffi::OsStr, ) -> Result { - let handle = File::open(parent).map_err(|_| unsupported("LOCK", "parent_open_failed"))?; + let handle = parent + .handle + .try_clone() + .map_err(|_| unsupported("LOCK", "parent_open_failed"))?; crate::file_lock::lock_exclusive(&handle) .map_err(|_| unsupported("LOCK", "parent_lock_failed"))?; Ok(ProbeParentLock(handle)) @@ -688,10 +828,10 @@ fn lock_probe_parent( #[cfg(windows)] fn lock_probe_parent( - parent: &Path, + parent: &LifecycleDirectory, target_name: &std::ffi::OsStr, ) -> Result { - let name = stable_probe_name(parent, target_name); + let name = stable_probe_name(&parent.path, target_name); let lock = named_lock::NamedLock::create(&format!("GraphForge.{name}")) .map_err(|_| unsupported("LOCK", "parent_lock_create_failed"))?; lock.lock() @@ -699,19 +839,19 @@ fn lock_probe_parent( } #[cfg(all(not(unix), not(windows)))] -fn lock_probe_parent(_parent: &Path, _target_name: &std::ffi::OsStr) -> Result<(), GfError> { +fn lock_probe_parent( + _parent: &LifecycleDirectory, + _target_name: &std::ffi::OsStr, +) -> Result<(), GfError> { Err(unsupported("LOCK", "parent_lock_unsupported")) } -fn canonical_parent_and_name(root: &Path) -> Result<(PathBuf, std::ffi::OsString), GfError> { - let absolute = if root.is_absolute() { - root.to_path_buf() - } else { - std::env::current_dir() - .map_err(|_| unsupported("CLASSIFY", "working_directory_unavailable"))? - .join(root) - }; - if absolute.components().any(|component| { +fn resolve_project_path(root: &Path) -> Result { + resolve_project_path_with_hook(root, |_, _| {}) +} + +fn validate_plain_path(root: &Path) -> Result { + if root.components().any(|component| { matches!( component, std::path::Component::CurDir | std::path::Component::ParentDir @@ -719,78 +859,194 @@ fn canonical_parent_and_name(root: &Path) -> Result<(PathBuf, std::ffi::OsString }) { return Err(unsupported("CLASSIFY", "path_traversal")); } - let name = absolute + Ok(root .file_name() .filter(|name| !name.is_empty()) .ok_or_else(|| unsupported("CLASSIFY", "target_name_invalid"))? - .to_owned(); - let supplied_parent = absolute + .to_owned()) +} + +#[cfg(target_os = "macos")] +fn normalize_trusted_system_alias(path: &Path) -> Result { + let (alias, replacement, expected_link) = if path.starts_with("/var") { + ( + Path::new("/var"), + Path::new("/private/var"), + Path::new("private/var"), + ) + } else if path.starts_with("/tmp") { + ( + Path::new("/tmp"), + Path::new("/private/tmp"), + Path::new("private/tmp"), + ) + } else { + return Ok(path.to_path_buf()); + }; + let metadata = std::fs::symlink_metadata(alias) + .map_err(|_| unsupported("CLASSIFY", "system_alias_unavailable"))?; + let link = std::fs::read_link(alias) + .map_err(|_| unsupported("CLASSIFY", "system_alias_unavailable"))?; + if !metadata.file_type().is_symlink() || link != expected_link { + return Err(unsupported("CLASSIFY", "system_alias_changed")); + } + Ok(replacement.join(path.strip_prefix(alias).expect("prefix was checked"))) +} + +#[cfg(not(target_os = "macos"))] +fn normalize_trusted_system_alias(path: &Path) -> Result { + Ok(path.to_path_buf()) +} + +#[cfg(unix)] +fn resolve_project_path_with_hook( + root: &Path, + mut before_child_open: impl FnMut(&LifecycleDirectory, &std::ffi::OsStr), +) -> Result { + let target_name = validate_plain_path(root)?; + let supplied_parent = root .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")); - reject_ancestor_links(supplied_parent)?; - let parent = supplied_parent - .canonicalize() - .map_err(|_| unsupported("CLASSIFY", "parent_unavailable"))?; - let target = parent.join(&name); - if let Ok(metadata) = std::fs::symlink_metadata(&target) - && metadata.file_type().is_symlink() + + let (mut parent, components) = if root.is_absolute() { + let normalized_root = normalize_trusted_system_alias(root)?; + let normalized_parent = normalized_root + .parent() + .ok_or_else(|| unsupported("CLASSIFY", "parent_unavailable"))?; + let anchor = + LifecycleDirectory::open(Path::new("/"), "CLASSIFY", "ancestor_root_unavailable")?; + ( + anchor, + normalized_parent + .components() + .filter_map(|component| match component { + std::path::Component::Normal(name) => Some(name.to_owned()), + _ => None, + }) + .collect::>(), + ) + } else { + let cwd = std::env::current_dir() + .map_err(|_| unsupported("CLASSIFY", "working_directory_unavailable"))?; + let handle = open_directory_handle(Path::new(".")) + .map_err(|_| unsupported("CLASSIFY", "working_directory_unavailable"))?; + let anchor = LifecycleDirectory::from_handle( + cwd, + handle, + "CLASSIFY", + "working_directory_identity_changed", + )?; + ( + anchor, + supplied_parent + .components() + .filter_map(|component| match component { + std::path::Component::Normal(name) => Some(name.to_owned()), + _ => None, + }) + .collect::>(), + ) + }; + + for name in components { + before_child_open(&parent, &name); + let child_path = parent.path.join(&name); + parent = LifecycleDirectory::open_child( + &parent, + &name, + &child_path, + "CLASSIFY", + "ancestor_link_or_special", + )?; + } + + let root = parent.path.join(&target_name); + if let Ok(metadata) = child_metadata(&parent, &target_name) + && is_link_or_reparse(&metadata) { return Err(unsupported("CLASSIFY", "target_link")); } - Ok((parent, name)) + parent.revalidate("CLASSIFY", "parent_identity_changed")?; + Ok(ResolvedProjectPath { + parent, + target_name, + root, + }) } -fn reject_ancestor_links(parent: &Path) -> Result<(), GfError> { - let mut current = PathBuf::new(); - for component in parent.components() { - current.push(component.as_os_str()); - if matches!( - component, - std::path::Component::Prefix(_) | std::path::Component::RootDir - ) { - continue; - } - let metadata = std::fs::symlink_metadata(¤t) - .map_err(|_| unsupported("CLASSIFY", "ancestor_unavailable"))?; - if is_link_or_reparse(&metadata) { - if trusted_macos_system_alias(¤t) { - continue; +#[cfg(windows)] +fn resolve_project_path_with_hook( + root: &Path, + mut before_child_open: impl FnMut(&LifecycleDirectory, &std::ffi::OsStr), +) -> Result { + let target_name = validate_plain_path(root)?; + let absolute = if root.is_absolute() { + root.to_path_buf() + } else { + std::env::current_dir() + .map_err(|_| unsupported("CLASSIFY", "working_directory_unavailable"))? + .join(root) + }; + let supplied_parent = absolute + .parent() + .ok_or_else(|| unsupported("CLASSIFY", "parent_unavailable"))?; + let mut anchor = PathBuf::new(); + let mut names = Vec::new(); + for component in supplied_parent.components() { + match component { + std::path::Component::Prefix(_) | std::path::Component::RootDir => { + anchor.push(component.as_os_str()); + } + std::path::Component::Normal(name) => names.push(name.to_owned()), + std::path::Component::CurDir | std::path::Component::ParentDir => { + return Err(unsupported("CLASSIFY", "path_traversal")); } - return Err(unsupported("CLASSIFY", "ancestor_link_or_special")); - } - if !metadata.is_dir() { - return Err(unsupported("CLASSIFY", "ancestor_link_or_special")); } } - Ok(()) -} - -#[cfg(target_os = "macos")] -fn trusted_macos_system_alias(path: &Path) -> bool { - let expected = if path == Path::new("/var") { - Path::new("/private/var") - } else if path == Path::new("/tmp") { - Path::new("/private/tmp") - } else { - return false; - }; - path.canonicalize() - .is_ok_and(|resolved| resolved == expected) + let mut parent = LifecycleDirectory::open(&anchor, "CLASSIFY", "ancestor_root_unavailable")?; + for name in names { + before_child_open(&parent, &name); + let child_path = parent.path.join(&name); + parent = LifecycleDirectory::open_child( + &parent, + &name, + &child_path, + "CLASSIFY", + "ancestor_link_or_special", + )?; + } + let root = parent.path.join(&target_name); + if let Ok(metadata) = child_metadata(&parent, &target_name) + && is_link_or_reparse(&metadata) + { + return Err(unsupported("CLASSIFY", "target_link")); + } + parent.revalidate("CLASSIFY", "parent_identity_changed")?; + Ok(ResolvedProjectPath { + parent, + target_name, + root, + }) } -#[cfg(not(target_os = "macos"))] -const fn trusted_macos_system_alias(_path: &Path) -> bool { - false +#[cfg(all(not(unix), not(windows)))] +fn resolve_project_path_with_hook( + _root: &Path, + _before_child_open: impl FnMut(&LifecycleDirectory, &std::ffi::OsStr), +) -> Result { + Err(unsupported("CLASSIFY", "platform_unsupported")) } -fn classify_supported_local_volume(parent: &Path) -> Result { +fn classify_supported_local_volume(parent: &LifecycleDirectory) -> Result { classify_supported_local_volume_platform(parent) } #[cfg(target_os = "macos")] -fn classify_supported_local_volume_platform(parent: &Path) -> Result { - let stat = rustix::fs::statfs(parent) +fn classify_supported_local_volume_platform( + parent: &LifecycleDirectory, +) -> Result { + let stat = rustix::fs::fstatfs(&parent.handle) .map_err(|_| unsupported("CLASSIFY", "native_volume_query_failed"))?; let class = stat .f_fstypename @@ -812,13 +1068,15 @@ fn classify_supported_local_volume_platform(parent: &Path) -> Result Result { - let stat = rustix::fs::statfs(parent) +fn classify_supported_local_volume_platform( + parent: &LifecycleDirectory, +) -> Result { + let stat = rustix::fs::fstatfs(&parent.handle) .map_err(|_| unsupported("CLASSIFY", "native_volume_query_failed"))?; let class = match u64::try_from(stat.f_type).unwrap_or_default() { 0xEF53 => "ext", @@ -826,18 +1084,20 @@ fn classify_supported_local_volume_platform(parent: &Path) -> Result "btrfs", _ => return Err(unsupported("CLASSIFY", "filesystem_class_unproven")), }; - let vfs = rustix::fs::statvfs(parent) + let vfs = rustix::fs::fstatvfs(&parent.handle) .map_err(|_| unsupported("CLASSIFY", "native_volume_query_failed"))?; if vfs.f_flag.contains(rustix::fs::StatVfsMountFlags::RDONLY) { return Err(unsupported("CLASSIFY", "volume_read_only")); } - reject_removable_volume(parent)?; + reject_removable_volume(&parent.path)?; Ok(class.into()) } #[cfg(target_os = "windows")] -fn classify_supported_local_volume_platform(parent: &Path) -> Result { - let information = graphforge_filesystem::windows_volume_information(parent) +fn classify_supported_local_volume_platform( + parent: &LifecycleDirectory, +) -> Result { + let information = graphforge_filesystem::windows_volume_information(&parent.path) .map_err(|_| unsupported("CLASSIFY", "native_volume_query_failed"))?; classify_windows_volume( &information.filesystem_name, @@ -866,7 +1126,9 @@ fn classify_windows_volume( } #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] -fn classify_supported_local_volume_platform(_parent: &Path) -> Result { +fn classify_supported_local_volume_platform( + _parent: &LifecycleDirectory, +) -> Result { Err(unsupported("CLASSIFY", "platform_unsupported")) } @@ -913,13 +1175,17 @@ impl ProbeDirectory { } } -fn open_probe_directory(path: &Path) -> Result { - let named = std::fs::symlink_metadata(path) +fn open_probe_directory( + parent: &LifecycleDirectory, + name: &str, + path: &Path, +) -> Result { + let named = child_metadata(parent, std::ffi::OsStr::new(name)) .map_err(|_| unsupported("CREATE", "private_directory_missing"))?; if is_link_or_reparse(&named) || !named.is_dir() { return Err(unsupported("CREATE", "private_directory_substituted")); } - let handle = open_directory_handle(path) + let handle = open_child_directory_handle(parent, std::ffi::OsStr::new(name), path) .map_err(|_| unsupported("CREATE", "private_directory_open_failed"))?; let opened = handle .metadata() @@ -932,6 +1198,7 @@ fn open_probe_directory(path: &Path) -> Result { "private_directory_substituted_during_open", )); } + parent.revalidate("CREATE", "parent_identity_changed")?; Ok(ProbeDirectory { path: path.to_path_buf(), handle, @@ -940,7 +1207,105 @@ fn open_probe_directory(path: &Path) -> Result { } #[cfg(unix)] -fn open_directory_handle(path: &Path) -> std::io::Result { +fn open_child_directory_handle( + parent: &LifecycleDirectory, + name: &std::ffi::OsStr, + _path: &Path, +) -> std::io::Result { + use rustix::fs::{Mode, OFlags, openat}; + + openat( + &parent.handle, + name, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map(File::from) + .map_err(std::io::Error::from) +} + +#[cfg(windows)] +fn open_child_directory_handle( + _parent: &LifecycleDirectory, + _name: &std::ffi::OsStr, + path: &Path, +) -> std::io::Result { + open_directory_handle(path) +} + +#[cfg(all(not(unix), not(windows)))] +fn open_child_directory_handle( + _parent: &LifecycleDirectory, + _name: &std::ffi::OsStr, + _path: &Path, +) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "child directory handles are unsupported", + )) +} + +#[cfg(unix)] +fn child_metadata( + parent: &LifecycleDirectory, + name: &std::ffi::OsStr, +) -> std::io::Result { + use rustix::fs::{AtFlags, statat}; + use std::os::unix::fs::MetadataExt as _; + + let stat = + statat(&parent.handle, name, AtFlags::SYMLINK_NOFOLLOW).map_err(std::io::Error::from)?; + let path_metadata = std::fs::symlink_metadata(parent.path.join(name))?; + if path_metadata.dev() != u64::try_from(stat.st_dev).unwrap_or(u64::MAX) + || path_metadata.ino() != stat.st_ino + { + return Err(std::io::Error::other("child identity changed")); + } + Ok(path_metadata) +} + +#[cfg(not(unix))] +fn child_metadata( + parent: &LifecycleDirectory, + name: &std::ffi::OsStr, +) -> std::io::Result { + std::fs::symlink_metadata(parent.path.join(name)) +} + +#[cfg(unix)] +fn create_private_child_directory( + parent: &LifecycleDirectory, + name: &std::ffi::OsStr, + _path: &Path, +) -> std::io::Result<()> { + use rustix::fs::{Mode, mkdirat}; + + mkdirat(&parent.handle, name, Mode::RUSR | Mode::WUSR | Mode::XUSR) + .map_err(std::io::Error::from) +} + +#[cfg(not(unix))] +fn create_private_child_directory( + parent: &LifecycleDirectory, + name: &std::ffi::OsStr, + path: &Path, +) -> std::io::Result<()> { + parent + .revalidate("CREATE", "parent_identity_changed") + .map_err(std::io::Error::other)?; + graphforge_filesystem::create_private_directory(path)?; + parent + .revalidate("CREATE", "parent_identity_changed") + .map_err(std::io::Error::other)?; + let metadata = child_metadata(parent, name)?; + if is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(std::io::Error::other("created child is linked or special")); + } + Ok(()) +} + +#[cfg(unix)] +pub(crate) fn open_directory_handle(path: &Path) -> std::io::Result { use rustix::fs::{Mode, OFlags, open}; let handle = open( @@ -952,7 +1317,7 @@ fn open_directory_handle(path: &Path) -> std::io::Result { } #[cfg(windows)] -fn open_directory_handle(path: &Path) -> std::io::Result { +pub(crate) fn open_directory_handle(path: &Path) -> std::io::Result { use std::os::windows::fs::OpenOptionsExt as _; const FILE_SHARE_READ: u32 = 0x0000_0001; @@ -961,14 +1326,13 @@ fn open_directory_handle(path: &Path) -> std::io::Result { const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; OpenOptions::new() .read(true) - .write(true) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(path) } #[cfg(all(not(unix), not(windows)))] -fn open_directory_handle(_path: &Path) -> std::io::Result { +pub(crate) fn open_directory_handle(_path: &Path) -> std::io::Result { Err(std::io::Error::new( std::io::ErrorKind::Unsupported, "directory identity handles are unsupported", @@ -976,22 +1340,26 @@ fn open_directory_handle(_path: &Path) -> std::io::Result { } fn create_private_probe_directory( - parent: &Path, - _probe_name: &str, + parent: &LifecycleDirectory, + probe_name: &str, probe_root: &Path, ) -> Result { - graphforge_filesystem::create_private_directory(probe_root) + create_private_child_directory(parent, std::ffi::OsStr::new(probe_name), probe_root) .map_err(|_| unsupported("CREATE", "private_directory_create_failed"))?; - let probe = open_probe_directory(probe_root)?; - complete_namespace_barrier(parent) + let probe = open_probe_directory(parent, probe_name, probe_root)?; + complete_namespace_barrier_handle(parent) .map_err(|_| unsupported("CREATE", "parent_namespace_barrier_failed"))?; probe.revalidate("CREATE")?; Ok(probe) } -fn run_probe(parent: &Path, probe: &ProbeDirectory, fault: ProbeFault) -> Result<(), GfError> { +fn run_probe( + parent: &LifecycleDirectory, + probe: &ProbeDirectory, + fault: ProbeFault, +) -> Result<(), GfError> { probe.revalidate("CREATE")?; - if !same_volume_paths(parent, &probe.path)? { + if parent.identity.volume_serial != probe.identity.volume_serial { return Err(unsupported("CREATE", "private_directory_cross_volume")); } @@ -1003,7 +1371,7 @@ fn run_probe(parent: &Path, probe: &ProbeDirectory, fault: ProbeFault) -> Result lock.sync_all() .map_err(|_| unsupported("FILE_FLUSH", "lock_file_flush_failed"))?; hit(fault, ProbeFault::FileFlush, "FILE_FLUSH")?; - verify_stable_identity(&lock, &lock_path, parent)?; + verify_stable_identity(&lock, &lock_path, &parent.path)?; crate::file_lock::lock_exclusive(&lock) .map_err(|_| unsupported("LOCK", "exclusive_lock_failed"))?; @@ -1014,7 +1382,7 @@ fn run_probe(parent: &Path, probe: &ProbeDirectory, fault: ProbeFault) -> Result let _ = crate::file_lock::unlock(&contender); return Err(unsupported("LOCK", "exclusive_lock_not_enforced")); } - verify_stable_identity(&lock, &lock_path, parent)?; + verify_stable_identity(&lock, &lock_path, &parent.path)?; crate::file_lock::unlock(&lock).map_err(|_| unsupported("LOCK", "exclusive_unlock_failed"))?; crate::file_lock::lock_shared(&lock).map_err(|_| unsupported("LOCK", "shared_lock_failed"))?; @@ -1061,9 +1429,9 @@ fn run_probe(parent: &Path, probe: &ProbeDirectory, fault: ProbeFault) -> Result if bytes != PROBE_BYTES_B { return Err(unsupported("IDENTITY", "replacement_bytes_mismatch")); } - verify_stable_identity(&published, &target_path, parent)?; + verify_stable_identity(&published, &target_path, &parent.path)?; drop(target); - complete_namespace_barrier(parent) + complete_namespace_barrier_handle(parent) .map_err(|_| unsupported("NAMESPACE_DURABILITY", "parent_namespace_barrier_failed")) } @@ -1231,7 +1599,11 @@ fn verify_stable_identity(file: &File, path: &Path, parent: &Path) -> Result<(), Ok(()) } -fn cleanup_probe(parent: &Path, probe: ProbeDirectory, fault: ProbeFault) -> Result<(), GfError> { +fn cleanup_probe( + parent: &LifecycleDirectory, + probe: ProbeDirectory, + fault: ProbeFault, +) -> Result<(), GfError> { hit(fault, ProbeFault::Cleanup, "CLEANUP")?; probe.revalidate("CLEANUP")?; let mut entries = Vec::new(); @@ -1277,10 +1649,21 @@ fn cleanup_probe(parent: &Path, probe: ProbeDirectory, fault: ProbeFault) -> Res drop(probe.handle); std::fs::remove_dir(path) .map_err(|_| unsupported("CLEANUP", "private_directory_remove_failed"))?; - complete_namespace_barrier(parent) + complete_namespace_barrier_handle(parent) .map_err(|_| unsupported("CLEANUP", "parent_namespace_barrier_failed")) } +fn complete_namespace_barrier_handle(parent: &LifecycleDirectory) -> std::io::Result<()> { + #[cfg(unix)] + { + parent.handle.sync_all() + } + #[cfg(not(unix))] + { + complete_namespace_barrier(&parent.path) + } +} + #[cfg(unix)] fn complete_namespace_barrier(path: &Path) -> std::io::Result<()> { File::open(path)?.sync_all() @@ -1848,6 +2231,69 @@ mod tests { assert_eq!(std::fs::read_dir(&destination).unwrap().count(), 0); } + #[cfg(unix)] + #[test] + fn retained_ancestor_handle_rejects_deterministic_namespace_substitution() { + use std::os::unix::fs::symlink; + + let parent = canonical_tempdir(); + let stable = parent.path().join("stable"); + let moved = parent.path().join("moved"); + let destination = parent.path().join("destination"); + std::fs::create_dir(&stable).unwrap(); + std::fs::create_dir(stable.join("inner")).unwrap(); + std::fs::create_dir(&destination).unwrap(); + let target = stable.join("inner/project"); + let mut substituted = false; + + let error = resolve_project_path_with_hook(&target, |opened_parent, next_name| { + if !substituted && next_name == "inner" { + assert_eq!(opened_parent.path, stable); + std::fs::rename(&stable, &moved).unwrap(); + symlink(&destination, &stable).unwrap(); + substituted = true; + } + }) + .unwrap_err(); + + assert!(substituted); + assert_eq!(error.code(), "GF_UNSUPPORTED_FILESYSTEM"); + assert!( + error.to_string().contains("ancestor_identity_changed") + || error.to_string().contains("ancestor_link_or_special"), + "{error}" + ); + assert_eq!(std::fs::read_dir(&destination).unwrap().count(), 0); + assert!(moved.join("inner").is_dir()); + } + + #[cfg(windows)] + #[test] + fn retained_ancestor_handles_prevent_deterministic_namespace_substitution() { + let parent = canonical_tempdir(); + let stable = parent.path().join("stable"); + let moved = parent.path().join("moved"); + std::fs::create_dir(&stable).unwrap(); + std::fs::create_dir(stable.join("inner")).unwrap(); + let target = stable.join("inner/project"); + let mut replacement_was_blocked = false; + + let resolved = resolve_project_path_with_hook(&target, |opened_parent, next_name| { + if next_name == "inner" { + assert_eq!(opened_parent.path, stable); + replacement_was_blocked = std::fs::rename(&stable, &moved).is_err(); + } + }) + .unwrap(); + + assert!(replacement_was_blocked); + assert_eq!(resolved.parent.path, stable.join("inner")); + resolved + .parent + .revalidate("TEST", "ancestor_identity_changed") + .unwrap(); + } + #[cfg(windows)] #[test] fn ancestor_junction_is_rejected_without_touching_its_destination() { From a8965c80b7fadae4c594932752bb20fd3f3523e7 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:18:27 -0600 Subject: [PATCH 09/19] fix(storage): preserve admitted publication authority (#780) --- .../src/graph_delta_compaction.rs | 240 ++++++++++++++- .../src/graph_delta_journal.rs | 138 ++++++++- crates/graphforge-storage/src/lib.rs | 17 +- .../src/project_generation.rs | 17 +- .../src/project_portable.rs | 72 ++++- .../src/project_publication.rs | 278 ++++++++++++++++-- 6 files changed, 700 insertions(+), 62 deletions(-) diff --git a/crates/graphforge-storage/src/graph_delta_compaction.rs b/crates/graphforge-storage/src/graph_delta_compaction.rs index e98731df..c2add066 100644 --- a/crates/graphforge-storage/src/graph_delta_compaction.rs +++ b/crates/graphforge-storage/src/graph_delta_compaction.rs @@ -27,7 +27,7 @@ use crate::graph_files::{capture_graph_files, verify_graph_tree}; use crate::project_generation::resolve_project_generation; use crate::project_publication::{ ProjectCapability, ProjectGenerationRequest, ProjectPublicationReceipt, ProjectStageOutcome, - published_project_transaction, stage_project_generation_with_graph_tree, + published_project_transaction, stage_project_generation_from_admitted_parent, }; use crate::project_retention::{ ProjectCleanupReport, ProjectRetentionLimits, ProjectRetentionPolicy, execute_project_cleanup, @@ -181,15 +181,34 @@ pub fn preview_graph_delta_compaction( container_root: impl AsRef, request: &GraphDeltaCompactionRequest, cancel: Option<&AtomicBool>, +) -> Result { + preview_graph_delta_compaction_with_mode( + container_root, + request, + cancel, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Preview compaction using the lifecycle mode established by the owning facade. +/// +/// # Errors +/// Returns the same errors as [`preview_graph_delta_compaction`]. +pub fn preview_graph_delta_compaction_with_mode( + container_root: impl AsRef, + request: &GraphDeltaCompactionRequest, + cancel: Option<&AtomicBool>, + mode: crate::filesystem_admission::ProjectLifecycleMode, ) -> Result { let started = Instant::now(); let admission = crate::filesystem_admission::admit_project_lifecycle( container_root, - crate::filesystem_admission::ProjectLifecycleMode::Durable, + mode, crate::filesystem_admission::ProjectRootRequirement::Existing, )?; admission.revalidate_identity()?; - let prepared = prepare_compaction(admission.root(), request, cancel)?; + let parent = resolve_project_generation(admission.root())?; + let prepared = prepare_compaction(admission.root(), &parent, request, cancel)?; Ok(report_from_prepared( &prepared, true, @@ -209,11 +228,39 @@ pub fn compact_graph_delta( container_root: impl AsRef, request: &GraphDeltaCompactionRequest, cancel: Option<&AtomicBool>, +) -> Result { + compact_graph_delta_with_mode( + container_root, + request, + cancel, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Compact using the lifecycle mode established by the owning facade. +/// +/// # Errors +/// Returns the same errors as [`compact_graph_delta`]. +pub fn compact_graph_delta_with_mode( + container_root: impl AsRef, + request: &GraphDeltaCompactionRequest, + cancel: Option<&AtomicBool>, + mode: crate::filesystem_admission::ProjectLifecycleMode, +) -> Result { + compact_graph_delta_after_prepare(container_root, request, cancel, mode, |_| Ok(())) +} + +fn compact_graph_delta_after_prepare( + container_root: impl AsRef, + request: &GraphDeltaCompactionRequest, + cancel: Option<&AtomicBool>, + mode: crate::filesystem_admission::ProjectLifecycleMode, + before_stage: impl FnOnce(&Path) -> Result<(), GfError>, ) -> Result { let started = Instant::now(); let admission = crate::filesystem_admission::admit_project_lifecycle( container_root, - crate::filesystem_admission::ProjectLifecycleMode::Durable, + mode, crate::filesystem_admission::ProjectRootRequirement::Existing, )?; admission.revalidate_identity()?; @@ -230,7 +277,8 @@ pub fn compact_graph_delta( return replay_compaction_receipt(root, request, publication, elapsed_ms(started)); } - let prepared = prepare_compaction(root, request, cancel)?; + let parent = resolve_project_generation(root)?; + let prepared = prepare_compaction(root, &parent, request, cancel)?; check_cancel(cancel)?; let staging = tempfile::tempdir().map_err(|error| { @@ -264,9 +312,10 @@ pub fn compact_graph_delta( }; check_cancel(cancel)?; - drop(admission); - let publication = match stage_project_generation_with_graph_tree( - root, + before_stage(root)?; + let publication = match stage_project_generation_from_admitted_parent( + admission, + parent, &generation_request, Some(staging.path()), )? { @@ -318,7 +367,31 @@ pub fn graph_delta_compaction_status( policy: GraphDeltaCompactionPolicy, limits: GraphDeltaJournalLimits, ) -> Result { - let resolved = resolve_project_generation(container_root.as_ref())?; + graph_delta_compaction_status_with_mode( + container_root, + policy, + limits, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Inspect compaction status using the lifecycle mode established by the owner. +/// +/// # Errors +/// Returns the same errors as [`graph_delta_compaction_status`]. +pub fn graph_delta_compaction_status_with_mode( + container_root: impl AsRef, + policy: GraphDeltaCompactionPolicy, + limits: GraphDeltaJournalLimits, + mode: crate::filesystem_admission::ProjectLifecycleMode, +) -> Result { + let admission = crate::filesystem_admission::admit_project_lifecycle( + container_root, + mode, + crate::filesystem_admission::ProjectRootRequirement::Existing, + )?; + admission.revalidate_identity()?; + let resolved = resolve_project_generation(admission.root())?; let inventory = resolved .graph_files_inventory()? .ok_or_else(|| validation("CURRENT generation lacks graph/files inventory"))?; @@ -372,13 +445,13 @@ struct PreparedCompaction { fn prepare_compaction( root: &Path, + parent: &crate::ResolvedProjectGeneration, request: &GraphDeltaCompactionRequest, cancel: Option<&AtomicBool>, ) -> Result { let limits = request.limits.validate()?; check_cancel(cancel)?; - let parent = resolve_project_generation(root)?; let parent_inventory = parent .graph_files_inventory()? .ok_or_else(|| validation("parent generation lacks graph/files inventory"))?; @@ -823,11 +896,109 @@ fn storage(action: &str, path: &Path, error: impl std::fmt::Display) -> GfError #[cfg(test)] mod crash_oracle_tests { + use super::*; use crate::project_fault_oracle::{ AuthorityClass, PublicationIds, PublicationPhase, default_durable_ids, expected_authority, publication_ops, simulate_crash, }; + fn publish_graph_base(root: &Path) { + crate::open_or_initialize_project(root).unwrap(); + let workspace = tempfile::tempdir().unwrap(); + stage_base_graph_workspace( + workspace.path(), + &[ + ("topology/nodes.parquet", b"nodes"), + ("topology/edges.parquet", b"edges"), + ], + Some(&ReconstructedGraphState::default()), + ) + .unwrap(); + let (_, files) = capture_graph_files(workspace.path()).unwrap(); + let mut participants = empty_workspace_participants().unwrap(); + participants.insert(0, files); + let request = ProjectGenerationRequest { + transaction_uuid: Uuid::now_v7(), + generation_uuid: Uuid::now_v7(), + capabilities: vec![ + ProjectCapability { + capability_id: GRAPH_CAPABILITY_ID.into(), + capability_version: GRAPH_CAPABILITY_VERSION, + }, + ProjectCapability { + capability_id: "workspace".into(), + capability_version: 1, + }, + ], + participants, + }; + let ProjectStageOutcome::Staged(staged) = + crate::stage_project_generation_with_graph_tree(root, &request, Some(workspace.path())) + .unwrap() + else { + panic!("base publication unexpectedly replayed"); + }; + staged + .validate(|_| Ok(()), |_, _| Ok(())) + .unwrap() + .publish() + .unwrap(); + } + + fn publish_one_node_delta(root: &Path) -> Uuid { + let generation_uuid = Uuid::now_v7(); + crate::publish_graph_delta( + root, + &crate::GraphDeltaPublishRequest { + transaction_uuid: Uuid::now_v7(), + generation_uuid, + run_uuid: Uuid::now_v7(), + operations: vec![GraphDeltaOp { + operation_uuid: Uuid::now_v7(), + kind: crate::GraphDeltaOpKind::UpsertNode, + payload: crate::GraphDeltaPayload::UpsertNode { + node_uuid: Uuid::now_v7().hyphenated().to_string(), + type_ids: vec![1], + }, + }], + limits: GraphDeltaJournalLimits::default(), + }, + ) + .unwrap(); + generation_uuid + } + + fn stage_graph_clone(root: &Path) -> (Uuid, Box) { + let current = resolve_project_generation(root).unwrap(); + let graph_tree = current.graph_tree_root(); + let (_, files) = capture_graph_files(&graph_tree).unwrap(); + let mut participants = empty_workspace_participants().unwrap(); + participants.insert(0, files); + let generation_uuid = Uuid::now_v7(); + let request = ProjectGenerationRequest { + transaction_uuid: Uuid::now_v7(), + generation_uuid, + capabilities: vec![ + ProjectCapability { + capability_id: GRAPH_CAPABILITY_ID.into(), + capability_version: GRAPH_CAPABILITY_VERSION, + }, + ProjectCapability { + capability_id: "workspace".into(), + capability_version: 1, + }, + ], + participants, + }; + let ProjectStageOutcome::Staged(staged) = + crate::stage_project_generation_with_graph_tree(root, &request, Some(&graph_tree)) + .unwrap() + else { + panic!("clone publication unexpectedly replayed"); + }; + (generation_uuid, staged) + } + #[test] fn crash_oracle_before_and_after_ack_matches_frozen_contract() { let seed = 753u64; @@ -853,4 +1024,53 @@ mod crash_oracle_tests { } } } + + #[test] + fn prepared_compaction_fails_busy_behind_a_live_current_writer() { + let root = tempfile::tempdir().unwrap(); + publish_graph_base(root.path()); + publish_one_node_delta(root.path()); + let (concurrent_generation, concurrent) = stage_graph_clone(root.path()); + let request = GraphDeltaCompactionRequest { + transaction_uuid: Uuid::now_v7(), + generation_uuid: Uuid::now_v7(), + through_run_sequence: None, + limits: GraphDeltaCompactionLimits::default(), + cleanup_after_commit: false, + cleanup_policy: ProjectRetentionPolicy::default(), + cleanup_limits: ProjectRetentionLimits::default(), + }; + + let error = compact_graph_delta_after_prepare( + root.path(), + &request, + None, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + |_| Ok(()), + ) + .unwrap_err(); + + assert_eq!(error.code(), "GF_WRITER_BUSY"); + concurrent + .validate(|_| Ok(()), |_, _| Ok(())) + .unwrap() + .publish() + .unwrap(); + assert_eq!( + resolve_project_generation(root.path()) + .unwrap() + .generation_uuid(), + concurrent_generation + ); + let current = resolve_project_generation(root.path()).unwrap(); + assert_eq!( + list_delta_runs( + ¤t.graph_files_inventory().unwrap().unwrap(), + GraphDeltaJournalLimits::default() + ) + .unwrap() + .len(), + 1 + ); + } } diff --git a/crates/graphforge-storage/src/graph_delta_journal.rs b/crates/graphforge-storage/src/graph_delta_journal.rs index 8f6f55ca..e6894fdc 100644 --- a/crates/graphforge-storage/src/graph_delta_journal.rs +++ b/crates/graphforge-storage/src/graph_delta_journal.rs @@ -20,7 +20,7 @@ use crate::graph_files::{ use crate::project_generation::resolve_project_generation; use crate::project_publication::{ ProjectCapability, ProjectGenerationRequest, ProjectPublicationReceipt, ProjectStageOutcome, - published_project_transaction, stage_project_generation_with_graph_tree, + published_project_transaction, stage_project_generation_from_admitted_parent, }; use crate::{GRAPH_CAPABILITY_ID, GRAPH_CAPABILITY_VERSION, empty_workspace_participants}; @@ -709,10 +709,18 @@ pub fn reconstruct_graph_state( /// # Errors /// Unsupported kinds are rejected before staging. Publication and idempotency /// errors follow the project generation protocol. -#[allow(clippy::too_many_lines)] // Publication stages copy, encode, and CURRENT commit together. pub fn publish_graph_delta( container_root: &Path, request: &GraphDeltaPublishRequest, +) -> Result { + publish_graph_delta_after_prepare(container_root, request, |_| Ok(())) +} + +#[allow(clippy::too_many_lines)] // Publication stages copy, encode, and CURRENT commit together. +fn publish_graph_delta_after_prepare( + container_root: &Path, + request: &GraphDeltaPublishRequest, + before_stage: impl FnOnce(&Path) -> Result<(), GfError>, ) -> Result { let admission = crate::filesystem_admission::admit_project_lifecycle( container_root, @@ -870,9 +878,11 @@ pub fn publish_graph_delta( participants, }; - drop(admission); - let publication = match stage_project_generation_with_graph_tree( - container_root, + let parent_generation_uuid = parent.generation_uuid(); + before_stage(container_root)?; + let publication = match stage_project_generation_from_admitted_parent( + admission, + parent, &generation_request, Some(staging.path()), )? { @@ -891,7 +901,7 @@ pub fn publish_graph_delta( &child_inventory, request.limits, )?; - evidence.base_generation_uuid = Some(parent.generation_uuid()); + evidence.base_generation_uuid = Some(parent_generation_uuid); Ok(GraphDeltaPublicationReceipt { publication, @@ -1134,11 +1144,103 @@ fn storage(action: &str, path: &Path, error: impl std::fmt::Display) -> GfError #[cfg(test)] mod crash_oracle_tests { + use super::*; use crate::project_fault_oracle::{ AuthorityClass, PublicationIds, PublicationPhase, default_durable_ids, expected_authority, publication_ops, simulate_crash, }; + fn publish_graph_base(root: &Path) { + crate::open_or_initialize_project(root).unwrap(); + let workspace = tempfile::tempdir().unwrap(); + stage_base_graph_workspace( + workspace.path(), + &[ + ("topology/nodes.parquet", b"nodes"), + ("topology/edges.parquet", b"edges"), + ], + Some(&ReconstructedGraphState::default()), + ) + .unwrap(); + let (_, files) = capture_graph_files(workspace.path()).unwrap(); + let mut participants = empty_workspace_participants().unwrap(); + participants.insert(0, files); + let request = ProjectGenerationRequest { + transaction_uuid: Uuid::now_v7(), + generation_uuid: Uuid::now_v7(), + capabilities: vec![ + ProjectCapability { + capability_id: GRAPH_CAPABILITY_ID.into(), + capability_version: GRAPH_CAPABILITY_VERSION, + }, + ProjectCapability { + capability_id: "workspace".into(), + capability_version: 1, + }, + ], + participants, + }; + let ProjectStageOutcome::Staged(staged) = + crate::stage_project_generation_with_graph_tree(root, &request, Some(workspace.path())) + .unwrap() + else { + panic!("base publication unexpectedly replayed"); + }; + staged + .validate(|_| Ok(()), |_, _| Ok(())) + .unwrap() + .publish() + .unwrap(); + } + + fn one_node_request() -> GraphDeltaPublishRequest { + GraphDeltaPublishRequest { + transaction_uuid: Uuid::now_v7(), + generation_uuid: Uuid::now_v7(), + run_uuid: Uuid::now_v7(), + operations: vec![GraphDeltaOp { + operation_uuid: Uuid::now_v7(), + kind: GraphDeltaOpKind::UpsertNode, + payload: GraphDeltaPayload::UpsertNode { + node_uuid: Uuid::now_v7().hyphenated().to_string(), + type_ids: vec![1], + }, + }], + limits: GraphDeltaJournalLimits::default(), + } + } + + fn stage_graph_clone(root: &Path) -> (Uuid, Box) { + let current = resolve_project_generation(root).unwrap(); + let graph_tree = current.graph_tree_root(); + let (_, files) = capture_graph_files(&graph_tree).unwrap(); + let mut participants = empty_workspace_participants().unwrap(); + participants.insert(0, files); + let generation_uuid = Uuid::now_v7(); + let request = ProjectGenerationRequest { + transaction_uuid: Uuid::now_v7(), + generation_uuid, + capabilities: vec![ + ProjectCapability { + capability_id: GRAPH_CAPABILITY_ID.into(), + capability_version: GRAPH_CAPABILITY_VERSION, + }, + ProjectCapability { + capability_id: "workspace".into(), + capability_version: 1, + }, + ], + participants, + }; + let ProjectStageOutcome::Staged(staged) = + crate::stage_project_generation_with_graph_tree(root, &request, Some(&graph_tree)) + .unwrap() + else { + panic!("clone publication unexpectedly replayed"); + }; + (generation_uuid, staged) + } + #[test] fn crash_oracle_before_and_after_ack_matches_frozen_contract() { let seed = 752u64; @@ -1164,4 +1266,28 @@ mod crash_oracle_tests { } } } + + #[test] + fn prepared_delta_fails_busy_behind_a_live_current_writer() { + let root = tempfile::tempdir().unwrap(); + publish_graph_base(root.path()); + let prepared = one_node_request(); + let (concurrent_generation, concurrent) = stage_graph_clone(root.path()); + + let error = + publish_graph_delta_after_prepare(root.path(), &prepared, |_| Ok(())).unwrap_err(); + + assert_eq!(error.code(), "GF_WRITER_BUSY"); + concurrent + .validate(|_| Ok(()), |_, _| Ok(())) + .unwrap() + .publish() + .unwrap(); + assert_eq!( + resolve_project_generation(root.path()) + .unwrap() + .generation_uuid(), + concurrent_generation + ); + } } diff --git a/crates/graphforge-storage/src/lib.rs b/crates/graphforge-storage/src/lib.rs index 617c81d7..197a8654 100644 --- a/crates/graphforge-storage/src/lib.rs +++ b/crates/graphforge-storage/src/lib.rs @@ -55,7 +55,9 @@ pub use graph_delta_compaction::{ DEFAULT_COMPACTION_MAX_SPILL_BYTES, GRAPH_DELTA_COMPACTION_SPILL_DIR, GraphDeltaCompactionLimits, GraphDeltaCompactionPolicy, GraphDeltaCompactionReport, GraphDeltaCompactionRequest, GraphDeltaCompactionStatus, compact_graph_delta, - graph_delta_compaction_status, preview_graph_delta_compaction, + compact_graph_delta_with_mode, graph_delta_compaction_status, + graph_delta_compaction_status_with_mode, preview_graph_delta_compaction, + preview_graph_delta_compaction_with_mode, }; pub mod project_generation; @@ -76,8 +78,10 @@ pub mod project_certification; pub mod project_checkpoints; pub use project_checkpoints::{ CheckpointCreateRequest, CheckpointDeleteRequest, CheckpointReceipt, CheckpointRecord, - CheckpointRevertRequest, create_checkpoint, delete_checkpoint, list_checkpoints, - open_checkpoint_generation, revert_checkpoint, + CheckpointRevertRequest, create_checkpoint, create_checkpoint_with_mode, delete_checkpoint, + delete_checkpoint_with_mode, list_checkpoints, list_checkpoints_with_mode, + open_checkpoint_generation, open_checkpoint_generation_with_mode, revert_checkpoint, + revert_checkpoint_with_mode, }; pub mod project_publication; @@ -86,7 +90,8 @@ pub use project_publication::{ ProjectPublicationReceipt, ProjectStageOutcome, StagedParticipant, StagedProjectGeneration, ValidatedProjectGeneration, published_project_transaction, stage_project_generation, stage_project_generation_optimistic, stage_project_generation_optimistic_with_graph_tree, - stage_project_generation_with_graph_tree, + stage_project_generation_optimistic_with_graph_tree_mode, + stage_project_generation_with_graph_tree, stage_project_generation_with_graph_tree_mode, }; pub mod project_recovery; @@ -104,7 +109,9 @@ pub use project_retention::{ DEFAULT_RETENTION_MAX_WORK_UNITS, ProjectCleanupDisposition, ProjectCleanupEntry, ProjectCleanupLocation, ProjectCleanupReport, ProjectReachabilityReport, ProjectRetentionLimits, ProjectRetentionPolicy, execute_project_cleanup, - inspect_project_reachability, preview_project_cleanup, + execute_project_cleanup_with_mode, inspect_project_reachability, + inspect_project_reachability_with_mode, preview_project_cleanup, + preview_project_cleanup_with_mode, }; pub mod project_portable; diff --git a/crates/graphforge-storage/src/project_generation.rs b/crates/graphforge-storage/src/project_generation.rs index d216be46..59b275c6 100644 --- a/crates/graphforge-storage/src/project_generation.rs +++ b/crates/graphforge-storage/src/project_generation.rs @@ -14,7 +14,6 @@ use std::sync::Arc; #[cfg(windows)] use std::sync::{Condvar, Mutex, OnceLock}; -use atomicwrites::{AllowOverwrite, AtomicFile}; use graphforge_core::{GfError, ProjectErrorCode}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -938,14 +937,14 @@ fn initialize_empty_generation( let mut current_bytes = serde_json::to_vec(¤t) .map_err(|error| GfError::Storage(format!("failed to encode CURRENT: {error}")))?; current_bytes.push(b'\n'); - AtomicFile::new(root.join(CURRENT_FILE), AllowOverwrite) - .write(|file| { - use std::io::Write as _; - - file.write_all(¤t_bytes)?; - file.sync_all() - }) - .map_err(|error| GfError::Storage(format!("failed to write CURRENT: {error}")))?; + crate::project_publication::publish_atomic_bytes( + &root.join(CURRENT_FILE), + ¤t_bytes, + || Ok(()), + || Ok(()), + || Ok(()), + ) + .map_err(|error| GfError::Storage(format!("failed to write CURRENT: {error}")))?; sync_directory(root)?; resolve_project_generation(root) } diff --git a/crates/graphforge-storage/src/project_portable.rs b/crates/graphforge-storage/src/project_portable.rs index 2f867b0f..929748e9 100644 --- a/crates/graphforge-storage/src/project_portable.rs +++ b/crates/graphforge-storage/src/project_portable.rs @@ -18,10 +18,11 @@ use uuid::Uuid; #[cfg(test)] use crate::open_or_initialize_project; use crate::project_generation::open_or_initialize_project_admitted; +use crate::project_publication::stage_project_generation_from_admitted_parent; use crate::{ ProjectCapability, ProjectGenerationRequest, ProjectParticipant, ProjectParticipantEncoding, ProjectPublicationReceipt, ProjectStageOutcome, ResolvedProjectGeneration, - resolve_project_generation, stage_project_generation, + resolve_project_generation, }; const MAGIC: &[u8; 16] = b"graphforge-exp\0\n"; @@ -239,6 +240,26 @@ pub fn import_portable_project( generation_uuid: Uuid, supported_capabilities: &[ProjectCapability], limits: PortableProjectLimits, +) -> Result { + import_portable_project_after_prepare( + envelope, + target, + transaction_uuid, + generation_uuid, + supported_capabilities, + limits, + |_| Ok(()), + ) +} + +fn import_portable_project_after_prepare( + envelope: &[u8], + target: impl AsRef, + transaction_uuid: Uuid, + generation_uuid: Uuid, + supported_capabilities: &[ProjectCapability], + limits: PortableProjectLimits, + before_stage: impl FnOnce(&Path) -> Result<(), GfError>, ) -> Result { let validated = validate_envelope(envelope, supported_capabilities, limits)?; let target = target.as_ref(); @@ -251,7 +272,7 @@ pub fn import_portable_project( let target = admission.root(); let existing_parent = prepare_import_target(target)?; let initialized_parent; - let _parent = if let Some(parent) = existing_parent { + let parent = if let Some(parent) = existing_parent { parent } else { initialized_parent = open_or_initialize_project_admitted(target)?; @@ -264,13 +285,14 @@ pub fn import_portable_project( participants: validated.participants, }; let target = target.to_owned(); - drop(admission); - let publication = match stage_project_generation(&target, &request)? { - ProjectStageOutcome::AlreadyPublished(receipt) => receipt, - ProjectStageOutcome::Staged(staged) => { - staged.validate(|_| Ok(()), |_, _| Ok(()))?.publish()? - } - }; + before_stage(&target)?; + let publication = + match stage_project_generation_from_admitted_parent(admission, parent, &request, None)? { + ProjectStageOutcome::AlreadyPublished(receipt) => receipt, + ProjectStageOutcome::Staged(staged) => { + staged.validate(|_| Ok(()), |_, _| Ok(()))?.publish()? + } + }; Ok(PortableImportReceipt { envelope_sha256: validated.envelope_sha256, source_generation_uuid: validated.source_generation_uuid, @@ -977,6 +999,38 @@ mod tests { assert!(!target.join("cache").exists()); } + #[cfg(unix)] + #[test] + fn admitted_import_never_stages_into_a_replacement_root() { + let source = tempfile::tempdir().unwrap(); + let source_generation = open_or_initialize_project(source.path()).unwrap(); + let limits = PortableProjectLimits::default(); + let (envelope, _) = encode_portable_project(&source_generation, limits).unwrap(); + let parent = tempfile::tempdir().unwrap(); + let target = parent.path().join("imported"); + let moved = parent.path().join("admitted-original"); + + let error = import_portable_project_after_prepare( + &envelope, + &target, + Uuid::now_v7(), + Uuid::now_v7(), + &supported(&source_generation), + limits, + |path| { + std::fs::rename(path, &moved) + .map_err(|error| GfError::Storage(format!("rename admitted root: {error}")))?; + graphforge_filesystem::create_private_directory(path) + .map_err(|error| GfError::Storage(format!("create replacement root: {error}"))) + }, + ) + .unwrap_err(); + + assert_eq!(error.code(), "GF_UNSUPPORTED_FILESYSTEM"); + assert_eq!(std::fs::read_dir(&target).unwrap().count(), 0); + resolve_project_generation(&moved).unwrap(); + } + #[test] fn pristine_initialized_target_is_importable() { let source = tempfile::tempdir().unwrap(); diff --git a/crates/graphforge-storage/src/project_publication.rs b/crates/graphforge-storage/src/project_publication.rs index 311374b5..658b6605 100644 --- a/crates/graphforge-storage/src/project_publication.rs +++ b/crates/graphforge-storage/src/project_publication.rs @@ -8,7 +8,6 @@ use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; -use atomicwrites::{AllowOverwrite, AtomicFile}; use graphforge_core::{GfError, ProjectErrorCode}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -273,7 +272,30 @@ pub fn stage_project_generation_with_graph_tree( request: &ProjectGenerationRequest, graph_tree: Option<&Path>, ) -> Result { - stage_project_generation_inner(container_root.as_ref(), request, graph_tree) + stage_project_generation_with_graph_tree_mode( + container_root, + request, + graph_tree, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Stage a generation using the lifecycle mode established when the owning +/// facade opened the project. +/// +/// Ephemeral mode is reserved for process-owned temporary projects. Durable +/// callers must use the default wrapper or pass `Durable` explicitly. +/// +/// # Errors +/// Returns the same stable staging errors as +/// [`stage_project_generation_with_graph_tree`]. +pub fn stage_project_generation_with_graph_tree_mode( + container_root: impl AsRef, + request: &ProjectGenerationRequest, + graph_tree: Option<&Path>, + mode: crate::filesystem_admission::ProjectLifecycleMode, +) -> Result { + stage_project_generation_inner(container_root.as_ref(), request, graph_tree, mode) .map_err(|error| map_stage_error(request, error)) } @@ -311,16 +333,94 @@ pub fn stage_project_generation_optimistic_with_graph_tree( request: &ProjectGenerationRequest, operation_fingerprint: [u8; 32], graph_tree: Option<&Path>, +) -> Result { + stage_project_generation_optimistic_with_graph_tree_mode( + container_root, + request, + operation_fingerprint, + graph_tree, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Optimistic staging using the lifecycle mode established when the owning +/// facade opened the project. +/// +/// # Errors +/// Returns the same stable staging errors as +/// [`stage_project_generation_optimistic_with_graph_tree`]. +pub fn stage_project_generation_optimistic_with_graph_tree_mode( + container_root: impl AsRef, + request: &ProjectGenerationRequest, + operation_fingerprint: [u8; 32], + graph_tree: Option<&Path>, + mode: crate::filesystem_admission::ProjectLifecycleMode, ) -> Result { stage_project_generation_optimistic_inner( container_root.as_ref(), request, operation_fingerprint, graph_tree, + mode, ) .map_err(|error| map_stage_error(request, error)) } +/// Stage against one caller-prepared, lifetime-pinned CURRENT generation. +/// +/// The caller passes the full admission that resolved `parent`. This path takes +/// the writer while admission is still held, verifies CURRENT still names the +/// exact prepared generation, and only then releases the lifecycle lock while +/// retaining root identity under the writer. +pub(crate) fn stage_project_generation_from_admitted_parent( + admission: crate::filesystem_admission::ProjectLifecycleAdmission, + parent: ResolvedProjectGeneration, + request: &ProjectGenerationRequest, + graph_tree: Option<&Path>, +) -> Result { + let result = (|| { + validate_request(request)?; + admission.revalidate_identity()?; + let root = canonical_supported_root(admission.root())?; + if parent.container_root() != root { + return Err(project_error( + ProjectErrorCode::UnsupportedFilesystem, + "prepared generation does not belong to the admitted project root", + )); + } + let writer_lock = acquire_writer_lock(&root, request)?; + project_failpoint::hit( + "project.after_writer_lock", + Some(request.transaction_uuid), + Some(request.generation_uuid), + "WRITER_LOCK", + false, + )?; + admission.revalidate_identity()?; + let current = resolve_project_generation(&root)?; + if current.generation_uuid() != parent.generation_uuid() + || current.manifest_sha256() != parent.manifest_sha256() + { + return Err(project_error( + ProjectErrorCode::WriteConflict, + "prepared CURRENT changed before admitted publication acquired the writer", + )); + } + let identity = admission.into_identity()?; + stage_project_generation_inner_with_locks( + StagedAdmission::Exclusive(identity), + root, + PublicationLock::Exclusive(writer_lock), + parent, + request, + None, + None, + graph_tree, + ) + })(); + result.map_err(|error| map_stage_error(request, error)) +} + fn map_stage_error(request: &ProjectGenerationRequest, error: GfError) -> GfError { match error { GfError::Storage(message) => publication_error(request, "STAGE", false, &message), @@ -332,13 +432,14 @@ fn stage_project_generation_inner( container_root: &Path, request: &ProjectGenerationRequest, graph_tree: Option<&Path>, + mode: crate::filesystem_admission::ProjectLifecycleMode, ) -> Result { // Reject malformed contracts before taking the writer lock so concurrent // readers/writers are never blocked by validation-only failures. validate_request(request)?; let admission = crate::filesystem_admission::admit_project_lifecycle( container_root, - crate::filesystem_admission::ProjectLifecycleMode::Durable, + mode, crate::filesystem_admission::ProjectRootRequirement::Existing, )?; admission.revalidate_identity()?; @@ -370,11 +471,12 @@ fn stage_project_generation_optimistic_inner( request: &ProjectGenerationRequest, operation_fingerprint: [u8; 32], graph_tree: Option<&Path>, + mode: crate::filesystem_admission::ProjectLifecycleMode, ) -> Result { validate_request(request)?; let admission = crate::filesystem_admission::admit_project_lifecycle( container_root, - crate::filesystem_admission::ProjectLifecycleMode::Durable, + mode, crate::filesystem_admission::ProjectRootRequirement::Existing, )?; admission.revalidate_identity()?; @@ -1267,24 +1369,28 @@ fn replace_current( }; let current_bytes = canonical_line(¤t)?; let current_path = staged.root.join(CURRENT_FILE); - AtomicFile::new(¤t_path, AllowOverwrite) - .write(|file| { - file.write_all(¤t_bytes)?; + publish_atomic_bytes( + ¤t_path, + ¤t_bytes, + || { failpoint_as_io( "project.after_current_temp_write", staged.transaction_uuid, staged.generation_uuid, "CURRENT", false, - )?; - file.sync_all()?; + ) + }, + || { failpoint_as_io( "project.after_current_temp_fsync", staged.transaction_uuid, staged.generation_uuid, "CURRENT", false, - )?; + ) + }, + || { failpoint_as_io( "project.before_current_replace", staged.transaction_uuid, @@ -1292,16 +1398,17 @@ fn replace_current( "CURRENT", false, ) - }) - .map_err(|error| { - publication_error_from_parts( - staged.transaction_uuid, - staged.generation_uuid, - "CURRENT", - false, - &error.to_string(), - ) - })?; + }, + ) + .map_err(|error| { + publication_error_from_parts( + staged.transaction_uuid, + staged.generation_uuid, + "CURRENT", + false, + &error.to_string(), + ) + })?; project_failpoint::hit( "project.after_current_replace", Some(staged.transaction_uuid), @@ -1854,15 +1961,92 @@ fn verify_exact_file(path: &Path, expected: &[u8]) -> Result<(), GfError> { pub(crate) fn write_journal(path: &Path, journal: &JournalRecord) -> Result<(), GfError> { let bytes = canonical_line(journal)?; - AtomicFile::new(path, AllowOverwrite) - .write(|file| file.write_all(&bytes)) - .map_err(|error| publication_io(std::io::Error::other(error.to_string())))?; + publish_atomic_bytes(path, &bytes, || Ok(()), || Ok(()), || Ok(())).map_err(publication_io)?; sync_directory( path.parent() .expect("transaction journal always has a parent"), ) } +pub(crate) fn publish_atomic_bytes( + path: &Path, + bytes: &[u8], + after_write: impl FnOnce() -> std::io::Result<()>, + after_sync: impl FnOnce() -> std::io::Result<()>, + before_replace: impl FnOnce() -> std::io::Result<()>, +) -> std::io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| std::io::Error::other("atomic publication target has no parent"))?; + let target_name = path + .file_name() + .ok_or_else(|| std::io::Error::other("atomic publication target has no file name"))?; + let target_text = target_name + .to_str() + .ok_or_else(|| std::io::Error::other("atomic publication target is not UTF-8"))?; + let digest: [u8; 32] = Sha256::digest(target_text.as_bytes()).into(); + let temp_name = format!(".graphforge-atomic-{}.tmp", hex_digest(digest)); + let temp_path = parent.join(&temp_name); + remove_stale_atomic_temp(&temp_path)?; + + let mut temp = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path)?; + temp.write_all(bytes)?; + after_write()?; + temp.sync_all()?; + after_sync()?; + drop(temp); + before_replace()?; + + let directory = crate::filesystem_admission::open_directory_handle(parent)?; + match std::fs::symlink_metadata(path) { + Ok(metadata) => { + if !metadata.is_file() + || metadata.file_type().is_symlink() + || graphforge_filesystem::path_link_count(path)? != 1 + { + return Err(std::io::Error::other( + "atomic publication target is not a regular single-link file", + )); + } + graphforge_filesystem::replace_file( + &directory, + std::ffi::OsStr::new(&temp_name), + target_name, + ) + .map_err(std::io::Error::other) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + graphforge_filesystem::install_new_file( + &directory, + std::ffi::OsStr::new(&temp_name), + target_name, + ) + } + Err(error) => Err(error), + } +} + +fn remove_stale_atomic_temp(path: &Path) -> std::io::Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) => { + if !metadata.is_file() + || metadata.file_type().is_symlink() + || graphforge_filesystem::path_link_count(path)? != 1 + { + return Err(std::io::Error::other( + "atomic publication temporary is not a regular single-link file", + )); + } + std::fs::remove_file(path) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + pub(crate) fn read_journal(path: &Path) -> Result { let metadata = std::fs::symlink_metadata(path).map_err(publication_io)?; if !metadata.is_file() @@ -1902,6 +2086,26 @@ pub(crate) fn cleanup_atomicwrite_temp(path: &Path) -> Result { let Some(name) = path.file_name().and_then(|name| name.to_str()) else { return Ok(false); }; + if let Some(digest) = name + .strip_prefix(".graphforge-atomic-") + .and_then(|name| name.strip_suffix(".tmp")) + && digest.len() == 64 + && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + let metadata = std::fs::symlink_metadata(path).map_err(publication_io)?; + if !metadata.is_file() + || metadata.file_type().is_symlink() + || graphforge_filesystem::path_link_count(path).map_err(publication_io)? != 1 + { + return Ok(false); + } + std::fs::remove_file(path).map_err(publication_io)?; + sync_directory( + path.parent() + .expect("atomic-write temporary directory always has a parent"), + )?; + return Ok(true); + } let Some(suffix) = name.strip_prefix(".atomicwrite") else { return Ok(false); }; @@ -2994,6 +3198,34 @@ mod tests { std::fs::read(hostile.join("unexpected")).unwrap(), b"caller bytes" ); + + let native_temp = root.path().join(format!( + ".graphforge-atomic-{}.tmp", + hex_digest(Sha256::digest(b"CURRENT").into()) + )); + std::fs::write(&native_temp, b"abandoned").unwrap(); + assert!(cleanup_atomicwrite_temp(&native_temp).unwrap()); + assert!(!native_temp.exists()); + } + + #[test] + fn atomic_bytes_install_and_replace_use_one_bounded_native_temp() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("CURRENT"); + + publish_atomic_bytes(&target, b"first\n", || Ok(()), || Ok(()), || Ok(())).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"first\n"); + publish_atomic_bytes(&target, b"second\n", || Ok(()), || Ok(()), || Ok(())).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"second\n"); + assert!( + std::fs::read_dir(root.path()) + .unwrap() + .filter_map(Result::ok) + .all(|entry| !entry + .file_name() + .to_string_lossy() + .starts_with(".graphforge-atomic-")) + ); } #[test] From 745ca2289a02944cfc447381c26e9123648041f1 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:18:46 -0600 Subject: [PATCH 10/19] fix(api): preserve ephemeral lifecycle mode (#780) --- crates/graphforge-api/src/algorithm_runs.rs | 5 +- .../graphforge-api/src/belief_projection.rs | 5 +- crates/graphforge-api/src/capabilities.rs | 47 ++++---- .../src/checkpoint_graph_diff.rs | 16 ++- crates/graphforge-api/src/checkpoints.rs | 87 ++++++++++++--- .../graphforge-api/src/composite_publish.rs | 31 +++++- .../graphforge-api/src/embedding_refresh.rs | 1 + crates/graphforge-api/src/graph_inspection.rs | 3 +- crates/graphforge-api/src/hypotheses.rs | 5 +- crates/graphforge-api/src/knowledge.rs | 17 ++- crates/graphforge-api/src/lib.rs | 33 +++++- crates/graphforge-api/src/maintenance.rs | 57 ++++++++-- crates/graphforge-api/src/portable.rs | 33 +++++- crates/graphforge-api/src/valid_time.rs | 5 +- .../graphforge-api/src/workspace_ontology.rs | 2 +- .../src/project_checkpoints.rs | 101 ++++++++++++++++-- .../src/project_retention.rs | 63 ++++++++++- 17 files changed, 419 insertions(+), 92 deletions(-) diff --git a/crates/graphforge-api/src/algorithm_runs.rs b/crates/graphforge-api/src/algorithm_runs.rs index 501abea1..f02bdcea 100644 --- a/crates/graphforge-api/src/algorithm_runs.rs +++ b/crates/graphforge-api/src/algorithm_runs.rs @@ -519,10 +519,7 @@ fn publish( .collect(), participants, }; - let receipt = match graphforge_storage::stage_project_generation( - graph.resolved_generation.container_root(), - &request, - )? { + let receipt = match graph.stage_project_generation(&request)? { ProjectStageOutcome::AlreadyPublished(receipt) => receipt, ProjectStageOutcome::Staged(staged) => staged .validate( diff --git a/crates/graphforge-api/src/belief_projection.rs b/crates/graphforge-api/src/belief_projection.rs index f1ad51a0..ab5e4fd3 100644 --- a/crates/graphforge-api/src/belief_projection.rs +++ b/crates/graphforge-api/src/belief_projection.rs @@ -1313,10 +1313,7 @@ fn publish_attachment( .collect(), participants, }; - let receipt = match graphforge_storage::stage_project_generation( - graph.resolved_generation.container_root(), - &request, - )? { + let receipt = match graph.stage_project_generation(&request)? { ProjectStageOutcome::AlreadyPublished(receipt) => receipt, ProjectStageOutcome::Staged(staged) => staged .validate( diff --git a/crates/graphforge-api/src/capabilities.rs b/crates/graphforge-api/src/capabilities.rs index 54de4083..e657b719 100644 --- a/crates/graphforge-api/src/capabilities.rs +++ b/crates/graphforge-api/src/capabilities.rs @@ -228,28 +228,27 @@ impl GraphForge { capabilities, participants, }; - let generation_uuid = - match graphforge_storage::stage_project_generation(root, &publication)? { - ProjectStageOutcome::AlreadyPublished(receipt) => receipt.generation_uuid, - ProjectStageOutcome::Staged(staged) => { - let expected_parent = parent.generation_uuid(); - staged - .validate( - |_| Ok(()), - |actual_parent, _| { - if actual_parent.generation_uuid() != expected_parent { - return Err(GfError::Validation( - "project generation changed before capability publication" - .into(), - )); - } - Ok(()) - }, - )? - .publish()? - .generation_uuid - } - }; + let generation_uuid = match self.stage_project_generation(&publication)? { + ProjectStageOutcome::AlreadyPublished(receipt) => receipt.generation_uuid, + ProjectStageOutcome::Staged(staged) => { + let expected_parent = parent.generation_uuid(); + staged + .validate( + |_| Ok(()), + |actual_parent, _| { + if actual_parent.generation_uuid() != expected_parent { + return Err(GfError::Validation( + "project generation changed before capability publication" + .into(), + )); + } + Ok(()) + }, + )? + .publish()? + .generation_uuid + } + }; *self .current_generation_uuid .lock() @@ -396,6 +395,10 @@ mod tests { #[test] fn capability_enable_is_atomic_and_idempotent() { let graph = GraphForge::new(None).unwrap(); + assert_eq!( + graph.lifecycle_mode, + graphforge_storage::filesystem_admission::ProjectLifecycleMode::Ephemeral + ); let request = EnableCapabilityRequest { context: WriteContext { operation_uuid: OperationId(Uuid::now_v7()), diff --git a/crates/graphforge-api/src/checkpoint_graph_diff.rs b/crates/graphforge-api/src/checkpoint_graph_diff.rs index a02220ae..7f3db410 100644 --- a/crates/graphforge-api/src/checkpoint_graph_diff.rs +++ b/crates/graphforge-api/src/checkpoint_graph_diff.rs @@ -38,15 +38,29 @@ pub(crate) struct LogicalGraphRecords { /// relationship types and endpoints, and user properties. Storage surrogate /// IDs, timestamps, Parquet row groups, archive paths, and batch boundaries do /// not participate. +#[cfg(test)] pub(crate) fn extract_logical_graph_records( generation: &ResolvedProjectGeneration, cancellation: Option<&CancellationToken>, +) -> Result { + extract_logical_graph_records_with_mode( + generation, + cancellation, + graphforge_storage::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +pub(crate) fn extract_logical_graph_records_with_mode( + generation: &ResolvedProjectGeneration, + cancellation: Option<&CancellationToken>, + lifecycle_mode: graphforge_storage::filesystem_admission::ProjectLifecycleMode, ) -> Result { checkpoint(cancellation)?; - let graph = GraphForge::open_resolved_with_mode( + let graph = GraphForge::open_resolved_with_lifecycle_mode( generation.container_root().to_path_buf(), generation.clone(), true, + lifecycle_mode, )?; let nodes = streamed_logical_rows( diff --git a/crates/graphforge-api/src/checkpoints.rs b/crates/graphforge-api/src/checkpoints.rs index 2c3f9dd0..b0f4820d 100644 --- a/crates/graphforge-api/src/checkpoints.rs +++ b/crates/graphforge-api/src/checkpoints.rs @@ -638,7 +638,7 @@ impl CheckpointView { impl GraphForge { /// Create a durable named checkpoint. pub fn checkpoint(&self, request: CheckpointRequest) -> Result { - let receipt = graphforge_storage::create_checkpoint( + let receipt = graphforge_storage::create_checkpoint_with_mode( self.resolved_generation.container_root(), &graphforge_storage::CheckpointCreateRequest { operation_uuid: request.idempotency_key.0, @@ -646,6 +646,7 @@ impl GraphForge { description: request.description, actor_uuid: request.actor_uuid, }, + self.lifecycle_mode, )?; Ok(receipt_result(&receipt)) } @@ -657,7 +658,10 @@ impl GraphForge { ) -> Result { let ListCheckpointsRequest { page } = request; cancellation(&page)?; - let rows = graphforge_storage::list_checkpoints(self.resolved_generation.container_root())?; + let rows = graphforge_storage::list_checkpoints_with_mode( + self.resolved_generation.container_root(), + self.lifecycle_mode, + )?; let snapshot = checkpoint_list_snapshot(&rows); let binding = request_binding("checkpoint-list", 0, 0); let cursors = rows @@ -688,9 +692,10 @@ impl GraphForge { request: ShowCheckpointRequest, ) -> Result { let ShowCheckpointRequest { name } = request; - let (checkpoint, _) = graphforge_storage::open_checkpoint_generation( + let (checkpoint, _) = graphforge_storage::open_checkpoint_generation_with_mode( self.resolved_generation.container_root(), &name, + self.lifecycle_mode, )?; checkpoint_rows(std::slice::from_ref(&checkpoint), None) } @@ -713,14 +718,16 @@ impl GraphForge { /// Open an immutable view pinned to the named checkpoint generation. pub fn open_checkpoint(&self, name: &str) -> Result { - let (checkpoint, generation) = graphforge_storage::open_checkpoint_generation( + let (checkpoint, generation) = graphforge_storage::open_checkpoint_generation_with_mode( self.resolved_generation.container_root(), name, + self.lifecycle_mode, )?; - let graph = Self::open_resolved_with_mode( + let graph = Self::open_resolved_with_lifecycle_mode( self.resolved_generation.container_root().to_owned(), generation, true, + self.lifecycle_mode, )?; Ok(CheckpointView { checkpoint, graph }) } @@ -730,13 +737,14 @@ impl GraphForge { &self, request: DeleteCheckpointRequest, ) -> Result { - let receipt = graphforge_storage::delete_checkpoint( + let receipt = graphforge_storage::delete_checkpoint_with_mode( self.resolved_generation.container_root(), &graphforge_storage::CheckpointDeleteRequest { operation_uuid: request.idempotency_key.0, name: request.name, actor_uuid: request.actor_uuid, }, + self.lifecycle_mode, )?; Ok(receipt_result(&receipt)) } @@ -751,11 +759,12 @@ impl GraphForge { } let container_root = self.resolved_generation.container_root().to_path_buf(); let clock = self.clock.lock().expect("clock lock poisoned").clone(); + let lifecycle_mode = self.lifecycle_mode; let write_options = self.write_options.clone(); let resource_policy = self.resource_policy.clone(); let select_clock = Arc::clone(&clock); let prepared = std::cell::RefCell::new(None); - let (receipt, resolved) = graphforge_storage::revert_checkpoint( + let (receipt, resolved) = graphforge_storage::revert_checkpoint_with_mode( &container_root, &graphforge_storage::CheckpointRevertRequest { operation_uuid: request.idempotency_key.0, @@ -765,7 +774,7 @@ impl GraphForge { }, move || select_clock(), |generation| { - validate_revert_source(generation)?; + validate_revert_source(generation, lifecycle_mode)?; prepared.replace(Some(GraphForge::open_resolved_with_options( container_root.clone(), generation.clone(), @@ -778,12 +787,14 @@ impl GraphForge { )?)); Ok(()) }, + self.lifecycle_mode, )?; let result = receipt_result(&receipt); let mut reopened = prepared .into_inner() .expect("successful revert validation prepares the replacement facade"); + reopened.lifecycle_mode = lifecycle_mode; reopened.resolved_generation = resolved; *reopened .current_generation_uuid @@ -838,16 +849,19 @@ impl GraphForge { let to = resolve(&to)?; match detail { CheckpointDiffDetail::Summary => summary_diff(&from, &to, scope, binding, &page), - CheckpointDiffDetail::Records => record_diff(&from, &to, scope, binding, &page), + CheckpointDiffDetail::Records => { + record_diff(&from, &to, scope, binding, &page, self.lifecycle_mode) + } } } fn resolve_selector(&self, selector: &CheckpointSelector) -> Result { let (checkpoint_uuid, generation) = match selector { CheckpointSelector::Named(name) => { - let (row, generation) = graphforge_storage::open_checkpoint_generation( + let (row, generation) = graphforge_storage::open_checkpoint_generation_with_mode( self.resolved_generation.container_root(), name, + self.lifecycle_mode, )?; (row.checkpoint_uuid, generation) } @@ -945,9 +959,10 @@ fn record_diff( scope: CheckpointDiffScope, binding: Uuid, page: &PageRequest, + lifecycle_mode: graphforge_storage::filesystem_admission::ProjectLifecycleMode, ) -> Result { - let left = logical_records(&from.generation, scope, page)?; - let right = logical_records(&to.generation, scope, page)?; + let left = logical_records(&from.generation, scope, page, lifecycle_mode)?; + let right = logical_records(&to.generation, scope, page, lifecycle_mode)?; let mut keys = left.keys().chain(right.keys()).cloned().collect::>(); keys.sort(); keys.dedup(); @@ -1023,6 +1038,7 @@ fn logical_records( generation: &graphforge_storage::ResolvedProjectGeneration, scope: CheckpointDiffScope, page: &PageRequest, + lifecycle_mode: graphforge_storage::filesystem_admission::ProjectLifecycleMode, ) -> Result { let adapters = record_adapters()?; let mut out = BTreeMap::new(); @@ -1035,9 +1051,10 @@ fn logical_records( if descriptor.capability_id == "graph" && matches!(descriptor.record_family_id.as_str(), "snapshot" | "files") { - let records = crate::checkpoint_graph_diff::extract_logical_graph_records( + let records = crate::checkpoint_graph_diff::extract_logical_graph_records_with_mode( generation, page.cancellation.as_ref(), + lifecycle_mode, )?; for (family, records) in [("nodes", records.nodes), ("edges", records.edges)] { for record in records { @@ -1307,6 +1324,7 @@ type Inventory = fn validate_revert_source( generation: &graphforge_storage::ResolvedProjectGeneration, + lifecycle_mode: graphforge_storage::filesystem_admission::ProjectLifecycleMode, ) -> Result<(), GfError> { generation.validate_complete_participant_inventory()?; let _workspace = crate::hydrate_graph_workspace(generation, true)?; @@ -1330,6 +1348,7 @@ fn validate_revert_source( generation, CheckpointDiffScope::All, &PageRequest::default(), + lifecycle_mode, )?; // Run each domain owner's decoder as well as the generic checkpoint adapters. // These readers enforce each ledger's schema and ledger-local invariants. @@ -3358,6 +3377,48 @@ mod tests { ); } + #[test] + fn in_memory_checkpoint_lifecycle_remains_ephemeral() { + let mut graph = GraphForge::new(None).unwrap(); + graph.execute("CREATE (:Person {name: 'before'})").unwrap(); + graph + .checkpoint(CheckpointRequest { + name: "Ephemeral".into(), + description: None, + idempotency_key: operation(230), + actor_uuid: None, + }) + .unwrap(); + graph.execute("CREATE (:Person {name: 'after'})").unwrap(); + graph + .revert_to_checkpoint(RevertCheckpointRequest { + name: "Ephemeral".into(), + reason: "restore ephemeral checkpoint".into(), + idempotency_key: operation(232), + actor_uuid: None, + }) + .unwrap(); + assert_eq!( + graph.lifecycle_mode, + graphforge_storage::filesystem_admission::ProjectLifecycleMode::Ephemeral + ); + assert_eq!( + graph + .list_checkpoints(ListCheckpointsRequest::default()) + .unwrap() + .stats + .rows_produced, + 1 + ); + graph + .delete_checkpoint(DeleteCheckpointRequest { + name: "Ephemeral".into(), + idempotency_key: operation(233), + actor_uuid: None, + }) + .unwrap(); + } + #[test] fn list_and_summary_diff_are_arrow_ordered_and_page_bound() { let directory = tempdir().unwrap(); diff --git a/crates/graphforge-api/src/composite_publish.rs b/crates/graphforge-api/src/composite_publish.rs index 2a75cf7c..290fcd1d 100644 --- a/crates/graphforge-api/src/composite_publish.rs +++ b/crates/graphforge-api/src/composite_publish.rs @@ -231,17 +231,19 @@ impl GraphForge { participants, }; let staged = if optimistic { - graphforge_storage::stage_project_generation_optimistic_with_graph_tree( + graphforge_storage::stage_project_generation_optimistic_with_graph_tree_mode( root, &publication, content_fingerprint, Some(self.dir.as_path()), + self.lifecycle_mode, )? } else { - graphforge_storage::stage_project_generation_with_graph_tree( + graphforge_storage::stage_project_generation_with_graph_tree_mode( root, &publication, Some(self.dir.as_path()), + self.lifecycle_mode, )? }; #[cfg(test)] @@ -1544,6 +1546,31 @@ mod tests { ); } + #[test] + fn in_memory_optimistic_publication_uses_ephemeral_lifecycle_mode() { + let graph = GraphForge::new_with_options(None, optimistic_options(1)).unwrap(); + assert_eq!( + graph.lifecycle_mode, + graphforge_storage::filesystem_admission::ProjectLifecycleMode::Ephemeral + ); + let before = *graph + .current_generation_uuid + .lock() + .expect("generation UUID lock poisoned"); + + graph + .publish_composite_transaction(graph_request(240, 241, "ephemeral")) + .unwrap(); + + assert_ne!( + before, + *graph + .current_generation_uuid + .lock() + .expect("generation UUID lock poisoned") + ); + } + #[test] fn rebase_compatibility_rejects_administrative_drift_and_removed_targets() { let graph = GraphForge::new(None).unwrap(); diff --git a/crates/graphforge-api/src/embedding_refresh.rs b/crates/graphforge-api/src/embedding_refresh.rs index 1f454d8e..41d38d66 100644 --- a/crates/graphforge-api/src/embedding_refresh.rs +++ b/crates/graphforge-api/src/embedding_refresh.rs @@ -250,6 +250,7 @@ impl GraphForge { Self { identity: self.identity.clone(), path: self.path.clone(), + lifecycle_mode: self.lifecycle_mode, resolved_generation: self.resolved_generation.clone(), read_only: self.read_only, current_generation_uuid: Arc::clone(&self.current_generation_uuid), diff --git a/crates/graphforge-api/src/graph_inspection.rs b/crates/graphforge-api/src/graph_inspection.rs index 7123191f..ca05c28f 100644 --- a/crates/graphforge-api/src/graph_inspection.rs +++ b/crates/graphforge-api/src/graph_inspection.rs @@ -99,10 +99,11 @@ impl GraphForge { /// the catalog supplies names only, while logical rows determine presence. pub(crate) fn inspect_graph(&self) -> Result { let generation = self.generation_for_read()?; - let mut view = Self::open_resolved_with_mode( + let mut view = Self::open_resolved_with_lifecycle_mode( generation.container_root().to_path_buf(), generation, true, + self.lifecycle_mode, )?; let catalog = self .runtime_catalog diff --git a/crates/graphforge-api/src/hypotheses.rs b/crates/graphforge-api/src/hypotheses.rs index 441f4f1a..ccfa71e9 100644 --- a/crates/graphforge-api/src/hypotheses.rs +++ b/crates/graphforge-api/src/hypotheses.rs @@ -681,10 +681,7 @@ fn publish( capabilities, participants, }; - let receipt = match graphforge_storage::stage_project_generation( - graph.resolved_generation.container_root(), - &request, - )? { + let receipt = match graph.stage_project_generation(&request)? { ProjectStageOutcome::AlreadyPublished(receipt) => receipt, ProjectStageOutcome::Staged(staged) => staged .validate( diff --git a/crates/graphforge-api/src/knowledge.rs b/crates/graphforge-api/src/knowledge.rs index 0945de55..628f2fd4 100644 --- a/crates/graphforge-api/src/knowledge.rs +++ b/crates/graphforge-api/src/knowledge.rs @@ -339,7 +339,7 @@ impl GraphForge { capabilities, participants, }; - let receipt = match graphforge_storage::stage_project_generation(root, &publication)? { + let receipt = match self.stage_project_generation(&publication)? { ProjectStageOutcome::AlreadyPublished(receipt) => receipt, ProjectStageOutcome::Staged(staged_generation) => staged_generation .validate( @@ -1606,7 +1606,7 @@ fn publish_status( capabilities, participants, }; - let receipt = match graphforge_storage::stage_project_generation(root, &publication)? { + let receipt = match graph.stage_project_generation(&publication)? { ProjectStageOutcome::AlreadyPublished(receipt) => receipt, ProjectStageOutcome::Staged(staged) => staged .validate( @@ -1657,7 +1657,7 @@ fn publish_supersession( capabilities, participants, }; - let receipt = match graphforge_storage::stage_project_generation(root, &publication)? { + let receipt = match graph.stage_project_generation(&publication)? { ProjectStageOutcome::AlreadyPublished(receipt) => receipt, ProjectStageOutcome::Staged(staged) => staged .validate( @@ -1719,7 +1719,7 @@ fn publish_assertion_status_bundle( capabilities, participants, }; - let receipt = match graphforge_storage::stage_project_generation(root, &publication)? { + let receipt = match graph.stage_project_generation(&publication)? { ProjectStageOutcome::AlreadyPublished(receipt) => receipt, ProjectStageOutcome::Staged(staged) => staged .validate( @@ -1765,7 +1765,6 @@ fn publish_reasoning( expected_parent: Uuid, reasoning: &ReasoningLedger, ) -> Result { - let root = graph.resolved_generation.container_root(); let participants = reasoning_publication_participants(parent, reasoning)?; let capabilities = parent .capabilities() @@ -1785,7 +1784,7 @@ fn publish_reasoning( capabilities, participants, }; - let receipt = match graphforge_storage::stage_project_generation(root, &publication)? { + let receipt = match graph.stage_project_generation(&publication)? { ProjectStageOutcome::AlreadyPublished(receipt) => receipt, ProjectStageOutcome::Staged(staged) => staged .validate( @@ -1834,7 +1833,7 @@ fn publish_confidence( capabilities, participants, }; - let receipt = match graphforge_storage::stage_project_generation(root, &publication)? { + let receipt = match graph.stage_project_generation(&publication)? { ProjectStageOutcome::AlreadyPublished(receipt) => receipt, ProjectStageOutcome::Staged(staged_generation) => staged_generation .validate( @@ -1896,7 +1895,7 @@ fn publish_evidence( capabilities, participants, }; - let receipt = match graphforge_storage::stage_project_generation(root, &publication)? { + let receipt = match graph.stage_project_generation(&publication)? { ProjectStageOutcome::AlreadyPublished(receipt) => receipt, ProjectStageOutcome::Staged(staged_generation) => staged_generation .validate( @@ -1957,7 +1956,7 @@ fn publish_assertion_evidence( capabilities, participants, }; - let receipt = match graphforge_storage::stage_project_generation(root, &publication)? { + let receipt = match graph.stage_project_generation(&publication)? { ProjectStageOutcome::AlreadyPublished(receipt) => receipt, ProjectStageOutcome::Staged(staged_generation) => staged_generation .validate( diff --git a/crates/graphforge-api/src/lib.rs b/crates/graphforge-api/src/lib.rs index 39ac9453..d9a0477c 100644 --- a/crates/graphforge-api/src/lib.rs +++ b/crates/graphforge-api/src/lib.rs @@ -353,6 +353,8 @@ pub struct GraphForge { /// The configured path, if the instance is Parquet-backed; `None` for an /// in-memory instance (whose data lives in `dir`). path: Option, + /// Filesystem lifecycle contract selected when this facade opened. + lifecycle_mode: graphforge_storage::filesystem_admission::ProjectLifecycleMode, /// One immutable committed generation selected exactly once at open. resolved_generation: ResolvedProjectGeneration, /// Whether this facade is an immutable historical checkpoint view. @@ -436,6 +438,7 @@ impl std::fmt::Debug for GraphForge { f.debug_struct("GraphForge") .field("identity", &self.identity) .field("path", &self.path) + .field("lifecycle_mode", &self.lifecycle_mode) .field( "generation_uuid", &self.resolved_generation.generation_uuid(), @@ -449,6 +452,18 @@ impl std::fmt::Debug for GraphForge { } impl GraphForge { + pub(crate) fn stage_project_generation( + &self, + request: &graphforge_storage::ProjectGenerationRequest, + ) -> Result { + graphforge_storage::stage_project_generation_with_graph_tree_mode( + self.resolved_generation.container_root(), + request, + None, + self.lifecycle_mode, + ) + } + /// Create a new in-memory (`None`) or Parquet-backed (`Some(path)`) instance. /// /// For a persistent instance, the directory may be absent when its parent @@ -501,6 +516,8 @@ impl GraphForge { Ok(Self { identity: GraphIdentity::new(), path: None, + lifecycle_mode: + graphforge_storage::filesystem_admission::ProjectLifecycleMode::Ephemeral, resolved_generation, read_only: false, current_generation_uuid: Arc::new(Mutex::new(generation_uuid)), @@ -586,10 +603,11 @@ impl GraphForge { ) } - fn open_resolved_with_mode( + fn open_resolved_with_lifecycle_mode( container_dir: PathBuf, resolved_generation: ResolvedProjectGeneration, read_only: bool, + lifecycle_mode: graphforge_storage::filesystem_admission::ProjectLifecycleMode, ) -> Result { let options = GraphForgeOptions::default(); let (_, resource_policy) = options.clone().validate()?; @@ -602,14 +620,16 @@ impl GraphForge { resolved_generation.generation_uuid(), ) }; - Self::open_resolved_with_options( + let mut graph = Self::open_resolved_with_options( container_dir, resolved_generation, read_only, options, resource_policy, project_open_recovery, - ) + )?; + graph.lifecycle_mode = lifecycle_mode; + Ok(graph) } fn open_resolved_with_options( @@ -651,6 +671,7 @@ impl GraphForge { let graph = Self { identity: GraphIdentity::new(), path: Some(container_dir), + lifecycle_mode: graphforge_storage::filesystem_admission::ProjectLifecycleMode::Durable, resolved_generation, read_only, current_generation_uuid: Arc::new(Mutex::new(generation_uuid)), @@ -1112,10 +1133,11 @@ impl GraphForge { capabilities, participants, }; - let publication = match graphforge_storage::stage_project_generation_with_graph_tree( + let publication = match graphforge_storage::stage_project_generation_with_graph_tree_mode( root, &request, Some(self.dir.as_path()), + self.lifecycle_mode, )? { ProjectStageOutcome::AlreadyPublished(receipt) => Ok(receipt), ProjectStageOutcome::Staged(staged) => staged @@ -1198,10 +1220,11 @@ impl GraphForge { capabilities, participants, }; - let publication = match graphforge_storage::stage_project_generation_with_graph_tree( + let publication = match graphforge_storage::stage_project_generation_with_graph_tree_mode( root, &request, Some(self.dir.as_path()), + self.lifecycle_mode, )? { ProjectStageOutcome::AlreadyPublished(receipt) => Ok(receipt), ProjectStageOutcome::Staged(staged) => staged diff --git a/crates/graphforge-api/src/maintenance.rs b/crates/graphforge-api/src/maintenance.rs index 19ff52a8..c185e8c5 100644 --- a/crates/graphforge-api/src/maintenance.rs +++ b/crates/graphforge-api/src/maintenance.rs @@ -7,9 +7,10 @@ use graphforge_core::{GfError, ProjectErrorCode}; use graphforge_storage::{ GraphDeltaCompactionPolicy, GraphDeltaCompactionReport, GraphDeltaCompactionRequest, GraphDeltaCompactionStatus, GraphDeltaJournalLimits, ProjectCleanupReport, - ProjectReachabilityReport, ProjectRetentionLimits, ProjectRetentionPolicy, compact_graph_delta, - graph_delta_compaction_status, inspect_project_reachability, preview_graph_delta_compaction, - preview_project_cleanup, + ProjectReachabilityReport, ProjectRetentionLimits, ProjectRetentionPolicy, + compact_graph_delta_with_mode, graph_delta_compaction_status_with_mode, + inspect_project_reachability_with_mode, preview_graph_delta_compaction_with_mode, + preview_project_cleanup_with_mode, }; use crate::{CancellationToken, GraphForge}; @@ -32,7 +33,7 @@ impl GraphForge { limits: ProjectRetentionLimits, ) -> Result { let root = self.require_mutable_project_root()?; - inspect_project_reachability(root, policy, limits) + inspect_project_reachability_with_mode(root, policy, limits, self.lifecycle_mode) } /// Preview retention/GC candidates without removing anything. @@ -42,7 +43,7 @@ impl GraphForge { limits: ProjectRetentionLimits, ) -> Result { let root = self.require_mutable_project_root()?; - preview_project_cleanup(root, policy, limits) + preview_project_cleanup_with_mode(root, policy, limits, self.lifecycle_mode) } /// Execute retention/GC for unreachable generations using the shared oracle. @@ -52,7 +53,12 @@ impl GraphForge { limits: ProjectRetentionLimits, ) -> Result { let root = self.require_mutable_project_root()?; - graphforge_storage::execute_project_cleanup(root, policy, limits) + graphforge_storage::execute_project_cleanup_with_mode( + root, + policy, + limits, + self.lifecycle_mode, + ) } /// Report whether CURRENT's verified delta chain should compact under policy. @@ -62,7 +68,7 @@ impl GraphForge { limits: GraphDeltaJournalLimits, ) -> Result { let root = self.require_mutable_project_root()?; - graph_delta_compaction_status(root, policy, limits) + graph_delta_compaction_status_with_mode(root, policy, limits, self.lifecycle_mode) } /// Preview delta compaction without publishing CURRENT. @@ -72,7 +78,12 @@ impl GraphForge { cancellation: Option<&CancellationToken>, ) -> Result { let root = self.require_mutable_project_root()?; - preview_graph_delta_compaction(root, request, cancellation.map(CancellationToken::flag)) + preview_graph_delta_compaction_with_mode( + root, + request, + cancellation.map(CancellationToken::flag), + self.lifecycle_mode, + ) } /// Compact a contiguous verified delta prefix into a new Parquet generation. @@ -82,7 +93,12 @@ impl GraphForge { cancellation: Option<&CancellationToken>, ) -> Result { let root = self.require_mutable_project_root()?; - compact_graph_delta(root, request, cancellation.map(CancellationToken::flag)) + compact_graph_delta_with_mode( + root, + request, + cancellation.map(CancellationToken::flag), + self.lifecycle_mode, + ) } } @@ -150,4 +166,27 @@ mod tests { let _ = graph.preview_graph_delta_compaction(&request, None); let _ = graph.compact_graph_delta(&request, None); } + + #[test] + fn in_memory_retention_uses_ephemeral_lifecycle_mode() { + let graph = GraphForge::new(None).unwrap(); + graph + .inspect_project_reachability( + ProjectRetentionPolicy::default(), + ProjectRetentionLimits::default(), + ) + .unwrap(); + graph + .preview_project_cleanup( + ProjectRetentionPolicy::default(), + ProjectRetentionLimits::default(), + ) + .unwrap(); + graph + .execute_project_cleanup( + ProjectRetentionPolicy::default(), + ProjectRetentionLimits::default(), + ) + .unwrap(); + } } diff --git a/crates/graphforge-api/src/portable.rs b/crates/graphforge-api/src/portable.rs index f1e22544..2f4a0a2f 100644 --- a/crates/graphforge-api/src/portable.rs +++ b/crates/graphforge-api/src/portable.rs @@ -87,7 +87,11 @@ impl GraphForge { None, ), PortableSelection::Checkpoint(name) => { - let (_, generation) = graphforge_storage::open_checkpoint_generation(root, &name)?; + let (_, generation) = graphforge_storage::open_checkpoint_generation_with_mode( + root, + &name, + self.lifecycle_mode, + )?; (generation, "checkpoint", Some(name)) } }; @@ -173,7 +177,7 @@ fn hex(digest: [u8; 32]) -> String { #[cfg(test)] mod tests { use super::*; - use crate::{AdoptOntologyRequest, ClearOntologyRequest, WriteContext}; + use crate::{AdoptOntologyRequest, CheckpointRequest, ClearOntologyRequest, WriteContext}; use graphforge_core::OntologyMode; const ONTOLOGY: &str = "ontology_id: portable-authority\nversion: \"1\"\nentity_types:\n - name: Person\n abstract: false\nrelation_types: []\n"; @@ -220,6 +224,31 @@ mod tests { GraphForge::new(target.to_str()).expect("imported CURRENT must reopen"); } + #[test] + fn in_memory_checkpoint_can_be_exported() { + let graph = GraphForge::new(None).unwrap(); + graph + .checkpoint(CheckpointRequest { + name: "Export".into(), + description: None, + idempotency_key: OperationId(Uuid::from_u128(901)), + actor_uuid: None, + }) + .unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let output = output_dir.path().join("checkpoint.gfportable"); + + let exported = graph + .export_portable(PortableExportRequest { + selection: PortableSelection::Checkpoint("Export".into()), + output, + }) + .unwrap(); + + assert_eq!(exported.source, "checkpoint"); + assert_eq!(exported.checkpoint.as_deref(), Some("Export")); + } + #[test] fn import_rejects_nonempty_target_without_changing_it() { let source = tempfile::tempdir().unwrap(); diff --git a/crates/graphforge-api/src/valid_time.rs b/crates/graphforge-api/src/valid_time.rs index f44d873b..7e1ee0fe 100644 --- a/crates/graphforge-api/src/valid_time.rs +++ b/crates/graphforge-api/src/valid_time.rs @@ -400,10 +400,7 @@ fn publish( capabilities, participants, }; - let receipt = match graphforge_storage::stage_project_generation( - graph.resolved_generation.container_root(), - &request, - )? { + let receipt = match graph.stage_project_generation(&request)? { ProjectStageOutcome::AlreadyPublished(receipt) => receipt, ProjectStageOutcome::Staged(staged) => staged .validate( diff --git a/crates/graphforge-api/src/workspace_ontology.rs b/crates/graphforge-api/src/workspace_ontology.rs index acf9e533..73c5e514 100644 --- a/crates/graphforge-api/src/workspace_ontology.rs +++ b/crates/graphforge-api/src/workspace_ontology.rs @@ -248,7 +248,7 @@ fn publish_workspace_records( .collect(), participants, }; - let receipt = match graphforge_storage::stage_project_generation(&root, &request)? { + let receipt = match graph.stage_project_generation(&request)? { ProjectStageOutcome::AlreadyPublished(receipt) => receipt, ProjectStageOutcome::Staged(staged) => staged .validate(validate_workspace_record_inventory, |actual_parent, _| { diff --git a/crates/graphforge-storage/src/project_checkpoints.rs b/crates/graphforge-storage/src/project_checkpoints.rs index 03c2c7bb..e1c20adb 100644 --- a/crates/graphforge-storage/src/project_checkpoints.rs +++ b/crates/graphforge-storage/src/project_checkpoints.rs @@ -236,10 +236,26 @@ impl Drop for MutationLocks { pub fn create_checkpoint( container_root: impl AsRef, request: &CheckpointCreateRequest, +) -> Result { + create_checkpoint_with_mode( + container_root, + request, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Create a checkpoint using the lifecycle mode established by the owning facade. +/// +/// # Errors +/// Returns the same errors as [`create_checkpoint`]. +pub fn create_checkpoint_with_mode( + container_root: impl AsRef, + request: &CheckpointCreateRequest, + mode: crate::filesystem_admission::ProjectLifecycleMode, ) -> Result { let name = validate_name(&request.name)?; validate_description(request.description.as_deref())?; - let admission = admit_existing_project(container_root.as_ref())?; + let admission = admit_existing_project(container_root.as_ref(), mode)?; let root = canonical_project_root(admission.root())?; let _locks = acquire_mutation_locks(&root)?; let checkpoint_root = checkpoint_root(&root)?; @@ -331,9 +347,25 @@ pub fn create_checkpoint( pub fn delete_checkpoint( container_root: impl AsRef, request: &CheckpointDeleteRequest, +) -> Result { + delete_checkpoint_with_mode( + container_root, + request, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Delete a checkpoint using the lifecycle mode established by the owning facade. +/// +/// # Errors +/// Returns the same errors as [`delete_checkpoint`]. +pub fn delete_checkpoint_with_mode( + container_root: impl AsRef, + request: &CheckpointDeleteRequest, + mode: crate::filesystem_admission::ProjectLifecycleMode, ) -> Result { let name = validate_name(&request.name)?; - let admission = admit_existing_project(container_root.as_ref())?; + let admission = admit_existing_project(container_root.as_ref(), mode)?; let root = canonical_project_root(admission.root())?; let _locks = acquire_mutation_locks(&root)?; let checkpoint_root = checkpoint_root(&root)?; @@ -420,7 +452,21 @@ pub fn delete_checkpoint( pub fn list_checkpoints( container_root: impl AsRef, ) -> Result, GfError> { - let admission = admit_existing_project(container_root.as_ref())?; + list_checkpoints_with_mode( + container_root, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// List checkpoints using the lifecycle mode established by the owning facade. +/// +/// # Errors +/// Returns the same errors as [`list_checkpoints`]. +pub fn list_checkpoints_with_mode( + container_root: impl AsRef, + mode: crate::filesystem_admission::ProjectLifecycleMode, +) -> Result, GfError> { + let admission = admit_existing_project(container_root.as_ref(), mode)?; let root = canonical_project_root(admission.root())?; let checkpoint_root = checkpoint_root(&root)?; let (_checkpoint_lock, registry) = read_registry_for_read(&root, &checkpoint_root)?; @@ -431,9 +477,25 @@ pub fn list_checkpoints( pub fn open_checkpoint_generation( container_root: impl AsRef, name: &str, +) -> Result<(CheckpointRecord, crate::ResolvedProjectGeneration), GfError> { + open_checkpoint_generation_with_mode( + container_root, + name, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Open a checkpoint using the lifecycle mode established by the owning facade. +/// +/// # Errors +/// Returns the same errors as [`open_checkpoint_generation`]. +pub fn open_checkpoint_generation_with_mode( + container_root: impl AsRef, + name: &str, + mode: crate::filesystem_admission::ProjectLifecycleMode, ) -> Result<(CheckpointRecord, crate::ResolvedProjectGeneration), GfError> { let name = validate_name(name)?; - let admission = admit_existing_project(container_root.as_ref())?; + let admission = admit_existing_project(container_root.as_ref(), mode)?; let root = canonical_project_root(admission.root())?; let checkpoint_root = checkpoint_root(&root)?; let (_checkpoint_lock, registry) = read_registry_for_read(&root, &checkpoint_root)?; @@ -466,15 +528,39 @@ pub fn open_checkpoint_generation( } /// Publish a complete-workspace restoration as a new child generation. +pub fn revert_checkpoint( + container_root: impl AsRef, + request: &CheckpointRevertRequest, + select_timestamp: T, + validate_source: V, +) -> Result<(CheckpointReceipt, crate::ResolvedProjectGeneration), GfError> +where + T: FnOnce() -> Result, + V: FnOnce(&crate::ResolvedProjectGeneration) -> Result<(), GfError>, +{ + revert_checkpoint_with_mode( + container_root, + request, + select_timestamp, + validate_source, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Revert a checkpoint using the lifecycle mode established by the owning facade. +/// +/// # Errors +/// Returns the same errors as [`revert_checkpoint`]. #[expect( clippy::too_many_lines, reason = "the revert transaction is intentionally linear so lock ownership and publication order remain auditable" )] -pub fn revert_checkpoint( +pub fn revert_checkpoint_with_mode( container_root: impl AsRef, request: &CheckpointRevertRequest, select_timestamp: T, validate_source: V, + mode: crate::filesystem_admission::ProjectLifecycleMode, ) -> Result<(CheckpointReceipt, crate::ResolvedProjectGeneration), GfError> where T: FnOnce() -> Result, @@ -482,7 +568,7 @@ where { let requested_name = validate_name(&request.name)?; let requested_reason = validate_reason(&request.reason)?; - let admission = admit_existing_project(container_root.as_ref())?; + let admission = admit_existing_project(container_root.as_ref(), mode)?; let root = canonical_project_root(admission.root())?; let transaction_uuid = revert_transaction_uuid(request.operation_uuid); let mut locks = acquire_mutation_locks(&root)?; @@ -894,10 +980,11 @@ fn canonical_project_root(path: &Path) -> Result { fn admit_existing_project( root: &Path, + mode: crate::filesystem_admission::ProjectLifecycleMode, ) -> Result { let admission = crate::filesystem_admission::admit_project_lifecycle( root, - crate::filesystem_admission::ProjectLifecycleMode::Durable, + mode, crate::filesystem_admission::ProjectRootRequirement::Existing, )?; admission.revalidate_identity()?; diff --git a/crates/graphforge-storage/src/project_retention.rs b/crates/graphforge-storage/src/project_retention.rs index 9ce41dbc..a9bb778b 100644 --- a/crates/graphforge-storage/src/project_retention.rs +++ b/crates/graphforge-storage/src/project_retention.rs @@ -240,11 +240,29 @@ pub fn inspect_project_reachability( container_root: impl AsRef, policy: ProjectRetentionPolicy, limits: ProjectRetentionLimits, +) -> Result { + inspect_project_reachability_with_mode( + container_root, + policy, + limits, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Inspect reachability using the lifecycle mode established by the owning facade. +/// +/// # Errors +/// Returns the same errors as [`inspect_project_reachability`]. +pub fn inspect_project_reachability_with_mode( + container_root: impl AsRef, + policy: ProjectRetentionPolicy, + limits: ProjectRetentionLimits, + mode: crate::filesystem_admission::ProjectLifecycleMode, ) -> Result { let started = Instant::now(); let admission = crate::filesystem_admission::admit_project_lifecycle( container_root, - crate::filesystem_admission::ProjectLifecycleMode::Durable, + mode, crate::filesystem_admission::ProjectRootRequirement::Existing, )?; admission.revalidate_identity()?; @@ -322,7 +340,25 @@ pub fn preview_project_cleanup( policy: ProjectRetentionPolicy, limits: ProjectRetentionLimits, ) -> Result { - run_cleanup(container_root.as_ref(), policy, limits, true) + preview_project_cleanup_with_mode( + container_root, + policy, + limits, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Preview cleanup using the lifecycle mode established by the owning facade. +/// +/// # Errors +/// Returns the same errors as [`preview_project_cleanup`]. +pub fn preview_project_cleanup_with_mode( + container_root: impl AsRef, + policy: ProjectRetentionPolicy, + limits: ProjectRetentionLimits, + mode: crate::filesystem_admission::ProjectLifecycleMode, +) -> Result { + run_cleanup(container_root.as_ref(), policy, limits, true, mode) } /// Execute bounded orphan/unreachable cleanup. @@ -336,7 +372,25 @@ pub fn execute_project_cleanup( policy: ProjectRetentionPolicy, limits: ProjectRetentionLimits, ) -> Result { - run_cleanup(container_root.as_ref(), policy, limits, false) + execute_project_cleanup_with_mode( + container_root, + policy, + limits, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Execute cleanup using the lifecycle mode established by the owning facade. +/// +/// # Errors +/// Returns the same errors as [`execute_project_cleanup`]. +pub fn execute_project_cleanup_with_mode( + container_root: impl AsRef, + policy: ProjectRetentionPolicy, + limits: ProjectRetentionLimits, + mode: crate::filesystem_admission::ProjectLifecycleMode, +) -> Result { + run_cleanup(container_root.as_ref(), policy, limits, false, mode) } fn run_cleanup( @@ -344,11 +398,12 @@ fn run_cleanup( policy: ProjectRetentionPolicy, limits: ProjectRetentionLimits, dry_run: bool, + mode: crate::filesystem_admission::ProjectLifecycleMode, ) -> Result { let started = Instant::now(); let admission = crate::filesystem_admission::admit_project_lifecycle( root, - crate::filesystem_admission::ProjectLifecycleMode::Durable, + mode, crate::filesystem_admission::ProjectRootRequirement::Existing, )?; admission.revalidate_identity()?; From 993f0bd6ff75d35f1a3420cad6eef70e753dd0fc Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:24:27 -0600 Subject: [PATCH 11/19] style(bindings): format admission smoke coverage --- crates/graphforge-bindings-py/tests/smoke.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/graphforge-bindings-py/tests/smoke.py b/crates/graphforge-bindings-py/tests/smoke.py index 70181d70..72db32e6 100644 --- a/crates/graphforge-bindings-py/tests/smoke.py +++ b/crates/graphforge-bindings-py/tests/smoke.py @@ -75,9 +75,7 @@ def check_exception_hierarchy() -> None: forge.close() (parent / "hop").mkdir() current_before = (project / "CURRENT").read_bytes() - generations_before = sorted( - path.name for path in (project / "generations").iterdir() - ) + generations_before = sorted(path.name for path in (project / "generations").iterdir()) parent_before = sorted(path.name for path in parent.iterdir()) traversal = parent / "hop" / ".." / "project" @@ -90,8 +88,7 @@ def check_exception_hierarchy() -> None: assert (project / "CURRENT").read_bytes() == current_before assert ( - sorted(path.name for path in (project / "generations").iterdir()) - == generations_before + sorted(path.name for path in (project / "generations").iterdir()) == generations_before ) assert sorted(path.name for path in parent.iterdir()) == parent_before From 47713d353ae5b3f2722451f25b7e8271f69cf5b4 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:30:51 -0600 Subject: [PATCH 12/19] fix(storage): reconcile admitted publication outcomes (#780) --- crates/graphforge-api/src/maintenance.rs | 88 ++++++++++++ .../src/graph_delta_compaction.rs | 74 +++++++++- .../src/graph_delta_journal.rs | 31 ++++- crates/graphforge-storage/src/lib.rs | 4 +- .../src/project_publication.rs | 129 ++++++++++++++++-- 5 files changed, 301 insertions(+), 25 deletions(-) diff --git a/crates/graphforge-api/src/maintenance.rs b/crates/graphforge-api/src/maintenance.rs index c185e8c5..8f7099c0 100644 --- a/crates/graphforge-api/src/maintenance.rs +++ b/crates/graphforge-api/src/maintenance.rs @@ -189,4 +189,92 @@ mod tests { ) .unwrap(); } + + #[test] + fn in_memory_compaction_cleanup_uses_ephemeral_lifecycle_mode() { + use graphforge_storage::{ + GraphDeltaOp, GraphDeltaOpKind, GraphDeltaPayload, GraphDeltaPublishRequest, + ProjectCapability, ProjectGenerationRequest, ProjectStageOutcome, + ReconstructedGraphState, + }; + + let graph = GraphForge::new(None).unwrap(); + let root = graph.resolved_generation.container_root(); + let workspace = tempfile::tempdir().unwrap(); + graphforge_storage::stage_base_graph_workspace( + workspace.path(), + &[ + ("topology/nodes.parquet", b"nodes"), + ("topology/edges.parquet", b"edges"), + ], + Some(&ReconstructedGraphState::default()), + ) + .unwrap(); + let (_, files) = graphforge_storage::capture_graph_files(workspace.path()).unwrap(); + let mut participants = graphforge_storage::empty_workspace_participants().unwrap(); + participants.insert(0, files); + let base = ProjectGenerationRequest { + transaction_uuid: Uuid::now_v7(), + generation_uuid: Uuid::now_v7(), + capabilities: vec![ + ProjectCapability { + capability_id: "graph".into(), + capability_version: 1, + }, + ProjectCapability { + capability_id: "workspace".into(), + capability_version: 1, + }, + ], + participants, + }; + let ProjectStageOutcome::Staged(staged) = + graphforge_storage::stage_project_generation_with_graph_tree_mode( + root, + &base, + Some(workspace.path()), + graph.lifecycle_mode, + ) + .unwrap() + else { + panic!("base publication unexpectedly replayed"); + }; + staged + .validate(|_| Ok(()), |_, _| Ok(())) + .unwrap() + .publish() + .unwrap(); + graphforge_storage::publish_graph_delta_with_mode( + root, + &GraphDeltaPublishRequest { + transaction_uuid: Uuid::now_v7(), + generation_uuid: Uuid::now_v7(), + run_uuid: Uuid::now_v7(), + operations: vec![GraphDeltaOp { + operation_uuid: Uuid::now_v7(), + kind: GraphDeltaOpKind::UpsertNode, + payload: GraphDeltaPayload::UpsertNode { + node_uuid: Uuid::now_v7().hyphenated().to_string(), + type_ids: vec![1], + }, + }], + limits: GraphDeltaJournalLimits::default(), + }, + graph.lifecycle_mode, + ) + .unwrap(); + let request = GraphDeltaCompactionRequest { + transaction_uuid: Uuid::now_v7(), + generation_uuid: Uuid::now_v7(), + through_run_sequence: None, + limits: GraphDeltaCompactionLimits::default(), + cleanup_after_commit: true, + cleanup_policy: ProjectRetentionPolicy::default(), + cleanup_limits: ProjectRetentionLimits::default(), + }; + + let report = graph.compact_graph_delta(&request, None).unwrap(); + + assert!(report.cleanup.is_some()); + } } diff --git a/crates/graphforge-storage/src/graph_delta_compaction.rs b/crates/graphforge-storage/src/graph_delta_compaction.rs index c2add066..60f3701b 100644 --- a/crates/graphforge-storage/src/graph_delta_compaction.rs +++ b/crates/graphforge-storage/src/graph_delta_compaction.rs @@ -30,7 +30,8 @@ use crate::project_publication::{ published_project_transaction, stage_project_generation_from_admitted_parent, }; use crate::project_retention::{ - ProjectCleanupReport, ProjectRetentionLimits, ProjectRetentionPolicy, execute_project_cleanup, + ProjectCleanupReport, ProjectRetentionLimits, ProjectRetentionPolicy, + execute_project_cleanup_with_mode, }; use crate::{GRAPH_CAPABILITY_ID, GRAPH_CAPABILITY_VERSION, empty_workspace_participants}; @@ -338,10 +339,11 @@ fn compact_graph_delta_after_prepare( }; let cleanup = if request.cleanup_after_commit { - Some(execute_project_cleanup( + Some(execute_project_cleanup_with_mode( root, request.cleanup_policy, request.cleanup_limits, + mode, )?) } else { None @@ -903,7 +905,24 @@ mod crash_oracle_tests { }; fn publish_graph_base(root: &Path) { - crate::open_or_initialize_project(root).unwrap(); + publish_graph_base_with_mode( + root, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ); + } + + fn publish_graph_base_with_mode( + root: &Path, + mode: crate::filesystem_admission::ProjectLifecycleMode, + ) { + match mode { + crate::filesystem_admission::ProjectLifecycleMode::Durable => { + crate::open_or_initialize_project(root).unwrap(); + } + crate::filesystem_admission::ProjectLifecycleMode::Ephemeral => { + crate::open_or_initialize_ephemeral_project(root).unwrap(); + } + } let workspace = tempfile::tempdir().unwrap(); stage_base_graph_workspace( workspace.path(), @@ -933,8 +952,13 @@ mod crash_oracle_tests { participants, }; let ProjectStageOutcome::Staged(staged) = - crate::stage_project_generation_with_graph_tree(root, &request, Some(workspace.path())) - .unwrap() + crate::stage_project_generation_with_graph_tree_mode( + root, + &request, + Some(workspace.path()), + mode, + ) + .unwrap() else { panic!("base publication unexpectedly replayed"); }; @@ -946,8 +970,18 @@ mod crash_oracle_tests { } fn publish_one_node_delta(root: &Path) -> Uuid { + publish_one_node_delta_with_mode( + root, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) + } + + fn publish_one_node_delta_with_mode( + root: &Path, + mode: crate::filesystem_admission::ProjectLifecycleMode, + ) -> Uuid { let generation_uuid = Uuid::now_v7(); - crate::publish_graph_delta( + crate::publish_graph_delta_with_mode( root, &crate::GraphDeltaPublishRequest { transaction_uuid: Uuid::now_v7(), @@ -963,6 +997,7 @@ mod crash_oracle_tests { }], limits: GraphDeltaJournalLimits::default(), }, + mode, ) .unwrap(); generation_uuid @@ -1073,4 +1108,31 @@ mod crash_oracle_tests { 1 ); } + + #[test] + fn ephemeral_compaction_cleanup_keeps_the_original_lifecycle_mode() { + let root = tempfile::tempdir().unwrap(); + let mode = crate::filesystem_admission::ProjectLifecycleMode::Ephemeral; + publish_graph_base_with_mode(root.path(), mode); + publish_one_node_delta_with_mode(root.path(), mode); + let request = GraphDeltaCompactionRequest { + transaction_uuid: Uuid::now_v7(), + generation_uuid: Uuid::now_v7(), + through_run_sequence: None, + limits: GraphDeltaCompactionLimits::default(), + cleanup_after_commit: true, + cleanup_policy: ProjectRetentionPolicy::default(), + cleanup_limits: ProjectRetentionLimits::default(), + }; + + let report = compact_graph_delta_with_mode(root.path(), &request, None, mode).unwrap(); + + assert!(report.cleanup.is_some()); + assert_eq!( + resolve_project_generation(root.path()) + .unwrap() + .generation_uuid(), + request.generation_uuid + ); + } } diff --git a/crates/graphforge-storage/src/graph_delta_journal.rs b/crates/graphforge-storage/src/graph_delta_journal.rs index e6894fdc..3bdcbc12 100644 --- a/crates/graphforge-storage/src/graph_delta_journal.rs +++ b/crates/graphforge-storage/src/graph_delta_journal.rs @@ -713,18 +713,36 @@ pub fn publish_graph_delta( container_root: &Path, request: &GraphDeltaPublishRequest, ) -> Result { - publish_graph_delta_after_prepare(container_root, request, |_| Ok(())) + publish_graph_delta_with_mode( + container_root, + request, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + ) +} + +/// Publish a graph delta using the lifecycle mode established by the owning +/// facade. +/// +/// # Errors +/// Returns the same errors as [`publish_graph_delta`]. +pub fn publish_graph_delta_with_mode( + container_root: &Path, + request: &GraphDeltaPublishRequest, + mode: crate::filesystem_admission::ProjectLifecycleMode, +) -> Result { + publish_graph_delta_after_prepare(container_root, request, mode, |_| Ok(())) } #[allow(clippy::too_many_lines)] // Publication stages copy, encode, and CURRENT commit together. fn publish_graph_delta_after_prepare( container_root: &Path, request: &GraphDeltaPublishRequest, + mode: crate::filesystem_admission::ProjectLifecycleMode, before_stage: impl FnOnce(&Path) -> Result<(), GfError>, ) -> Result { let admission = crate::filesystem_admission::admit_project_lifecycle( container_root, - crate::filesystem_admission::ProjectLifecycleMode::Durable, + mode, crate::filesystem_admission::ProjectRootRequirement::Existing, )?; admission.revalidate_identity()?; @@ -1274,8 +1292,13 @@ mod crash_oracle_tests { let prepared = one_node_request(); let (concurrent_generation, concurrent) = stage_graph_clone(root.path()); - let error = - publish_graph_delta_after_prepare(root.path(), &prepared, |_| Ok(())).unwrap_err(); + let error = publish_graph_delta_after_prepare( + root.path(), + &prepared, + crate::filesystem_admission::ProjectLifecycleMode::Durable, + |_| Ok(()), + ) + .unwrap_err(); assert_eq!(error.code(), "GF_WRITER_BUSY"); concurrent diff --git a/crates/graphforge-storage/src/lib.rs b/crates/graphforge-storage/src/lib.rs index 197a8654..b0012acd 100644 --- a/crates/graphforge-storage/src/lib.rs +++ b/crates/graphforge-storage/src/lib.rs @@ -45,8 +45,8 @@ pub use graph_delta_journal::{ MAX_GRAPH_DELTA_RECORDS_PER_RUN, MAX_GRAPH_DELTA_REPLAY_MEMORY_BYTES, MAX_GRAPH_DELTA_RUN_BYTES, MAX_GRAPH_DELTA_RUNS, ReconstructedGraphState, apply_delta_runs, decode_delta_run, delta_run_relative_path, encode_delta_run, list_delta_runs, - load_verified_delta_runs, publish_graph_delta, reconstruct_graph_state, - stage_base_graph_workspace, + load_verified_delta_runs, publish_graph_delta, publish_graph_delta_with_mode, + reconstruct_graph_state, stage_base_graph_workspace, }; pub mod graph_delta_compaction; diff --git a/crates/graphforge-storage/src/project_publication.rs b/crates/graphforge-storage/src/project_publication.rs index 658b6605..4fefa021 100644 --- a/crates/graphforge-storage/src/project_publication.rs +++ b/crates/graphforge-storage/src/project_publication.rs @@ -1369,7 +1369,7 @@ fn replace_current( }; let current_bytes = canonical_line(¤t)?; let current_path = staged.root.join(CURRENT_FILE); - publish_atomic_bytes( + let replace_result = publish_atomic_bytes( ¤t_path, ¤t_bytes, || { @@ -1399,16 +1399,16 @@ fn replace_current( false, ) }, - ) - .map_err(|error| { - publication_error_from_parts( + ); + if let Err(error) = replace_result { + reconcile_current_replacement_error( + &staged.root, staged.transaction_uuid, staged.generation_uuid, - "CURRENT", - false, - &error.to_string(), - ) - })?; + manifest_sha256, + &error, + )?; + } project_failpoint::hit( "project.after_current_replace", Some(staged.transaction_uuid), @@ -1418,6 +1418,40 @@ fn replace_current( ) } +fn reconcile_current_replacement_error( + root: &Path, + transaction_uuid: Uuid, + generation_uuid: Uuid, + manifest_sha256: [u8; 32], + error: &AtomicPublishError, +) -> Result<(), GfError> { + // The native primitive distinguishes a proved no-op from an outcome whose + // namespace state requires reconciliation. Re-read CURRENT under the + // still-held writer lock for every error so callers never receive + // committed=false after the child actually became authoritative. + let resolved = resolve_project_generation(root).map_err(|authority_error| { + project_error( + ProjectErrorCode::ProjectCorrupt, + format!( + "CURRENT authority could not be reconciled after native replacement error: {}", + safe_cause(&authority_error.to_string()) + ), + ) + })?; + if resolved.generation_uuid() == generation_uuid + && resolved.manifest_sha256() == manifest_sha256 + { + return Ok(()); + } + Err(publication_error_from_parts( + transaction_uuid, + generation_uuid, + "CURRENT", + false, + &error.to_string(), + )) +} + fn finish_published_generation( staged: &StagedProjectGeneration, manifest_sha256: [u8; 32], @@ -1968,13 +2002,36 @@ pub(crate) fn write_journal(path: &Path, journal: &JournalRecord) -> Result<(), ) } +#[derive(Debug)] +pub(crate) enum AtomicPublishError { + Io(std::io::Error), + Replacement(graphforge_filesystem::ReplaceFileError), +} + +impl std::fmt::Display for AtomicPublishError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(error) => error.fmt(formatter), + Self::Replacement(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for AtomicPublishError {} + +impl From for AtomicPublishError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + pub(crate) fn publish_atomic_bytes( path: &Path, bytes: &[u8], after_write: impl FnOnce() -> std::io::Result<()>, after_sync: impl FnOnce() -> std::io::Result<()>, before_replace: impl FnOnce() -> std::io::Result<()>, -) -> std::io::Result<()> { +) -> Result<(), AtomicPublishError> { let parent = path .parent() .ok_or_else(|| std::io::Error::other("atomic publication target has no parent"))?; @@ -2009,14 +2066,15 @@ pub(crate) fn publish_atomic_bytes( { return Err(std::io::Error::other( "atomic publication target is not a regular single-link file", - )); + ) + .into()); } graphforge_filesystem::replace_file( &directory, std::ffi::OsStr::new(&temp_name), target_name, ) - .map_err(std::io::Error::other) + .map_err(AtomicPublishError::Replacement) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { graphforge_filesystem::install_new_file( @@ -2024,8 +2082,9 @@ pub(crate) fn publish_atomic_bytes( std::ffi::OsStr::new(&temp_name), target_name, ) + .map_err(AtomicPublishError::Io) } - Err(error) => Err(error), + Err(error) => Err(AtomicPublishError::Io(error)), } } @@ -3228,6 +3287,50 @@ mod tests { ); } + #[test] + fn replacement_error_reconciliation_never_reports_a_committed_child_as_false() { + let root = project(); + let receipt = publish( + root.path(), + request(vec![participant("graph", "nodes", b"nodes")]), + ); + let state_unknown = + AtomicPublishError::Replacement(graphforge_filesystem::ReplaceFileError::StateUnknown( + std::io::Error::other("injected replacement status"), + )); + + reconcile_current_replacement_error( + root.path(), + receipt.transaction_uuid, + receipt.generation_uuid, + receipt.generation_manifest_sha256, + &state_unknown, + ) + .unwrap(); + + let error = reconcile_current_replacement_error( + root.path(), + Uuid::now_v7(), + Uuid::now_v7(), + [0xabu8; 32], + &state_unknown, + ) + .unwrap_err(); + assert_eq!(error.code(), "GF_PUBLICATION_FAILED"); + assert!(error.to_string().contains("committed=false")); + + std::fs::write(root.path().join(CURRENT_FILE), b"{torn\n").unwrap(); + let error = reconcile_current_replacement_error( + root.path(), + Uuid::now_v7(), + receipt.generation_uuid, + receipt.generation_manifest_sha256, + &state_unknown, + ) + .unwrap_err(); + assert_eq!(error.code(), "GF_PROJECT_CORRUPT"); + } + #[test] fn wave9_journal_metadata_and_lock_aliases_fail_closed() { let root = project(); From 17fd1448b7f49aa913af096f0751c52b366b02b5 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:37:33 -0600 Subject: [PATCH 13/19] test(cli): map filesystem admission in Bazel (#780) --- crates/graphforge-cli/BUILD.bazel | 11 +++++++++++ docs/development/bazel-migration-ledger.md | 1 + tools/bazel/parity/migration_target_map.json | 12 +++++++++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/graphforge-cli/BUILD.bazel b/crates/graphforge-cli/BUILD.bazel index dffbf974..be091afa 100644 --- a/crates/graphforge-cli/BUILD.bazel +++ b/crates/graphforge-cli/BUILD.bazel @@ -82,6 +82,16 @@ gf_rust_integration_test( deps = _CLI_DEPS, ) +gf_rust_integration_test( + name = "filesystem_admission", + srcs = ["tests/filesystem_admission.rs"], + crate = ":graphforge_cli", + data = _CLI_TEST_DATA, + rustc_env = _CLI_BIN_ENV, + size = "medium", + deps = _CLI_DEPS, +) + gf_rust_integration_test( name = "portable", srcs = ["tests/portable.rs"], @@ -109,6 +119,7 @@ test_suite( name = "cli_tests", tests = [ ":checkpoints", + ":filesystem_admission", ":graphforge_cli_test", ":portable", ":repository", diff --git a/docs/development/bazel-migration-ledger.md b/docs/development/bazel-migration-ledger.md index 36f6899b..3a5db7b8 100644 --- a/docs/development/bazel-migration-ledger.md +++ b/docs/development/bazel-migration-ledger.md @@ -114,6 +114,7 @@ Authoritative machine-readable map: `tools/bazel/parity/migration_target_map.jso | `graphforge-bindings-py` | `graphforge_bindings_py` | `cdylib` | `crates/graphforge-bindings-py/src/lib.rs` | `//crates/graphforge-bindings-py:graphforge_bindings_py` | `mapped` | #7; packaging `//:python_wheel_smoke` | | `graphforge-cli` | `graphforge_cli` | `lib` | `crates/graphforge-cli/src/lib.rs` | `//crates/graphforge-cli:graphforge_cli` | `mapped` | #7/#8; unit `//crates/graphforge-cli:graphforge_cli_test` | | `graphforge-cli` | `gf` | `bin` | `crates/graphforge-cli/src/main.rs` | `//crates/graphforge-cli:gf` | `mapped` | #8 | +| `graphforge-cli` | `filesystem_admission` | `integration-test` | `crates/graphforge-cli/tests/filesystem_admission.rs` | `//crates/graphforge-cli:filesystem_admission` | `mapped` | #780 | | `graphforge-cli` | `checkpoints` | `integration-test` | `crates/graphforge-cli/tests/checkpoints.rs` | `//crates/graphforge-cli:checkpoints` | `mapped` | #8 | | `graphforge-cli` | `portable` | `integration-test` | `crates/graphforge-cli/tests/portable.rs` | `//crates/graphforge-cli:portable` | `mapped` | #8 | | `graphforge-cli` | `repository` | `integration-test` | `crates/graphforge-cli/tests/repository.rs` | `//crates/graphforge-cli:repository` | `mapped` | #8 | diff --git a/tools/bazel/parity/migration_target_map.json b/tools/bazel/parity/migration_target_map.json index 7267e303..65ddb3d1 100644 --- a/tools/bazel/parity/migration_target_map.json +++ b/tools/bazel/parity/migration_target_map.json @@ -1,7 +1,7 @@ { "schema": "graphforge.bazel-migration-target-map.v1", "issue": 6, - "cargo_target_count": 101, + "cargo_target_count": 102, "targets": [ { "package": "graphforge-api", @@ -603,6 +603,16 @@ "exception_id": null, "notes": "#8" }, + { + "package": "graphforge-cli", + "target": "filesystem_admission", + "class": "integration-test", + "source": "crates/graphforge-cli/tests/filesystem_admission.rs", + "status": "mapped", + "bazel_label": "//crates/graphforge-cli:filesystem_admission", + "exception_id": null, + "notes": "#780" + }, { "package": "graphforge-cli", "target": "graphforge_cli", From a6d70c4ba43ebb6bcf16e1e987b5f4ca4aae43da Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:48:46 -0600 Subject: [PATCH 14/19] fix(storage): align platform admission checks (#780) --- .../tests/checkpoints.test.mjs | 4 +++- .../src/filesystem_admission.rs | 23 +++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/crates/graphforge-bindings-node/tests/checkpoints.test.mjs b/crates/graphforge-bindings-node/tests/checkpoints.test.mjs index 2a281723..986af15c 100644 --- a/crates/graphforge-bindings-node/tests/checkpoints.test.mjs +++ b/crates/graphforge-bindings-node/tests/checkpoints.test.mjs @@ -170,7 +170,9 @@ test( "async checkpoint preserves filesystem admission code after root substitution", { skip: process.platform === "win32" }, async () => { - const fixture = mkdtempSync(join(tmpdir(), "gf-node-checkpoint-admission-")); + const fixture = mkdtempSync( + join(tmpdir(), "gf-node-checkpoint-admission-"), + ); const parent = realpathSync(fixture); const project = join(parent, "project"); const moved = join(parent, "project-moved"); diff --git a/crates/graphforge-storage/src/filesystem_admission.rs b/crates/graphforge-storage/src/filesystem_admission.rs index 37187b4e..037d3cd2 100644 --- a/crates/graphforge-storage/src/filesystem_admission.rs +++ b/crates/graphforge-storage/src/filesystem_admission.rs @@ -893,11 +893,6 @@ fn normalize_trusted_system_alias(path: &Path) -> Result { Ok(replacement.join(path.strip_prefix(alias).expect("prefix was checked"))) } -#[cfg(not(target_os = "macos"))] -fn normalize_trusted_system_alias(path: &Path) -> Result { - Ok(path.to_path_buf()) -} - #[cfg(unix)] fn resolve_project_path_with_hook( root: &Path, @@ -910,7 +905,10 @@ fn resolve_project_path_with_hook( .unwrap_or_else(|| Path::new(".")); let (mut parent, components) = if root.is_absolute() { + #[cfg(target_os = "macos")] let normalized_root = normalize_trusted_system_alias(root)?; + #[cfg(not(target_os = "macos"))] + let normalized_root = root.to_path_buf(); let normalized_parent = normalized_root .parent() .ok_or_else(|| unsupported("CLASSIFY", "parent_unavailable"))?; @@ -1256,9 +1254,11 @@ fn child_metadata( let stat = statat(&parent.handle, name, AtFlags::SYMLINK_NOFOLLOW).map_err(std::io::Error::from)?; let path_metadata = std::fs::symlink_metadata(parent.path.join(name))?; - if path_metadata.dev() != u64::try_from(stat.st_dev).unwrap_or(u64::MAX) - || path_metadata.ino() != stat.st_ino - { + #[cfg(target_os = "linux")] + let stat_device = stat.st_dev; + #[cfg(not(target_os = "linux"))] + let stat_device = u64::try_from(stat.st_dev).unwrap_or(u64::MAX); + if path_metadata.dev() != stat_device || path_metadata.ino() != stat.st_ino { return Err(std::io::Error::other("child identity changed")); } Ok(path_metadata) @@ -2019,7 +2019,7 @@ mod tests { } #[test] - fn existing_ephemeral_root_is_canonicalized_before_ancestor_policy() { + fn existing_ephemeral_root_retains_canonical_identity_before_ancestor_policy() { let root = tempfile::tempdir().unwrap(); let admission = admit_project_lifecycle( root.path(), @@ -2027,7 +2027,10 @@ mod tests { ProjectRootRequirement::Existing, ) .unwrap(); - assert_eq!(admission.root(), root.path().canonicalize().unwrap()); + assert_eq!( + graphforge_filesystem::path_identity(admission.root()).unwrap(), + graphforge_filesystem::path_identity(root.path()).unwrap() + ); admission.revalidate_identity().unwrap(); } From e96121ac01a19355cb5345e75482c1387ecc9343 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:54:54 -0600 Subject: [PATCH 15/19] test(bdd): align absent project lifecycle contract (#780) --- tests/features/api/errors.feature | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/features/api/errors.feature b/tests/features/api/errors.feature index 3a9b85dd..0f04f559 100644 --- a/tests/features/api/errors.feature +++ b/tests/features/api/errors.feature @@ -18,11 +18,11 @@ Feature: Error Handling When I execute "MATCH (p:Person) RETURN p.age + 1 AS result" Then an ExecutionError is raised - Scenario: StorageError is raised on unreadable Parquet path + Scenario: An absent durable project path is initialized on open Given a path that does not exist on disk When I open a graph at that path And I execute "MATCH (n) RETURN n" - Then a StorageError is raised + Then the table has 0 rows Scenario: ParseError on undefined variable in RETURN From 4f4cc8eef3ca1013dd2b66887f32f7d712c5f42e Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:00:12 -0600 Subject: [PATCH 16/19] test(bdd): share admissible absent-root fixture (#780) --- crates/graphforge-api/tests/bdd/api_steps.rs | 21 ++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/graphforge-api/tests/bdd/api_steps.rs b/crates/graphforge-api/tests/bdd/api_steps.rs index 2e3c57cc..6159432f 100644 --- a/crates/graphforge-api/tests/bdd/api_steps.rs +++ b/crates/graphforge-api/tests/bdd/api_steps.rs @@ -336,7 +336,9 @@ async fn given_papers_with_vectors(world: &mut GraphForgeWorld, dims: u32) { #[given(regex = r#"^a path that does not exist on disk$"#)] async fn given_nonexistent_path(world: &mut GraphForgeWorld) { world.forge = None; - world.last_error = Some("path does not exist".to_string()); + world.persistent_fixture = Some(tempfile::TempDir::new().expect("absent project parent")); + world.last_error = None; + world.last_error_code = None; } #[given(regex = r#"^a persistent graph backed by Parquet$"#)] @@ -1367,9 +1369,20 @@ async fn when_clear(world: &mut GraphForgeWorld) { #[when(regex = r#"^I open a graph at that path$"#)] async fn when_open_bad_path(world: &mut GraphForgeWorld) { - match graphforge_api::GraphForge::new(Some("/nonexistent/path/xyz")) { - Ok(f) => world.forge = Some(f), - Err(e) => world.last_error = Some(e.to_string()), + let path = world + .persistent_fixture + .as_ref() + .expect("absent project parent fixture") + .path() + .join("does_not_exist"); + world.last_error = None; + world.last_error_code = None; + match graphforge_api::GraphForge::new(Some(path.to_str().expect("UTF-8 fixture path"))) { + Ok(forge) => world.forge = Some(forge), + Err(error) => { + world.last_error_code = Some(error.code()); + world.last_error = Some(error.to_string()); + } } } From c1c632d44f6343a168971e81e04c4d12b2109109 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:25:19 -0600 Subject: [PATCH 17/19] fix(storage): release inherited checkpoint reads (#780) --- .../src/project_checkpoints.rs | 30 ++++++++++++++-- scripts/ci/bulk-construction-conformance.py | 20 +++++++++-- scripts/ci/concurrency-short-gate.py | 19 ++++++++-- .../ci/test-bulk-construction-conformance.py | 36 +++++++++++++++++++ scripts/ci/test-concurrency-short-gate.py | 36 +++++++++++++++++++ 5 files changed, 134 insertions(+), 7 deletions(-) diff --git a/crates/graphforge-storage/src/project_checkpoints.rs b/crates/graphforge-storage/src/project_checkpoints.rs index e1c20adb..c1f7c22e 100644 --- a/crates/graphforge-storage/src/project_checkpoints.rs +++ b/crates/graphforge-storage/src/project_checkpoints.rs @@ -204,6 +204,14 @@ struct MutationLocks { checkpoint: Option, } +struct CheckpointReadLock(File); + +impl Drop for CheckpointReadLock { + fn drop(&mut self) { + let _ = crate::file_lock::unlock(&self.0); + } +} + impl MutationLocks { fn transfer_writer_for_revert_publication(&mut self) -> File { self.writer @@ -1018,7 +1026,7 @@ fn acquire_mutation_locks(root: &Path) -> Result { }) } -fn acquire_checkpoint_read_lock(root: &Path) -> Result { +fn acquire_checkpoint_read_lock(root: &Path) -> Result { let lock_root = ensure_machine_directory(root, Path::new(LOCKS_DIR))?; let checkpoint = open_regular_lock(&lock_root.join(CHECKPOINT_LOCK_FILE))?; if !crate::file_lock::try_lock_shared(&checkpoint).map_err(storage_io)? { @@ -1027,13 +1035,13 @@ fn acquire_checkpoint_read_lock(root: &Path) -> Result { "checkpoint read could not acquire checkpoints.lock", )); } - Ok(checkpoint) + Ok(CheckpointReadLock(checkpoint)) } fn read_registry_for_read( root: &Path, checkpoint_root: &Path, -) -> Result<(File, Registry), GfError> { +) -> Result<(CheckpointReadLock, Registry), GfError> { let checkpoint = acquire_checkpoint_read_lock(root)?; if !checkpoint_root.join(INTENT_FILE).exists() { return read_registry(checkpoint_root).map(|registry| (checkpoint, registry)); @@ -2581,6 +2589,22 @@ mod tests { assert_checkpoint_mutation_busy_message(&error); } + #[test] + fn checkpoint_read_guard_unlocks_before_a_cloned_descriptor_closes() { + let directory = tempdir().unwrap(); + crate::open_or_initialize_project(directory.path()).unwrap(); + let read_lock = acquire_checkpoint_read_lock(directory.path()).unwrap(); + let inherited_descriptor = read_lock.0.try_clone().unwrap(); + + drop(read_lock); + + assert_mutation_locks_free( + directory.path(), + "after checkpoint read guard drop with a cloned descriptor", + ); + drop(inherited_descriptor); + } + #[test] fn open_list_then_mutation_leaves_checkpoint_locks_free() { // Rules out a same-thread shared-lock leak on the #275 failing sequence diff --git a/scripts/ci/bulk-construction-conformance.py b/scripts/ci/bulk-construction-conformance.py index a0e681e4..86a60a1e 100644 --- a/scripts/ci/bulk-construction-conformance.py +++ b/scripts/ci/bulk-construction-conformance.py @@ -13,6 +13,7 @@ import os from pathlib import Path import platform +import re import subprocess import sys import tempfile @@ -25,6 +26,7 @@ NODE_TEST = ROOT / "crates/graphforge-bindings-node/tests/bulk_construction.test.mjs" PARITY_TEST = ROOT / "scripts/ci/bulk-construction-parity.py" CASE_TIMEOUT_SECONDS = 900 +PERSISTENT_ADMISSION_LOCK_NAME = re.compile(r"\.graphforge-admission-[0-9a-f]{64}\.lock\Z") REQUIRED_CASES: dict[str, tuple[str, list[str]]] = { "rust-bulk-construction-lib": ( @@ -205,6 +207,20 @@ def git_head() -> str: return (completed.stdout or "").strip() or "unknown" +def unexpected_lock_artifacts(work: Path) -> list[str]: + """Return lock artifacts other than durable lifecycle rendezvous files.""" + unexpected = [] + for path in work.rglob("*.lock"): + is_file = path.is_file() + is_symlink = path.is_symlink() + if not is_file and not is_symlink: + continue + if is_file and not is_symlink and PERSISTENT_ADMISSION_LOCK_NAME.fullmatch(path.name): + continue + unexpected.append(str(path.relative_to(work))) + return sorted(unexpected) + + def run_case( case: dict[str, Any], env: dict[str, str], @@ -237,9 +253,9 @@ def run_case( ) if completed.returncode != 0: raise GateError(f"{case_id}: command failed exit={completed.returncode} log={log_path}") - leftover_locks = sorted(path.name for path in work.rglob("*.lock") if path.is_file()) + leftover_locks = unexpected_lock_artifacts(work) if leftover_locks: - raise GateError(f"{case_id}: leaked lock files under work root: {leftover_locks}") + raise GateError(f"{case_id}: leaked transient locks under work root: {leftover_locks}") return { "id": case_id, "surface": case["surface"], diff --git a/scripts/ci/concurrency-short-gate.py b/scripts/ci/concurrency-short-gate.py index 332276e8..22ded62c 100644 --- a/scripts/ci/concurrency-short-gate.py +++ b/scripts/ci/concurrency-short-gate.py @@ -24,6 +24,7 @@ NODE_TEST = ROOT / "crates/graphforge-bindings-node/tests/concurrency-parity.test.mjs" CASE_TIMEOUT_SECONDS = 300 FORBIDDEN_TIMING = re.compile(r"\b(?:time\.sleep|asyncio\.sleep|setTimeout)\s*\(") +PERSISTENT_ADMISSION_LOCK_NAME = re.compile(r"\.graphforge-admission-[0-9a-f]{64}\.lock\Z") # Required id → (surface, argv). Additional cases are allowed; these must match exactly. REQUIRED_CASES: dict[str, tuple[str, list[str]]] = { @@ -270,6 +271,20 @@ def git_head() -> str: return (completed.stdout or "").strip() or "unknown" +def unexpected_lock_artifacts(work: Path) -> list[str]: + """Return lock artifacts other than durable lifecycle rendezvous files.""" + unexpected = [] + for path in work.rglob("*.lock"): + is_file = path.is_file() + is_symlink = path.is_symlink() + if not is_file and not is_symlink: + continue + if is_file and not is_symlink and PERSISTENT_ADMISSION_LOCK_NAME.fullmatch(path.name): + continue + unexpected.append(str(path.relative_to(work))) + return sorted(unexpected) + + def run_case( case: dict[str, Any], env: dict[str, str], @@ -302,7 +317,7 @@ def run_case( ) if completed.returncode != 0: raise GateError(f"{case_id}: command failed exit={completed.returncode} log={log_path}") - leftover_locks = sorted(path.name for path in work.rglob("*.lock") if path.is_file()) + leftover_locks = unexpected_lock_artifacts(work) leftover_staging = sorted( str(path.relative_to(work)) for path in work.rglob("*") @@ -310,7 +325,7 @@ def run_case( ) if leftover_locks or leftover_staging: raise GateError( - f"{case_id}: leaked lock/staging under work root " + f"{case_id}: leaked transient lock/staging under work root " f"locks={leftover_locks} staging={leftover_staging}" ) return { diff --git a/scripts/ci/test-bulk-construction-conformance.py b/scripts/ci/test-bulk-construction-conformance.py index 7879ec5a..e861163d 100644 --- a/scripts/ci/test-bulk-construction-conformance.py +++ b/scripts/ci/test-bulk-construction-conformance.py @@ -34,6 +34,40 @@ def reject(matrix: dict) -> None: assert GATE.MATRIX_PATH.read_text(encoding="utf-8") == original +def assert_persistent_admission_lock_contract() -> None: + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + nested = work / "case" + nested.mkdir() + admission_lock = nested / f".graphforge-admission-{'a' * 64}.lock" + admission_lock.touch() + assert GATE.unexpected_lock_artifacts(work) == [] + + uppercase_dir = work / "uppercase" + uppercase_dir.mkdir() + unexpected = [ + nested / "writer.lock", + nested / f".graphforge-admission-{'a' * 63}.lock", + uppercase_dir / f".graphforge-admission-{'A' * 64}.lock", + nested / ".graphforge-admission-not-a-digest.lock", + ] + for path in unexpected: + path.touch() + + expected = sorted(str(path.relative_to(work)) for path in unexpected) + assert GATE.unexpected_lock_artifacts(work) == expected + + symlink = nested / f".graphforge-admission-{'b' * 64}.lock" + try: + symlink.symlink_to(admission_lock) + except (NotImplementedError, OSError): + pass + else: + assert GATE.unexpected_lock_artifacts(work) == sorted( + [*expected, str(symlink.relative_to(work))] + ) + + def main() -> None: matrix = GATE.validate_matrix() assert matrix["issue"] == 2552 @@ -57,6 +91,8 @@ def main() -> None: raise AssertionError("python-bulk-acceptance case missing") reject(mutated) + assert_persistent_admission_lock_contract() + print("bulk construction conformance mutation tests passed") diff --git a/scripts/ci/test-concurrency-short-gate.py b/scripts/ci/test-concurrency-short-gate.py index 170dc51a..8e30389e 100644 --- a/scripts/ci/test-concurrency-short-gate.py +++ b/scripts/ci/test-concurrency-short-gate.py @@ -34,6 +34,40 @@ def reject(matrix: dict) -> None: assert GATE.MATRIX_PATH.read_text(encoding="utf-8") == original +def assert_persistent_admission_lock_contract() -> None: + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + nested = work / "case" + nested.mkdir() + admission_lock = nested / f".graphforge-admission-{'a' * 64}.lock" + admission_lock.touch() + assert GATE.unexpected_lock_artifacts(work) == [] + + uppercase_dir = work / "uppercase" + uppercase_dir.mkdir() + unexpected = [ + nested / "writer.lock", + nested / f".graphforge-admission-{'a' * 63}.lock", + uppercase_dir / f".graphforge-admission-{'A' * 64}.lock", + nested / ".graphforge-admission-not-a-digest.lock", + ] + for path in unexpected: + path.touch() + + expected = sorted(str(path.relative_to(work)) for path in unexpected) + assert GATE.unexpected_lock_artifacts(work) == expected + + symlink = nested / f".graphforge-admission-{'b' * 64}.lock" + try: + symlink.symlink_to(admission_lock) + except (NotImplementedError, OSError): + pass + else: + assert GATE.unexpected_lock_artifacts(work) == sorted( + [*expected, str(symlink.relative_to(work))] + ) + + def main() -> None: matrix = GATE.validate_matrix() assert matrix["issue"] == 2417 @@ -66,6 +100,8 @@ def main() -> None: raise AssertionError("required rust-same-instance case missing from matrix") reject(mutated_argv) + assert_persistent_admission_lock_contract() + print("concurrency short gate mutation tests passed") From 7ac7483122a3c285cf5a433caf51c4ce80028e02 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:40:41 -0600 Subject: [PATCH 18/19] fix(storage): close lifecycle review races (#780) --- .../src/filesystem_admission.rs | 74 +++++++++++- .../src/project_publication.rs | 106 +++++++++++++++++- 2 files changed, 170 insertions(+), 10 deletions(-) diff --git a/crates/graphforge-storage/src/filesystem_admission.rs b/crates/graphforge-storage/src/filesystem_admission.rs index 037d3cd2..93fcd654 100644 --- a/crates/graphforge-storage/src/filesystem_admission.rs +++ b/crates/graphforge-storage/src/filesystem_admission.rs @@ -336,11 +336,12 @@ fn admit_project_lifecycle_inner( if requirement == ProjectRootRequirement::Existing { return Err(unsupported("IDENTITY", "target_missing")); } - create_private_child_directory(&parent, &target_name, &root) - .map_err(|_| unsupported("CREATE", "project_directory_create_failed"))?; - complete_namespace_barrier_handle(&parent) - .map_err(|_| unsupported("CREATE", "parent_namespace_barrier_failed"))?; - created_root = true; + created_root = create_missing_project_root_with( + &parent, + &target_name, + || create_private_child_directory(&parent, &target_name, &root), + || complete_namespace_barrier_handle(&parent), + )?; } Err(_) => return Err(unsupported("IDENTITY", "target_metadata_unavailable")), } @@ -372,6 +373,34 @@ fn admit_project_lifecycle_inner( Ok(admission) } +fn create_missing_project_root_with( + parent: &LifecycleDirectory, + target_name: &std::ffi::OsStr, + create: Create, + creator_barrier: Barrier, +) -> Result +where + Create: FnOnce() -> std::io::Result<()>, + Barrier: FnOnce() -> std::io::Result<()>, +{ + match create() { + Ok(()) => { + creator_barrier() + .map_err(|_| unsupported("CREATE", "parent_namespace_barrier_failed"))?; + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let metadata = child_metadata(parent, target_name) + .map_err(|_| unsupported("IDENTITY", "target_metadata_unavailable"))?; + if is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(unsupported("IDENTITY", "target_link_or_special")); + } + Ok(false) + } + Err(_) => Err(unsupported("CREATE", "project_directory_create_failed")), + } +} + #[derive(Debug)] struct LifecycleDirectory { path: PathBuf, @@ -1870,6 +1899,41 @@ mod tests { assert!(lock.is_file()); } + #[test] + fn concurrent_already_exists_directory_is_reopened_without_creator_credit() { + let parent = canonical_tempdir(); + let target = parent.path().join("project"); + let ResolvedProjectPath { + parent, + target_name, + root, + } = resolve_project_path(&target).unwrap(); + + let created_root = create_missing_project_root_with( + &parent, + &target_name, + || { + graphforge_filesystem::create_private_directory(&root).unwrap(); + Err(std::io::ErrorKind::AlreadyExists.into()) + }, + || panic!("a concurrent creator owns the namespace barrier"), + ) + .unwrap(); + + assert!(!created_root); + let project = LifecycleDirectory::open_child( + &parent, + &target_name, + &root, + "IDENTITY", + "project_identity_unavailable", + ) + .unwrap(); + project + .revalidate("IDENTITY", "project_identity_changed") + .unwrap(); + } + #[test] fn ephemeral_lifecycle_is_an_explicit_probe_and_lock_bypass() { let parent = canonical_tempdir(); diff --git a/crates/graphforge-storage/src/project_publication.rs b/crates/graphforge-storage/src/project_publication.rs index 4fefa021..196b7336 100644 --- a/crates/graphforge-storage/src/project_publication.rs +++ b/crates/graphforge-storage/src/project_publication.rs @@ -166,6 +166,8 @@ enum PublicationLock { Optimistic(File), } +struct CommitLock(File); + enum StagedAdmission { Exclusive(crate::filesystem_admission::ProjectRootIdentity), Optimistic(Option), @@ -218,6 +220,12 @@ impl Drop for PublicationLock { } } +impl Drop for CommitLock { + fn drop(&mut self) { + let _ = crate::file_lock::unlock(&self.0); + } +} + /// Canonical revert metadata persisted in every ADR 0015 journal phase. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -384,7 +392,7 @@ pub(crate) fn stage_project_generation_from_admitted_parent( let root = canonical_supported_root(admission.root())?; if parent.container_root() != root { return Err(project_error( - ProjectErrorCode::UnsupportedFilesystem, + ProjectErrorCode::PublicationFailed, "prepared generation does not belong to the admitted project root", )); } @@ -1145,10 +1153,21 @@ impl ValidatedProjectGeneration { /// # Errors /// Returns a stable publication error whose diagnostic states whether the /// commit point was crossed. - pub fn publish(mut self) -> Result { + pub fn publish(self) -> Result { + self.publish_with_commit_lock_hook(|_| Ok(())) + } + + fn publish_with_commit_lock_hook( + mut self, + after_commit_lock: AfterCommitLock, + ) -> Result + where + AfterCommitLock: FnOnce(&StagedProjectGeneration) -> Result<(), GfError>, + { self.0.admission.revalidate_identity()?; - let lifecycle_admission = self.0.admission.readmit_for_publish()?; let commit_lock = self.prepare_commit_lock()?; + after_commit_lock(&self.0)?; + let lifecycle_admission = self.0.admission.readmit_for_publish()?; if let Some(admission) = &lifecycle_admission { admission.revalidate_identity()?; } else { @@ -1172,7 +1191,7 @@ impl ValidatedProjectGeneration { result } - fn prepare_commit_lock(&self) -> Result, GfError> { + fn prepare_commit_lock(&self) -> Result, GfError> { if matches!(self.0.publication_lock, PublicationLock::Exclusive(_)) { return Ok(None); } @@ -1199,7 +1218,7 @@ impl ValidatedProjectGeneration { ), )); } - Ok(Some(writer_lock)) + Ok(Some(CommitLock(writer_lock))) } fn publish_inner(&self) -> Result { @@ -2805,6 +2824,32 @@ mod tests { assert!(parent.generation_root().exists()); } + #[test] + fn admitted_parent_from_another_root_is_a_publication_failure() { + let admitted_root = project(); + let other_root = project(); + let admission = crate::filesystem_admission::admit_project_lifecycle( + admitted_root.path(), + crate::filesystem_admission::ProjectLifecycleMode::Durable, + crate::filesystem_admission::ProjectRootRequirement::Existing, + ) + .unwrap(); + let other_parent = resolve_project_generation(other_root.path()).unwrap(); + let request = request(vec![participant("graph", "nodes", b"wrong-root")]); + + let error = + stage_project_generation_from_admitted_parent(admission, other_parent, &request, None) + .err() + .expect("a prepared parent from another root must fail"); + + assert_eq!(error.code(), "GF_PUBLICATION_FAILED"); + assert!( + error + .to_string() + .contains("prepared generation does not belong") + ); + } + #[test] fn optimistic_attempts_stage_concurrently_and_compare_parent_at_commit() { let root = project(); @@ -2859,6 +2904,42 @@ mod tests { ); } + #[test] + fn optimistic_publish_holds_writer_before_consuming_lifecycle_identity() { + let root = project(); + let request = request(vec![participant("graph", "nodes", b"ordered")]); + let operation: [u8; 32] = Sha256::digest(b"commit-lock-before-readmission").into(); + let ProjectStageOutcome::Staged(staged) = + stage_project_generation_optimistic(root.path(), &request, operation).unwrap() + else { + panic!("optimistic operation replayed unexpectedly"); + }; + let validated = staged.validate(|_| Ok(()), |_, _| Ok(())).unwrap(); + + validated + .publish_with_commit_lock_hook(|staged| { + assert!(matches!( + &staged.admission, + StagedAdmission::Optimistic(Some(_)) + )); + let contender = + open_regular_lock(&staged.root.join(LOCKS_DIR).join(WRITER_LOCK_FILE))?; + assert!( + !crate::file_lock::try_lock_exclusive(&contender).map_err(publication_io)?, + "writer must be unavailable before lifecycle readmission" + ); + Ok(()) + }) + .unwrap(); + + assert_eq!( + resolve_project_generation(root.path()) + .unwrap() + .generation_uuid(), + request.generation_uuid + ); + } + #[test] fn optimistic_validation_conflict_aborts_only_its_own_rebase_attempt() { let root = project(); @@ -3048,6 +3129,21 @@ mod tests { ); } + #[test] + fn commit_lock_guard_unlocks_before_a_cloned_descriptor_closes() { + let root = project(); + let writer = wait_for_writer_lock(root.path()).unwrap(); + let inherited_descriptor = writer.try_clone().unwrap(); + + drop(CommitLock(writer)); + + let contender = + open_regular_lock(&root.path().join(LOCKS_DIR).join(WRITER_LOCK_FILE)).unwrap(); + assert!(crate::file_lock::try_lock_exclusive(&contender).unwrap()); + crate::file_lock::unlock(&contender).unwrap(); + drop(inherited_descriptor); + } + #[test] fn malformed_generation_contracts_fail_before_staging_or_current_change() { let root = project(); From a80cb4aae8afed64f225e6667894c0e41bd53237 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:43:51 -0600 Subject: [PATCH 19/19] fix(storage): preserve admitted commit ordering (#780) --- .../src/project_publication.rs | 55 ++----------------- 1 file changed, 4 insertions(+), 51 deletions(-) diff --git a/crates/graphforge-storage/src/project_publication.rs b/crates/graphforge-storage/src/project_publication.rs index 196b7336..b9b969b3 100644 --- a/crates/graphforge-storage/src/project_publication.rs +++ b/crates/graphforge-storage/src/project_publication.rs @@ -1153,21 +1153,10 @@ impl ValidatedProjectGeneration { /// # Errors /// Returns a stable publication error whose diagnostic states whether the /// commit point was crossed. - pub fn publish(self) -> Result { - self.publish_with_commit_lock_hook(|_| Ok(())) - } - - fn publish_with_commit_lock_hook( - mut self, - after_commit_lock: AfterCommitLock, - ) -> Result - where - AfterCommitLock: FnOnce(&StagedProjectGeneration) -> Result<(), GfError>, - { + pub fn publish(mut self) -> Result { self.0.admission.revalidate_identity()?; - let commit_lock = self.prepare_commit_lock()?; - after_commit_lock(&self.0)?; let lifecycle_admission = self.0.admission.readmit_for_publish()?; + let commit_lock = self.prepare_commit_lock()?; if let Some(admission) = &lifecycle_admission { admission.revalidate_identity()?; } else { @@ -1196,7 +1185,7 @@ impl ValidatedProjectGeneration { return Ok(None); } let staged = &self.0; - let writer_lock = wait_for_writer_lock(&staged.root)?; + let writer_lock = CommitLock(wait_for_writer_lock(&staged.root)?); project_failpoint::hit( "project.after_optimistic_commit_lock", Some(staged.transaction_uuid), @@ -1218,7 +1207,7 @@ impl ValidatedProjectGeneration { ), )); } - Ok(Some(CommitLock(writer_lock))) + Ok(Some(writer_lock)) } fn publish_inner(&self) -> Result { @@ -2904,42 +2893,6 @@ mod tests { ); } - #[test] - fn optimistic_publish_holds_writer_before_consuming_lifecycle_identity() { - let root = project(); - let request = request(vec![participant("graph", "nodes", b"ordered")]); - let operation: [u8; 32] = Sha256::digest(b"commit-lock-before-readmission").into(); - let ProjectStageOutcome::Staged(staged) = - stage_project_generation_optimistic(root.path(), &request, operation).unwrap() - else { - panic!("optimistic operation replayed unexpectedly"); - }; - let validated = staged.validate(|_| Ok(()), |_, _| Ok(())).unwrap(); - - validated - .publish_with_commit_lock_hook(|staged| { - assert!(matches!( - &staged.admission, - StagedAdmission::Optimistic(Some(_)) - )); - let contender = - open_regular_lock(&staged.root.join(LOCKS_DIR).join(WRITER_LOCK_FILE))?; - assert!( - !crate::file_lock::try_lock_exclusive(&contender).map_err(publication_io)?, - "writer must be unavailable before lifecycle readmission" - ); - Ok(()) - }) - .unwrap(); - - assert_eq!( - resolve_project_generation(root.path()) - .unwrap() - .generation_uuid(), - request.generation_uuid - ); - } - #[test] fn optimistic_validation_conflict_aborts_only_its_own_rebase_attempt() { let root = project();