Exercise canonical fixtures, scoped persistence, and Server transaction contracts

This commit is contained in:
2026-11-04 10:47:53 +09:00
parent 223aa125de
commit 29cb24a878
14 changed files with 1933 additions and 36 deletions
@@ -0,0 +1,39 @@
use config_source::{ConfigContentType, ConfigEntry, ConfigTreeSnapshot, VirtualPath};
use content_history_adapters::{settings, ticket};
fn main() -> Result<(), Box<dyn std::error::Error>> {
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(())
}
@@ -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)?
@@ -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<!-- keep -->\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: "<!-- keep -->\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::<Vec<_>>();
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::<Vec<_>>(),
["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::<BTreeMap<_, _>>();
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::<Vec<_>>();
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::<Vec<_>>(),
["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::<Vec<_>>(),
["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, &current.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());
}
+94 -34
View File
@@ -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<bool> = 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<bool> = 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<Option<HistoryId>> {
let raw: Option<Vec<u8>> = 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<Vec<u8>> = 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<u8> = 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<Item=ObjectId>, public: bool) -> Result<BTreeMap<ObjectId, Object>> {
fn walk(
&self,
roots: impl IntoIterator<Item = ObjectId>,
public: bool,
) -> Result<BTreeMap<ObjectId, Object>> {
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<HistoryId>, actor: String, timestamp_micros: i64) -> Result<HistoryId> {
pub fn adopt(
&self,
root: TreeId,
expected: Option<HistoryId>,
actor: String,
timestamp_micros: i64,
) -> Result<HistoryId> {
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<T>(&self, f: impl FnOnce()->Result<T>) -> Result<T> {
self.tx.execute_batch("SAVEPOINT content_history_operation")?;
fn atomic<T>(&self, f: impl FnOnce() -> Result<T>) -> Result<T> {
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<u8>) {
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()),
}
}
@@ -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<HistoryId>, 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<u8>, String, Vec<u8>) = 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(_))));
}
+8
View File
@@ -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
```
@@ -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)]
@@ -0,0 +1,77 @@
use content_history::{ContentId, Entry, History, Object, ObjectId, Tree};
pub fn objects() -> Vec<Object> {
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())
}
+630
View File
@@ -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<u8> {
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<Entry>) -> 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<ObjectId, Vec<u8>>,
reads: Vec<ObjectId>,
}
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<Vec<u8>, 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<RestoredEntry>) -> 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"))
);
}
+10
View File
@@ -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
1 # name canonical_hex sha256_hex
2 empty_blob 594348000001010000000000000000 46f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de03
3 full_blob 594348000001010000000000000006610d0ac3a900 5baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f4
4 empty_map 59434800000102000000000000000000 e90da7b51cdefa812a4dd9f5ef177e13f2ac53d59e693bad83533ef817d43671
5 empty_sequence 59434800000102010000000000000000 da589a35a84b290befc701f31a5c0916eadcf002590c406fb200951d659c2488
6 map 5943480000010200000000000000000200000000000000017a0146f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de030000000000000002c3a9015baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f4 4ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f527
7 sequence 594348000001020100000000000000020000000000000002c3a9015baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f400000000000000017a0146f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de03 ba054669ee973912f8fb1e9ef99a0a9d0c674c58c3d46eaa5af1024491c8ce3f
8 history 594348000001034ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f52700000000000000000b757365723ae7b7a8e99b86ffffffffffffffff ab0ac8d9c204969f555c0267ed572c3eace9f7a7df4b5f2e9f3b32fd46d0ab95
9 restored_history 594348000001034ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f52701ab0ac8d9c204969f555c0267ed572c3eace9f7a7df4b5f2e9f3b32fd46d0ab95000000000000000b757365723ae7b7a8e99b860000000000000000 87bb274c7f99ae8aa653e0211eb26ee910d274420adcf8ec49bade8a46620187
10 nested_tree 5943480000010200000000000000000100000000000000066e6573746564024ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f527 a6a53aebc4f3d7e4da21ad3328b9f35858a339aea23f328750eada698c6248f2
@@ -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 <wasm>");
}
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`,
);
+3
View File
@@ -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"
@@ -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<HistoryId>,
fail_after_adopt: bool,
) -> Result<HistoryId> {
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::<std::result::Result<Vec<_>,_>>()?;
assert_eq!(events,vec![("assignee".into(),"current-worker".into()),("relation".into(),"depends-on-current".into()),("state".into(),"closed".into())]);
Ok(())
}).unwrap();
}
+2
View File
@@ -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;