Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
8edaee1
feat(git): H1 cut 1, native Git objects, packfiles, full-history read…
drakestapleton Oct 17, 2026
b4fe463
crumb: recompile for H1 cut 1 (crumb.lock d0f0b95)
drakestapleton Oct 17, 2026
9cab509
fix(git): bound the native Git reader against hostile repositories (#…
drakestapleton Oct 17, 2026
aecc4dd
crumb: recompile for the H1 hostile-repository fix (crumb.lock d0f0b95)
drakestapleton Oct 17, 2026
9504d56
fix(git): open repository files with O_NOFOLLOW | O_NONBLOCK (#48, re…
drakestapleton Oct 17, 2026
28d783c
crumb: recompile for the O_NOFOLLOW open (crumb.lock d0f0b95)
drakestapleton Oct 17, 2026
5f69285
feat(git,store): H1 cut 2: persistent GitOid map, native import, expo…
drakestapleton Oct 17, 2026
b69db2d
crumb: recompile for H1 cut 2 (crumb.lock d0f0b95)
drakestapleton Oct 17, 2026
d0e9a83
fix(git,store): H1 cut 2 review: whole-import check before writing, G…
drakestapleton Oct 17, 2026
ceae255
crumb: recompile for H1 cut 2 review fixes (crumb.lock d0f0b95)
drakestapleton Oct 17, 2026
e4dece7
Merge remote-tracking branch 'origin/main' into pr96
aien-spark Oct 31, 2026
ec565a8
fix(store): seal Git bridge kind 10 like revision kinds 7 to 11 (#101…
aien-spark Oct 31, 2026
d3383fb
Merge remote-tracking branch 'origin/main' into rv
aien-spark Oct 31, 2026
79054c4
docs(store): kind 10 in the sealed revision kind lists (seal.rs, open…
aien-spark Oct 31, 2026
c13f505
test(store): kind 10 without a seal maps nothing on sealed replay (is…
aien-spark Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions crates/synapse-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ pub use revision::{
};
pub use revision_state::RevisionState;
pub use tree::{
tree_entry_order, validate_tree_entry_name, TreeDigest, TreeNodeEncoder, DOMAIN_TREE_V1,
MAX_TREE_ENTRY_NAME_BYTES, TREE_TAG_DIRECTORY,
decode_tree_node, tree_entry_order, validate_tree_entry_name, TreeDigest, TreeNodeEncoder,
TreeNodeEntry, TreeNodeEntryKind, DOMAIN_TREE_V1, MAX_TREE_ENTRY_NAME_BYTES,
TREE_TAG_DIRECTORY,
};

/// Current canonical changeset envelope schema version.
Expand Down Expand Up @@ -159,8 +160,8 @@ pub enum RefusalCode {
/// without the mover's private key (ADR 0003 sections 5.3 and 5.4, step B4, issue #89).
RevisionStateMoveUnverified,
/// Strict recovery of a store opened with a revision seal key found a revision record
/// (kind 7, 8, 9, or 11) whose server seal is missing or does not verify against the seal
/// chain so far: it was written, changed, reordered, or followed by a removed record
/// (kind 7, 8, 9, 10, or 11) whose server seal is missing or does not verify against the
/// seal chain so far: it was written, changed, reordered, or followed by a removed record
/// without the server's key (issue #101).
RevisionRecordUnsealed,
/// A `DerivationAttestation` does not name the revision it is presented with, or its
Expand Down Expand Up @@ -193,6 +194,12 @@ pub enum RefusalCode {
RadicleQuorumInsufficient,
StaleBaseHead,
SplitBrainDivergence,
/// A Git object the native model cannot hold without loss, so it could not be exported
/// with its original object ID (ADR 0003 step H1): a gitlink or empty subtree, an entry
/// name that is not a valid path segment, a mode other than the canonical `100644`,
/// `100755`, `120000` and `40000`, or a commit or tag whose links are not written in
/// Git's canonical layout.
GitObjectNotRepresentable,

// Content admission refusals
/// An admitted blob matches a rule of the versioned secret-scanning rule set (a private key
Expand Down Expand Up @@ -375,6 +382,31 @@ impl fmt::Display for BlobDigest {
}
}

/// The Git tree payload a canonical tree node (C1, version 1) stands for: per entry, in the
/// node's order (which is Git's tree order), `{mode} {name}\0{oid}`, where a file's mode is
/// [`FileMode::git_octal_str`] and a directory's is `40000`. `blob_oid` and `tree_oid` give
/// the Git ID of each entry. Both the Git bridge's export and the store's check of a mapped
/// tree's Git ID use this one function.
pub fn git_tree_payload_of_node(
node: &[u8],
blob_oid: &mut dyn FnMut(BlobDigest) -> SynapseResult<GitOid>,
tree_oid: &mut dyn FnMut(TreeDigest) -> SynapseResult<GitOid>,
) -> SynapseResult<Vec<u8>> {
let mut out = Vec::with_capacity(node.len());
for e in decode_tree_node(node)? {
let (mode, oid) = match e.kind {
TreeNodeEntryKind::File { mode, blob, .. } => (mode.git_octal_str(), blob_oid(blob)?),
TreeNodeEntryKind::Dir(child) => ("40000", tree_oid(child)?),
};
out.extend_from_slice(mode.as_bytes());
out.push(b' ');
out.extend_from_slice(e.name.as_bytes());
out.push(0);
out.extend_from_slice(&oid.0);
}
Ok(out)
}

/// 20-byte standard Git SHA-1 object identifier (`blob`, `tree`, or `commit`).
/// Distinct from `ChangesetId` and `BlobDigest`.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
Expand Down
108 changes: 108 additions & 0 deletions crates/synapse-core/src/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,119 @@ impl TreeNodeEncoder {
}
}

/// What one entry of a decoded tree node names.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TreeNodeEntryKind {
/// A file: its mode, `BlobDigest`, and size in bytes.
File {
mode: FileMode,
blob: crate::BlobDigest,
size_bytes: u64,
},
/// A subdirectory: the `TreeDigest` of its node.
Dir(TreeDigest),
}

/// One entry of a decoded tree node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeNodeEntry {
pub name: String,
pub kind: TreeNodeEntryKind,
}

/// Smallest encoded entry: tag, a one-byte name and a directory digest.
const MIN_TREE_ENTRY_BYTES: usize = 1 + 2 + 1 + 32;

/// Decode exactly one canonical tree node (version 1). The count is checked against the bytes
/// that remain before anything is reserved; the node is accepted only if re-encoding its
/// entries through [`TreeNodeEncoder`] gives back exactly `bytes`, so names, order, duplicates
/// and tags follow the encoder's rules. Any other input is `TreeNodeMalformed`.
pub fn decode_tree_node(bytes: &[u8]) -> SynapseResult<Vec<TreeNodeEntry>> {
let bad = |msg: &str| SynapseError::new(RefusalCode::TreeNodeMalformed, msg.to_string());
let take = |pos: &mut usize, n: usize| -> SynapseResult<&[u8]> {
let end = pos
.checked_add(n)
.filter(|&e| e <= bytes.len())
.ok_or_else(|| bad("tree node truncated"))?;
let s = &bytes[*pos..end];
*pos = end;
Ok(s)
};
let mut pos = 0usize;
let count = u32::from_le_bytes(take(&mut pos, 4)?.try_into().expect("4 bytes")) as usize;
if count.saturating_mul(MIN_TREE_ENTRY_BYTES) > bytes.len() - pos {
return Err(bad(
"tree node declares more entries than its bytes can hold",
));
}
let mut entries = Vec::with_capacity(count);
let mut enc = TreeNodeEncoder::new(count as u32);
for _ in 0..count {
let tag = take(&mut pos, 1)?[0];
let len = u16::from_le_bytes(take(&mut pos, 2)?.try_into().expect("2 bytes")) as usize;
let name = std::str::from_utf8(take(&mut pos, len)?)
.map_err(|_| bad("tree entry name is not UTF-8"))?
.to_string();
let kind = if tag == TREE_TAG_DIRECTORY {
let d = TreeDigest(take(&mut pos, 32)?.try_into().expect("32 bytes"));
enc.dir(&name, &d)?;
TreeNodeEntryKind::Dir(d)
} else {
let mode = FileMode::from_tag_byte(tag).ok_or_else(|| bad("unknown tree entry tag"))?;
let blob = crate::BlobDigest(take(&mut pos, 32)?.try_into().expect("32 bytes"));
let size_bytes = u64::from_le_bytes(take(&mut pos, 8)?.try_into().expect("8 bytes"));
enc.file(&name, mode, &blob, size_bytes)?;
TreeNodeEntryKind::File {
mode,
blob,
size_bytes,
}
};
entries.push(TreeNodeEntry { name, kind });
}
if pos != bytes.len() {
return Err(bad("tree node has trailing bytes"));
}
let (_, again) = enc.finish()?;
if again != bytes {
return Err(bad("tree node is not in its canonical encoding"));
}
Ok(entries)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::BlobDigest;

#[test]
fn h1_tree_node_decodes_canonically_and_refuses_every_other_form() {
let blob = BlobDigest::compute(b"x");
let mut enc = TreeNodeEncoder::new(2);
enc.file("a", FileMode::Executable755, &blob, 1).unwrap();
enc.dir("b", &TreeDigest::empty()).unwrap();
let (_, bytes) = enc.finish().unwrap();
let entries = decode_tree_node(&bytes).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[1].kind, TreeNodeEntryKind::Dir(TreeDigest::empty()));
assert_eq!(decode_tree_node(&0u32.to_le_bytes()).unwrap(), vec![]);
for cut in 0..bytes.len() {
assert!(
decode_tree_node(&bytes[..cut]).is_err(),
"truncated at {cut}"
);
}
let mut extra = bytes.clone();
extra.push(0);
assert!(decode_tree_node(&extra).is_err(), "trailing byte");
let mut huge = bytes.clone();
huge[..4].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(decode_tree_node(&huge).is_err(), "count over the input");
let mut tag = bytes.clone();
tag[4] = 9;
assert!(decode_tree_node(&tag).is_err(), "unknown tag");
}

#[test]
fn empty_tree_digest_is_the_domain_separated_hash_of_a_zero_count() {
let mut h = Sha256::new();
Expand Down
Loading
Loading