Introduce canonical content history and scoped transaction foundation
This commit is contained in:
Generated
+29
@@ -837,6 +837,35 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "content-history"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"sha2 0.11.0",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "content-history-adapters"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"config-source",
|
||||
"content-history",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "content-history-store"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"config-source",
|
||||
"content-history",
|
||||
"content-history-adapters",
|
||||
"rusqlite",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
|
||||
@@ -31,6 +31,9 @@ members = [
|
||||
"crates/builtin-source",
|
||||
"crates/config-source",
|
||||
"crates/config-source-wasm",
|
||||
"crates/content-history",
|
||||
"crates/content-history-store",
|
||||
"crates/content-history-adapters",
|
||||
"crates/workdir",
|
||||
"crates/tui",
|
||||
"crates/memory",
|
||||
@@ -74,6 +77,9 @@ default-members = [
|
||||
"crates/builtin-source",
|
||||
"crates/config-source",
|
||||
"crates/config-source-wasm",
|
||||
"crates/content-history",
|
||||
"crates/content-history-store",
|
||||
"crates/content-history-adapters",
|
||||
"crates/workdir",
|
||||
"crates/tui",
|
||||
"crates/memory",
|
||||
@@ -140,6 +146,9 @@ secrets = { path = "crates/secrets" }
|
||||
tools = { path = "crates/tools" }
|
||||
builtin-source = { path = "crates/builtin-source" }
|
||||
config-source = { path = "crates/config-source" }
|
||||
content-history = { path = "crates/content-history" }
|
||||
content-history-store = { path = "crates/content-history-store" }
|
||||
content-history-adapters = { path = "crates/content-history-adapters" }
|
||||
error-contract = { path = "crates/error-contract" }
|
||||
fs-operation = { path = "crates/fs-operation" }
|
||||
workdir = { path = "crates/workdir" }
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "content-history-adapters"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
content-history.workspace = true
|
||||
config-source.workspace = true
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,9 @@
|
||||
# content-history-adapters
|
||||
|
||||
Contract examples mapping configuration sources and Ticket compound documents onto the independent content-history model. These adapters prepare immutable candidates and restore complete content; they do not save, authorize, evaluate, adopt, or migrate business data.
|
||||
|
||||
- [`settings`] preserves virtual paths, full UTF-8 sources, and explicit entry metadata.
|
||||
- [`ticket`] separates document content and original authorship from business state and edit history.
|
||||
- [`Snapshot`] supplies a candidate root and objects for caller-owned store preparation. A successful preparation is not business adoption; the caller must validate and atomically adopt source, projection, and history head in its own transaction.
|
||||
|
||||
Readers must be scoped to an authorized object/root by the store. Knowing a hash is not authorization. Missing or erased content must fail restoration rather than be reconstructed from another scope. Retention, explicit erasure, expected-history-head CAS, and new authenticated editor history on restoration belong to the store and caller, not these examples.
|
||||
@@ -0,0 +1,159 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use content_history::{BlobId, ContentId, Entry, Object, ObjectId, Tree, TreeId};
|
||||
|
||||
pub mod settings;
|
||||
pub mod ticket;
|
||||
|
||||
/// Complete immutable candidate for store preparation, not a saved or adopted version.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Snapshot {
|
||||
pub root: TreeId,
|
||||
pub objects: BTreeMap<ObjectId, Object>,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// In-memory reader for examples. Production readers must enforce authorization and erasure.
|
||||
pub fn get(&self, id: ObjectId) -> Result<Object, Error> {
|
||||
self.objects.get(&id).cloned().ok_or(Error::Missing(id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Restoration fails closed: no partial document is returned on any read or contract error.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Model(#[from] content_history::Error),
|
||||
#[error(transparent)]
|
||||
Config(#[from] config_source::ConfigTreeError),
|
||||
#[error("missing object: {0:?}")]
|
||||
Missing(ObjectId),
|
||||
#[error("reader returned an object not matching requested identity: {0:?}")]
|
||||
HashMismatch(ObjectId),
|
||||
#[error("invalid adapter structure: {0}")]
|
||||
Structure(&'static str),
|
||||
#[error("unsupported adapter format")]
|
||||
Format,
|
||||
#[error("content is not UTF-8")]
|
||||
Utf8(#[from] std::string::FromUtf8Error),
|
||||
#[error("file/directory conflict: {0}")]
|
||||
PathConflict(String),
|
||||
#[error("Ticket identity does not match the restoration target")]
|
||||
TicketIdentity,
|
||||
#[error("authorized reader failed: {0}")]
|
||||
Read(String),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Builder {
|
||||
objects: BTreeMap<ObjectId, Object>,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
fn blob(&mut self, bytes: impl AsRef<[u8]>) -> ContentId {
|
||||
let object = Object::Blob(bytes.as_ref().to_vec());
|
||||
let ObjectId::Blob(id) = object.id() else {
|
||||
unreachable!()
|
||||
};
|
||||
self.objects.insert(object.id(), object);
|
||||
ContentId::Blob(id)
|
||||
}
|
||||
|
||||
fn tree(&mut self, tree: Tree) -> TreeId {
|
||||
let object = Object::Tree(tree);
|
||||
let ObjectId::Tree(id) = object.id() else {
|
||||
unreachable!()
|
||||
};
|
||||
self.objects.insert(object.id(), object);
|
||||
id
|
||||
}
|
||||
|
||||
fn map(&mut self, entries: Vec<Entry>) -> Result<TreeId, Error> {
|
||||
Ok(self.tree(Tree::map(entries)?))
|
||||
}
|
||||
|
||||
fn finish(self, root: TreeId) -> Snapshot {
|
||||
Snapshot {
|
||||
root,
|
||||
objects: self.objects,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn entry(key: impl Into<String>, target: ContentId) -> Entry {
|
||||
Entry {
|
||||
key: key.into(),
|
||||
target,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_object(
|
||||
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
|
||||
id: ObjectId,
|
||||
) -> Result<Object, Error> {
|
||||
let object = read(id)?;
|
||||
if object.id() != id {
|
||||
return Err(Error::HashMismatch(id));
|
||||
}
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
fn read_tree(
|
||||
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
|
||||
id: TreeId,
|
||||
ordered: bool,
|
||||
) -> Result<Tree, Error> {
|
||||
let Object::Tree(tree) = read_object(read, ObjectId::Tree(id))? else {
|
||||
return Err(Error::Structure("expected tree"));
|
||||
};
|
||||
if tree.is_ordered() != ordered {
|
||||
return Err(Error::Structure("incorrect tree ordering"));
|
||||
}
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
fn tree_id(target: ContentId) -> Result<TreeId, Error> {
|
||||
match target {
|
||||
ContentId::Tree(id) => Ok(id),
|
||||
_ => Err(Error::Structure("expected tree target")),
|
||||
}
|
||||
}
|
||||
|
||||
fn blob_id(target: ContentId) -> Result<BlobId, Error> {
|
||||
match target {
|
||||
ContentId::Blob(id) => Ok(id),
|
||||
_ => Err(Error::Structure("expected blob target")),
|
||||
}
|
||||
}
|
||||
|
||||
fn text(
|
||||
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
|
||||
target: ContentId,
|
||||
) -> Result<String, Error> {
|
||||
let Object::Blob(bytes) = read_object(read, ObjectId::Blob(blob_id(target)?))? else {
|
||||
return Err(Error::Structure("expected blob"));
|
||||
};
|
||||
Ok(String::from_utf8(bytes)?)
|
||||
}
|
||||
|
||||
fn fields(tree: &Tree, expected: &[&str]) -> Result<(), Error> {
|
||||
if tree.is_ordered()
|
||||
|| tree.entries().len() != expected.len()
|
||||
|| expected
|
||||
.iter()
|
||||
.any(|key| !tree.entries().iter().any(|e| e.key == *key))
|
||||
{
|
||||
return Err(Error::Structure("unexpected fields"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn field(tree: &Tree, key: &str) -> Result<ContentId, Error> {
|
||||
tree.entries()
|
||||
.iter()
|
||||
.find(|e| e.key == key)
|
||||
.map(|e| e.target)
|
||||
.ok_or(Error::Structure("missing field"))
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Configuration content is a real directory/name Merkle hierarchy with full-source blob leaves.
|
||||
//!
|
||||
//! The versioned root separates `files` from `types`: explicit content types are keyed by full
|
||||
//! virtual path in `types`. A type change therefore changes the root without changing source bytes.
|
||||
//! Types must not be guessed from suffixes: [`ConfigEntry`] permits explicit metadata independent
|
||||
//! of a filename. Digests are reconstructed through config-source, never substituted by Merkle IDs.
|
||||
//!
|
||||
//! Preparation validates the original snapshot, then rejects file/directory prefix conflicts that
|
||||
//! a flat snapshot can represent but a directory tree cannot. Restoration rejects empty non-root
|
||||
//! directories, malformed paths, unknown types, surplus/missing metadata, invalid UTF-8 and limits.
|
||||
//! Both directions use existing config-source validation; neither parses nor normalizes Decodal.
|
||||
//! Evaluation, policy checks and transactional adoption remain the caller's responsibility.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use config_source::{
|
||||
ConfigContentType, ConfigEntry, ConfigTreeSnapshot, MAX_ENTRY_COUNT, MAX_PATH_BYTES,
|
||||
MAX_TOTAL_BYTES, VirtualPath,
|
||||
};
|
||||
use content_history::{ContentId, Object, ObjectId, TreeId};
|
||||
|
||||
use crate::{Builder, Error, Snapshot, entry, field, fields, read_tree, text, tree_id};
|
||||
|
||||
const FORMAT: &str = "config-tree/v1";
|
||||
|
||||
#[derive(Default)]
|
||||
struct Directory {
|
||||
children: BTreeMap<String, Node>,
|
||||
}
|
||||
|
||||
enum Node {
|
||||
File(ContentId),
|
||||
Directory(Directory),
|
||||
}
|
||||
|
||||
impl Directory {
|
||||
fn insert(&mut self, parts: &[&str], target: ContentId, path: &str) -> Result<(), Error> {
|
||||
let name = parts[0];
|
||||
if parts.len() == 1 {
|
||||
if self
|
||||
.children
|
||||
.insert(name.into(), Node::File(target))
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error::PathConflict(path.into()));
|
||||
}
|
||||
} else {
|
||||
let child = self
|
||||
.children
|
||||
.entry(name.into())
|
||||
.or_insert_with(|| Node::Directory(Directory::default()));
|
||||
match child {
|
||||
Node::Directory(directory) => directory.insert(&parts[1..], target, path)?,
|
||||
Node::File(_) => return Err(Error::PathConflict(path.into())),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build(self, builder: &mut Builder) -> Result<TreeId, Error> {
|
||||
let mut entries = Vec::new();
|
||||
for (name, node) in self.children {
|
||||
let target = match node {
|
||||
Node::File(target) => target,
|
||||
Node::Directory(directory) => ContentId::Tree(directory.build(builder)?),
|
||||
};
|
||||
entries.push(entry(name, target));
|
||||
}
|
||||
builder.map(entries)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare objects only after validating all original snapshot metadata.
|
||||
pub fn prepare(snapshot: &ConfigTreeSnapshot) -> Result<Snapshot, Error> {
|
||||
snapshot.validate()?;
|
||||
let mut builder = Builder::default();
|
||||
let mut directory = Directory::default();
|
||||
let mut types = Vec::new();
|
||||
for (path, source) in &snapshot.entries {
|
||||
directory.insert(
|
||||
&path.as_str().split('/').collect::<Vec<_>>(),
|
||||
builder.blob(&source.content),
|
||||
path.as_str(),
|
||||
)?;
|
||||
let content_type = match source.content_type {
|
||||
ConfigContentType::Decodal => "decodal",
|
||||
ConfigContentType::Text => "text",
|
||||
};
|
||||
types.push(entry(path.as_str(), builder.blob(content_type)));
|
||||
}
|
||||
let files = directory.build(&mut builder)?;
|
||||
let types = builder.map(types)?;
|
||||
let format = builder.blob(FORMAT);
|
||||
let root = builder.map(vec![
|
||||
entry("format", format),
|
||||
entry("files", ContentId::Tree(files)),
|
||||
entry("types", ContentId::Tree(types)),
|
||||
])?;
|
||||
Ok(builder.finish(root))
|
||||
}
|
||||
|
||||
/// Read through an authorized reader and reconstruct validated ConfigEntry metadata and digests.
|
||||
/// The returned snapshot is a candidate, not a validated evaluation or an applied configuration.
|
||||
pub fn restore(
|
||||
root: TreeId,
|
||||
mut read: impl FnMut(ObjectId) -> Result<Object, Error>,
|
||||
) -> Result<ConfigTreeSnapshot, Error> {
|
||||
let root = read_tree(&mut read, root, false)?;
|
||||
fields(&root, &["format", "files", "types"])?;
|
||||
if text(&mut read, field(&root, "format")?)? != FORMAT {
|
||||
return Err(Error::Format);
|
||||
}
|
||||
let types = read_tree(&mut read, tree_id(field(&root, "types")?)?, false)?;
|
||||
if types.entries().len() > MAX_ENTRY_COUNT {
|
||||
return Err(Error::Structure("too many config types"));
|
||||
}
|
||||
let mut metadata = BTreeMap::new();
|
||||
for item in types.entries() {
|
||||
let path = VirtualPath::parse(&item.key)?;
|
||||
let kind = match text(&mut read, item.target)?.as_str() {
|
||||
"decodal" => ConfigContentType::Decodal,
|
||||
"text" => ConfigContentType::Text,
|
||||
_ => return Err(Error::Structure("unknown config content type")),
|
||||
};
|
||||
metadata.insert(path, kind);
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let mut total = 0;
|
||||
restore_directory(
|
||||
tree_id(field(&root, "files")?)?,
|
||||
"",
|
||||
&mut read,
|
||||
&mut metadata,
|
||||
&mut entries,
|
||||
&mut total,
|
||||
)?;
|
||||
if !metadata.is_empty() {
|
||||
return Err(Error::Structure("surplus config metadata"));
|
||||
}
|
||||
Ok(ConfigTreeSnapshot::from_entries(entries)?)
|
||||
}
|
||||
|
||||
fn restore_directory(
|
||||
id: TreeId,
|
||||
prefix: &str,
|
||||
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
|
||||
metadata: &mut BTreeMap<VirtualPath, ConfigContentType>,
|
||||
entries: &mut Vec<ConfigEntry>,
|
||||
total: &mut usize,
|
||||
) -> Result<(), Error> {
|
||||
let directory = read_tree(read, id, false)?;
|
||||
if !prefix.is_empty() && directory.entries().is_empty() {
|
||||
return Err(Error::Structure("empty non-root config directory"));
|
||||
}
|
||||
// Limit directory fanout and path length even for malformed externally supplied trees.
|
||||
if directory.entries().len() > MAX_ENTRY_COUNT {
|
||||
return Err(Error::Structure("too many config children"));
|
||||
}
|
||||
for item in directory.entries() {
|
||||
if item.key.contains('/') {
|
||||
return Err(Error::Structure("config child must be one path component"));
|
||||
}
|
||||
let path = if prefix.is_empty() {
|
||||
item.key.clone()
|
||||
} else {
|
||||
format!("{prefix}/{}", item.key)
|
||||
};
|
||||
if path.len() > MAX_PATH_BYTES {
|
||||
return Err(Error::Structure("config path too long"));
|
||||
}
|
||||
let path = VirtualPath::parse(path)?;
|
||||
match item.target {
|
||||
ContentId::Tree(child) => {
|
||||
// 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)) {
|
||||
return Err(Error::Structure("config directory has no file metadata"));
|
||||
}
|
||||
restore_directory(child, path.as_str(), read, metadata, entries, total)?
|
||||
}
|
||||
ContentId::Blob(_) => {
|
||||
let kind = metadata
|
||||
.remove(&path)
|
||||
.ok_or(Error::Structure("missing config metadata"))?;
|
||||
let source = text(read, item.target)?;
|
||||
*total = total
|
||||
.checked_add(source.len())
|
||||
.ok_or(Error::Structure("config bytes overflow"))?;
|
||||
if *total > MAX_TOTAL_BYTES || entries.len() >= MAX_ENTRY_COUNT {
|
||||
return Err(Error::Structure("config snapshot limits exceeded"));
|
||||
}
|
||||
entries.push(ConfigEntry::new(path, kind, source)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Ticket compound-document contract, independent of the Ticket service and business events.
|
||||
//!
|
||||
//! The versioned root map includes `ticket-id`, `title`, `initial` and `posts`. `initial` is a map
|
||||
//! of `author` and `body`; `posts` is an ordered sequence keyed by stable post IDs, each targeting
|
||||
//! an `author`/`body` map. Order is never sorted for hash stability. All text is complete UTF-8,
|
||||
//! including comments, Unicode and original line endings. Empty bodies and titles are preserved.
|
||||
//!
|
||||
//! Ticket identity deliberately participates in content: equal text on different Tickets yields
|
||||
//! different roots. It is a business identity, not a content ID. Restoration requires the expected
|
||||
//! identity and cannot retarget content to a different Ticket. Original authors participate in the
|
||||
//! snapshot; authenticated history editors and edit times belong to a separate History record.
|
||||
//!
|
||||
//! These local types exclude state, assignment, relations, evidence and the existing content_digest.
|
||||
//! Restoration returns only a document candidate; it cannot rewind business events or replace the
|
||||
//! existing API's CAS. A caller restoring an old root must append a new history with current parent
|
||||
//! and authenticated editor facts, and adopt content under its own business authorization/CAS.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use content_history::{ContentId, Object, ObjectId, Tree, TreeId};
|
||||
|
||||
use crate::{Builder, Error, Snapshot, entry, field, fields, read_tree, text, tree_id};
|
||||
|
||||
const FORMAT: &str = "ticket-document/v1";
|
||||
|
||||
/// Content-only view. `initial_author` is the original author, not the latest history editor.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Document {
|
||||
pub ticket_id: String,
|
||||
pub title: String,
|
||||
pub initial_body: String,
|
||||
pub initial_author: String,
|
||||
pub posts: Vec<Post>,
|
||||
}
|
||||
|
||||
/// Stable business identity and original authorship survive edits and restoration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Post {
|
||||
pub id: String,
|
||||
pub author: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
fn validate(&self) -> Result<(), Error> {
|
||||
if self.ticket_id.is_empty() {
|
||||
return Err(Error::Structure("empty Ticket identity"));
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
for post in &self.posts {
|
||||
if post.id.is_empty() || !ids.insert(&post.id) {
|
||||
return Err(Error::Structure("empty or duplicate post identity"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn message(builder: &mut Builder, author: &str, body: &str) -> Result<TreeId, Error> {
|
||||
let author = builder.blob(author);
|
||||
let body = builder.blob(body);
|
||||
builder.map(vec![entry("author", author), entry("body", body)])
|
||||
}
|
||||
|
||||
/// Prepare a candidate with explicit root components and semantically ordered posts.
|
||||
pub fn prepare(document: &Document) -> Result<Snapshot, Error> {
|
||||
document.validate()?;
|
||||
let mut builder = Builder::default();
|
||||
let initial = message(
|
||||
&mut builder,
|
||||
&document.initial_author,
|
||||
&document.initial_body,
|
||||
)?;
|
||||
let mut posts = Vec::new();
|
||||
for post in &document.posts {
|
||||
let content = message(&mut builder, &post.author, &post.body)?;
|
||||
posts.push(entry(&post.id, ContentId::Tree(content)));
|
||||
}
|
||||
let posts = builder.tree(Tree::sequence(posts)?);
|
||||
let format = builder.blob(FORMAT);
|
||||
let identity = builder.blob(&document.ticket_id);
|
||||
let title = builder.blob(&document.title);
|
||||
let root = builder.map(vec![
|
||||
entry("format", format),
|
||||
entry("ticket-id", identity),
|
||||
entry("title", title),
|
||||
entry("initial", ContentId::Tree(initial)),
|
||||
entry("posts", ContentId::Tree(posts)),
|
||||
])?;
|
||||
Ok(builder.finish(root))
|
||||
}
|
||||
|
||||
/// Restore content only and verify the intended business identity before returning any document.
|
||||
pub fn restore(
|
||||
root: TreeId,
|
||||
expected_ticket_id: &str,
|
||||
mut read: impl FnMut(ObjectId) -> Result<Object, Error>,
|
||||
) -> Result<Document, Error> {
|
||||
let root = read_tree(&mut read, root, false)?;
|
||||
fields(&root, &["format", "ticket-id", "title", "initial", "posts"])?;
|
||||
if text(&mut read, field(&root, "format")?)? != FORMAT {
|
||||
return Err(Error::Format);
|
||||
}
|
||||
let ticket_id = text(&mut read, field(&root, "ticket-id")?)?;
|
||||
if ticket_id != expected_ticket_id {
|
||||
return Err(Error::TicketIdentity);
|
||||
}
|
||||
let title = text(&mut read, field(&root, "title")?)?;
|
||||
let (initial_author, initial_body) = restore_message(&mut read, field(&root, "initial")?)?;
|
||||
let posts = read_tree(&mut read, tree_id(field(&root, "posts")?)?, true)?;
|
||||
let mut restored = Vec::new();
|
||||
for post in posts.entries() {
|
||||
let (author, body) = restore_message(&mut read, post.target)?;
|
||||
restored.push(Post {
|
||||
id: post.key.clone(),
|
||||
author,
|
||||
body,
|
||||
});
|
||||
}
|
||||
let document = Document {
|
||||
ticket_id,
|
||||
title,
|
||||
initial_body,
|
||||
initial_author,
|
||||
posts: restored,
|
||||
};
|
||||
document.validate()?;
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
fn restore_message(
|
||||
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
|
||||
target: ContentId,
|
||||
) -> Result<(String, String), Error> {
|
||||
let message = read_tree(read, tree_id(target)?, false)?;
|
||||
fields(&message, &["author", "body"])?;
|
||||
Ok((
|
||||
text(read, field(&message, "author")?)?,
|
||||
text(read, field(&message, "body")?)?,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "content-history-store"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
content-history.workspace = true
|
||||
rusqlite.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
content-history-adapters.workspace = true
|
||||
config-source.workspace = true
|
||||
@@ -0,0 +1,38 @@
|
||||
# content-history-store
|
||||
|
||||
Persists immutable content and append-only editing history on the caller's SQLite transaction.
|
||||
The pure canonical model belongs to `content-history`; this crate does not evaluate configuration,
|
||||
edit Tickets, authenticate users, or replace their existing CAS contracts.
|
||||
|
||||
[`ScopedStore`] is a trusted Host adapter, not an untrusted SQL service. Its caller implements
|
||||
[`Authority`] using existing current operation authorization for the exact [`ScopeKey`]. Every
|
||||
operation rechecks authority before reading stored metadata. A hash is never a capability: even
|
||||
preparation requires complete verified bytes, and public reads require reachability from an
|
||||
adopted history in that same workspace and target. Physical deduplication does not grant access.
|
||||
|
||||
[`SCHEMA`] is the domain's schema contribution. The eventual adopting Server domain must install
|
||||
it through its existing ordered migration and fresh-schema path, not through a second startup
|
||||
migration runner. This foundation does not change the production configuration writer. The
|
||||
adapter accepts the same `rusqlite::Transaction` used by the Server. The caller prepares complete
|
||||
objects, validates one candidate snapshot, calls [`ScopedStore::adopt`] with the observed history
|
||||
head, and updates source/projection/business records before committing that **same** transaction.
|
||||
An error must roll back the caller's transaction; a successful adapter call alone is not successful
|
||||
business adoption. Each individual mutating method also uses a savepoint, so catching its error
|
||||
cannot retain its partial changes. SQLite writer acquisition and BUSY/retry policy remain caller-owned.
|
||||
No automatic retries or external effects are hidden here.
|
||||
|
||||
Normal edits retain all adopted ancestors and their full snapshots; [`ScopedStore::collect_unadopted`]
|
||||
only removes preparations not used by any adopted history. Object rows cannot be overwritten
|
||||
through the adapter. A supplied hash/bytes mismatch, a differing existing row, a missing child,
|
||||
wrong child kind or unsupported canonical format fails rather than publishing an incomplete head.
|
||||
Stored bytes are verified on every read, not reserialized under a newer version.
|
||||
|
||||
[`ScopedStore::erase`] requires separate explicit authorization, tombstones the exact target
|
||||
permanently and removes its ownership references. Old hashes cannot recreate that target's data.
|
||||
Other targets' retained or prepared references keep shared physical objects alive. A newly created
|
||||
business object needs a new target identity; erasure is not an edit or a rewind. This is logical
|
||||
store erasure, not secure disk overwrite or revocation of already exported copies/backups. The
|
||||
caller owns retention decisions, backup policy and authenticated editor/time facts. Restoring an
|
||||
old content root is a new adoption with the **current** history parent, never resetting head to an
|
||||
old history. Existing Ticket `content_digest`, Ticket/post identities and business events remain
|
||||
separate contracts owned by their domain.
|
||||
@@ -0,0 +1,248 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use content_history::{History, HistoryId, Object, ObjectId, TreeId, verify};
|
||||
use rusqlite::{OptionalExtension, Transaction, params};
|
||||
|
||||
/// New domain tables, deliberately not an alternate Server migration runner.
|
||||
/// The adopting domain installs these through its ordered schema migration and fresh schema.
|
||||
pub const SCHEMA: &str = include_str!("schema.sql");
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("content access denied")]
|
||||
Denied,
|
||||
#[error("content unavailable in authorized scope")]
|
||||
Unavailable,
|
||||
#[error("content scope was explicitly erased")]
|
||||
Erased,
|
||||
#[error("observed history head is stale")]
|
||||
Conflict,
|
||||
#[error("invalid content: {0}")]
|
||||
Model(#[from] content_history::Error),
|
||||
#[error("invalid stored history reference")]
|
||||
InvalidReference,
|
||||
#[error("immutable content differs from stored bytes")]
|
||||
ImmutableMismatch,
|
||||
#[error("sqlite content store: {0}")]
|
||||
Sql(#[from] rusqlite::Error),
|
||||
}
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// The business owner supplies exact identities, not path containment rules.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScopeKey {
|
||||
pub workspace: String,
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
Read,
|
||||
Write,
|
||||
Erase,
|
||||
}
|
||||
|
||||
/// Existing caller authority is checked at each operation, before querying objects.
|
||||
/// This is a trusted Host integration seam, not a grant constructor or authentication system.
|
||||
pub trait Authority {
|
||||
fn allows(&self, key: &ScopeKey, access: Access) -> bool;
|
||||
}
|
||||
|
||||
/// An adapter borrows the caller's real transaction; it never commits business adoption.
|
||||
/// Do not expose this type or its authority implementation directly to untrusted code.
|
||||
pub struct ScopedStore<'a, 'conn> {
|
||||
tx: &'a Transaction<'conn>,
|
||||
key: ScopeKey,
|
||||
authority: &'a dyn Authority,
|
||||
}
|
||||
|
||||
impl<'a, 'conn> ScopedStore<'a, 'conn> {
|
||||
pub fn new(tx: &'a Transaction<'conn>, key: ScopeKey, authority: &'a dyn Authority) -> Self {
|
||||
Self { tx, key, authority }
|
||||
}
|
||||
|
||||
fn authorize(&self, access: Access) -> Result<()> {
|
||||
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); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_scope(&self) -> Result<()> {
|
||||
self.tx.execute(
|
||||
"INSERT OR IGNORE INTO content_scopes(workspace,target) VALUES(?1,?2)",
|
||||
params![self.key.workspace, self.key.target],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn head(&self) -> Result<Option<HistoryId>> {
|
||||
self.authorize(Access::Read)?;
|
||||
self.head_inner()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// Preparation requires the full canonical bytes, not possession of another scope's hash.
|
||||
/// Prepared objects are not readable snapshots until adoption succeeds.
|
||||
pub fn put(&self, claimed: ObjectId, bytes: &[u8]) -> Result<()> {
|
||||
self.authorize(Access::Write)?;
|
||||
verify(claimed, bytes)?;
|
||||
self.atomic(|| self.put_inner(claimed, bytes))
|
||||
}
|
||||
|
||||
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])?;
|
||||
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); }
|
||||
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(())
|
||||
}
|
||||
|
||||
/// A hash read is confined to objects reachable from this target's adopted history.
|
||||
/// Unknown and cross-target hashes have the same result, regardless of physical deduplication.
|
||||
pub fn read(&self, id: ObjectId) -> Result<Object> {
|
||||
self.authorize(Access::Read)?;
|
||||
let (kind, hash) = parts(id);
|
||||
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); }
|
||||
self.load_prepared(id)
|
||||
}
|
||||
|
||||
fn load_prepared(&self, id: ObjectId) -> Result<Object> {
|
||||
let (kind, hash) = parts(id);
|
||||
let bytes: Option<Vec<u8>> = self.tx.query_row(
|
||||
"SELECT o.bytes FROM content_objects o JOIN content_scope_objects s ON s.kind=o.kind AND s.hash=o.hash WHERE s.workspace=?1 AND s.target=?2 AND s.kind=?3 AND s.hash=?4",
|
||||
params![self.key.workspace, self.key.target, kind, hash], |r| r.get(0)).optional()?;
|
||||
Ok(verify(id, &bytes.ok_or(Error::Unavailable)?)?)
|
||||
}
|
||||
|
||||
/// Restore uses a retained history, not a chain of text deltas. The returned graph includes
|
||||
/// every full blob and structural node and fails on any missing or corrupt descendant.
|
||||
pub fn snapshot(&self, history: HistoryId) -> Result<BTreeMap<ObjectId, Object>> {
|
||||
let Object::History(record) = self.read(ObjectId::History(history))? else {
|
||||
return Err(Error::InvalidReference);
|
||||
};
|
||||
self.walk([ObjectId::Tree(record.root)], true)
|
||||
}
|
||||
|
||||
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)? };
|
||||
match &object {
|
||||
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)); }
|
||||
}
|
||||
}
|
||||
objects.insert(id, object);
|
||||
}
|
||||
Ok(objects)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
self.authorize(Access::Write)?;
|
||||
self.atomic(|| {
|
||||
if self.head_inner()? != expected { return Err(Error::Conflict); }
|
||||
let mut graph = self.walk([ObjectId::Tree(root)], false)?;
|
||||
if let Some(parent) = expected {
|
||||
// Validate all retained ancestors too; corruption cannot become a successful new head.
|
||||
graph.extend(self.walk([ObjectId::History(parent)], false)?);
|
||||
}
|
||||
let history = Object::History(History { root, parent: expected, actor, timestamp_micros });
|
||||
let ObjectId::History(id) = history.id() else { unreachable!() };
|
||||
self.put_inner(history.id(), &history.encode())?;
|
||||
graph.insert(history.id(), history);
|
||||
for object in graph.keys() {
|
||||
let (kind, hash) = parts(*object);
|
||||
self.tx.execute("INSERT OR IGNORE INTO content_reachable(workspace,target,kind,hash) VALUES(?1,?2,?3,?4)",
|
||||
params![self.key.workspace, self.key.target, kind, hash])?;
|
||||
}
|
||||
let changed = self.tx.execute(
|
||||
"UPDATE content_scopes SET head=?3 WHERE workspace=?1 AND target=?2 AND erased=0 AND head IS ?4",
|
||||
params![self.key.workspace, self.key.target, id.as_bytes().as_slice(), expected.map(|h| h.as_bytes().to_vec())])?;
|
||||
if changed != 1 { return Err(Error::Conflict); }
|
||||
Ok(id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Retains every adopted history. Only unadopted preparations are reclaimed.
|
||||
/// Physical rows survive while *any* target has prepared or retained ownership.
|
||||
pub fn collect_unadopted(&self) -> Result<()> {
|
||||
self.authorize(Access::Write)?;
|
||||
self.atomic(|| {
|
||||
self.tx.execute("DELETE FROM content_scope_objects AS s WHERE workspace=?1 AND target=?2 AND NOT EXISTS(SELECT 1 FROM content_reachable r WHERE r.workspace=s.workspace AND r.target=s.target AND r.kind=s.kind AND r.hash=s.hash)",
|
||||
params![self.key.workspace, self.key.target])?;
|
||||
self.collect_physical()
|
||||
})
|
||||
}
|
||||
|
||||
/// Erasure is permanent for this exact business identity, including its old roots.
|
||||
/// Shared bytes retained by other authorized targets are not destroyed. Backups and bytes
|
||||
/// already exported by callers are outside this adapter's logical erasure guarantee.
|
||||
pub fn erase(&self) -> Result<()> {
|
||||
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.collect_physical()
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_physical(&self) -> Result<()> {
|
||||
self.tx.execute("DELETE FROM content_objects AS o WHERE NOT EXISTS(SELECT 1 FROM content_scope_objects s WHERE s.kind=o.kind AND s.hash=o.hash)", [])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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) },
|
||||
Err(error) => {
|
||||
self.tx.execute_batch("ROLLBACK TO content_history_operation; RELEASE content_history_operation")?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE content_objects (
|
||||
kind INTEGER NOT NULL CHECK(kind IN (1,2,3)),
|
||||
hash BLOB NOT NULL CHECK(length(hash)=32),
|
||||
bytes BLOB NOT NULL,
|
||||
PRIMARY KEY(kind,hash)
|
||||
) WITHOUT ROWID;
|
||||
CREATE TABLE content_scopes (
|
||||
workspace TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
head BLOB CHECK(head IS NULL OR length(head)=32),
|
||||
erased INTEGER NOT NULL DEFAULT 0 CHECK(erased IN (0,1)),
|
||||
PRIMARY KEY(workspace,target),
|
||||
CHECK(erased=0 OR head IS NULL)
|
||||
) WITHOUT ROWID;
|
||||
CREATE TABLE content_scope_objects (
|
||||
workspace TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
kind INTEGER NOT NULL,
|
||||
hash BLOB NOT NULL,
|
||||
PRIMARY KEY(workspace,target,kind,hash),
|
||||
FOREIGN KEY(workspace,target) REFERENCES content_scopes(workspace,target),
|
||||
FOREIGN KEY(kind,hash) REFERENCES content_objects(kind,hash)
|
||||
) WITHOUT ROWID;
|
||||
CREATE TABLE content_reachable (
|
||||
workspace TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
kind INTEGER NOT NULL,
|
||||
hash BLOB NOT NULL,
|
||||
PRIMARY KEY(workspace,target,kind,hash),
|
||||
FOREIGN KEY(workspace,target,kind,hash)
|
||||
REFERENCES content_scope_objects(workspace,target,kind,hash)
|
||||
) WITHOUT ROWID;
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "content-history"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[[example]]
|
||||
name = "wasm_parity"
|
||||
crate-type = ["cdylib"]
|
||||
@@ -0,0 +1,65 @@
|
||||
# Content history
|
||||
|
||||
This crate owns immutable full-text content, typed identities and pure structural
|
||||
operations. It does not own persistence, authorization, retention decisions,
|
||||
transaction adoption, head compare-and-set, application events or text rendering.
|
||||
[`Object`] and [`Tree`] define canonical content; [`History`] separates editing
|
||||
identity from content identity. [`restore`], [`diff`] and [`replace_path`] operate
|
||||
through a caller-scoped [`ObjectReader`].
|
||||
|
||||
## Canonical identity and compatibility
|
||||
|
||||
Version 1 hashes the entire canonical encoding using SHA-256, including object
|
||||
kind and format version. Every object starts with `YCH\0`, a big-endian `u16`
|
||||
version (`1`), and a kind byte (`1` blob, `2` tree, `3` history). Integer lengths
|
||||
are unsigned big-endian `u64`; strings are UTF-8 prefixed by their byte length.
|
||||
|
||||
- A blob contains a length and the exact full bytes.
|
||||
- A tree contains a mode byte (`0` map, `1` sequence), an entry count, then each
|
||||
length-prefixed key, child kind byte (`1` blob, `2` tree), and 32 hash bytes.
|
||||
- A history contains 32 root hash bytes, a parent flag (`0` absent, `1` present),
|
||||
optional 32 parent hash bytes, a length-prefixed actor, and a signed big-endian
|
||||
`i64` timestamp in microseconds.
|
||||
|
||||
Map keys are unique and sorted by exact UTF-8 byte order. Sequences also have
|
||||
unique keys but preserve input order. Decoding never sorts a noncanonical map.
|
||||
There is no Unicode, newline, whitespace, path or source normalization. Empty
|
||||
keys and separator characters are opaque strings; adapters own path validation.
|
||||
No extension fields or trailing bytes are accepted. Unsupported versions fail
|
||||
explicitly: a future migration must retain old bytes and identities, not silently
|
||||
reserialize old objects under a new format. [`verify`] checks kind and hash after
|
||||
strict decoding. Typed IDs are identities, not evidence that an object exists.
|
||||
|
||||
## Store and adapter boundary
|
||||
|
||||
Readers must be bound to an authorized business target and retained root/history.
|
||||
Knowing a hash must not grant access. They must refuse erased or revoked content
|
||||
for that target even when shared physical bytes remain for another target. The
|
||||
model cannot enforce this policy or safely implement global garbage collection.
|
||||
Readers also own I/O buffer bounds; [`TraversalLimits`] bounds work after reading.
|
||||
Full restoration validates every reachable object and returns no partial success.
|
||||
A structural diff intentionally skips equal IDs and does not prove closure integrity.
|
||||
|
||||
Path replacement returns new ancestor objects without writing anything. The
|
||||
caller must validate and save the complete closure, append a history with the
|
||||
current head as its parent, and CAS-adopt the new history in the same caller-owned
|
||||
transaction as business projections. A rollback or stale head must leave the old
|
||||
head and content unchanged. Comparing only content roots would accept stale
|
||||
A-to-B-to-A edits. Restoring an old root reuses content but creates a new history;
|
||||
it never rewinds business events or adopts the old history identity.
|
||||
|
||||
Configuration adapters can map validated virtual path components to map entries
|
||||
and exact source files to blobs. Ticket adapters can map stable document fields
|
||||
to blobs and stable post IDs to a sequence; reordering posts changes content.
|
||||
Ticket IDs, post IDs, existing content digests, content roots and history heads
|
||||
remain distinct contracts. Neither adapter belongs in this pure model. Contract
|
||||
fixtures exercise these shapes without claiming database or authorization coverage.
|
||||
|
||||
## Cross-target fixture
|
||||
|
||||
`tests/fixtures/v1.tsv` freezes bytes and hashes independently derived from the
|
||||
format. The `wasm_parity` raw `cdylib` example exports `parity_len` (digest words)
|
||||
and `parity_word(index)` (four digest bytes interpreted big-endian). It has no JS
|
||||
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.
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Raw WASM parity fixture. No allocator pointers, JavaScript packages or host imports.
|
||||
#[path = "../tests/common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn parity_len() -> u32 {
|
||||
64
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn parity_word(index: u32) -> u32 {
|
||||
common::parity_word(index)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod model;
|
||||
mod traversal;
|
||||
|
||||
pub use model::{
|
||||
BlobId, ContentId, Entry, Error, History, HistoryId, Object, ObjectId, Tree, TreeId, verify,
|
||||
};
|
||||
pub use traversal::{
|
||||
Change, ObjectReader, RestoredContent, RestoredEntry, Rewrite, TraversalLimits, diff,
|
||||
replace_path, restore,
|
||||
};
|
||||
@@ -0,0 +1,337 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeSet, fmt};
|
||||
|
||||
macro_rules! identifier {
|
||||
($name:ident) => {
|
||||
/// SHA-256 identity of canonical versioned bytes, not an access capability.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct $name([u8; 32]);
|
||||
impl $name {
|
||||
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
pub const fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for byte in self.0 {
|
||||
write!(f, "{byte:02x}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl fmt::Debug for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}({self})", stringify!($name))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
identifier!(BlobId);
|
||||
identifier!(TreeId);
|
||||
identifier!(HistoryId);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum ContentId {
|
||||
Blob(BlobId),
|
||||
Tree(TreeId),
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum ObjectId {
|
||||
Blob(BlobId),
|
||||
Tree(TreeId),
|
||||
History(HistoryId),
|
||||
}
|
||||
impl From<ContentId> for ObjectId {
|
||||
fn from(id: ContentId) -> Self {
|
||||
match id {
|
||||
ContentId::Blob(id) => Self::Blob(id),
|
||||
ContentId::Tree(id) => Self::Tree(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure is explicit: malformed or incomplete snapshots are never partial successes.
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
#[error("invalid object magic")]
|
||||
InvalidMagic,
|
||||
#[error("unsupported format version {0}")]
|
||||
UnsupportedVersion(u16),
|
||||
#[error("invalid object tag {0}")]
|
||||
InvalidTag(u8),
|
||||
#[error("invalid child kind {0}")]
|
||||
InvalidChildKind(u8),
|
||||
#[error("invalid tree mode {0}")]
|
||||
InvalidTreeMode(u8),
|
||||
#[error("invalid parent flag {0}")]
|
||||
InvalidParentFlag(u8),
|
||||
#[error("truncated object")]
|
||||
Truncated,
|
||||
#[error("length does not fit remaining bytes or platform")]
|
||||
InvalidLength,
|
||||
#[error("invalid UTF-8")]
|
||||
InvalidUtf8,
|
||||
#[error("trailing bytes")]
|
||||
TrailingBytes,
|
||||
#[error("duplicate tree key {0:?}")]
|
||||
DuplicateKey(String),
|
||||
#[error("map entries are not in strict UTF-8 byte order")]
|
||||
NonCanonicalMap,
|
||||
#[error("object kind or hash does not match requested identity")]
|
||||
HashMismatch,
|
||||
#[error("missing object {0:?}")]
|
||||
MissingObject(ObjectId),
|
||||
#[error("reader failed: {0}")]
|
||||
Read(String),
|
||||
#[error("missing path key {0:?}")]
|
||||
MissingKey(String),
|
||||
#[error("path component is not a tree")]
|
||||
NotTree,
|
||||
#[error("replacement path must not be empty")]
|
||||
EmptyPath,
|
||||
#[error("traversal exceeds {0} limit")]
|
||||
LimitExceeded(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub key: String,
|
||||
pub target: ContentId,
|
||||
}
|
||||
|
||||
/// Maps sort by exact UTF-8 bytes; sequences retain semantic order. Both have unique keys.
|
||||
/// Empty strings, slashes and Unicode are opaque keys, not normalized virtual paths.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Tree {
|
||||
ordered: bool,
|
||||
entries: Vec<Entry>,
|
||||
}
|
||||
impl Tree {
|
||||
pub fn map(mut entries: Vec<Entry>) -> Result<Self, Error> {
|
||||
entries.sort_by(|a, b| a.key.cmp(&b.key));
|
||||
Self::build(entries, false)
|
||||
}
|
||||
pub fn sequence(entries: Vec<Entry>) -> Result<Self, Error> {
|
||||
Self::build(entries, true)
|
||||
}
|
||||
fn build(entries: Vec<Entry>, ordered: bool) -> Result<Self, Error> {
|
||||
let mut keys = BTreeSet::new();
|
||||
for entry in &entries {
|
||||
if !keys.insert(&entry.key) {
|
||||
return Err(Error::DuplicateKey(entry.key.clone()));
|
||||
}
|
||||
}
|
||||
Ok(Self { ordered, entries })
|
||||
}
|
||||
pub fn entries(&self) -> &[Entry] {
|
||||
&self.entries
|
||||
}
|
||||
pub fn is_ordered(&self) -> bool {
|
||||
self.ordered
|
||||
}
|
||||
/// Replace in place, append new sequence keys, or delete an existing key.
|
||||
/// Deleting a missing key is an error; this never mutates the old tree.
|
||||
pub fn replace(&self, key: &str, replacement: Option<ContentId>) -> Result<Self, Error> {
|
||||
let mut entries = self.entries.clone();
|
||||
match (entries.iter().position(|e| e.key == key), replacement) {
|
||||
(Some(index), Some(target)) => entries[index].target = target,
|
||||
(Some(index), None) => {
|
||||
entries.remove(index);
|
||||
}
|
||||
(None, Some(target)) => entries.push(Entry {
|
||||
key: key.into(),
|
||||
target,
|
||||
}),
|
||||
(None, None) => return Err(Error::MissingKey(key.into())),
|
||||
}
|
||||
if self.ordered {
|
||||
Self::sequence(entries)
|
||||
} else {
|
||||
Self::map(entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Caller-authenticated editor/time facts. Restoring old content creates a new record
|
||||
/// whose parent is the current head; this type does not implement adoption or head CAS.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct History {
|
||||
pub root: TreeId,
|
||||
pub parent: Option<HistoryId>,
|
||||
pub actor: String,
|
||||
pub timestamp_micros: i64,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Object {
|
||||
Blob(Vec<u8>),
|
||||
Tree(Tree),
|
||||
History(History),
|
||||
}
|
||||
|
||||
impl Object {
|
||||
/// Canonical format v1. See the crate documentation for the compatibility boundary.
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = b"YCH\0\0\x01".to_vec();
|
||||
match self {
|
||||
Self::Blob(bytes) => {
|
||||
out.push(1);
|
||||
put_bytes(&mut out, bytes);
|
||||
}
|
||||
Self::Tree(tree) => {
|
||||
out.push(2);
|
||||
out.push(u8::from(tree.ordered));
|
||||
put_u64(&mut out, tree.entries.len());
|
||||
for entry in &tree.entries {
|
||||
put_bytes(&mut out, entry.key.as_bytes());
|
||||
match entry.target {
|
||||
ContentId::Blob(id) => {
|
||||
out.push(1);
|
||||
out.extend_from_slice(id.as_bytes());
|
||||
}
|
||||
ContentId::Tree(id) => {
|
||||
out.push(2);
|
||||
out.extend_from_slice(id.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::History(history) => {
|
||||
out.push(3);
|
||||
out.extend_from_slice(history.root.as_bytes());
|
||||
match history.parent {
|
||||
Some(id) => {
|
||||
out.push(1);
|
||||
out.extend_from_slice(id.as_bytes());
|
||||
}
|
||||
None => out.push(0),
|
||||
}
|
||||
put_bytes(&mut out, history.actor.as_bytes());
|
||||
out.extend_from_slice(&history.timestamp_micros.to_be_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Strict decoding rejects unknown versions, duplicate/unsorted maps and trailing bytes.
|
||||
/// Lengths are checked against input before allocation; no input-driven capacity reservation.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
|
||||
let mut input = Input(bytes);
|
||||
if input.take(4)? != b"YCH\0" {
|
||||
return Err(Error::InvalidMagic);
|
||||
}
|
||||
let version = u16::from_be_bytes(input.array()?);
|
||||
if version != 1 {
|
||||
return Err(Error::UnsupportedVersion(version));
|
||||
}
|
||||
let object = match input.byte()? {
|
||||
1 => Self::Blob(input.bytes()?.to_vec()),
|
||||
2 => {
|
||||
let ordered = match input.byte()? {
|
||||
0 => false,
|
||||
1 => true,
|
||||
mode => return Err(Error::InvalidTreeMode(mode)),
|
||||
};
|
||||
let count = input.length()?;
|
||||
// Each entry needs a u64 key length, a kind and 32 digest bytes.
|
||||
if count > input.0.len() / 41 {
|
||||
return Err(Error::InvalidLength);
|
||||
}
|
||||
let mut entries: Vec<Entry> = Vec::new();
|
||||
for _ in 0..count {
|
||||
let key = input.string()?;
|
||||
if !ordered && entries.last().is_some_and(|last| last.key >= key) {
|
||||
return Err(Error::NonCanonicalMap);
|
||||
}
|
||||
let kind = input.byte()?;
|
||||
let target = match kind {
|
||||
1 => ContentId::Blob(BlobId(input.array()?)),
|
||||
2 => ContentId::Tree(TreeId(input.array()?)),
|
||||
_ => return Err(Error::InvalidChildKind(kind)),
|
||||
};
|
||||
entries.push(Entry { key, target });
|
||||
}
|
||||
Self::Tree(Tree::build(entries, ordered)?)
|
||||
}
|
||||
3 => {
|
||||
let root = TreeId(input.array()?);
|
||||
let parent = match input.byte()? {
|
||||
0 => None,
|
||||
1 => Some(HistoryId(input.array()?)),
|
||||
flag => return Err(Error::InvalidParentFlag(flag)),
|
||||
};
|
||||
let actor = input.string()?;
|
||||
let timestamp_micros = i64::from_be_bytes(input.array()?);
|
||||
Self::History(History {
|
||||
root,
|
||||
parent,
|
||||
actor,
|
||||
timestamp_micros,
|
||||
})
|
||||
}
|
||||
tag => return Err(Error::InvalidTag(tag)),
|
||||
};
|
||||
if !input.0.is_empty() {
|
||||
return Err(Error::TrailingBytes);
|
||||
}
|
||||
Ok(object)
|
||||
}
|
||||
pub fn id(&self) -> ObjectId {
|
||||
let hash: [u8; 32] = Sha256::digest(self.encode()).into();
|
||||
match self {
|
||||
Self::Blob(_) => ObjectId::Blob(BlobId(hash)),
|
||||
Self::Tree(_) => ObjectId::Tree(TreeId(hash)),
|
||||
Self::History(_) => ObjectId::History(HistoryId(hash)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate exact stored bytes and requested kind before returning any content.
|
||||
/// Decoding is intentionally first, so unsupported formats remain diagnosable.
|
||||
pub fn verify(id: ObjectId, bytes: &[u8]) -> Result<Object, Error> {
|
||||
let object = Object::decode(bytes)?;
|
||||
if object.id() != id {
|
||||
return Err(Error::HashMismatch);
|
||||
}
|
||||
Ok(object)
|
||||
}
|
||||
fn put_u64(out: &mut Vec<u8>, length: usize) {
|
||||
out.extend_from_slice(&(length as u64).to_be_bytes());
|
||||
}
|
||||
fn put_bytes(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||
put_u64(out, bytes.len());
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
struct Input<'a>(&'a [u8]);
|
||||
impl<'a> Input<'a> {
|
||||
fn take(&mut self, count: usize) -> Result<&'a [u8], Error> {
|
||||
if count > self.0.len() {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
let (head, tail) = self.0.split_at(count);
|
||||
self.0 = tail;
|
||||
Ok(head)
|
||||
}
|
||||
fn array<const N: usize>(&mut self) -> Result<[u8; N], Error> {
|
||||
Ok(self.take(N)?.try_into().expect("checked slice length"))
|
||||
}
|
||||
fn byte(&mut self) -> Result<u8, Error> {
|
||||
Ok(self.array::<1>()?[0])
|
||||
}
|
||||
fn length(&mut self) -> Result<usize, Error> {
|
||||
usize::try_from(u64::from_be_bytes(self.array()?)).map_err(|_| Error::InvalidLength)
|
||||
}
|
||||
fn bytes(&mut self) -> Result<&'a [u8], Error> {
|
||||
let length = self.length()?;
|
||||
if length > self.0.len() {
|
||||
return Err(Error::InvalidLength);
|
||||
}
|
||||
self.take(length)
|
||||
}
|
||||
fn string(&mut self) -> Result<String, Error> {
|
||||
std::str::from_utf8(self.bytes()?)
|
||||
.map(str::to_owned)
|
||||
.map_err(|_| Error::InvalidUtf8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
use crate::{ContentId, Error, Object, ObjectId, Tree, TreeId, verify};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Reader already bound to an authorized target and retention policy by its caller.
|
||||
/// Hash possession is not authority. Revoked/erased objects must fail here even if
|
||||
/// the physical bytes are deduplicated with another target. Missing data is an error.
|
||||
/// The reader owns I/O size limits; traversal checks bytes after each returned buffer.
|
||||
pub trait ObjectReader {
|
||||
fn read(&mut self, id: ObjectId) -> Result<Vec<u8>, Error>;
|
||||
}
|
||||
impl<F: FnMut(ObjectId) -> Result<Vec<u8>, Error>> ObjectReader for F {
|
||||
fn read(&mut self, id: ObjectId) -> Result<Vec<u8>, Error> {
|
||||
self(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Work budgets count all reads (including repeated shared subtrees) and encoded bytes.
|
||||
/// Root depth is zero. Depth is additionally capped at 256 to bound Rust stack use.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TraversalLimits {
|
||||
pub max_depth: usize,
|
||||
pub max_objects: usize,
|
||||
pub max_bytes: usize,
|
||||
}
|
||||
impl Default for TraversalLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_depth: 128,
|
||||
max_objects: 100_000,
|
||||
max_bytes: 64 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Budget {
|
||||
limits: TraversalLimits,
|
||||
objects: usize,
|
||||
bytes: usize,
|
||||
}
|
||||
impl Budget {
|
||||
fn new(limits: TraversalLimits) -> Self {
|
||||
Self {
|
||||
limits,
|
||||
objects: 0,
|
||||
bytes: 0,
|
||||
}
|
||||
}
|
||||
fn depth(&self, depth: usize) -> Result<(), Error> {
|
||||
if depth > self.limits.max_depth.min(256) {
|
||||
return Err(Error::LimitExceeded("depth"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn read<R: ObjectReader + ?Sized>(
|
||||
&mut self,
|
||||
reader: &mut R,
|
||||
id: ObjectId,
|
||||
depth: usize,
|
||||
) -> Result<Object, Error> {
|
||||
self.depth(depth)?;
|
||||
if self.objects >= self.limits.max_objects {
|
||||
return Err(Error::LimitExceeded("objects"));
|
||||
}
|
||||
self.objects += 1;
|
||||
let bytes = reader.read(id)?;
|
||||
self.bytes = self
|
||||
.bytes
|
||||
.checked_add(bytes.len())
|
||||
.ok_or(Error::LimitExceeded("bytes"))?;
|
||||
if self.bytes > self.limits.max_bytes {
|
||||
return Err(Error::LimitExceeded("bytes"));
|
||||
}
|
||||
verify(id, &bytes)
|
||||
}
|
||||
fn tree<R: ObjectReader + ?Sized>(
|
||||
&mut self,
|
||||
reader: &mut R,
|
||||
id: TreeId,
|
||||
depth: usize,
|
||||
) -> Result<Tree, Error> {
|
||||
match self.read(reader, ObjectId::Tree(id), depth)? {
|
||||
Object::Tree(tree) => Ok(tree),
|
||||
_ => Err(Error::NotTree),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fully materialized bytes and exact component order, not an applied business object.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RestoredContent {
|
||||
Blob(Vec<u8>),
|
||||
Tree {
|
||||
ordered: bool,
|
||||
entries: Vec<RestoredEntry>,
|
||||
},
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RestoredEntry {
|
||||
pub key: String,
|
||||
pub content: RestoredContent,
|
||||
}
|
||||
|
||||
/// Restore a complete content snapshot without reading or replaying history/diffs.
|
||||
/// On failure no partial result is returned. This does not restore state, assignments,
|
||||
/// authorization, or other business events, nor adopt a historical head.
|
||||
pub fn restore<R: ObjectReader + ?Sized>(
|
||||
reader: &mut R,
|
||||
root: TreeId,
|
||||
limits: TraversalLimits,
|
||||
) -> Result<RestoredContent, Error> {
|
||||
restore_content(reader, ContentId::Tree(root), &mut Budget::new(limits), 0)
|
||||
}
|
||||
fn restore_content<R: ObjectReader + ?Sized>(
|
||||
reader: &mut R,
|
||||
id: ContentId,
|
||||
budget: &mut Budget,
|
||||
depth: usize,
|
||||
) -> Result<RestoredContent, Error> {
|
||||
match budget.read(reader, id.into(), depth)? {
|
||||
Object::Blob(bytes) => Ok(RestoredContent::Blob(bytes)),
|
||||
Object::Tree(tree) => {
|
||||
let mut entries = Vec::new();
|
||||
for entry in tree.entries() {
|
||||
entries.push(RestoredEntry {
|
||||
key: entry.key.clone(),
|
||||
content: restore_content(reader, entry.target, budget, depth + 1)?,
|
||||
});
|
||||
}
|
||||
Ok(RestoredContent::Tree {
|
||||
ordered: tree.is_ordered(),
|
||||
entries,
|
||||
})
|
||||
}
|
||||
Object::History(_) => unreachable!("verification enforces requested kind"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A replacement at an opaque key path. Empty path denotes the root itself.
|
||||
/// Added/removed subtrees and tree mode/sequence layout changes are coarse replacements;
|
||||
/// otherwise differences recurse by key. This is structural, not a text-line diff.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Change {
|
||||
pub path: Vec<String>,
|
||||
pub before: Option<ContentId>,
|
||||
pub after: Option<ContentId>,
|
||||
}
|
||||
|
||||
/// Skip identical subtree hashes without even reading them. This is not a closure
|
||||
/// integrity check: unchanged or added/removed subtrees are intentionally not loaded.
|
||||
/// Use [`restore`] when complete snapshot integrity is needed before adoption.
|
||||
/// Order is deterministic: map keys sort by UTF-8; stable-layout sequences follow order.
|
||||
pub fn diff<R: ObjectReader + ?Sized>(
|
||||
reader: &mut R,
|
||||
before: TreeId,
|
||||
after: TreeId,
|
||||
limits: TraversalLimits,
|
||||
) -> Result<Vec<Change>, Error> {
|
||||
let mut changes = Vec::new();
|
||||
diff_content(
|
||||
reader,
|
||||
ContentId::Tree(before),
|
||||
ContentId::Tree(after),
|
||||
&mut Vec::new(),
|
||||
&mut Budget::new(limits),
|
||||
&mut changes,
|
||||
)?;
|
||||
Ok(changes)
|
||||
}
|
||||
fn diff_content<R: ObjectReader + ?Sized>(
|
||||
reader: &mut R,
|
||||
before: ContentId,
|
||||
after: ContentId,
|
||||
path: &mut Vec<String>,
|
||||
budget: &mut Budget,
|
||||
changes: &mut Vec<Change>,
|
||||
) -> Result<(), Error> {
|
||||
if before == after {
|
||||
return Ok(());
|
||||
}
|
||||
budget.depth(path.len())?;
|
||||
if let (ContentId::Tree(left), ContentId::Tree(right)) = (before, after) {
|
||||
let left = budget.tree(reader, left, path.len())?;
|
||||
let right = budget.tree(reader, right, path.len())?;
|
||||
let layout_changed = left.is_ordered() != right.is_ordered()
|
||||
|| (left.is_ordered()
|
||||
&& !left
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|e| &e.key)
|
||||
.eq(right.entries().iter().map(|e| &e.key)));
|
||||
if !layout_changed {
|
||||
let left_by_key: BTreeMap<_, _> = left
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|e| (e.key.as_str(), e.target))
|
||||
.collect();
|
||||
let right_by_key: BTreeMap<_, _> = right
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|e| (e.key.as_str(), e.target))
|
||||
.collect();
|
||||
let keys: Vec<&str> = if left.is_ordered() {
|
||||
left.entries().iter().map(|e| e.key.as_str()).collect()
|
||||
} else {
|
||||
left_by_key
|
||||
.keys()
|
||||
.chain(right_by_key.keys())
|
||||
.copied()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
};
|
||||
for key in keys {
|
||||
path.push(key.into());
|
||||
let old = left_by_key.get(key).copied();
|
||||
let new = right_by_key.get(key).copied();
|
||||
match (old, new) {
|
||||
(Some(old), Some(new)) => {
|
||||
diff_content(reader, old, new, path, budget, changes)?
|
||||
}
|
||||
_ => changes.push(Change {
|
||||
path: path.clone(),
|
||||
before: old,
|
||||
after: new,
|
||||
}),
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
changes.push(Change {
|
||||
path: path.clone(),
|
||||
before: Some(before),
|
||||
after: Some(after),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// New ancestor objects, leaf-first. The replacement object itself is caller-owned.
|
||||
/// Nothing is saved or adopted; caller must persist a complete verified closure and
|
||||
/// history before atomically CAS-adopting the new head in its transaction.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Rewrite {
|
||||
pub root: TreeId,
|
||||
pub objects: Vec<Object>,
|
||||
}
|
||||
|
||||
/// Copy-on-write replacement along exact keys (no slash splitting/normalization).
|
||||
/// Intermediate keys must exist and refer to trees. The final key may be inserted,
|
||||
/// replaced, or deleted. Sequence insertion appends; replacement preserves position.
|
||||
/// Only ancestors are read; this does not validate the replacement object's closure.
|
||||
pub fn replace_path<R: ObjectReader + ?Sized>(
|
||||
reader: &mut R,
|
||||
root: TreeId,
|
||||
path: &[&str],
|
||||
replacement: Option<ContentId>,
|
||||
limits: TraversalLimits,
|
||||
) -> Result<Rewrite, Error> {
|
||||
if path.is_empty() {
|
||||
return Err(Error::EmptyPath);
|
||||
}
|
||||
let mut budget = Budget::new(limits);
|
||||
budget.depth(path.len())?;
|
||||
let mut objects = Vec::new();
|
||||
let root = rewrite(
|
||||
reader,
|
||||
root,
|
||||
path,
|
||||
replacement,
|
||||
&mut budget,
|
||||
0,
|
||||
&mut objects,
|
||||
)?;
|
||||
Ok(Rewrite { root, objects })
|
||||
}
|
||||
fn rewrite<R: ObjectReader + ?Sized>(
|
||||
reader: &mut R,
|
||||
root: TreeId,
|
||||
path: &[&str],
|
||||
replacement: Option<ContentId>,
|
||||
budget: &mut Budget,
|
||||
depth: usize,
|
||||
objects: &mut Vec<Object>,
|
||||
) -> Result<TreeId, Error> {
|
||||
let tree = budget.tree(reader, root, depth)?;
|
||||
let target = if path.len() == 1 {
|
||||
replacement
|
||||
} else {
|
||||
let entry = tree
|
||||
.entries()
|
||||
.iter()
|
||||
.find(|e| e.key == path[0])
|
||||
.ok_or_else(|| Error::MissingKey(path[0].into()))?;
|
||||
let ContentId::Tree(child) = entry.target else {
|
||||
return Err(Error::NotTree);
|
||||
};
|
||||
Some(ContentId::Tree(rewrite(
|
||||
reader,
|
||||
child,
|
||||
&path[1..],
|
||||
replacement,
|
||||
budget,
|
||||
depth + 1,
|
||||
objects,
|
||||
)?))
|
||||
};
|
||||
let object = Object::Tree(tree.replace(path[0], target)?);
|
||||
let ObjectId::Tree(id) = object.id() else {
|
||||
unreachable!("tree object")
|
||||
};
|
||||
if id != root {
|
||||
objects.push(object);
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
Reference in New Issue
Block a user