diff --git a/Cargo.lock b/Cargo.lock index 30a5d87ca..b5edee248 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index da9875613..6fa60d16a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/content-history-adapters/Cargo.toml b/crates/content-history-adapters/Cargo.toml new file mode 100644 index 000000000..509ebf29f --- /dev/null +++ b/crates/content-history-adapters/Cargo.toml @@ -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 diff --git a/crates/content-history-adapters/README.md b/crates/content-history-adapters/README.md new file mode 100644 index 000000000..8161ff84f --- /dev/null +++ b/crates/content-history-adapters/README.md @@ -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. diff --git a/crates/content-history-adapters/src/lib.rs b/crates/content-history-adapters/src/lib.rs new file mode 100644 index 000000000..6ddbb0f1b --- /dev/null +++ b/crates/content-history-adapters/src/lib.rs @@ -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, +} + +impl Snapshot { + /// In-memory reader for examples. Production readers must enforce authorization and erasure. + pub fn get(&self, id: ObjectId) -> Result { + 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, +} + +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) -> Result { + Ok(self.tree(Tree::map(entries)?)) + } + + fn finish(self, root: TreeId) -> Snapshot { + Snapshot { + root, + objects: self.objects, + } + } +} + +fn entry(key: impl Into, target: ContentId) -> Entry { + Entry { + key: key.into(), + target, + } +} + +fn read_object( + read: &mut impl FnMut(ObjectId) -> Result, + id: ObjectId, +) -> Result { + let object = read(id)?; + if object.id() != id { + return Err(Error::HashMismatch(id)); + } + Ok(object) +} + +fn read_tree( + read: &mut impl FnMut(ObjectId) -> Result, + id: TreeId, + ordered: bool, +) -> Result { + 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 { + match target { + ContentId::Tree(id) => Ok(id), + _ => Err(Error::Structure("expected tree target")), + } +} + +fn blob_id(target: ContentId) -> Result { + match target { + ContentId::Blob(id) => Ok(id), + _ => Err(Error::Structure("expected blob target")), + } +} + +fn text( + read: &mut impl FnMut(ObjectId) -> Result, + target: ContentId, +) -> Result { + 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 { + tree.entries() + .iter() + .find(|e| e.key == key) + .map(|e| e.target) + .ok_or(Error::Structure("missing field")) +} diff --git a/crates/content-history-adapters/src/settings.rs b/crates/content-history-adapters/src/settings.rs new file mode 100644 index 000000000..bd0cea662 --- /dev/null +++ b/crates/content-history-adapters/src/settings.rs @@ -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, +} + +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 { + 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.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::>(), + 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, +) -> Result { + 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, + metadata: &mut BTreeMap, + entries: &mut Vec, + 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(()) +} diff --git a/crates/content-history-adapters/src/ticket.rs b/crates/content-history-adapters/src/ticket.rs new file mode 100644 index 000000000..e56071dcd --- /dev/null +++ b/crates/content-history-adapters/src/ticket.rs @@ -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, +} + +/// 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 { + 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 { + 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, +) -> Result { + 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, + 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")?)?, + )) +} diff --git a/crates/content-history-store/Cargo.toml b/crates/content-history-store/Cargo.toml new file mode 100644 index 000000000..20f72ba12 --- /dev/null +++ b/crates/content-history-store/Cargo.toml @@ -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 diff --git a/crates/content-history-store/README.md b/crates/content-history-store/README.md new file mode 100644 index 000000000..aa583b507 --- /dev/null +++ b/crates/content-history-store/README.md @@ -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. diff --git a/crates/content-history-store/src/lib.rs b/crates/content-history-store/src/lib.rs new file mode 100644 index 000000000..4c0d15f73 --- /dev/null +++ b/crates/content-history-store/src/lib.rs @@ -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 = std::result::Result; + +/// 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 = 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> { + self.authorize(Access::Read)?; + self.head_inner() + } + + fn head_inner(&self) -> Result> { + let raw: Option> = self.tx.query_row( + "SELECT head FROM content_scopes WHERE workspace=?1 AND target=?2", + params![self.key.workspace, self.key.target], |r| r.get(0), + ).optional()?.flatten(); + raw.map(|v| Ok(HistoryId::from_bytes(v.try_into().map_err(|_| Error::InvalidReference)?))) + .transpose() + } + + /// 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 = 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 { + 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 { + let (kind, hash) = parts(id); + let bytes: Option> = 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> { + 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, public: bool) -> Result> { + let mut pending: Vec<_> = roots.into_iter().collect(); + let mut objects = BTreeMap::new(); + while let Some(id) = pending.pop() { + if objects.contains_key(&id) { continue; } + let object = if public { self.read(id)? } else { self.load_prepared(id)? }; + 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, actor: String, timestamp_micros: i64) -> Result { + 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(&self, f: impl FnOnce()->Result) -> Result { + self.tx.execute_batch("SAVEPOINT content_history_operation")?; + match f() { + Ok(value) => { self.tx.execute_batch("RELEASE content_history_operation")?; Ok(value) }, + Err(error) => { + self.tx.execute_batch("ROLLBACK TO content_history_operation; RELEASE content_history_operation")?; + Err(error) + } + } + } +} + +fn parts(id: ObjectId) -> (u8, Vec) { + match id { + ObjectId::Blob(h) => (1,h.as_bytes().to_vec()), + ObjectId::Tree(h) => (2,h.as_bytes().to_vec()), + ObjectId::History(h) => (3,h.as_bytes().to_vec()), + } +} diff --git a/crates/content-history-store/src/schema.sql b/crates/content-history-store/src/schema.sql new file mode 100644 index 000000000..96e14c329 --- /dev/null +++ b/crates/content-history-store/src/schema.sql @@ -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; diff --git a/crates/content-history/Cargo.toml b/crates/content-history/Cargo.toml new file mode 100644 index 000000000..c7e962bb1 --- /dev/null +++ b/crates/content-history/Cargo.toml @@ -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"] diff --git a/crates/content-history/README.md b/crates/content-history/README.md new file mode 100644 index 000000000..a83dedd68 --- /dev/null +++ b/crates/content-history/README.md @@ -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. diff --git a/crates/content-history/examples/wasm_parity.rs b/crates/content-history/examples/wasm_parity.rs new file mode 100644 index 000000000..23832ad79 --- /dev/null +++ b/crates/content-history/examples/wasm_parity.rs @@ -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) +} diff --git a/crates/content-history/src/lib.rs b/crates/content-history/src/lib.rs new file mode 100644 index 000000000..29b93b5d8 --- /dev/null +++ b/crates/content-history/src/lib.rs @@ -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, +}; diff --git a/crates/content-history/src/model.rs b/crates/content-history/src/model.rs new file mode 100644 index 000000000..bba521707 --- /dev/null +++ b/crates/content-history/src/model.rs @@ -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 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, +} +impl Tree { + pub fn map(mut entries: Vec) -> Result { + entries.sort_by(|a, b| a.key.cmp(&b.key)); + Self::build(entries, false) + } + pub fn sequence(entries: Vec) -> Result { + Self::build(entries, true) + } + fn build(entries: Vec, ordered: bool) -> Result { + 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) -> Result { + 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, + pub actor: String, + pub timestamp_micros: i64, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Object { + Blob(Vec), + Tree(Tree), + History(History), +} + +impl Object { + /// Canonical format v1. See the crate documentation for the compatibility boundary. + pub fn encode(&self) -> Vec { + 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 { + 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 = 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 { + let object = Object::decode(bytes)?; + if object.id() != id { + return Err(Error::HashMismatch); + } + Ok(object) +} +fn put_u64(out: &mut Vec, length: usize) { + out.extend_from_slice(&(length as u64).to_be_bytes()); +} +fn put_bytes(out: &mut Vec, 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(&mut self) -> Result<[u8; N], Error> { + Ok(self.take(N)?.try_into().expect("checked slice length")) + } + fn byte(&mut self) -> Result { + Ok(self.array::<1>()?[0]) + } + fn length(&mut self) -> Result { + 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 { + std::str::from_utf8(self.bytes()?) + .map(str::to_owned) + .map_err(|_| Error::InvalidUtf8) + } +} diff --git a/crates/content-history/src/traversal.rs b/crates/content-history/src/traversal.rs new file mode 100644 index 000000000..0c1b5a6aa --- /dev/null +++ b/crates/content-history/src/traversal.rs @@ -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, Error>; +} +impl Result, Error>> ObjectReader for F { + fn read(&mut self, id: ObjectId) -> Result, 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( + &mut self, + reader: &mut R, + id: ObjectId, + depth: usize, + ) -> Result { + 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( + &mut self, + reader: &mut R, + id: TreeId, + depth: usize, + ) -> Result { + 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), + Tree { + ordered: bool, + entries: Vec, + }, +} +#[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( + reader: &mut R, + root: TreeId, + limits: TraversalLimits, +) -> Result { + restore_content(reader, ContentId::Tree(root), &mut Budget::new(limits), 0) +} +fn restore_content( + reader: &mut R, + id: ContentId, + budget: &mut Budget, + depth: usize, +) -> Result { + 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, + pub before: Option, + pub after: Option, +} + +/// 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( + reader: &mut R, + before: TreeId, + after: TreeId, + limits: TraversalLimits, +) -> Result, 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( + reader: &mut R, + before: ContentId, + after: ContentId, + path: &mut Vec, + budget: &mut Budget, + changes: &mut Vec, +) -> 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::>() + .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, +} + +/// 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( + reader: &mut R, + root: TreeId, + path: &[&str], + replacement: Option, + limits: TraversalLimits, +) -> Result { + 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( + reader: &mut R, + root: TreeId, + path: &[&str], + replacement: Option, + budget: &mut Budget, + depth: usize, + objects: &mut Vec, +) -> Result { + 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) +}