Introduce canonical content history and scoped transaction foundation

This commit is contained in:
2026-11-04 10:41:44 +09:00
parent d9295f4439
commit 223aa125de
17 changed files with 1647 additions and 0 deletions
Generated
+29
View File
@@ -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"
+9
View File
@@ -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.
+159
View File
@@ -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")?)?,
))
}
+16
View File
@@ -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
+38
View File
@@ -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.
+248
View File
@@ -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;
+14
View File
@@ -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"]
+65
View File
@@ -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)
}
+13
View File
@@ -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,
};
+337
View File
@@ -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)
}
}
+315
View File
@@ -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)
}