From aab289a1e003d57a0a440dd4906a1612066bfabb Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 13:34:18 +0800 Subject: [PATCH] fix(store): verify restored story snapshots --- crates/nana-runtime/src/lib.rs | 6 +- crates/nana-store/src/lib.rs | 218 +++++++++++++++++++++++++++++++-- 2 files changed, 215 insertions(+), 9 deletions(-) diff --git a/crates/nana-runtime/src/lib.rs b/crates/nana-runtime/src/lib.rs index a9fc2c4..60945a6 100644 --- a/crates/nana-runtime/src/lib.rs +++ b/crates/nana-runtime/src/lib.rs @@ -339,6 +339,9 @@ fn map_store_error(error: StoreError) -> TurnFailure { StoreError::NodeAlreadyExists(_) | StoreError::ParentNotFound(_) | StoreError::StateMismatch(_) + | StoreError::StateHashMismatch { .. } + | StoreError::Sqlite(_) + | StoreError::Serialization(_) | StoreError::Poisoned => internal_failure("turn could not be committed"), } } @@ -816,7 +819,8 @@ mod persistent_turn_tests { user_input: String::new(), beats: Vec::new(), delta: StateDelta { ops: Vec::new() }, - state_hash: format!("hash_{id}"), + state_hash: hash_runtime_state(&state(id, branch)) + .expect("serializable test state"), } } diff --git a/crates/nana-store/src/lib.rs b/crates/nana-store/src/lib.rs index 420a10d..d9d8aa5 100644 --- a/crates/nana-store/src/lib.rs +++ b/crates/nana-store/src/lib.rs @@ -4,7 +4,7 @@ use std::{ sync::{Mutex, MutexGuard}, }; -use nana_domain::{RuntimeState, StoryNode}; +use nana_domain::{RuntimeState, StoryNode, stable_json_hash}; use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; use thiserror::Error; @@ -22,6 +22,8 @@ pub enum StoreError { StaleBranchHead { expected: String, actual: String }, #[error("node and materialized state disagree: {0}")] StateMismatch(&'static str), + #[error("materialized state hash does not match node: {node_id}")] + StateHashMismatch { node_id: String }, #[error("sqlite storage error: {0}")] Sqlite(String), #[error("JSON serialization error: {0}")] @@ -46,6 +48,8 @@ pub trait StoryStore: Send + Sync { fn append_node(&self, node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError>; fn load_state(&self, story_id: &str, branch_id: &str) -> Result; + + fn load_node(&self, story_id: &str, node_id: &str) -> Result; } #[derive(Debug, Default)] @@ -169,6 +173,14 @@ impl StoryStore for InMemoryStoryStore { .cloned() .ok_or(StoreError::StateMismatch("branch head has no state")) } + + fn load_node(&self, story_id: &str, node_id: &str) -> Result { + let data = self.lock()?; + data.nodes + .get(&(story_id.to_owned(), node_id.to_owned())) + .cloned() + .ok_or_else(|| StoreError::ParentNotFound(node_id.to_owned())) + } } /// Durable SQLite implementation of the append-only story store. @@ -222,7 +234,8 @@ impl SqliteStoryStore { let connection = self.lock()?; let stored = connection .query_row( - "SELECT nodes.branch_id, materialized_states.state_json + "SELECT nodes.branch_id, nodes.parent_id, nodes.node_json, + materialized_states.state_json FROM nodes LEFT JOIN materialized_states ON materialized_states.story_id = nodes.story_id @@ -233,17 +246,22 @@ impl SqliteStoryStore { Ok(( row.get::<_, String>(0)?, row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, )) }, ) .optional()? .ok_or_else(|| StoreError::ParentNotFound(node_id.to_owned()))?; + let node = deserialize_node(&stored.2)?; + validate_loaded_node(&node, story_id, node_id, &stored.0, stored.1.as_deref())?; let state_json = stored - .1 + .3 .ok_or(StoreError::StateMismatch("node has no materialized state"))?; let state = deserialize_state(&state_json)?; validate_loaded_state(&state, story_id, node_id, &stored.0)?; + validate_state_hash(&node, &state)?; Ok(state) } @@ -364,8 +382,13 @@ impl StoryStore for SqliteStoryStore { let stored = connection .query_row( - "SELECT branch_heads.head_node_id, materialized_states.state_json + "SELECT branch_heads.head_node_id, nodes.branch_id, nodes.parent_id, + nodes.node_json, materialized_states.state_json FROM branch_heads + JOIN nodes + ON nodes.story_id = branch_heads.story_id + AND nodes.node_id = branch_heads.head_node_id + AND nodes.branch_id = branch_heads.branch_id LEFT JOIN materialized_states ON materialized_states.story_id = branch_heads.story_id AND materialized_states.node_id = branch_heads.head_node_id @@ -375,7 +398,10 @@ impl StoryStore for SqliteStoryStore { |row| { Ok(( row.get::<_, String>(0)?, - row.get::<_, Option>(1)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, String>(3)?, + row.get::<_, Option>(4)?, )) }, ) @@ -385,13 +411,45 @@ impl StoryStore for SqliteStoryStore { branch_id: branch_id.to_owned(), })?; + let node = deserialize_node(&stored.3)?; + validate_loaded_node( + &node, + story_id, + &stored.0, + &stored.1, + stored.2.as_deref(), + )?; let state_json = stored - .1 + .4 .ok_or(StoreError::StateMismatch("branch head has no state"))?; let state = deserialize_state(&state_json)?; validate_loaded_state(&state, story_id, &stored.0, branch_id)?; + validate_state_hash(&node, &state)?; Ok(state) } + + fn load_node(&self, story_id: &str, node_id: &str) -> Result { + let connection = self.lock()?; + let stored = connection + .query_row( + "SELECT branch_id, parent_id, node_json + FROM nodes + WHERE story_id = ?1 AND node_id = ?2", + params![story_id, node_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional()? + .ok_or_else(|| StoreError::ParentNotFound(node_id.to_owned()))?; + let node = deserialize_node(&stored.2)?; + validate_loaded_node(&node, story_id, node_id, &stored.0, stored.1.as_deref())?; + Ok(node) + } } fn initialize_schema(connection: &Connection) -> Result<(), StoreError> { @@ -453,13 +511,40 @@ fn validate_materialized_state( if node.branch_id != state.current_branch { return Err(StoreError::StateMismatch("current_branch")); } + validate_state_hash(node, state)?; Ok(()) } +fn deserialize_node(node_json: &str) -> Result { + serde_json::from_str(node_json).map_err(StoreError::from) +} + fn deserialize_state(state_json: &str) -> Result { serde_json::from_str(state_json).map_err(StoreError::from) } +fn validate_loaded_node( + node: &StoryNode, + story_id: &str, + node_id: &str, + branch_id: &str, + parent_id: Option<&str>, +) -> Result<(), StoreError> { + if node.story_id != story_id { + return Err(StoreError::StateMismatch("story_id")); + } + if node.id != node_id { + return Err(StoreError::StateMismatch("current_node")); + } + if node.branch_id != branch_id { + return Err(StoreError::StateMismatch("current_branch")); + } + if node.parent_id.as_deref() != parent_id { + return Err(StoreError::StateMismatch("parent_id")); + } + Ok(()) +} + fn validate_loaded_state( state: &RuntimeState, story_id: &str, @@ -478,6 +563,16 @@ fn validate_loaded_state( Ok(()) } +fn validate_state_hash(node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError> { + let bytes = serde_json::to_vec(state)?; + if stable_json_hash(&bytes) != node.state_hash { + return Err(StoreError::StateHashMismatch { + node_id: node.id.clone(), + }); + } + Ok(()) +} + #[cfg(test)] mod tests { use std::{ @@ -487,7 +582,7 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use nana_domain::{RuntimeState, StateDelta, StoryNode}; + use nana_domain::{RuntimeState, StateDelta, StoryNode, stable_json_hash}; use rusqlite::params; use super::{InMemoryStoryStore, SqliteStoryStore, StoreError, StoryStore}; @@ -509,6 +604,7 @@ mod tests { } fn node(id: &str, parent_id: Option<&str>, branch: &str) -> StoryNode { + let materialized = state(id, branch); StoryNode { id: id.to_owned(), story_id: "story_demo".to_owned(), @@ -518,7 +614,9 @@ mod tests { user_input: String::new(), beats: Vec::new(), delta: StateDelta { ops: Vec::new() }, - state_hash: format!("hash_{id}"), + state_hash: stable_json_hash( + &serde_json::to_vec(&materialized).expect("serializable test state"), + ), } } @@ -600,6 +698,14 @@ mod tests { .current_node, "node_001" ); + assert_eq!( + store + .load_node("story_demo", "node_002") + .expect("stored node") + .parent_id + .as_deref(), + Some("node_001") + ); } fn assert_forks_from_an_old_node(store: &impl InspectableStoryStore) { @@ -814,6 +920,102 @@ mod tests { )); } + #[test] + fn sqlite_rejects_valid_state_json_with_a_mismatched_hash() { + let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store"); + store + .append_node( + &node("node_001", None, "branch_main"), + &state("node_001", "branch_main"), + ) + .expect("root append"); + let mut corrupted = state("node_001", "branch_main"); + corrupted + .world_flags + .insert("silently_changed".to_owned(), true); + let corrupted_json = serde_json::to_string(&corrupted).expect("corrupt test JSON"); + store + .connection + .lock() + .expect("SQLite connection lock") + .execute( + "UPDATE materialized_states SET state_json = ?1", + params![corrupted_json], + ) + .expect("corrupt test state"); + + assert_eq!( + store.load_state("story_demo", "branch_main"), + Err(StoreError::StateHashMismatch { + node_id: "node_001".to_owned() + }) + ); + } + + #[test] + fn sqlite_rejects_node_json_that_disagrees_with_structure_columns() { + let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store"); + store + .append_node( + &node("node_001", None, "branch_main"), + &state("node_001", "branch_main"), + ) + .expect("root append"); + let mut corrupted = node("node_001", None, "branch_main"); + corrupted.branch_id = "hidden_branch".to_owned(); + let corrupted_json = serde_json::to_string(&corrupted).expect("corrupt node JSON"); + store + .connection + .lock() + .expect("SQLite connection lock") + .execute( + "UPDATE nodes SET node_json = ?1", + params![corrupted_json], + ) + .expect("corrupt test node"); + + assert_eq!( + store.load_node("story_demo", "node_001"), + Err(StoreError::StateMismatch("current_branch")) + ); + } + + #[test] + fn sqlite_rejects_a_branch_head_pointing_at_another_branch() { + let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store"); + store + .append_node( + &node("node_001", None, "branch_main"), + &state("node_001", "branch_main"), + ) + .expect("root append"); + store + .append_node( + &node("node_other", Some("node_001"), "branch_other"), + &state("node_other", "branch_other"), + ) + .expect("other branch append"); + store + .connection + .lock() + .expect("SQLite connection lock") + .execute( + "UPDATE branch_heads + SET head_node_id = 'node_other' + WHERE story_id = 'story_demo' AND branch_id = 'branch_main'", + [], + ) + .expect("corrupt branch head"); + + assert_eq!( + store.load_state("story_demo", "branch_main"), + Err(StoreError::BranchNotFound { + story_id: "story_demo".to_owned(), + branch_id: "branch_main".to_owned() + }) + ); + } + struct TemporaryDatabase { path: PathBuf, }