heddle_object_model/object/
tree_canonical.rs1use sley::{ObjectFormat as GitObjectFormat, ObjectId as GitObjectId};
10
11use super::tree::{git_format_from_tag, git_format_to_tag};
12use super::tree_stream::TreeStreamError;
13use super::{ContentHash, EntryType, FileMode, SpoolId, StateId, Tree, TreeEntry, TreeError};
14
15pub const TREE_ENCODING_VERSION: u8 = 4;
17pub const TREE_CANONICAL_MAGIC: &[u8; 4] = b"HTR4";
19pub const TREE_HEADER_LEN: usize = 4 + 1 + 32 + 8 + 8 + 8;
21
22#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct TreeHeader {
25 pub version: u8,
26 pub tree_id: ContentHash,
27 pub entry_count: u64,
28 pub payload_len: u64,
29 pub logical_len: u64,
30}
31
32pub fn is_canonical_tree(bytes: &[u8]) -> bool {
34 bytes.starts_with(TREE_CANONICAL_MAGIC)
35}
36
37impl Tree {
38 pub fn encode_canonical(&self) -> Result<Vec<u8>, TreeStreamError> {
40 self.validate()?;
41 let tree_id = self.hash();
42 let mut payload = Vec::new();
43 let mut logical_len = 0u64;
44 for entry in self.entries() {
45 logical_len = logical_len
46 .checked_add(entry.encoded_len() as u64)
47 .ok_or_else(|| TreeStreamError::Malformed("logical length overflow".into()))?;
48 let frame = encode_entry_frame(entry)?;
49 let frame_len = u32::try_from(frame.len()).map_err(|_| {
50 TreeStreamError::Malformed(format!("entry '{}' frame exceeds u32", entry.name()))
51 })?;
52 payload.extend_from_slice(&frame_len.to_le_bytes());
53 payload.extend_from_slice(&frame);
54 }
55 let mut out = Vec::with_capacity(TREE_HEADER_LEN + payload.len());
56 out.extend_from_slice(TREE_CANONICAL_MAGIC);
57 out.push(TREE_ENCODING_VERSION);
58 out.extend_from_slice(tree_id.as_bytes());
59 out.extend_from_slice(&(self.len() as u64).to_le_bytes());
60 out.extend_from_slice(&(payload.len() as u64).to_le_bytes());
61 out.extend_from_slice(&logical_len.to_le_bytes());
62 out.extend_from_slice(&payload);
63 Ok(out)
64 }
65
66 pub fn decode_canonical(data: &[u8]) -> Result<Self, TreeStreamError> {
68 let header = decode_header(data)?;
69 let expected_len = TREE_HEADER_LEN as u64 + header.payload_len;
70 if (data.len() as u64) < expected_len {
71 return Err(TreeStreamError::TruncatedFrame {
72 offset: data.len() as u64,
73 });
74 }
75 if (data.len() as u64) > expected_len {
76 return Err(TreeStreamError::TrailingBytes {
77 extra: data.len() as u64 - expected_len,
78 });
79 }
80 let mut entries = Vec::new();
81 let mut offset = TREE_HEADER_LEN;
82 let payload_end = data.len();
83 for _ in 0..header.entry_count {
84 let (entry, consumed) = decode_entry_at(data, offset, payload_end)?;
85 entries.push(entry);
86 offset += consumed;
87 }
88 if offset != payload_end {
89 return Err(TreeStreamError::TrailingBytes {
90 extra: (payload_end - offset) as u64,
91 });
92 }
93 let tree = Tree::try_from_decoded_entries(entries)?;
94 let found = tree.hash();
95 if found != header.tree_id {
96 return Err(TreeStreamError::HashMismatch {
97 expected: header.tree_id,
98 found,
99 });
100 }
101 if tree
102 .entries()
103 .iter()
104 .map(|entry| entry.encoded_len() as u64)
105 .sum::<u64>()
106 != header.logical_len
107 {
108 return Err(TreeStreamError::Malformed(
109 "declared logical length does not match entries".into(),
110 ));
111 }
112 Ok(tree)
113 }
114}
115
116pub fn decode_header(data: &[u8]) -> Result<TreeHeader, TreeStreamError> {
118 if data.len() < TREE_HEADER_LEN {
119 return Err(TreeStreamError::TruncatedFrame { offset: 0 });
120 }
121 if !is_canonical_tree(data) {
122 return Err(TreeStreamError::Malformed(
123 "bytes are not a canonical HTR4 tree".into(),
124 ));
125 }
126 let version = data[4];
127 if version != TREE_ENCODING_VERSION {
128 return Err(TreeStreamError::UnsupportedVersion { found: version });
129 }
130 let tree_id = ContentHash::from_bytes(
131 data[5..37]
132 .try_into()
133 .map_err(|_| TreeStreamError::Malformed("tree id slice is not 32 bytes".into()))?,
134 );
135 let entry_count = u64::from_le_bytes(
136 data[37..45]
137 .try_into()
138 .map_err(|_| TreeStreamError::Malformed("entry count slice is not 8 bytes".into()))?,
139 );
140 let payload_len =
141 u64::from_le_bytes(data[45..53].try_into().map_err(|_| {
142 TreeStreamError::Malformed("payload length slice is not 8 bytes".into())
143 })?);
144 let logical_len =
145 u64::from_le_bytes(data[53..61].try_into().map_err(|_| {
146 TreeStreamError::Malformed("logical length slice is not 8 bytes".into())
147 })?);
148 Ok(TreeHeader {
149 version,
150 tree_id,
151 entry_count,
152 payload_len,
153 logical_len,
154 })
155}
156
157pub(crate) fn encode_entry_frame(entry: &TreeEntry) -> Result<Vec<u8>, TreeStreamError> {
158 let name = entry.name().as_bytes();
159 let name_len = u16::try_from(name.len()).map_err(|_| {
160 TreeStreamError::Malformed(format!("entry name '{}' exceeds u16", entry.name()))
161 })?;
162 let mut frame = Vec::new();
163 frame.push(entry.mode().to_byte());
164 frame.push(entry.entry_type().to_byte());
165 frame.extend_from_slice(&name_len.to_le_bytes());
166 frame.extend_from_slice(name);
167 encode_target(&mut frame, entry)?;
168 Ok(frame)
169}
170
171pub(crate) fn decode_entry_at(
172 data: &[u8],
173 offset: usize,
174 payload_end: usize,
175) -> Result<(TreeEntry, usize), TreeStreamError> {
176 if offset + 4 > payload_end {
177 return Err(TreeStreamError::TruncatedFrame {
178 offset: offset as u64,
179 });
180 }
181 let frame_len = u32::from_le_bytes(
182 data[offset..offset + 4]
183 .try_into()
184 .map_err(|_| TreeStreamError::Malformed("frame length slice is not 4 bytes".into()))?,
185 ) as usize;
186 let frame_start = offset + 4;
187 let frame_end = frame_start
188 .checked_add(frame_len)
189 .ok_or(TreeStreamError::TruncatedFrame {
190 offset: offset as u64,
191 })?;
192 if frame_end > payload_end {
193 return Err(TreeStreamError::TruncatedFrame {
194 offset: offset as u64,
195 });
196 }
197 let entry = decode_entry_frame(&data[frame_start..frame_end])?;
198 Ok((entry, 4 + frame_len))
199}
200
201pub(crate) fn decode_entry_frame(frame: &[u8]) -> Result<TreeEntry, TreeStreamError> {
202 if frame.len() < 4 {
203 return Err(TreeStreamError::TruncatedFrame { offset: 0 });
204 }
205 let mode = FileMode::from_byte(frame[0]).ok_or_else(|| {
206 TreeStreamError::Malformed(format!("malformed tree entry mode {}", frame[0]))
207 })?;
208 let kind = EntryType::from_byte(frame[1]).ok_or_else(|| {
209 TreeStreamError::Malformed(format!("malformed tree entry kind {}", frame[1]))
210 })?;
211 let name_len = u16::from_le_bytes([frame[2], frame[3]]) as usize;
212 let name_end = 4 + name_len;
213 if frame.len() < name_end {
214 return Err(TreeStreamError::TruncatedFrame { offset: 0 });
215 }
216 let name = std::str::from_utf8(&frame[4..name_end])
217 .map_err(|_| TreeStreamError::Malformed("tree entry name is not UTF-8".into()))?
218 .to_string();
219 let entry = decode_target(name, kind, mode, &frame[name_end..])?;
220 if entry.mode() != mode {
221 return Err(TreeStreamError::Malformed(format!(
222 "tree kind/mode mismatch for {}: {kind:?}/{mode:?}",
223 entry.name()
224 )));
225 }
226 Ok(entry)
227}
228
229fn encode_target(frame: &mut Vec<u8>, entry: &TreeEntry) -> Result<(), TreeStreamError> {
230 match entry.entry_type() {
231 EntryType::Blob | EntryType::Tree | EntryType::Symlink => {
232 frame.extend_from_slice(entry.require_content_hash().as_bytes());
233 }
234 EntryType::Gitlink => {
235 let target = entry.gitlink_target().ok_or_else(|| {
236 TreeStreamError::Malformed("gitlink entry is missing target".into())
237 })?;
238 frame.push(git_format_to_tag(target.format()));
239 frame.extend_from_slice(target.as_bytes());
240 }
241 EntryType::Spoollink => {
242 let (spool, state) = entry.spoollink_target().ok_or_else(|| {
243 TreeStreamError::Malformed("spoollink entry is missing target".into())
244 })?;
245 let spool_bytes = spool.as_str().as_bytes();
246 let spool_len = u16::try_from(spool_bytes.len())
247 .map_err(|_| TreeStreamError::Malformed("spool id exceeds u16".into()))?;
248 frame.extend_from_slice(&spool_len.to_le_bytes());
249 frame.extend_from_slice(spool_bytes);
250 frame.extend_from_slice(state.as_bytes());
251 }
252 }
253 Ok(())
254}
255
256fn decode_target(
257 name: String,
258 kind: EntryType,
259 mode: FileMode,
260 payload: &[u8],
261) -> Result<TreeEntry, TreeStreamError> {
262 match kind {
263 EntryType::Blob => TreeEntry::file(name, take_hash(payload)?, mode == FileMode::Executable)
264 .map_err(TreeStreamError::from),
265 EntryType::Tree => {
266 TreeEntry::directory(name, take_hash(payload)?).map_err(TreeStreamError::from)
267 }
268 EntryType::Symlink => {
269 TreeEntry::symlink(name, take_hash(payload)?).map_err(TreeStreamError::from)
270 }
271 EntryType::Gitlink => decode_gitlink(name, payload),
272 EntryType::Spoollink => decode_spoollink(name, payload),
273 }
274}
275
276fn take_hash(payload: &[u8]) -> Result<ContentHash, TreeStreamError> {
277 let bytes: [u8; 32] = payload
278 .try_into()
279 .map_err(|_| TreeStreamError::Malformed("malformed tree entry object id".into()))?;
280 Ok(ContentHash::from_bytes(bytes))
281}
282
283fn decode_gitlink(name: String, payload: &[u8]) -> Result<TreeEntry, TreeStreamError> {
284 if payload.is_empty() {
285 return Err(TreeStreamError::Malformed(
286 "malformed tree entry object id".into(),
287 ));
288 }
289 let format = git_format_from_tag(payload[0])?;
290 let oid = &payload[1..];
291 let expected = match format {
292 GitObjectFormat::Sha1 => 20,
293 GitObjectFormat::Sha256 => 32,
294 };
295 if oid.len() != expected {
296 return Err(TreeStreamError::Malformed(
297 "malformed tree entry object id".into(),
298 ));
299 }
300 let target = GitObjectId::from_raw(format, oid)
301 .map_err(|err| TreeError::InvalidStructure(format!("invalid gitlink target: {err}")))?;
302 TreeEntry::gitlink(name, target).map_err(TreeStreamError::from)
303}
304
305fn decode_spoollink(name: String, payload: &[u8]) -> Result<TreeEntry, TreeStreamError> {
306 if payload.len() < 2 {
307 return Err(TreeStreamError::TruncatedFrame { offset: 0 });
308 }
309 let spool_len = u16::from_le_bytes([payload[0], payload[1]]) as usize;
310 let spool_end = 2 + spool_len;
311 let state_end = spool_end + 32;
312 if payload.len() != state_end {
313 return Err(TreeStreamError::Malformed(
314 "malformed tree entry object id".into(),
315 ));
316 }
317 let spool = std::str::from_utf8(&payload[2..spool_end])
318 .map_err(|_| TreeStreamError::Malformed("spool id is not UTF-8".into()))?;
319 let spool_id = SpoolId::parse(spool)
320 .map_err(|err| TreeStreamError::Malformed(format!("invalid spool id: {err}")))?;
321 let state =
322 StateId::from_bytes(payload[spool_end..state_end].try_into().map_err(|_| {
323 TreeStreamError::Malformed("spoollink state id is not 32 bytes".into())
324 })?);
325 TreeEntry::spoollink(name, spool_id, state).map_err(TreeStreamError::from)
326}