diff --git a/crates/content-history-adapters/examples/candidates.rs b/crates/content-history-adapters/examples/candidates.rs new file mode 100644 index 000000000..a68fe1e38 --- /dev/null +++ b/crates/content-history-adapters/examples/candidates.rs @@ -0,0 +1,39 @@ +use config_source::{ConfigContentType, ConfigEntry, ConfigTreeSnapshot, VirtualPath}; +use content_history_adapters::{settings, ticket}; + +fn main() -> Result<(), Box> { + let sources = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + VirtualPath::parse("nested/main.dcdl")?, + ConfigContentType::Decodal, + "// preserve this comment\r\n{}\r\n", + )?])?; + let config = settings::prepare(&sources)?; + assert_eq!( + settings::restore(config.root, |id| config.get(id))?, + sources + ); + + let document = ticket::Document { + ticket_id: "T-example".into(), + title: "History contract".into(), + initial_author: "user:original".into(), + initial_body: "Full text\r\n".into(), + posts: vec![ticket::Post { + id: "stable-post-id".into(), + author: "user:other".into(), + body: "Reply\n".into(), + }], + }; + let candidate = ticket::prepare(&document)?; + assert_eq!( + ticket::restore(candidate.root, "T-example", |id| candidate.get(id))?, + document + ); + // Pass candidate.root and candidate.objects to store preparation in a caller-owned transaction. + // Do not interpret successful preparation as business adoption or change an authoritative head. + println!( + "config candidate: {}; Ticket candidate: {}", + config.root, candidate.root + ); + Ok(()) +} diff --git a/crates/content-history-adapters/src/settings.rs b/crates/content-history-adapters/src/settings.rs index bd0cea662..d934b8785 100644 --- a/crates/content-history-adapters/src/settings.rs +++ b/crates/content-history-adapters/src/settings.rs @@ -174,7 +174,10 @@ fn restore_directory( // A valid directory must lead to an as-yet unrestored metadata entry. This also // bounds traversal of adversarial shared subtrees by the existing entry/path limits. let child_prefix = format!("{path}/"); - if !metadata.keys().any(|key| key.as_str().starts_with(&child_prefix)) { + if !metadata + .keys() + .any(|key| key.as_str().starts_with(&child_prefix)) + { return Err(Error::Structure("config directory has no file metadata")); } restore_directory(child, path.as_str(), read, metadata, entries, total)? diff --git a/crates/content-history-adapters/tests/contracts.rs b/crates/content-history-adapters/tests/contracts.rs new file mode 100644 index 000000000..f3be04bc2 --- /dev/null +++ b/crates/content-history-adapters/tests/contracts.rs @@ -0,0 +1,513 @@ +use std::collections::BTreeMap; + +use config_source::{ConfigContentType, ConfigEntry, ConfigTreeSnapshot, VirtualPath}; +use content_history::{ContentId, Entry, History, Object, ObjectId, Tree, TreeId}; +use content_history_adapters::{Error, Snapshot, settings, ticket}; + +fn source(path: &str, kind: ConfigContentType, text: &str) -> ConfigEntry { + ConfigEntry::new(VirtualPath::parse(path).unwrap(), kind, text).unwrap() +} + +fn config() -> ConfigTreeSnapshot { + ConfigTreeSnapshot::from_entries([ + source( + "main.dcdl", + ConfigContentType::Decodal, + "// comment\r\n{ title: \"雪\" }\r\n", + ), + // Explicit metadata is intentionally independent of the filename extension. + source( + "nested/prompt.dcdl", + ConfigContentType::Text, + "# Prompt\r\n\r\n\r\n", + ), + source("nested/深い/empty.txt", ConfigContentType::Text, ""), + ]) + .unwrap() +} + +fn document() -> ticket::Document { + ticket::Document { + ticket_id: "T-835".into(), + title: "設定の履歴".into(), + initial_author: "user:original".into(), + initial_body: "\r\nbody\r\n".into(), + posts: vec![ + ticket::Post { + id: "post-20".into(), + author: "user:alice".into(), + body: "first\r\n".into(), + }, + ticket::Post { + id: "post-03".into(), + author: "user:bob".into(), + body: "次\n".into(), + }, + ], + } +} + +#[test] +fn version_one_fixed_root_and_full_source_fixtures() { + // Constants pin the adapter schema as well as the independent model's canonical encoding. + let config = settings::prepare(&config()).unwrap(); + assert_eq!( + config.root.to_string(), + "cbd69a2f456e9c854d24c04725ac400de76d17101d3d1b21b19190d3f24284c9" + ); + let ticket = ticket::prepare(&document()).unwrap(); + assert_eq!( + ticket.root.to_string(), + "12dfd2e11581ea17e797c29b476a2b03ad6c0561cbccb611123f37be0771ff37" + ); + let ContentId::Blob(source) = target( + tree(&config, child(&config, config.root, "files")), + "main.dcdl", + ) else { + unreachable!() + }; + assert_eq!( + source.to_string(), + "02e6cfe23152f0576d14a0806717168e544a7abb2309e64354f6bb4e62ee81bb" + ); + assert_eq!( + config.get(ObjectId::Blob(source)).unwrap(), + Object::Blob("// comment\r\n{ title: \"雪\" }\r\n".as_bytes().to_vec()) + ); +} + +#[test] +fn config_explicit_type_change_changes_only_metadata_branch() { + let original = config(); + let old = settings::prepare(&original).unwrap(); + let mut entries = original.entries.values().cloned().collect::>(); + entries[0].content_type = ConfigContentType::Text; + let candidate = ConfigTreeSnapshot::from_entries(entries).unwrap(); + let new = settings::prepare(&candidate).unwrap(); + assert_ne!(old.root, new.root); + assert_eq!( + child(&old, old.root, "files"), + child(&new, new.root, "files") + ); + assert_eq!( + settings::restore(new.root, |id| new.get(id)).unwrap(), + candidate + ); +} + +#[test] +fn config_restore_rejects_missing_surplus_metadata_and_empty_directories() { + let original = settings::prepare(&config()).unwrap(); + let mut invalid = original.clone(); + let empty = Object::Tree(Tree::map(Vec::new()).unwrap()); + let ObjectId::Tree(empty) = add(&mut invalid, empty) else { + unreachable!() + }; + invalid.root = change_field(&mut invalid, original.root, "types", ContentId::Tree(empty)); + assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err()); + invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(empty)); + assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err()); + + // An empty non-root directory is not representable by ConfigTreeSnapshot. + let ObjectId::Tree(files) = add( + &mut invalid, + Object::Tree( + Tree::map(vec![Entry { + key: "empty".into(), + target: ContentId::Tree(empty), + }]) + .unwrap(), + ), + ) else { + unreachable!() + }; + invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(files)); + assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err()); +} + +#[test] +fn raw_reader_surfaces_unsupported_model_formats_without_partial_restore() { + let snapshot = ticket::prepare(&document()).unwrap(); + let result = ticket::restore(snapshot.root, "T-835", |id| { + let mut bytes = snapshot.get(id)?.encode(); + bytes[5] = 99; + Ok(Object::decode(&bytes)?) + }); + assert!(matches!(result, Err(Error::Model(_)))); +} + +fn tree(snapshot: &Snapshot, id: TreeId) -> &Tree { + let Object::Tree(tree) = &snapshot.objects[&ObjectId::Tree(id)] else { + panic!("expected tree") + }; + tree +} + +fn target(tree: &Tree, key: &str) -> ContentId { + tree.entries().iter().find(|e| e.key == key).unwrap().target +} + +fn child(snapshot: &Snapshot, id: TreeId, key: &str) -> TreeId { + let ContentId::Tree(id) = target(tree(snapshot, id), key) else { + panic!("expected tree child") + }; + id +} + +fn add(snapshot: &mut Snapshot, object: Object) -> ObjectId { + let id = object.id(); + snapshot.objects.insert(id, object); + id +} + +fn change_field(snapshot: &mut Snapshot, id: TreeId, key: &str, replacement: ContentId) -> TreeId { + let entries = tree(snapshot, id) + .entries() + .iter() + .map(|item| Entry { + key: item.key.clone(), + target: if item.key == key { + replacement + } else { + item.target + }, + }) + .collect(); + let ObjectId::Tree(id) = add(snapshot, Object::Tree(Tree::map(entries).unwrap())) else { + unreachable!() + }; + id +} + +fn blob(snapshot: &mut Snapshot, bytes: &[u8]) -> ContentId { + let ObjectId::Blob(id) = add(snapshot, Object::Blob(bytes.to_vec())) else { + unreachable!() + }; + ContentId::Blob(id) +} + +#[test] +fn config_roundtrip_preserves_sources_paths_types_and_metadata() { + let original = config(); + let snapshot = settings::prepare(&original).unwrap(); + let restored = settings::restore(snapshot.root, |id| snapshot.get(id)).unwrap(); + assert_eq!(original, restored); + // Directory tree uses actual names, not a flat list of full paths. + let files = child(&snapshot, snapshot.root, "files"); + assert_eq!( + tree(&snapshot, files) + .entries() + .iter() + .map(|e| e.key.as_str()) + .collect::>(), + ["main.dcdl", "nested"] + ); + let nested = child(&snapshot, files, "nested"); + assert!(matches!( + target(tree(&snapshot, nested), "prompt.dcdl"), + ContentId::Blob(_) + )); + let deep = child(&snapshot, nested, "深い"); + assert!(matches!( + target(tree(&snapshot, deep), "empty.txt"), + ContentId::Blob(_) + )); + // Reopening canonical bytes does not reserialize domain sources or alter root identity. + let reopened = snapshot + .objects + .iter() + .map(|(id, object)| (*id, Object::decode(&object.encode()).unwrap())) + .collect::>(); + assert_eq!( + settings::restore(snapshot.root, |id| reopened + .get(&id) + .cloned() + .ok_or(Error::Missing(id))) + .unwrap(), + original + ); + let reverse = + ConfigTreeSnapshot::from_entries(original.entries.values().rev().cloned()).unwrap(); + assert_eq!(snapshot.root, settings::prepare(&reverse).unwrap().root); +} + +#[test] +fn config_edits_names_types_additions_and_deletions_change_root_share_unchanged_blobs() { + let original = config(); + let old = settings::prepare(&original).unwrap(); + for entries in [ + vec![source("main.dcdl", ConfigContentType::Decodal, "changed\n")], + vec![source( + "renamed.dcdl", + ConfigContentType::Decodal, + "// comment\r\n{ title: \"雪\" }\r\n", + )], + vec![source( + "main.dcdl", + ConfigContentType::Text, + "// comment\r\n{ title: \"雪\" }\r\n", + )], + ] { + assert_ne!( + old.root, + settings::prepare(&ConfigTreeSnapshot::from_entries(entries).unwrap()) + .unwrap() + .root + ); + } + let mut edited = original.entries.values().cloned().collect::>(); + edited[0] = source("main.dcdl", ConfigContentType::Decodal, "new\r\n"); + edited.push(source("extra.txt", ConfigContentType::Text, "added")); + let new = settings::prepare(&ConfigTreeSnapshot::from_entries(edited).unwrap()).unwrap(); + let files_old = child(&old, old.root, "files"); + let files_new = child(&new, new.root, "files"); + assert_eq!( + target(tree(&old, files_old), "nested"), + target(tree(&new, files_new), "nested") + ); + let mut union = old.objects.clone(); + union.extend(new.objects); + assert_eq!( + settings::restore(old.root, |id| union + .get(&id) + .cloned() + .ok_or(Error::Missing(id))) + .unwrap(), + original + ); + let empty = settings::prepare(&ConfigTreeSnapshot::empty()).unwrap(); + assert_ne!(old.root, empty.root); + assert_eq!( + settings::restore(empty.root, |id| empty.get(id)).unwrap(), + ConfigTreeSnapshot::empty() + ); +} + +#[test] +fn config_rejects_inconsistent_snapshot_and_file_directory_conflicts() { + let mut invalid = config(); + invalid.digest.push('0'); + assert!(matches!(settings::prepare(&invalid), Err(Error::Config(_)))); + let mut invalid = config(); + invalid + .entries + .values_mut() + .next() + .unwrap() + .content + .push('!'); + assert!(matches!(settings::prepare(&invalid), Err(Error::Config(_)))); + let mut invalid = config(); + invalid.entries.values_mut().next().unwrap().path = VirtualPath::parse("other.dcdl").unwrap(); + assert!(matches!(settings::prepare(&invalid), Err(Error::Config(_)))); + let prefix_conflict = ConfigTreeSnapshot::from_entries([ + source("a", ConfigContentType::Text, "file"), + source("a/b", ConfigContentType::Text, "nested"), + ]) + .unwrap(); + assert!(matches!( + settings::prepare(&prefix_conflict), + Err(Error::PathConflict(_)) + )); +} + +#[test] +fn config_restore_rejects_invalid_utf8_metadata_paths_and_ordering() { + let original = settings::prepare(&config()).unwrap(); + let mut invalid = original.clone(); + let files = child(&invalid, invalid.root, "files"); + let bad = blob(&mut invalid, &[0xff]); + let files = change_field(&mut invalid, files, "main.dcdl", bad); + invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(files)); + assert!(matches!( + settings::restore(invalid.root, |id| invalid.get(id)), + Err(Error::Utf8(_)) + )); + + let mut invalid = original.clone(); + let types = child(&invalid, invalid.root, "types"); + let bad = blob(&mut invalid, b"unknown"); + let types = change_field(&mut invalid, types, "main.dcdl", bad); + invalid.root = change_field(&mut invalid, original.root, "types", ContentId::Tree(types)); + assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err()); + + for key in ["../escape", "a/b", "$builtin", ""] { + let mut invalid = original.clone(); + let value = blob(&mut invalid, b"bad"); + // Empty keys may already be rejected by the independent model. + let Ok(files) = Tree::map(vec![Entry { + key: key.into(), + target: value, + }]) else { + continue; + }; + let ObjectId::Tree(files) = add(&mut invalid, Object::Tree(files)) else { + unreachable!() + }; + invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(files)); + assert!( + settings::restore(invalid.root, |id| invalid.get(id)).is_err(), + "{key}" + ); + } + let mut invalid = original.clone(); + let files = child(&invalid, invalid.root, "files"); + let ordered = Tree::sequence(tree(&invalid, files).entries().to_vec()).unwrap(); + let ObjectId::Tree(files) = add(&mut invalid, Object::Tree(ordered)) else { + unreachable!() + }; + invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(files)); + assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err()); +} + +#[test] +fn ticket_roundtrip_explicit_structure_and_semantic_order() { + let original = document(); + let snapshot = ticket::prepare(&original).unwrap(); + assert_eq!( + ticket::restore(snapshot.root, "T-835", |id| snapshot.get(id)).unwrap(), + original + ); + assert_eq!( + tree(&snapshot, snapshot.root) + .entries() + .iter() + .map(|e| e.key.as_str()) + .collect::>(), + ["format", "initial", "posts", "ticket-id", "title"] + ); + let posts = child(&snapshot, snapshot.root, "posts"); + assert!(tree(&snapshot, posts).is_ordered()); + assert_eq!( + tree(&snapshot, posts) + .entries() + .iter() + .map(|e| e.key.as_str()) + .collect::>(), + ["post-20", "post-03"] + ); + let mut swapped = original.clone(); + swapped.posts.swap(0, 1); + assert_ne!(snapshot.root, ticket::prepare(&swapped).unwrap().root); + let mut other = original.clone(); + other.ticket_id = "T-836".into(); + assert_ne!(snapshot.root, ticket::prepare(&other).unwrap().root); + assert!(matches!( + ticket::restore(snapshot.root, "T-836", |id| snapshot.get(id)), + Err(Error::TicketIdentity) + )); + let mut edited = original; + edited.posts[0].body = "edited".into(); + let restored = ticket::restore(ticket::prepare(&edited).unwrap().root, "T-835", |id| { + ticket::prepare(&edited).unwrap().get(id) + }) + .unwrap(); + assert_eq!(restored.posts[0].id, "post-20"); + assert_eq!(restored.posts[0].author, "user:alice"); +} + +#[test] +fn restore_old_document_leaves_separate_business_events_and_original_authors_untouched() { + #[derive(Debug, PartialEq)] + struct BusinessState { + state: &'static str, + assignment: &'static str, + events: Vec<&'static str>, + } + struct Ticket { + document: ticket::Document, + business: BusinessState, + } + let original = document(); + let old = ticket::prepare(&original).unwrap(); + let mut current = Ticket { + document: original.clone(), + business: BusinessState { + state: "closed", + assignment: "worker:new", + events: vec!["relation added", "evidence attached"], + }, + }; + current.document.title = "changed".into(); + current.document = + ticket::restore(old.root, ¤t.document.ticket_id, |id| old.get(id)).unwrap(); + assert_eq!(current.document, original); + assert_eq!( + current.business, + BusinessState { + state: "closed", + assignment: "worker:new", + events: vec!["relation added", "evidence attached"] + } + ); + let first_edit = History { + root: old.root, + parent: None, + actor: "user:editor".into(), + timestamp_micros: 1, + }; + let ObjectId::History(first_id) = Object::History(first_edit.clone()).id() else { + unreachable!() + }; + let restore_edit = History { + root: old.root, + parent: Some(first_id), + actor: "user:restorer".into(), + timestamp_micros: 2, + }; + assert_ne!( + Object::History(first_edit).id(), + Object::History(restore_edit).id() + ); + assert_eq!(current.document.posts[0].author, "user:alice"); +} + +#[test] +fn readers_fail_closed_on_missing_corruption_wrong_kind_and_unknown_adapter_version() { + let snapshot = ticket::prepare(&document()).unwrap(); + for id in snapshot.objects.keys() { + assert!( + ticket::restore(snapshot.root, "T-835", |requested| { + if requested == *id { + Err(Error::Missing(requested)) + } else { + snapshot.get(requested) + } + }) + .is_err() + ); + } + assert!(matches!( + ticket::restore(snapshot.root, "T-835", |_| Ok(Object::Blob( + b"tampered".to_vec() + ))), + Err(Error::HashMismatch(_)) + )); + let mut invalid = snapshot.clone(); + let bad = blob(&mut invalid, b"ticket-document/v999"); + invalid.root = change_field(&mut invalid, snapshot.root, "format", bad); + assert!(matches!( + ticket::restore(invalid.root, "T-835", |id| invalid.get(id)), + Err(Error::Format) + )); + let bad = blob(&mut invalid, b"not a post sequence"); + invalid.root = change_field(&mut invalid, snapshot.root, "posts", bad); + assert!(ticket::restore(invalid.root, "T-835", |id| invalid.get(id)).is_err()); + let posts = child(&snapshot, snapshot.root, "posts"); + let unordered = Tree::map(tree(&snapshot, posts).entries().to_vec()).unwrap(); + let ObjectId::Tree(posts) = add(&mut invalid, Object::Tree(unordered)) else { + unreachable!() + }; + invalid.root = change_field(&mut invalid, snapshot.root, "posts", ContentId::Tree(posts)); + assert!(ticket::restore(invalid.root, "T-835", |id| invalid.get(id)).is_err()); +} + +#[test] +fn duplicate_business_post_ids_and_empty_ticket_ids_are_not_accepted() { + let mut invalid = document(); + invalid.posts[1].id = invalid.posts[0].id.clone(); + assert!(ticket::prepare(&invalid).is_err()); + invalid = document(); + invalid.ticket_id.clear(); + assert!(ticket::prepare(&invalid).is_err()); +} diff --git a/crates/content-history-store/src/lib.rs b/crates/content-history-store/src/lib.rs index 4c0d15f73..d4597a9c0 100644 --- a/crates/content-history-store/src/lib.rs +++ b/crates/content-history-store/src/lib.rs @@ -64,16 +64,23 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { } fn authorize(&self, access: Access) -> Result<()> { - if self.key.workspace.is_empty() || self.key.target.is_empty() + if self.key.workspace.is_empty() + || self.key.target.is_empty() || !self.authority.allows(&self.key, access) { return Err(Error::Denied); } - let erased: Option = self.tx.query_row( - "SELECT erased FROM content_scopes WHERE workspace=?1 AND target=?2", - params![self.key.workspace, self.key.target], |r| r.get(0), - ).optional()?; - if erased == Some(true) { return Err(Error::Erased); } + let erased: Option = self + .tx + .query_row( + "SELECT erased FROM content_scopes WHERE workspace=?1 AND target=?2", + params![self.key.workspace, self.key.target], + |r| r.get(0), + ) + .optional()?; + if erased == Some(true) { + return Err(Error::Erased); + } Ok(()) } @@ -91,12 +98,21 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { } fn head_inner(&self) -> Result> { - let raw: Option> = self.tx.query_row( - "SELECT head FROM content_scopes WHERE workspace=?1 AND target=?2", - params![self.key.workspace, self.key.target], |r| r.get(0), - ).optional()?.flatten(); - raw.map(|v| Ok(HistoryId::from_bytes(v.try_into().map_err(|_| Error::InvalidReference)?))) - .transpose() + let raw: Option> = self + .tx + .query_row( + "SELECT head FROM content_scopes WHERE workspace=?1 AND target=?2", + params![self.key.workspace, self.key.target], + |r| r.get(0), + ) + .optional()? + .flatten(); + raw.map(|v| { + Ok(HistoryId::from_bytes( + v.try_into().map_err(|_| Error::InvalidReference)?, + )) + }) + .transpose() } /// Preparation requires the full canonical bytes, not possession of another scope's hash. @@ -110,11 +126,18 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { fn put_inner(&self, id: ObjectId, bytes: &[u8]) -> Result<()> { self.ensure_scope()?; let (kind, hash) = parts(id); - self.tx.execute("INSERT OR IGNORE INTO content_objects(kind,hash,bytes) VALUES(?1,?2,?3)", - params![kind, hash, bytes])?; + self.tx.execute( + "INSERT OR IGNORE INTO content_objects(kind,hash,bytes) VALUES(?1,?2,?3)", + params![kind, hash, bytes], + )?; let stored: Vec = self.tx.query_row( - "SELECT bytes FROM content_objects WHERE kind=?1 AND hash=?2", params![kind, hash], |r| r.get(0))?; - if stored != bytes { return Err(Error::ImmutableMismatch); } + "SELECT bytes FROM content_objects WHERE kind=?1 AND hash=?2", + params![kind, hash], + |r| r.get(0), + )?; + if stored != bytes { + return Err(Error::ImmutableMismatch); + } self.tx.execute("INSERT OR IGNORE INTO content_scope_objects(workspace,target,kind,hash) VALUES(?1,?2,?3,?4)", params![self.key.workspace, self.key.target, kind, hash])?; Ok(()) @@ -128,7 +151,9 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { let allowed: bool = self.tx.query_row( "SELECT EXISTS(SELECT 1 FROM content_reachable WHERE workspace=?1 AND target=?2 AND kind=?3 AND hash=?4)", params![self.key.workspace, self.key.target, kind, hash], |r| r.get(0))?; - if !allowed { return Err(Error::Unavailable); } + if !allowed { + return Err(Error::Unavailable); + } self.load_prepared(id) } @@ -149,18 +174,32 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { self.walk([ObjectId::Tree(record.root)], true) } - fn walk(&self, roots: impl IntoIterator, public: bool) -> Result> { + fn walk( + &self, + roots: impl IntoIterator, + public: bool, + ) -> Result> { let mut pending: Vec<_> = roots.into_iter().collect(); let mut objects = BTreeMap::new(); while let Some(id) = pending.pop() { - if objects.contains_key(&id) { continue; } - let object = if public { self.read(id)? } else { self.load_prepared(id)? }; + if objects.contains_key(&id) { + continue; + } + let object = if public { + self.read(id)? + } else { + self.load_prepared(id)? + }; match &object { - Object::Blob(_) => {}, - Object::Tree(tree) => pending.extend(tree.entries().iter().map(|e| ObjectId::from(e.target))), + Object::Blob(_) => {} + Object::Tree(tree) => { + pending.extend(tree.entries().iter().map(|e| ObjectId::from(e.target))) + } Object::History(history) => { pending.push(ObjectId::Tree(history.root)); - if let Some(parent) = history.parent { pending.push(ObjectId::History(parent)); } + if let Some(parent) = history.parent { + pending.push(ObjectId::History(parent)); + } } } objects.insert(id, object); @@ -171,7 +210,13 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { /// Authenticated actor and clock facts belong to the caller, not restored post authors. /// Restoring an old root appends a history with the current parent; no rewind operation exists. /// The private savepoint prevents even a caught error from publishing a partial adoption. - pub fn adopt(&self, root: TreeId, expected: Option, actor: String, timestamp_micros: i64) -> Result { + pub fn adopt( + &self, + root: TreeId, + expected: Option, + actor: String, + timestamp_micros: i64, + ) -> Result { self.authorize(Access::Write)?; self.atomic(|| { if self.head_inner()? != expected { return Err(Error::Conflict); } @@ -215,9 +260,18 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { self.authorize(Access::Erase)?; self.atomic(|| { self.ensure_scope()?; - self.tx.execute("DELETE FROM content_reachable WHERE workspace=?1 AND target=?2", params![self.key.workspace,self.key.target])?; - self.tx.execute("DELETE FROM content_scope_objects WHERE workspace=?1 AND target=?2", params![self.key.workspace,self.key.target])?; - self.tx.execute("UPDATE content_scopes SET head=NULL,erased=1 WHERE workspace=?1 AND target=?2", params![self.key.workspace,self.key.target])?; + self.tx.execute( + "DELETE FROM content_reachable WHERE workspace=?1 AND target=?2", + params![self.key.workspace, self.key.target], + )?; + self.tx.execute( + "DELETE FROM content_scope_objects WHERE workspace=?1 AND target=?2", + params![self.key.workspace, self.key.target], + )?; + self.tx.execute( + "UPDATE content_scopes SET head=NULL,erased=1 WHERE workspace=?1 AND target=?2", + params![self.key.workspace, self.key.target], + )?; self.collect_physical() }) } @@ -227,12 +281,18 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { Ok(()) } - fn atomic(&self, f: impl FnOnce()->Result) -> Result { - self.tx.execute_batch("SAVEPOINT content_history_operation")?; + fn atomic(&self, f: impl FnOnce() -> Result) -> Result { + self.tx + .execute_batch("SAVEPOINT content_history_operation")?; match f() { - Ok(value) => { self.tx.execute_batch("RELEASE content_history_operation")?; Ok(value) }, + Ok(value) => { + self.tx.execute_batch("RELEASE content_history_operation")?; + Ok(value) + } Err(error) => { - self.tx.execute_batch("ROLLBACK TO content_history_operation; RELEASE content_history_operation")?; + self.tx.execute_batch( + "ROLLBACK TO content_history_operation; RELEASE content_history_operation", + )?; Err(error) } } @@ -241,8 +301,8 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { fn parts(id: ObjectId) -> (u8, Vec) { match id { - ObjectId::Blob(h) => (1,h.as_bytes().to_vec()), - ObjectId::Tree(h) => (2,h.as_bytes().to_vec()), - ObjectId::History(h) => (3,h.as_bytes().to_vec()), + ObjectId::Blob(h) => (1, h.as_bytes().to_vec()), + ObjectId::Tree(h) => (2, h.as_bytes().to_vec()), + ObjectId::History(h) => (3, h.as_bytes().to_vec()), } } diff --git a/crates/content-history-store/tests/transactions.rs b/crates/content-history-store/tests/transactions.rs new file mode 100644 index 000000000..95e41a092 --- /dev/null +++ b/crates/content-history-store/tests/transactions.rs @@ -0,0 +1,366 @@ +//! Real SQLite component contracts. Authority is an exact-key Host fixture; SQL, savepoints, +//! graph validation and persistence are production implementations. No live Server DB is used. +use content_history::{ContentId, Entry, HistoryId, Object, ObjectId, Tree, TreeId}; +use content_history_store::{Access, Authority, Error, SCHEMA, ScopeKey, ScopedStore}; +use rusqlite::{Connection, Transaction, params}; + +struct Host { + key: ScopeKey, + erase: bool, +} +impl Authority for Host { + fn allows(&self, key: &ScopeKey, access: Access) -> bool { + *key == self.key && (access != Access::Erase || self.erase) + } +} +fn key(workspace: &str, target: &str) -> ScopeKey { + ScopeKey { + workspace: workspace.into(), + target: target.into(), + } +} +fn host(target: &str) -> Host { + Host { + key: key("ws", target), + erase: true, + } +} +fn store<'a, 'c>(tx: &'a Transaction<'c>, h: &'a Host) -> ScopedStore<'a, 'c> { + ScopedStore::new(tx, h.key.clone(), h) +} +fn setup(conn: &mut Connection) { + conn.execute_batch("PRAGMA foreign_keys=ON").unwrap(); + let tx = conn.transaction().unwrap(); + tx.execute_batch(SCHEMA).unwrap(); + tx.execute_batch("CREATE TABLE business(source BLOB, projection TEXT, adopted BLOB)") + .unwrap(); + tx.commit().unwrap(); +} +fn candidate(s: &ScopedStore<'_, '_>, bytes: &[u8]) -> (TreeId, ObjectId) { + let blob = Object::Blob(bytes.to_vec()); + let ObjectId::Blob(id) = blob.id() else { + unreachable!() + }; + s.put(blob.id(), &blob.encode()).unwrap(); + let tree = Object::Tree( + Tree::map(vec![Entry { + key: "source".into(), + target: ContentId::Blob(id), + }]) + .unwrap(), + ); + s.put(tree.id(), &tree.encode()).unwrap(); + let ObjectId::Tree(root) = tree.id() else { + unreachable!() + }; + (root, blob.id()) +} +fn adopt(s: &ScopedStore<'_, '_>, root: TreeId, expected: Option, t: i64) -> HistoryId { + s.adopt(root, expected, "authenticated editor".into(), t) + .unwrap() +} + +#[test] +fn reopen_full_text_restore_aba_and_stale_head_preserve_current() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("content.sqlite"); + let mut conn = Connection::open(&path).unwrap(); + setup(&mut conn); + let h = host("config"); + let (a, b, ha, hb, hc, blob_a) = { + let tx = conn.transaction().unwrap(); + let s = store(&tx, &h); + let (a, blob_a) = candidate(&s, b"# comment\r\nname = \"\xe6\x97\xa5\xe6\x9c\xac\"\r\n"); + assert!(matches!(s.read(blob_a), Err(Error::Unavailable))); + let ha = adopt(&s, a, None, 1); + let (b, _) = candidate(&s, b"other\n"); + let hb = adopt(&s, b, Some(ha), 2); + let hc = adopt(&s, a, Some(hb), 3); + assert_ne!(ha, hc); + assert!(matches!( + s.adopt(b, Some(ha), "stale".into(), 4), + Err(Error::Conflict) + )); + assert_eq!(s.head().unwrap(), Some(hc)); + assert!(s.snapshot(ha).unwrap().contains_key(&blob_a)); + tx.commit().unwrap(); + (a, b, ha, hb, hc, blob_a) + }; + drop(conn); + let mut reopened = Connection::open(path).unwrap(); + let tx = reopened.transaction().unwrap(); + let s = store(&tx, &h); + assert_ne!(a, b); + assert_ne!(ha, hb); + assert_eq!(s.head().unwrap(), Some(hc)); + let old = s.snapshot(ha).unwrap(); + let restored = s.snapshot(hc).unwrap(); + assert_eq!(old, restored); + assert_eq!( + s.read(blob_a).unwrap(), + Object::Blob(b"# comment\r\nname = \"\xe6\x97\xa5\xe6\x9c\xac\"\r\n".to_vec()) + ); +} + +#[test] +fn caller_rollback_keeps_source_projection_and_head_atomic() { + let mut conn = Connection::open_in_memory().unwrap(); + setup(&mut conn); + let h = host("config"); + let original = { + let tx = conn.transaction().unwrap(); + let s = store(&tx, &h); + let (root, _) = candidate(&s, b"old"); + let head = adopt(&s, root, None, 1); + tx.execute( + "INSERT INTO business VALUES(?1,'old projection',?2)", + params![b"old".as_slice(), head.as_bytes().as_slice()], + ) + .unwrap(); + tx.commit().unwrap(); + head + }; + { + let tx = conn.transaction().unwrap(); + let s = store(&tx, &h); + let (root, _) = candidate(&s, b"new"); + let head = adopt(&s, root, Some(original), 2); + tx.execute( + "UPDATE business SET source=?1,projection='new projection',adopted=?2", + params![b"new".as_slice(), head.as_bytes().as_slice()], + ) + .unwrap(); + tx.rollback().unwrap(); + } + let tx = conn.transaction().unwrap(); + let s = store(&tx, &h); + assert_eq!(s.head().unwrap(), Some(original)); + let row: (Vec, String, Vec) = tx + .query_row("SELECT * FROM business", [], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + }) + .unwrap(); + assert_eq!( + row, + ( + b"old".to_vec(), + "old projection".into(), + original.as_bytes().to_vec() + ) + ); +} + +#[test] +fn injected_history_or_head_failure_cannot_publish_partial_adoption_even_if_error_caught() { + for trigger in [ + "CREATE TEMP TRIGGER fail BEFORE INSERT ON content_objects WHEN NEW.kind=3 BEGIN SELECT RAISE(ABORT,'history failure'); END", + "CREATE TEMP TRIGGER fail BEFORE UPDATE OF head ON content_scopes BEGIN SELECT RAISE(ABORT,'head failure'); END", + "CREATE TEMP TRIGGER fail BEFORE INSERT ON content_reachable BEGIN SELECT RAISE(ABORT,'reachability failure'); END", + ] { + let mut conn = Connection::open_in_memory().unwrap(); + setup(&mut conn); + let h = host("config"); + let original = { + let tx = conn.transaction().unwrap(); + let s = store(&tx, &h); + let (root, _) = candidate(&s, b"old"); + let head = adopt(&s, root, None, 1); + tx.commit().unwrap(); + head + }; + { + let tx = conn.transaction().unwrap(); + let s = store(&tx, &h); + let (root, blob) = candidate(&s, b"new"); + tx.execute_batch(trigger).unwrap(); + assert!(matches!( + s.adopt(root, Some(original), "editor".into(), 2), + Err(Error::Sql(_)) + )); + assert_eq!(s.head().unwrap(), Some(original)); + assert!(matches!(s.read(blob), Err(Error::Unavailable))); + tx.execute_batch("DROP TRIGGER fail").unwrap(); + tx.commit().unwrap(); + } + let tx = conn.transaction().unwrap(); + let s = store(&tx, &h); + assert_eq!(s.head().unwrap(), Some(original)); + let count: i64 = tx + .query_row( + "SELECT count(*) FROM content_objects WHERE kind=3", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + } +} + +#[test] +fn object_failure_and_missing_or_wrong_kind_children_do_not_publish_head() { + let mut conn = Connection::open_in_memory().unwrap(); + setup(&mut conn); + let h = host("config"); + let tx = conn.transaction().unwrap(); + let s = store(&tx, &h); + tx.execute_batch("CREATE TEMP TRIGGER fail BEFORE INSERT ON content_objects BEGIN SELECT RAISE(ABORT,'object failure'); END").unwrap(); + let blob = Object::Blob(b"not saved".to_vec()); + assert!(matches!( + s.put(blob.id(), &blob.encode()), + Err(Error::Sql(_)) + )); + tx.execute_batch("DROP TRIGGER fail").unwrap(); + assert_eq!(s.head().unwrap(), None); + let ObjectId::Blob(id) = blob.id() else { + unreachable!() + }; + let tree = Object::Tree( + Tree::map(vec![Entry { + key: "missing".into(), + target: ContentId::Blob(id), + }]) + .unwrap(), + ); + s.put(tree.id(), &tree.encode()).unwrap(); + let ObjectId::Tree(root) = tree.id() else { + unreachable!() + }; + assert!(matches!( + s.adopt(root, None, "e".into(), 1), + Err(Error::Unavailable) + )); + s.put(blob.id(), &blob.encode()).unwrap(); + let tree = Object::Tree( + Tree::map(vec![Entry { + key: "wrong kind".into(), + target: ContentId::Tree(TreeId::from_bytes(*id.as_bytes())), + }]) + .unwrap(), + ); + s.put(tree.id(), &tree.encode()).unwrap(); + let ObjectId::Tree(root) = tree.id() else { + unreachable!() + }; + assert!(matches!( + s.adopt(root, None, "e".into(), 1), + Err(Error::Unavailable) + )); + assert_eq!(s.head().unwrap(), None); +} + +#[test] +fn scoped_hash_reads_and_adoption_cannot_cross_workspace_or_target() { + let mut conn = Connection::open_in_memory().unwrap(); + setup(&mut conn); + let a = host("a"); + let b = host("b"); + let tx = conn.transaction().unwrap(); + let sa = store(&tx, &a); + let (root, blob) = candidate(&sa, b"private"); + let head = adopt(&sa, root, None, 1); + let sb = store(&tx, &b); + assert!(matches!(sb.read(blob), Err(Error::Unavailable))); + assert!(matches!( + sb.read(ObjectId::History(head)), + Err(Error::Unavailable) + )); + assert!(matches!( + sb.adopt(root, None, "e".into(), 1), + Err(Error::Unavailable) + )); + let denied = ScopedStore::new(&tx, key("another-workspace", "a"), &a); + assert!(matches!(denied.read(blob), Err(Error::Denied))); + assert!(matches!(denied.head(), Err(Error::Denied))); + let denied = ScopedStore::new(&tx, b.key.clone(), &a); + assert!(matches!(denied.read(blob), Err(Error::Denied))); +} + +#[test] +fn explicit_erasure_never_revives_old_scope_and_preserves_shared_history() { + let mut conn = Connection::open_in_memory().unwrap(); + setup(&mut conn); + let a = host("a"); + let b = host("b"); + let (root, blob, ha, hb) = { + let tx = conn.transaction().unwrap(); + let sa = store(&tx, &a); + let sb = store(&tx, &b); + let (root, blob) = candidate(&sa, b"shared"); + let ha = adopt(&sa, root, None, 1); + let (root_b, _) = candidate(&sb, b"shared"); + assert_eq!(root, root_b); + let hb = adopt(&sb, root, None, 2); + let (old, _) = candidate(&sb, b"new"); + adopt(&sb, old, Some(hb), 3); + sb.collect_unadopted().unwrap(); + assert!(sb.snapshot(hb).unwrap().contains_key(&blob)); + sa.erase().unwrap(); + assert!(matches!(sa.read(blob), Err(Error::Erased))); + assert!(matches!(sa.snapshot(ha), Err(Error::Erased))); + assert!(sb.snapshot(hb).unwrap().contains_key(&blob)); + tx.commit().unwrap(); + (root, blob, ha, hb) + }; + let tx = conn.transaction().unwrap(); + let sa = store(&tx, &a); + let sb = store(&tx, &b); + assert!(matches!( + sa.adopt(root, Some(ha), "e".into(), 4), + Err(Error::Erased) + )); + assert!(matches!( + sa.put( + Object::Blob(b"shared".to_vec()).id(), + &Object::Blob(b"shared".to_vec()).encode() + ), + Err(Error::Erased) + )); + assert!(sb.snapshot(hb).unwrap().contains_key(&blob)); + sb.erase().unwrap(); + assert_eq!( + tx.query_row("SELECT count(*) FROM content_objects", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); +} + +#[test] +fn tampered_bytes_or_unsupported_format_fail_read_and_immutable_save() { + let mut conn = Connection::open_in_memory().unwrap(); + setup(&mut conn); + let h = host("config"); + let tx = conn.transaction().unwrap(); + let s = store(&tx, &h); + let (root, blob) = candidate(&s, b"original"); + let head = adopt(&s, root, None, 1); + let original = s.read(blob).unwrap(); + assert!(matches!( + s.put(blob, &Object::Blob(b"different".to_vec()).encode()), + Err(Error::Model(_)) + )); + let ObjectId::Blob(id) = blob else { + unreachable!() + }; + tx.execute( + "UPDATE content_objects SET bytes=?1 WHERE kind=1 AND hash=?2", + params![ + Object::Blob(b"tamper".to_vec()).encode(), + id.as_bytes().as_slice() + ], + ) + .unwrap(); + assert!(matches!(s.snapshot(head), Err(Error::Model(_)))); + assert!(matches!( + s.put(blob, &original.encode()), + Err(Error::ImmutableMismatch) + )); + let mut bad = original.encode(); + bad[4] = 255; + tx.execute( + "UPDATE content_objects SET bytes=?1 WHERE kind=1 AND hash=?2", + params![bad, id.as_bytes().as_slice()], + ) + .unwrap(); + assert!(matches!(s.snapshot(head), Err(Error::Model(_)))); +} diff --git a/crates/content-history/README.md b/crates/content-history/README.md index a83dedd68..04cabcb8e 100644 --- a/crates/content-history/README.md +++ b/crates/content-history/README.md @@ -63,3 +63,11 @@ and `parity_word(index)` (four digest bytes interpreted big-endian). It has no J or host-import dependency. A runner can compare its words with the fixture hashes on native and `wasm32-unknown-unknown`; an out-of-range index returns `u32::MAX`. Building a WASM module alone is not evidence of executed native/WASM parity. +The optional execution adapter uses only Deno's built-in WebAssembly runtime; +run from the workspace root after registering this crate and installing the target: + +```text +cargo test -p content-history +cargo build -p content-history --example wasm_parity --target wasm32-unknown-unknown +deno run --allow-read crates/content-history/tests/wasm_parity.ts target/wasm32-unknown-unknown/debug/examples/wasm_parity.wasm +``` diff --git a/crates/content-history/examples/wasm_parity.rs b/crates/content-history/examples/wasm_parity.rs index 23832ad79..6b47aa251 100644 --- a/crates/content-history/examples/wasm_parity.rs +++ b/crates/content-history/examples/wasm_parity.rs @@ -4,7 +4,7 @@ mod common; #[unsafe(no_mangle)] pub extern "C" fn parity_len() -> u32 { - 64 + (common::objects().len() * 8) as u32 } #[unsafe(no_mangle)] diff --git a/crates/content-history/tests/common/mod.rs b/crates/content-history/tests/common/mod.rs new file mode 100644 index 000000000..5e715d665 --- /dev/null +++ b/crates/content-history/tests/common/mod.rs @@ -0,0 +1,77 @@ +use content_history::{ContentId, Entry, History, Object, ObjectId, Tree}; + +pub fn objects() -> Vec { + let empty = Object::Blob(Vec::new()); + let text = Object::Blob(b"a\r\n\xc3\xa9\0".to_vec()); + let ObjectId::Blob(empty_id) = empty.id() else { + unreachable!() + }; + let ObjectId::Blob(text_id) = text.id() else { + unreachable!() + }; + let entries = vec![ + Entry { + key: "é".into(), + target: ContentId::Blob(text_id), + }, + Entry { + key: "z".into(), + target: ContentId::Blob(empty_id), + }, + ]; + let map = Object::Tree(Tree::map(entries.clone()).unwrap()); + let sequence = Object::Tree(Tree::sequence(entries).unwrap()); + let ObjectId::Tree(root) = map.id() else { + unreachable!() + }; + let first = Object::History(History { + root, + parent: None, + actor: "user:編集".into(), + timestamp_micros: -1, + }); + let ObjectId::History(parent) = first.id() else { + unreachable!() + }; + let returned = Object::History(History { + root, + parent: Some(parent), + actor: "user:編集".into(), + timestamp_micros: 0, + }); + vec![ + empty, + text, + Object::Tree(Tree::map(vec![]).unwrap()), + Object::Tree(Tree::sequence(vec![]).unwrap()), + map, + sequence, + first, + returned, + Object::Tree( + Tree::map(vec![Entry { + key: "nested".into(), + target: ContentId::Tree(root), + }]) + .unwrap(), + ), + ] +} + +pub fn digest_bytes(object: &Object) -> [u8; 32] { + match object.id() { + ObjectId::Blob(id) => *id.as_bytes(), + ObjectId::Tree(id) => *id.as_bytes(), + ObjectId::History(id) => *id.as_bytes(), + } +} + +pub fn parity_word(index: u32) -> u32 { + let objects = objects(); + let Some(object) = objects.get(index as usize / 8) else { + return u32::MAX; + }; + let hash = digest_bytes(object); + let offset = (index as usize % 8) * 4; + u32::from_be_bytes(hash[offset..offset + 4].try_into().unwrap()) +} diff --git a/crates/content-history/tests/contracts.rs b/crates/content-history/tests/contracts.rs new file mode 100644 index 000000000..0da3bb172 --- /dev/null +++ b/crates/content-history/tests/contracts.rs @@ -0,0 +1,630 @@ +//! Pure model contract tests; the fixture store substitutes only scoped byte I/O. +//! No claims about database transactions, authorization, head CAS or business adoption. +mod common; + +use content_history::*; +use std::collections::BTreeMap; + +fn unhex(text: &str) -> Vec { + text.as_bytes() + .chunks_exact(2) + .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()) + .collect() +} +fn content(object: &Object) -> ContentId { + match object.id() { + ObjectId::Blob(id) => ContentId::Blob(id), + ObjectId::Tree(id) => ContentId::Tree(id), + _ => panic!("content required"), + } +} +fn entry(key: &str, target: ContentId) -> Entry { + Entry { + key: key.into(), + target, + } +} +fn map(entries: Vec) -> Object { + Object::Tree(Tree::map(entries).unwrap()) +} +fn tree_id(object: &Object) -> TreeId { + let ContentId::Tree(id) = content(object) else { + panic!("tree required") + }; + id +} + +#[test] +fn version_one_vectors_freeze_bytes_hashes_and_parity_words() { + let lines: Vec<_> = include_str!("fixtures/v1.tsv") + .lines() + .filter(|line| !line.starts_with('#')) + .collect(); + let objects = common::objects(); + assert_eq!(lines.len(), objects.len()); + for (index, (line, object)) in lines.iter().zip(&objects).enumerate() { + let columns: Vec<_> = line.split('\t').collect(); + let bytes = unhex(columns[1]); + let digest = unhex(columns[2]); + assert_eq!(object.encode(), bytes, "{} bytes", columns[0]); + assert_eq!( + common::digest_bytes(object).as_slice(), + digest, + "{} hash", + columns[0] + ); + assert_eq!(Object::decode(&bytes).unwrap(), *object); + assert_eq!(verify(object.id(), &bytes).unwrap(), *object); + for word in 0..8 { + assert_eq!( + common::parity_word((index * 8 + word) as u32), + u32::from_be_bytes(digest[word * 4..word * 4 + 4].try_into().unwrap()) + ); + } + } + assert_eq!(common::parity_word((objects.len() * 8) as u32), u32::MAX); +} + +#[test] +fn maps_canonicalize_enumeration_but_sequences_keys_and_exact_bytes_affect_identity() { + let blob = Object::Blob(b"x\r\n".to_vec()); + let target = content(&blob); + let a = entry("a", target); + let b = entry("é", target); + assert_eq!( + map(vec![b.clone(), a.clone()]).id(), + map(vec![a.clone(), b.clone()]).id() + ); + assert_ne!( + Object::Tree(Tree::sequence(vec![a.clone(), b.clone()]).unwrap()).id(), + Object::Tree(Tree::sequence(vec![b.clone(), a.clone()]).unwrap()).id() + ); + assert_ne!( + map(vec![a.clone()]).id(), + map(vec![entry("b", target)]).id() + ); + assert_ne!(blob.id(), Object::Blob(b"x\n".to_vec()).id()); + assert_ne!( + Object::Blob("é".as_bytes().to_vec()).id(), + Object::Blob("e\u{301}".as_bytes().to_vec()).id() + ); + let ContentId::Blob(hash) = target else { + unreachable!() + }; + assert_ne!( + map(vec![a.clone()]).id(), + map(vec![entry( + "a", + ContentId::Tree(TreeId::from_bytes(*hash.as_bytes())) + )]) + .id() + ); + for result in [ + Tree::map(vec![a.clone(), a.clone()]), + Tree::sequence(vec![a.clone(), a]), + ] { + assert!(matches!(result, Err(Error::DuplicateKey(_)))); + } +} + +#[test] +fn decoder_rejects_all_truncations_and_trailing_bytes() { + for object in common::objects() { + let bytes = object.encode(); + for end in 0..bytes.len() { + assert!(Object::decode(&bytes[..end]).is_err(), "prefix {end}"); + } + let mut trailing = bytes; + trailing.push(0); + assert_eq!(Object::decode(&trailing), Err(Error::TrailingBytes)); + } +} + +#[test] +fn decoder_rejects_unknown_domains_and_malicious_lengths() { + let blob = Object::Blob(vec![]).encode(); + let mut malformed = blob.clone(); + malformed[0] = 0; + assert_eq!(Object::decode(&malformed), Err(Error::InvalidMagic)); + malformed = blob.clone(); + malformed[5] = 2; + assert_eq!( + Object::decode(&malformed), + Err(Error::UnsupportedVersion(2)) + ); + malformed = blob.clone(); + malformed[6] = 99; + assert_eq!(Object::decode(&malformed), Err(Error::InvalidTag(99))); + malformed = blob; + malformed[7..15].fill(255); + assert_eq!(Object::decode(&malformed), Err(Error::InvalidLength)); + let target = ContentId::Blob(BlobId::from_bytes([0; 32])); + let tree = map(vec![entry("a", target)]).encode(); + malformed = tree.clone(); + malformed[7] = 2; + assert_eq!(Object::decode(&malformed), Err(Error::InvalidTreeMode(2))); + malformed = tree.clone(); + malformed[8..16].fill(255); + assert_eq!(Object::decode(&malformed), Err(Error::InvalidLength)); + malformed = tree.clone(); + malformed[16..24].fill(255); + assert_eq!(Object::decode(&malformed), Err(Error::InvalidLength)); + malformed = tree.clone(); + malformed[24] = 255; + assert_eq!(Object::decode(&malformed), Err(Error::InvalidUtf8)); + malformed = tree; + malformed[25] = 3; + assert_eq!(Object::decode(&malformed), Err(Error::InvalidChildKind(3))); + let history = common::objects()[6].encode(); + malformed = history.clone(); + malformed[39] = 2; + assert_eq!(Object::decode(&malformed), Err(Error::InvalidParentFlag(2))); + malformed = history; + malformed[40..48].fill(255); + assert_eq!(Object::decode(&malformed), Err(Error::InvalidLength)); +} + +#[test] +fn decoder_refuses_unsorted_maps_and_duplicate_sequences_instead_of_repairing_bytes() { + let target = ContentId::Blob(BlobId::from_bytes([0; 32])); + let mut sequence = + Object::Tree(Tree::sequence(vec![entry("b", target), entry("a", target)]).unwrap()) + .encode(); + sequence[7] = 0; + assert_eq!(Object::decode(&sequence), Err(Error::NonCanonicalMap)); + sequence[7] = 1; + // First entry starts at 16, each single-byte-key entry occupies 42 bytes. + sequence[66] = b'b'; + assert!(matches!( + Object::decode(&sequence), + Err(Error::DuplicateKey(_)) + )); + sequence[7] = 0; + assert_eq!(Object::decode(&sequence), Err(Error::NonCanonicalMap)); +} + +#[test] +fn integrity_rejects_modified_content_and_wrong_typed_identity() { + let object = Object::Blob(b"original".to_vec()); + assert_eq!( + verify(object.id(), &Object::Blob(b"modified".to_vec()).encode()), + Err(Error::HashMismatch) + ); + let ObjectId::Blob(id) = object.id() else { + unreachable!() + }; + assert_eq!( + verify( + ObjectId::Tree(TreeId::from_bytes(*id.as_bytes())), + &object.encode() + ), + Err(Error::HashMismatch) + ); + assert_eq!(id.to_string().len(), 64); + assert!( + id.to_string() + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + ); +} + +#[derive(Default)] +struct Memory { + objects: BTreeMap>, + reads: Vec, +} +impl Memory { + fn put(&mut self, object: Object) -> ContentId { + self.objects.insert(object.id(), object.encode()); + content(&object) + } + fn root(&mut self, object: Object) -> TreeId { + let root = tree_id(&object); + self.put(object); + root + } +} +impl ObjectReader for Memory { + fn read(&mut self, id: ObjectId) -> Result, Error> { + self.reads.push(id); + self.objects + .get(&id) + .cloned() + .ok_or(Error::MissingObject(id)) + } +} +fn restored_entry(key: &str, content: RestoredContent) -> RestoredEntry { + RestoredEntry { + key: key.into(), + content, + } +} +fn restored_map(entries: Vec) -> RestoredContent { + RestoredContent::Tree { + ordered: false, + entries, + } +} + +#[test] +fn configuration_and_ticket_shapes_restore_exact_bytes_and_business_key_order() { + let mut store = Memory::default(); + let source = "# comment\r\nname = \"é\"\n".as_bytes(); + let file = store.put(Object::Blob(source.to_vec())); + let config = store.put(map(vec![entry("settings.dcdl", file)])); + let title = store.put(Object::Blob(b"title".to_vec())); + let body = store.put(Object::Blob(b"initial body".to_vec())); + let posts = store.put(Object::Tree( + Tree::sequence(vec![entry("post-z", body), entry("post-a", title)]).unwrap(), + )); + let ticket = store.put(map(vec![ + entry("title", title), + entry("body", body), + entry("posts", posts), + ])); + let root = store.root(map(vec![ + entry("config", config), + entry("ticket-identity", ticket), + ])); + let restored = restore(&mut store, root, TraversalLimits::default()).unwrap(); + assert_eq!( + restored, + restored_map(vec![ + restored_entry( + "config", + restored_map(vec![restored_entry( + "settings.dcdl", + RestoredContent::Blob(source.to_vec()) + )]) + ), + restored_entry( + "ticket-identity", + restored_map(vec![ + restored_entry("body", RestoredContent::Blob(b"initial body".to_vec())), + restored_entry( + "posts", + RestoredContent::Tree { + ordered: true, + entries: vec![ + restored_entry( + "post-z", + RestoredContent::Blob(b"initial body".to_vec()) + ), + restored_entry("post-a", RestoredContent::Blob(b"title".to_vec())) + ] + } + ), + restored_entry("title", RestoredContent::Blob(b"title".to_vec())), + ]) + ), + ]) + ); +} + +#[test] +fn copy_on_write_path_replacement_preserves_old_fulltext_and_shares_unchanged_objects() { + let mut store = Memory::default(); + let old_blob = store.put(Object::Blob(b"old full text".to_vec())); + let new_blob = store.put(Object::Blob(b"new full text".to_vec())); + let child = store.put(map(vec![ + entry("body", old_blob), + entry("unchanged", old_blob), + ])); + let root = store.root(map(vec![entry("document", child)])); + let old_snapshot = restore(&mut store, root, TraversalLimits::default()).unwrap(); + let rewrite = replace_path( + &mut store, + root, + &["document", "body"], + Some(new_blob), + TraversalLimits::default(), + ) + .unwrap(); + assert_eq!(rewrite.objects.len(), 2); + assert_ne!(rewrite.root, root); + for object in &rewrite.objects { + store.put(object.clone()); + } + assert_eq!( + restore(&mut store, root, TraversalLimits::default()).unwrap(), + old_snapshot + ); + assert_ne!( + restore(&mut store, rewrite.root, TraversalLimits::default()).unwrap(), + old_snapshot + ); + assert_eq!( + diff(&mut store, root, rewrite.root, TraversalLimits::default()).unwrap(), + vec![Change { + path: vec!["document".into(), "body".into()], + before: Some(old_blob), + after: Some(new_blob) + }] + ); + let unchanged = replace_path( + &mut store, + root, + &["document", "body"], + Some(old_blob), + TraversalLimits::default(), + ) + .unwrap(); + assert_eq!(unchanged.root, root); + assert!(unchanged.objects.is_empty()); + let returned = replace_path( + &mut store, + rewrite.root, + &["document", "body"], + Some(old_blob), + TraversalLimits::default(), + ) + .unwrap(); + assert_eq!(returned.root, root); + let first = Object::History(History { + root, + parent: None, + actor: "editor".into(), + timestamp_micros: 0, + }); + let ObjectId::History(parent) = first.id() else { + unreachable!() + }; + let restored = Object::History(History { + root: returned.root, + parent: Some(parent), + actor: "editor".into(), + timestamp_micros: 1, + }); + assert_ne!(first.id(), restored.id()); +} + +#[test] +fn structural_diff_skips_equal_subtrees_without_reading_them_and_reports_add_delete() { + let mut store = Memory::default(); + let shared = ContentId::Tree(TreeId::from_bytes([42; 32])); // Intentionally unavailable. + let old = ContentId::Blob(BlobId::from_bytes([1; 32])); + let new = ContentId::Blob(BlobId::from_bytes([2; 32])); + let before = store.root(map(vec![ + entry("shared", shared), + entry("removed", old), + entry("edit", old), + ])); + let after = store.root(map(vec![ + entry("shared", shared), + entry("added", new), + entry("edit", new), + ])); + assert!( + diff(&mut store, before, before, TraversalLimits::default()) + .unwrap() + .is_empty() + ); + assert!(store.reads.is_empty()); + let changes = diff(&mut store, before, after, TraversalLimits::default()).unwrap(); + assert_eq!( + store.reads, + vec![ObjectId::Tree(before), ObjectId::Tree(after)] + ); + assert_eq!( + changes, + vec![ + Change { + path: vec!["added".into()], + before: None, + after: Some(new) + }, + Change { + path: vec!["edit".into()], + before: Some(old), + after: Some(new) + }, + Change { + path: vec!["removed".into()], + before: Some(old), + after: None + }, + ] + ); + assert!(matches!( + restore(&mut store, before, TraversalLimits::default()), + Err(Error::MissingObject(_)) + )); +} + +#[test] +fn sequence_order_and_tree_mode_changes_are_not_hidden_by_equal_children() { + let mut store = Memory::default(); + let blob = ContentId::Blob(BlobId::from_bytes([1; 32])); + let entries = vec![entry("a", blob), entry("b", blob)]; + let before = store.root(Object::Tree(Tree::sequence(entries.clone()).unwrap())); + let after = store.root(Object::Tree( + Tree::sequence(entries.iter().rev().cloned().collect()).unwrap(), + )); + let mapped = store.root(map(entries)); + for target in [after, mapped] { + assert_eq!( + diff(&mut store, before, target, TraversalLimits::default()).unwrap(), + vec![Change { + path: vec![], + before: Some(ContentId::Tree(before)), + after: Some(ContentId::Tree(target)) + }] + ); + } +} + +#[test] +fn replacement_uses_opaque_keys_and_preserves_sequence_positions() { + let target = ContentId::Blob(BlobId::from_bytes([0; 32])); + let other = ContentId::Blob(BlobId::from_bytes([1; 32])); + let sequence = Tree::sequence(vec![entry("a/b", target), entry("", target)]).unwrap(); + let replaced = sequence.replace("a/b", Some(other)).unwrap(); + assert_eq!( + replaced.entries(), + &[entry("a/b", other), entry("", target)] + ); + let appended = replaced.replace("new", Some(target)).unwrap(); + assert_eq!(appended.entries().last().unwrap().key, "new"); + assert_eq!( + appended.replace("", None).unwrap().entries(), + &[entry("a/b", other), entry("new", target)] + ); + assert!(matches!( + sequence.replace("missing", None), + Err(Error::MissingKey(_)) + )); + let mut store = Memory::default(); + let root = store.root(Object::Tree(sequence)); + assert!( + replace_path( + &mut store, + root, + &["a/b"], + Some(other), + TraversalLimits::default() + ) + .is_ok() + ); + assert_eq!( + replace_path(&mut store, root, &[], None, TraversalLimits::default()), + Err(Error::EmptyPath) + ); + assert_eq!( + replace_path( + &mut store, + root, + &["a/b", "nested"], + None, + TraversalLimits::default() + ), + Err(Error::NotTree) + ); + assert!(matches!( + replace_path( + &mut store, + root, + &["missing", "nested"], + None, + TraversalLimits::default() + ), + Err(Error::MissingKey(_)) + )); +} + +#[test] +fn restoration_rejects_corruption_and_resource_budget_exhaustion() { + let mut store = Memory::default(); + let blob = store.put(Object::Blob(b"full bytes".to_vec())); + let root = store.root(map(vec![entry("leaf", blob)])); + for limits in [ + TraversalLimits { + max_depth: 0, + ..TraversalLimits::default() + }, + TraversalLimits { + max_objects: 1, + ..TraversalLimits::default() + }, + TraversalLimits { + max_bytes: 1, + ..TraversalLimits::default() + }, + ] { + assert!(matches!( + restore(&mut store, root, limits), + Err(Error::LimitExceeded(_)) + )); + } + store + .objects + .insert(blob.into(), Object::Blob(b"tampered".to_vec()).encode()); + assert_eq!( + restore(&mut store, root, TraversalLimits::default()), + Err(Error::HashMismatch) + ); + let mut denied = |_: ObjectId| Err(Error::Read("unavailable in authorized scope".into())); + assert!(matches!( + restore(&mut denied, root, TraversalLimits::default()), + Err(Error::Read(_)) + )); +} + +#[test] +fn path_addition_and_deletion_reuse_fulltext_without_mutating_old_roots() { + let mut store = Memory::default(); + let blob = store.put(Object::Blob(b"complete source".to_vec())); + let old = store.root(map(vec![entry("old", blob)])); + let added = replace_path( + &mut store, + old, + &["added"], + Some(blob), + TraversalLimits::default(), + ) + .unwrap(); + for object in added.objects { + store.put(object); + } + let deleted = replace_path( + &mut store, + added.root, + &["old"], + None, + TraversalLimits::default(), + ) + .unwrap(); + for object in deleted.objects { + store.put(object); + } + assert_eq!( + restore(&mut store, old, TraversalLimits::default()).unwrap(), + restored_map(vec![restored_entry( + "old", + RestoredContent::Blob(b"complete source".to_vec()) + )]) + ); + assert_eq!( + restore(&mut store, deleted.root, TraversalLimits::default()).unwrap(), + restored_map(vec![restored_entry( + "added", + RestoredContent::Blob(b"complete source".to_vec()) + )]) + ); + assert_eq!( + diff(&mut store, old, deleted.root, TraversalLimits::default()).unwrap(), + vec![ + Change { + path: vec!["added".into()], + before: None, + after: Some(blob) + }, + Change { + path: vec!["old".into()], + before: Some(blob), + after: None + }, + ] + ); +} + +#[test] +fn recursion_is_bounded_even_if_caller_requests_unlimited_depth() { + let mut store = Memory::default(); + let mut root = store.root(map(vec![])); + for _ in 0..258 { + root = store.root(map(vec![entry("nested", ContentId::Tree(root))])); + } + let limits = TraversalLimits { + max_depth: usize::MAX, + ..TraversalLimits::default() + }; + assert_eq!( + restore(&mut store, root, limits), + Err(Error::LimitExceeded("depth")) + ); + let path = vec!["nested"; 258]; + assert_eq!( + replace_path(&mut store, root, &path, None, limits), + Err(Error::LimitExceeded("depth")) + ); +} diff --git a/crates/content-history/tests/fixtures/v1.tsv b/crates/content-history/tests/fixtures/v1.tsv new file mode 100644 index 000000000..7cb4a38a6 --- /dev/null +++ b/crates/content-history/tests/fixtures/v1.tsv @@ -0,0 +1,10 @@ +# name canonical_hex sha256_hex +empty_blob 594348000001010000000000000000 46f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de03 +full_blob 594348000001010000000000000006610d0ac3a900 5baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f4 +empty_map 59434800000102000000000000000000 e90da7b51cdefa812a4dd9f5ef177e13f2ac53d59e693bad83533ef817d43671 +empty_sequence 59434800000102010000000000000000 da589a35a84b290befc701f31a5c0916eadcf002590c406fb200951d659c2488 +map 5943480000010200000000000000000200000000000000017a0146f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de030000000000000002c3a9015baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f4 4ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f527 +sequence 594348000001020100000000000000020000000000000002c3a9015baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f400000000000000017a0146f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de03 ba054669ee973912f8fb1e9ef99a0a9d0c674c58c3d46eaa5af1024491c8ce3f +history 594348000001034ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f52700000000000000000b757365723ae7b7a8e99b86ffffffffffffffff ab0ac8d9c204969f555c0267ed572c3eace9f7a7df4b5f2e9f3b32fd46d0ab95 +restored_history 594348000001034ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f52701ab0ac8d9c204969f555c0267ed572c3eace9f7a7df4b5f2e9f3b32fd46d0ab95000000000000000b757365723ae7b7a8e99b860000000000000000 87bb274c7f99ae8aa653e0211eb26ee910d274420adcf8ec49bade8a46620187 +nested_tree 5943480000010200000000000000000100000000000000066e6573746564024ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f527 a6a53aebc4f3d7e4da21ad3328b9f35858a339aea23f328750eada698c6248f2 diff --git a/crates/content-history/tests/wasm_parity.ts b/crates/content-history/tests/wasm_parity.ts new file mode 100644 index 000000000..0ededb748 --- /dev/null +++ b/crates/content-history/tests/wasm_parity.ts @@ -0,0 +1,37 @@ +// Optional execution adapter: only Deno's built-in WASM runtime, no JS packages. +// Native contracts.rs checks the same golden digests against the native model. +const wasmPath = Deno.args[0]; +if (!wasmPath) { + throw new Error("usage: deno run --allow-read tests/wasm_parity.ts "); +} +const bytes = await Deno.readFile(wasmPath); +const module = await WebAssembly.compile(bytes); +if (WebAssembly.Module.imports(module).length !== 0) { + throw new Error("raw parity module must have no host imports"); +} +const instance = await WebAssembly.instantiate(module, {}); +const length = instance.exports.parity_len as () => number; +const word = instance.exports.parity_word as (index: number) => number; +const fixture = await Deno.readTextFile( + new URL("fixtures/v1.tsv", import.meta.url), +); +const expected = fixture.trim().split("\n").filter((line) => + !line.startsWith("#") +) + .flatMap((line) => + line.split("\t")[2].match(/.{8}/g)!.map((hex) => parseInt(hex, 16)) + ); +if (length() !== expected.length) { + throw new Error("fixture digest count differs"); +} +for (let index = 0; index < expected.length; index++) { + if ((word(index) >>> 0) !== expected[index]) { + throw new Error(`WASM digest word mismatch at ${index}`); + } +} +if ((word(expected.length) >>> 0) !== 0xffffffff) { + throw new Error("out-of-range word did not return sentinel"); +} +console.log( + `WASM canonical SHA256 parity: ${expected.length / 8} fixture objects`, +); diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index fbabe2908..faec89491 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -66,6 +66,9 @@ webauthn-rs = { workspace = true, features = ["conditional-ui"] } webauthn-rs-proto = "0.5.5" [dev-dependencies] +content-history.workspace = true +content-history-store.workspace = true +content-history-adapters.workspace = true feature-storage = { workspace = true, features = ["test-support"] } native-tls = "0.2" tokio-native-tls = "0.3" diff --git a/crates/workspace-server/src/content_history_tests.rs b/crates/workspace-server/src/content_history_tests.rs new file mode 100644 index 000000000..cd5176708 --- /dev/null +++ b/crates/workspace-server/src/content_history_tests.rs @@ -0,0 +1,149 @@ +//! Contract fixture for the actual Server database/transaction owner and the content adapter. +//! Existing authorization is represented by an exact-target Host decision; this is not a route +//! or production policy cutover. Domain schema is installed only in this isolated fixture. +use config_source::{ + ConfigContentType, ConfigEntry, ConfigTreeSnapshot, SnapshotEnvironment, ToolchainContract, + VirtualPath, +}; +use content_history::{HistoryId, ObjectId}; +use content_history_adapters::{Snapshot, settings, ticket}; +use content_history_store::{Access, Authority, ScopeKey, ScopedStore}; +use rusqlite::{Transaction, params}; + +use crate::{Error, Result, store::SqliteWorkspaceStore}; + +struct ConfigHost; +impl Authority for ConfigHost { + fn allows(&self, key: &ScopeKey, _access: Access) -> bool { + key.workspace == "history-ws" && key.target == "configuration" + } +} +fn scoped<'a, 'c>(tx: &'a Transaction<'c>) -> ScopedStore<'a, 'c> { + ScopedStore::new( + tx, + ScopeKey { + workspace: "history-ws".into(), + target: "configuration".into(), + }, + &ConfigHost, + ) +} +fn content_error(e: impl std::fmt::Display) -> Error { + Error::Store(e.to_string()) +} +fn sources(value: &str) -> ConfigTreeSnapshot { + ConfigTreeSnapshot::from_entries([ + ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + "import \"./parts/value.dcdl\"", + ) + .unwrap(), + ConfigEntry::new( + VirtualPath::parse("parts/value.dcdl").unwrap(), + ConfigContentType::Decodal, + format!("// exact comment\r\n{{ title = \"{value}\"; }}\r\n"), + ) + .unwrap(), + ]) + .unwrap() +} +fn prepare(s: &ScopedStore<'_, '_>, candidate: &Snapshot) -> Result<()> { + for (id, object) in &candidate.objects { + s.put(*id, &object.encode()).map_err(content_error)?; + } + Ok(()) +} + +/// A minimal caller-owned writer, deliberately not installed as the production config API. +/// Its evaluation, exact sources, projection and new history share the Server's transaction. +fn adopt_sources( + db: &SqliteWorkspaceStore, + source: &ConfigTreeSnapshot, + expected: Option, + fail_after_adopt: bool, +) -> Result { + let candidate = settings::prepare(source).map_err(content_error)?; + let contract = ToolchainContract::new(1, vec![VirtualPath::parse("main.dcdl").unwrap()], 1); + let projection = SnapshotEnvironment::new(source.clone()) + .evaluate_contract(&contract) + .map_err(|e| content_error(format!("{e:?}")))?; + db.with_immediate_transaction(|tx| { + let s=scoped(tx); prepare(&s,&candidate)?; + let head=s.adopt(candidate.root,expected,"authenticated-user".into(),5).map_err(content_error)?; + tx.execute("INSERT INTO workspace_config_trees(workspace_id,content_digest,schema_version,entrypoints_json,decodal_version,import_policy_version,toolchain_fingerprint,projection_digest,updated_at) VALUES('history-ws',?1,1,'[\"main.dcdl\"]',?2,1,?3,?4,'now') ON CONFLICT(workspace_id) DO UPDATE SET content_digest=excluded.content_digest,projection_digest=excluded.projection_digest",params![source.digest,contract.decodal_version,contract.fingerprint,projection.projection_digest])?; + tx.execute("DELETE FROM workspace_config_entries WHERE workspace_id='history-ws'",[])?; + for entry in source.entries.values() { + tx.execute("INSERT INTO workspace_config_entries VALUES('history-ws',?1,'decodal',?2,?3)",params![entry.path.as_str(),entry.content,entry.content_digest])?; + } + if fail_after_adopt { return Err(Error::Store("injected business adoption failure".into())); } + Ok(head) + }) +} + +#[test] +fn server_owned_transaction_adopts_sources_projection_history_and_rolls_back_failed_business_write() +{ + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("server.sqlite"); + let db = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + db.with_immediate_transaction(|tx| { + tx.execute_batch(content_history_store::SCHEMA)?; + tx.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('history-owner','user','history-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('history-ws','history-owner','History fixture','active','1','1');")?; + Ok(()) + }).unwrap(); + let old = sources("旧版"); + let first = adopt_sources(&db, &old, None, false).unwrap(); + let new = sources("新版"); + assert!(adopt_sources(&db, &new, Some(first), true).is_err()); + db.with_immediate_transaction(|tx| { + let s=scoped(tx);assert_eq!(s.head().map_err(content_error)?,Some(first)); + let record=s.read(ObjectId::History(first)).map_err(content_error)?; + let content_history::Object::History(history)=record else {panic!("history expected")}; + let restored=settings::restore(history.root,|id|s.read(id).map_err(|e|content_history_adapters::Error::Read(e.to_string()))).map_err(content_error)?; + assert_eq!(restored,old); + let digest:String=tx.query_row("SELECT content_digest FROM workspace_config_trees WHERE workspace_id='history-ws'",[],|r|r.get(0))?; + assert_eq!(digest,old.digest); + let text:String=tx.query_row("SELECT content FROM workspace_config_entries WHERE workspace_id='history-ws' AND path='parts/value.dcdl'",[],|r|r.get(0))?; + assert_eq!(text,old.entries[&VirtualPath::parse("parts/value.dcdl").unwrap()].content); + Ok(()) + }).unwrap(); + let second = adopt_sources(&db, &new, Some(first), false).unwrap(); + assert_ne!(first, second); + assert!(adopt_sources(&db, &old, Some(first), false).is_err()); + drop(db); + let db = SqliteWorkspaceStore::open_for_test(path).unwrap(); + db.with_immediate_transaction(|tx| { + let s=scoped(tx);assert_eq!(s.head().map_err(content_error)?,Some(second)); + let projection:String=tx.query_row("SELECT projection_digest FROM workspace_config_trees WHERE workspace_id='history-ws'",[],|r|r.get(0))?; + let contract=ToolchainContract::new(1,vec![VirtualPath::parse("main.dcdl").unwrap()],1); + assert_eq!(projection,SnapshotEnvironment::new(new.clone()).evaluate_contract(&contract).unwrap().projection_digest); + assert!(s.snapshot(first).map_err(content_error)?.values().any(|o|matches!(o,content_history::Object::Blob(b) if b==old.entries[&VirtualPath::parse("parts/value.dcdl").unwrap()].content.as_bytes()))); + Ok(()) + }).unwrap(); +} + +#[test] +fn ticket_snapshot_and_history_restoration_do_not_rewind_separate_business_events() { + let db = SqliteWorkspaceStore::in_memory_for_test().unwrap(); + db.with_immediate_transaction(|tx| { + tx.execute_batch(content_history_store::SCHEMA)?; + // Business state is deliberately not a field accepted by the content adapter. + tx.execute_batch("CREATE TEMP TABLE business_events(kind TEXT,value TEXT); INSERT INTO business_events VALUES('state','closed'),('assignee','current-worker'),('relation','depends-on-current');")?; + let s=scoped(tx); + let doc=ticket::Document {ticket_id:"fixture-ticket".into(),title:"Title".into(),initial_body:"old body\r\n".into(),initial_author:"original-user".into(),posts:vec![ticket::Post{id:"post-z".into(),author:"original-poster".into(),body:"post body".into()}]}; + let original=ticket::prepare(&doc).map_err(content_error)?;prepare(&s,&original)?; + let first=s.adopt(original.root,None,"first-editor".into(),1).map_err(content_error)?; + let mut changed=doc.clone();changed.initial_body="edited body".into(); + let candidate=ticket::prepare(&changed).map_err(content_error)?;prepare(&s,&candidate)?; + let second=s.adopt(candidate.root,Some(first),"second-editor".into(),2).map_err(content_error)?; + let third=s.adopt(original.root,Some(second),"restore-editor".into(),3).map_err(content_error)?; + assert_ne!(first,third); + let restored=ticket::restore(original.root,"fixture-ticket",|id|s.read(id).map_err(|e|content_history_adapters::Error::Read(e.to_string()))).map_err(content_error)?; + assert_eq!(restored,doc); + let mut q=tx.prepare("SELECT kind,value FROM business_events ORDER BY kind")?; + let events=q.query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::,_>>()?; + assert_eq!(events,vec![("assignee".into(),"current-worker".into()),("relation".into(),"depends-on-current".into()),("state".into(),"closed".into())]); + Ok(()) + }).unwrap(); +} diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index b44bbfdab..26e411c0b 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -6,6 +6,8 @@ pub mod backend_job; pub mod companion; pub mod config; pub mod config_source; +#[cfg(test)] +mod content_history_tests; pub mod db_executor; pub mod feature_storage; pub mod hosts;