← snapshot
9140 bytes
//! The in-memory bridge adapter.
use std::collections::BTreeMap;
use std::str::FromStr as _;
use jac_core::{AgentId, Author, HumanId, Provenance, RepoPath, SnapshotId, Timestamp};
use jac_object::{ObjectStore, Segment, Snapshot, Tree, TreeEntry, TreeNode};
use smallvec::SmallVec;
use crate::image::{GitCommitImage, TrailerKey};
use crate::{GitError, GitInterop};
/// In-memory `GitInterop` adapter.
///
/// Proves round-trip fidelity: `export` then `import` yields the identical
/// snapshot id, because every field of a snapshot's hashed identity either
/// survives in the manifest or rides in a trailer.
#[derive(Debug, Default)]
pub struct FakeGit;
impl FakeGit {
/// A fresh adapter.
#[must_use]
pub const fn new() -> Self {
Self
}
}
/// Flattens a tree into `(full path, blob id)` pairs.
fn flatten(
store: &dyn ObjectStore,
tree: &Tree,
prefix: Option<&RepoPath>,
out: &mut Vec<(RepoPath, jac_core::BlobId)>,
) -> Result<(), GitError> {
for entry in tree.entries() {
let path = match prefix {
Some(p) => p
.join(entry.name.as_str())
.map_err(|_| GitError::InvalidPath)?,
None => RepoPath::parse(entry.name.as_str()).map_err(|_| GitError::InvalidPath)?,
};
match entry.node {
TreeNode::Blob(id) => out.push((path, id)),
TreeNode::Tree(id) => {
let sub = store.tree(id)?;
flatten(store, sub, Some(&path), out)?;
}
// A node kind this bridge does not know cannot be exported
// faithfully; refusing is the only honest answer.
_ => {
return Err(GitError::MalformedImage {
reason: "unrecognised tree node kind",
});
}
}
}
Ok(())
}
/// Rebuilds a nested tree from a sorted manifest, writing trees into the
/// store, returning the root tree id.
fn plant(
store: &mut dyn ObjectStore,
manifest: &[(RepoPath, jac_core::BlobId)],
) -> Result<jac_core::TreeId, GitError> {
// Group by first segment; leaves become blob entries, groups recurse.
#[derive(Default)]
struct Node {
blobs: Vec<(String, jac_core::BlobId)>,
children: BTreeMap<String, Vec<(String, jac_core::BlobId)>>,
}
fn build(
store: &mut dyn ObjectStore,
items: &[(String, jac_core::BlobId)],
) -> Result<jac_core::TreeId, GitError> {
let mut node = Node::default();
for (path, blob) in items {
match path.split_once('/') {
None => node.blobs.push((path.clone(), *blob)),
Some((head, rest)) => node
.children
.entry(head.to_owned())
.or_default()
.push((rest.to_owned(), *blob)),
}
}
let mut entries = Vec::new();
for (name, blob) in node.blobs {
entries.push(TreeEntry {
name: Segment::parse(&name).map_err(|_| GitError::InvalidPath)?,
node: TreeNode::Blob(blob),
});
}
for (name, inner) in node.children {
let sub = build(store, &inner)?;
entries.push(TreeEntry {
name: Segment::parse(&name).map_err(|_| GitError::InvalidPath)?,
node: TreeNode::Tree(sub),
});
}
let tree = Tree::new(entries).map_err(|_| GitError::InvalidPath)?;
Ok(store.put_tree(tree))
}
let items: Vec<(String, jac_core::BlobId)> = manifest
.iter()
.map(|(p, b)| (p.as_str().to_owned(), *b))
.collect();
build(store, &items)
}
fn parse_author(s: &str) -> Result<Author, GitError> {
let malformed = GitError::MalformedImage {
reason: "unparseable Jacquard-Author trailer",
};
match s.split_once(':') {
Some(("human", id)) => Ok(Author::Human(HumanId::from_str(id).map_err(|_| malformed)?)),
Some(("agent", id)) => Ok(Author::Agent(AgentId::from_str(id).map_err(|_| malformed)?)),
_ => Err(malformed),
}
}
fn render_author(author: Author) -> String {
match author {
Author::Human(id) => format!("human:{id}"),
Author::Agent(id) => format!("agent:{id}"),
_ => "unrecognised".to_owned(),
}
}
impl GitInterop for FakeGit {
fn export(
&mut self,
id: SnapshotId,
store: &dyn ObjectStore,
) -> Result<GitCommitImage, GitError> {
let snapshot = store.snapshot(id)?;
let tree = store.tree(snapshot.tree)?;
let mut manifest = Vec::new();
flatten(store, tree, None, &mut manifest)?;
manifest.sort();
Ok(GitCommitImage {
manifest,
parents: snapshot.parents.to_vec(),
message: snapshot.message.clone(),
trailers: vec![
(
TrailerKey::Provenance,
snapshot.provenance.label().to_owned(),
),
(TrailerKey::Author, render_author(snapshot.author)),
(TrailerKey::Timestamp, snapshot.at.as_millis().to_string()),
],
})
}
fn import(
&mut self,
image: &GitCommitImage,
store: &mut dyn ObjectStore,
) -> Result<SnapshotId, GitError> {
let tree = plant(store, &image.manifest)?;
let provenance = image
.trailer(TrailerKey::Provenance)
.and_then(Provenance::parse_label)
.ok_or(GitError::MalformedImage {
reason: "missing or unparseable Jacquard-Provenance trailer",
})?;
let author = parse_author(image.trailer(TrailerKey::Author).ok_or(
GitError::MalformedImage {
reason: "missing Jacquard-Author trailer",
},
)?)?;
let at = image
.trailer(TrailerKey::Timestamp)
.and_then(|s| s.parse::<i64>().ok())
.map(Timestamp::from_millis)
.ok_or(GitError::MalformedImage {
reason: "missing or unparseable Jacquard-Timestamp trailer",
})?;
let snapshot = Snapshot {
tree,
parents: SmallVec::from_slice(&image.parents),
author,
provenance,
message: image.message.clone(),
at,
};
Ok(store.put_snapshot(snapshot))
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "test assertions read better with unwrap"
)]
mod tests {
use jac_core::AgentId;
use jac_object::{Blob, MemoryObjectStore};
use super::*;
fn sample_snapshot(store: &mut MemoryObjectStore) -> SnapshotId {
let blob = store.put_blob(Blob::new(&b"fn retry() {}"[..]));
let inner = store.put_tree(
Tree::new(vec![TreeEntry {
name: Segment::parse("backoff.rs").unwrap(),
node: TreeNode::Blob(blob),
}])
.unwrap(),
);
let root = store.put_tree(
Tree::new(vec![TreeEntry {
name: Segment::parse("auth").unwrap(),
node: TreeNode::Tree(inner),
}])
.unwrap(),
);
store.put_snapshot(Snapshot {
tree: root,
parents: SmallVec::new(),
author: Author::Agent(AgentId::new(4).unwrap()),
provenance: Provenance::Agent,
message: "add retry backoff".to_owned(),
at: Timestamp::from_millis(42),
})
}
#[test]
fn round_trip_preserves_identity() {
let mut store = MemoryObjectStore::new();
let id = sample_snapshot(&mut store);
let mut bridge = FakeGit::new();
let image = bridge.export(id, &store).unwrap();
let back = bridge.import(&image, &mut store).unwrap();
assert_eq!(
back, id,
"provenance, author, and timestamp must survive the trip in trailers"
);
}
#[test]
fn trailers_carry_provenance_in_git_convention() {
let mut store = MemoryObjectStore::new();
let id = sample_snapshot(&mut store);
let image = FakeGit::new().export(id, &store).unwrap();
assert_eq!(image.trailer(TrailerKey::Provenance), Some("agent"));
assert!(image.full_message().contains("Jacquard-Provenance: agent"));
}
#[test]
fn a_tampered_provenance_trailer_changes_the_imported_id() {
let mut store = MemoryObjectStore::new();
let id = sample_snapshot(&mut store);
let mut bridge = FakeGit::new();
let mut image = bridge.export(id, &store).unwrap();
// Relabel the agent's work as human in the exported image.
for (key, value) in &mut image.trailers {
if *key == TrailerKey::Provenance {
"human".clone_into(value);
}
}
let relabelled = bridge.import(&image, &mut store).unwrap();
assert_ne!(
relabelled, id,
"a relabelled import is a different snapshot, visibly"
);
}
}