Exercise canonical fixtures, scoped persistence, and Server transaction contracts
This commit is contained in:
@@ -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
|
// 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.
|
// bounds traversal of adversarial shared subtrees by the existing entry/path limits.
|
||||||
let child_prefix = format!("{path}/");
|
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"));
|
return Err(Error::Structure("config directory has no file metadata"));
|
||||||
}
|
}
|
||||||
restore_directory(child, path.as_str(), read, metadata, entries, total)?
|
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, ¤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());
|
||||||
|
}
|
||||||
@@ -64,16 +64,23 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn authorize(&self, access: Access) -> Result<()> {
|
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)
|
|| !self.authority.allows(&self.key, access)
|
||||||
{
|
{
|
||||||
return Err(Error::Denied);
|
return Err(Error::Denied);
|
||||||
}
|
}
|
||||||
let erased: Option<bool> = self.tx.query_row(
|
let erased: Option<bool> = self
|
||||||
"SELECT erased FROM content_scopes WHERE workspace=?1 AND target=?2",
|
.tx
|
||||||
params![self.key.workspace, self.key.target], |r| r.get(0),
|
.query_row(
|
||||||
).optional()?;
|
"SELECT erased FROM content_scopes WHERE workspace=?1 AND target=?2",
|
||||||
if erased == Some(true) { return Err(Error::Erased); }
|
params![self.key.workspace, self.key.target],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if erased == Some(true) {
|
||||||
|
return Err(Error::Erased);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,12 +98,21 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn head_inner(&self) -> Result<Option<HistoryId>> {
|
fn head_inner(&self) -> Result<Option<HistoryId>> {
|
||||||
let raw: Option<Vec<u8>> = self.tx.query_row(
|
let raw: Option<Vec<u8>> = self
|
||||||
"SELECT head FROM content_scopes WHERE workspace=?1 AND target=?2",
|
.tx
|
||||||
params![self.key.workspace, self.key.target], |r| r.get(0),
|
.query_row(
|
||||||
).optional()?.flatten();
|
"SELECT head FROM content_scopes WHERE workspace=?1 AND target=?2",
|
||||||
raw.map(|v| Ok(HistoryId::from_bytes(v.try_into().map_err(|_| Error::InvalidReference)?)))
|
params![self.key.workspace, self.key.target],
|
||||||
.transpose()
|
|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.
|
/// 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<()> {
|
fn put_inner(&self, id: ObjectId, bytes: &[u8]) -> Result<()> {
|
||||||
self.ensure_scope()?;
|
self.ensure_scope()?;
|
||||||
let (kind, hash) = parts(id);
|
let (kind, hash) = parts(id);
|
||||||
self.tx.execute("INSERT OR IGNORE INTO content_objects(kind,hash,bytes) VALUES(?1,?2,?3)",
|
self.tx.execute(
|
||||||
params![kind, hash, bytes])?;
|
"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(
|
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))?;
|
"SELECT bytes FROM content_objects WHERE kind=?1 AND hash=?2",
|
||||||
if stored != bytes { return Err(Error::ImmutableMismatch); }
|
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)",
|
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])?;
|
params![self.key.workspace, self.key.target, kind, hash])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -128,7 +151,9 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> {
|
|||||||
let allowed: bool = self.tx.query_row(
|
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)",
|
"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))?;
|
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)
|
self.load_prepared(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,18 +174,32 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> {
|
|||||||
self.walk([ObjectId::Tree(record.root)], true)
|
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 pending: Vec<_> = roots.into_iter().collect();
|
||||||
let mut objects = BTreeMap::new();
|
let mut objects = BTreeMap::new();
|
||||||
while let Some(id) = pending.pop() {
|
while let Some(id) = pending.pop() {
|
||||||
if objects.contains_key(&id) { continue; }
|
if objects.contains_key(&id) {
|
||||||
let object = if public { self.read(id)? } else { self.load_prepared(id)? };
|
continue;
|
||||||
|
}
|
||||||
|
let object = if public {
|
||||||
|
self.read(id)?
|
||||||
|
} else {
|
||||||
|
self.load_prepared(id)?
|
||||||
|
};
|
||||||
match &object {
|
match &object {
|
||||||
Object::Blob(_) => {},
|
Object::Blob(_) => {}
|
||||||
Object::Tree(tree) => pending.extend(tree.entries().iter().map(|e| ObjectId::from(e.target))),
|
Object::Tree(tree) => {
|
||||||
|
pending.extend(tree.entries().iter().map(|e| ObjectId::from(e.target)))
|
||||||
|
}
|
||||||
Object::History(history) => {
|
Object::History(history) => {
|
||||||
pending.push(ObjectId::Tree(history.root));
|
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);
|
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.
|
/// 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.
|
/// 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.
|
/// 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.authorize(Access::Write)?;
|
||||||
self.atomic(|| {
|
self.atomic(|| {
|
||||||
if self.head_inner()? != expected { return Err(Error::Conflict); }
|
if self.head_inner()? != expected { return Err(Error::Conflict); }
|
||||||
@@ -215,9 +260,18 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> {
|
|||||||
self.authorize(Access::Erase)?;
|
self.authorize(Access::Erase)?;
|
||||||
self.atomic(|| {
|
self.atomic(|| {
|
||||||
self.ensure_scope()?;
|
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(
|
||||||
self.tx.execute("DELETE FROM content_scope_objects WHERE workspace=?1 AND target=?2", params![self.key.workspace,self.key.target])?;
|
"DELETE FROM content_reachable WHERE workspace=?1 AND target=?2",
|
||||||
self.tx.execute("UPDATE content_scopes SET head=NULL,erased=1 WHERE workspace=?1 AND target=?2", params![self.key.workspace,self.key.target])?;
|
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()
|
self.collect_physical()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -227,12 +281,18 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn atomic<T>(&self, f: impl FnOnce()->Result<T>) -> Result<T> {
|
fn atomic<T>(&self, f: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||||
self.tx.execute_batch("SAVEPOINT content_history_operation")?;
|
self.tx
|
||||||
|
.execute_batch("SAVEPOINT content_history_operation")?;
|
||||||
match f() {
|
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) => {
|
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)
|
Err(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,8 +301,8 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> {
|
|||||||
|
|
||||||
fn parts(id: ObjectId) -> (u8, Vec<u8>) {
|
fn parts(id: ObjectId) -> (u8, Vec<u8>) {
|
||||||
match id {
|
match id {
|
||||||
ObjectId::Blob(h) => (1,h.as_bytes().to_vec()),
|
ObjectId::Blob(h) => (1, h.as_bytes().to_vec()),
|
||||||
ObjectId::Tree(h) => (2,h.as_bytes().to_vec()),
|
ObjectId::Tree(h) => (2, h.as_bytes().to_vec()),
|
||||||
ObjectId::History(h) => (3,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(_))));
|
||||||
|
}
|
||||||
@@ -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
|
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`.
|
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.
|
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)]
|
#[unsafe(no_mangle)]
|
||||||
pub extern "C" fn parity_len() -> u32 {
|
pub extern "C" fn parity_len() -> u32 {
|
||||||
64
|
(common::objects().len() * 8) as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
#[unsafe(no_mangle)]
|
#[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())
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
@@ -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`,
|
||||||
|
);
|
||||||
@@ -66,6 +66,9 @@ webauthn-rs = { workspace = true, features = ["conditional-ui"] }
|
|||||||
webauthn-rs-proto = "0.5.5"
|
webauthn-rs-proto = "0.5.5"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
content-history.workspace = true
|
||||||
|
content-history-store.workspace = true
|
||||||
|
content-history-adapters.workspace = true
|
||||||
feature-storage = { workspace = true, features = ["test-support"] }
|
feature-storage = { workspace = true, features = ["test-support"] }
|
||||||
native-tls = "0.2"
|
native-tls = "0.2"
|
||||||
tokio-native-tls = "0.3"
|
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();
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ pub mod backend_job;
|
|||||||
pub mod companion;
|
pub mod companion;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod config_source;
|
pub mod config_source;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod content_history_tests;
|
||||||
pub mod db_executor;
|
pub mod db_executor;
|
||||||
pub mod feature_storage;
|
pub mod feature_storage;
|
||||||
pub mod hosts;
|
pub mod hosts;
|
||||||
|
|||||||
Reference in New Issue
Block a user