Skip to main content

git_zero_core/
snapshot.rs

1use crate::names::{cmp_tree_names, validate_name};
2use core::cmp::Ordering;
3
4use crate::{
5    Error, Object, ObjectFormat, ObjectId, ObjectKind, Repository, Result,
6    io::{SliceWriter, Write},
7    repository::validate_refs,
8};
9
10/// Maximum number of tree levels, including the root, to bound recursive stack use.
11pub const MAX_TREE_DEPTH: usize = 64;
12
13#[derive(Clone, Copy, Debug)]
14pub struct File<'a> {
15    /// A single root-level filename. Pass files in ascending byte order.
16    pub name: &'a str,
17    pub contents: &'a [u8],
18}
19
20/// Borrowed contents and mode of a tree entry. Symlink contents are target bytes.
21#[derive(Clone, Copy, Debug)]
22pub enum EntryKind<'a> {
23    File(&'a [u8]),
24    Executable(&'a [u8]),
25    Symlink(&'a [u8]),
26    Tree(&'a [Entry<'a>]),
27}
28
29impl EntryKind<'_> {
30    fn mode(self) -> &'static [u8] {
31        match self {
32            Self::File(_) => b"100644",
33            Self::Executable(_) => b"100755",
34            Self::Symlink(_) => b"120000",
35            Self::Tree(_) => b"40000",
36        }
37    }
38}
39
40#[derive(Clone, Copy, Debug)]
41pub struct Entry<'a> {
42    /// A single path component; each child tree has its own sorted entry slice.
43    pub name: &'a str,
44    pub kind: EntryKind<'a>,
45}
46
47impl Entry<'_> {
48    /// Initializer for unused builder slots; the empty name is not a valid entry.
49    pub const EMPTY: Self = Self {
50        name: "",
51        kind: EntryKind::File(&[]),
52    };
53
54    /// Git tree order: compare bytes with a trailing '/' for trees, NUL otherwise.
55    pub fn cmp_name(&self, other: &Self) -> Ordering {
56        cmp_tree_names(
57            self.name.as_bytes(),
58            matches!(self.kind, EntryKind::Tree(_)),
59            other.name.as_bytes(),
60            matches!(other.kind, EntryKind::Tree(_)),
61        )
62    }
63}
64
65/// One commit identity and its recorded time. The offset does not adjust seconds.
66#[derive(Clone, Copy, Debug)]
67pub struct CommitIdentity<'a> {
68    pub name: &'a str,
69    pub email: &'a str,
70    /// Unix seconds in the range 0..=i64::MAX, serialized identically on all targets.
71    pub timestamp: u64,
72    /// A sign and four ASCII digits: hours 00..23, minutes 00..59.
73    pub timezone: &'a str,
74}
75
76impl Default for CommitIdentity<'_> {
77    fn default() -> Self {
78        Self {
79            name: "git-zero",
80            email: "git-zero@example.invalid",
81            timestamp: 0,
82            timezone: "+0000",
83        }
84    }
85}
86
87/// Metadata for one root commit. Message bytes are preserved, including newlines.
88/// Names and emails must be nonempty, with no control bytes or angle brackets;
89/// messages may contain newlines but not NUL. Invalid metadata returns InvalidObject.
90#[derive(Clone, Copy, Debug)]
91pub struct CommitOptions<'a> {
92    pub author: CommitIdentity<'a>,
93    pub committer: CommitIdentity<'a>,
94    pub message: &'a str,
95}
96
97impl Default for CommitOptions<'_> {
98    fn default() -> Self {
99        Self {
100            author: CommitIdentity::default(),
101            committer: CommitIdentity::default(),
102            message: "In-memory snapshot\n",
103        }
104    }
105}
106
107impl CommitOptions<'_> {
108    fn validate(self) -> Result<()> {
109        for identity in [self.author, self.committer] {
110            let timezone = identity.timezone.as_bytes();
111            if [identity.name, identity.email].iter().any(|value| {
112                value.trim().is_empty()
113                    || value
114                        .bytes()
115                        .any(|byte| byte.is_ascii_control() || matches!(byte, b'<' | b'>'))
116            }) || identity.timestamp > i64::MAX as u64
117                || timezone.len() != 5
118                || !matches!(timezone[0], b'+' | b'-')
119                || !timezone[1..].iter().all(u8::is_ascii_digit)
120                || (timezone[1] - b'0') * 10 + timezone[2] - b'0' > 23
121                || (timezone[3] - b'0') * 10 + timezone[4] - b'0' > 59
122            {
123                return Err(Error::InvalidObject);
124            }
125        }
126        if self.message.as_bytes().contains(&0) {
127            return Err(Error::InvalidObject);
128        }
129        Ok(())
130    }
131}
132
133/// Incrementally collect root entries in caller-owned slots, without allocation.
134/// Push accepts any arrival order; borrowed subtrees must already be assembled.
135/// Nested validation and tree/commit buffer sizing are checked when finishing.
136pub struct SnapshotBuilder<'storage, 'data> {
137    entries: &'storage mut [Entry<'data>],
138    length: usize,
139    tree_storage: &'storage mut [u8],
140    commit_storage: &'storage mut [u8],
141}
142
143impl<'storage, 'data: 'storage> SnapshotBuilder<'storage, 'data> {
144    pub fn new(
145        entries: &'storage mut [Entry<'data>],
146        tree_storage: &'storage mut [u8],
147        commit_storage: &'storage mut [u8],
148    ) -> Self {
149        Self {
150            entries,
151            length: 0,
152            tree_storage,
153            commit_storage,
154        }
155    }
156
157    pub fn len(&self) -> usize {
158        self.length
159    }
160
161    pub fn is_empty(&self) -> bool {
162        self.length == 0
163    }
164
165    /// Insert in Git tree order. Invalid names, duplicates (UnsortedFiles), and
166    /// exhausted slots (BufferTooSmall) leave every occupied slot unchanged.
167    pub fn push(&mut self, entry: Entry<'data>) -> Result<()> {
168        validate_name(entry.name.as_bytes())?;
169        let occupied = &self.entries[..self.length];
170        if occupied.iter().any(|previous| previous.name == entry.name) {
171            return Err(Error::UnsortedFiles);
172        }
173        if self.length == self.entries.len() {
174            return Err(Error::BufferTooSmall);
175        }
176        let position = occupied
177            .iter()
178            .position(|previous| entry.cmp_name(previous).is_lt())
179            .unwrap_or(self.length);
180        self.entries
181            .copy_within(position..self.length, position + 1);
182        self.entries[position] = entry;
183        self.length += 1;
184        Ok(())
185    }
186
187    pub fn finish(self) -> Result<Snapshot<'storage>> {
188        self.finish_with_commit(CommitOptions::default())
189    }
190
191    /// Consume the builder and serialize metadata into commit_storage. Options
192    /// need not outlive the snapshot. Discard partial tree/commit bytes on error.
193    pub fn finish_with_commit(self, options: CommitOptions<'_>) -> Result<Snapshot<'storage>> {
194        self.finish_with_commit_and_format(options, ObjectFormat::Sha1)
195    }
196
197    pub fn finish_with_format(self, format: ObjectFormat) -> Result<Snapshot<'storage>> {
198        self.finish_with_commit_and_format(CommitOptions::default(), format)
199    }
200
201    pub fn finish_with_commit_and_format(
202        self,
203        options: CommitOptions<'_>,
204        format: ObjectFormat,
205    ) -> Result<Snapshot<'storage>> {
206        Snapshot::from_entries_with_commit_and_format(
207            &self.entries[..self.length],
208            self.tree_storage,
209            self.commit_storage,
210            options,
211            format,
212        )
213    }
214}
215
216#[derive(Clone, Copy)]
217enum Input<'a> {
218    Files(&'a [File<'a>]),
219    Entries(&'a [Entry<'a>]),
220}
221
222impl<'a> Input<'a> {
223    fn len(self) -> usize {
224        match self {
225            Self::Files(files) => files.len(),
226            Self::Entries(entries) => entries.len(),
227        }
228    }
229
230    fn entries(self) -> impl Iterator<Item = Entry<'a>> {
231        (0..self.len()).map(move |index| match self {
232            Self::Files(files) => Entry {
233                name: files[index].name,
234                kind: EntryKind::File(files[index].contents),
235            },
236            Self::Entries(entries) => entries[index],
237        })
238    }
239
240    fn blob_at(self, index: &mut usize) -> Option<&'a [u8]> {
241        if let Self::Files(files) = self {
242            return files.get(*index).map(|file| file.contents);
243        }
244        for entry in self.entries() {
245            match entry.kind {
246                EntryKind::Tree(children) => {
247                    if let Some(blob) = Self::Entries(children).blob_at(index) {
248                        return Some(blob);
249                    }
250                }
251                EntryKind::File(data) | EntryKind::Executable(data) | EntryKind::Symlink(data) => {
252                    if *index == 0 {
253                        return Some(data);
254                    }
255                    *index -= 1;
256                }
257            }
258        }
259        None
260    }
261}
262
263struct Blobs<'a> {
264    input: Input<'a>,
265    index: usize,
266}
267
268impl<'a> Iterator for Blobs<'a> {
269    type Item = &'a [u8];
270
271    fn next(&mut self) -> Option<Self::Item> {
272        let mut index = self.index;
273        let result = self.input.blob_at(&mut index)?;
274        self.index += 1;
275        Some(result)
276    }
277}
278
279/// Internal storage is one raw root tree for new, or length-prefixed trees in
280/// preorder for from_entries. Prefixes never enter Git objects or their hashes.
281struct Trees<'a> {
282    bytes: &'a [u8],
283    framed: bool,
284    done: bool,
285}
286
287impl<'a> Iterator for Trees<'a> {
288    type Item = &'a [u8];
289
290    fn next(&mut self) -> Option<Self::Item> {
291        if self.done {
292            return None;
293        }
294        if !self.framed {
295            self.done = true;
296            return Some(self.bytes);
297        }
298        if self.bytes.is_empty() {
299            return None;
300        }
301        let length = u32::from_le_bytes(self.bytes[..4].try_into().ok()?) as usize;
302        let (tree, rest) = self.bytes[4..].split_at(length);
303        self.bytes = rest;
304        Some(tree)
305    }
306}
307
308/// A tree hierarchy and one deterministic commit with no parents.
309/// All data and storage are borrowed; construction never allocates.
310pub struct Snapshot<'a> {
311    input: Input<'a>,
312    trees: &'a [u8],
313    framed: bool,
314    commit: &'a [u8],
315    head: ObjectId,
316    head_ref: &'a str,
317    refs: &'a [&'a str],
318}
319
320impl<'a> Snapshot<'a> {
321    fn blobs(&self) -> Blobs<'a> {
322        Blobs {
323            input: self.input,
324            index: 0,
325        }
326    }
327
328    fn trees(&self) -> Trees<'a> {
329        Trees {
330            bytes: self.trees,
331            framed: self.framed,
332            done: false,
333        }
334    }
335
336    fn unique_blobs(&self) -> impl Iterator<Item = &'a [u8]> + '_ {
337        self.blobs().enumerate().filter_map(|(index, data)| {
338            (!self.blobs().take(index).any(|previous| previous == data)).then_some(data)
339        })
340    }
341
342    fn unique_trees(&self) -> impl Iterator<Item = &'a [u8]> + '_ {
343        self.trees().enumerate().filter_map(|(index, data)| {
344            (!self.trees().take(index).any(|previous| previous == data)).then_some(data)
345        })
346    }
347
348    /// Build a flat snapshot of regular files. Existing buffer sizing and object
349    /// IDs are preserved; this constructor needs only the raw root tree's bytes.
350    pub fn new(
351        files: &'a [File<'a>],
352        tree_storage: &'a mut [u8],
353        commit_storage: &'a mut [u8],
354    ) -> Result<Self> {
355        Self::new_with_commit(
356            files,
357            tree_storage,
358            commit_storage,
359            CommitOptions::default(),
360        )
361    }
362
363    /// Build a flat snapshot with custom root-commit metadata, using raw tree storage.
364    pub fn new_with_commit(
365        files: &'a [File<'a>],
366        tree_storage: &'a mut [u8],
367        commit_storage: &'a mut [u8],
368        options: CommitOptions<'_>,
369    ) -> Result<Self> {
370        Self::new_with_commit_and_format(
371            files,
372            tree_storage,
373            commit_storage,
374            options,
375            ObjectFormat::Sha1,
376        )
377    }
378
379    pub fn new_with_format(
380        files: &'a [File<'a>],
381        tree_storage: &'a mut [u8],
382        commit_storage: &'a mut [u8],
383        format: ObjectFormat,
384    ) -> Result<Self> {
385        Self::new_with_commit_and_format(
386            files,
387            tree_storage,
388            commit_storage,
389            CommitOptions::default(),
390            format,
391        )
392    }
393
394    pub fn new_with_commit_and_format(
395        files: &'a [File<'a>],
396        tree_storage: &'a mut [u8],
397        commit_storage: &'a mut [u8],
398        options: CommitOptions<'_>,
399        format: ObjectFormat,
400    ) -> Result<Self> {
401        Self::build(
402            Input::Files(files),
403            tree_storage,
404            commit_storage,
405            false,
406            options,
407            format,
408        )
409    }
410
411    /// Build nested trees with regular/executable files, symlinks, and empty trees.
412    /// Entries at every level must be sorted using Entry::cmp_name, with no
413    /// duplicate names. Storage needs the sum of every raw tree's size plus four
414    /// bytes per tree, including duplicate/empty trees. Depth is limited by
415    /// MAX_TREE_DEPTH; excess depth returns Unsupported. Discard partial
416    /// storage after any error. Git checkout does not materialize empty trees.
417    pub fn from_entries(
418        entries: &'a [Entry<'a>],
419        tree_storage: &'a mut [u8],
420        commit_storage: &'a mut [u8],
421    ) -> Result<Self> {
422        Self::from_entries_with_commit(
423            entries,
424            tree_storage,
425            commit_storage,
426            CommitOptions::default(),
427        )
428    }
429
430    /// Build nested trees with custom root-commit metadata. Tree storage and
431    /// validation follow from_entries; options are copied into commit_storage.
432    pub fn from_entries_with_commit(
433        entries: &'a [Entry<'a>],
434        tree_storage: &'a mut [u8],
435        commit_storage: &'a mut [u8],
436        options: CommitOptions<'_>,
437    ) -> Result<Self> {
438        Self::from_entries_with_commit_and_format(
439            entries,
440            tree_storage,
441            commit_storage,
442            options,
443            ObjectFormat::Sha1,
444        )
445    }
446
447    pub fn from_entries_with_format(
448        entries: &'a [Entry<'a>],
449        tree_storage: &'a mut [u8],
450        commit_storage: &'a mut [u8],
451        format: ObjectFormat,
452    ) -> Result<Self> {
453        Self::from_entries_with_commit_and_format(
454            entries,
455            tree_storage,
456            commit_storage,
457            CommitOptions::default(),
458            format,
459        )
460    }
461
462    pub fn from_entries_with_commit_and_format(
463        entries: &'a [Entry<'a>],
464        tree_storage: &'a mut [u8],
465        commit_storage: &'a mut [u8],
466        options: CommitOptions<'_>,
467        format: ObjectFormat,
468    ) -> Result<Self> {
469        Self::build(
470            Input::Entries(entries),
471            tree_storage,
472            commit_storage,
473            true,
474            options,
475            format,
476        )
477    }
478
479    fn build(
480        input: Input<'a>,
481        tree_storage: &'a mut [u8],
482        commit_storage: &'a mut [u8],
483        framed: bool,
484        options: CommitOptions<'_>,
485        format: ObjectFormat,
486    ) -> Result<Self> {
487        options.validate()?;
488        let length = write_tree(input, tree_storage, 0, framed, format)?;
489        let trees = &tree_storage[..length];
490        let root = Trees {
491            bytes: trees,
492            framed,
493            done: false,
494        }
495        .next()
496        .ok_or(Error::ObjectNotFound)?;
497        let tree_id = Object {
498            kind: ObjectKind::Tree,
499            data: root,
500        }
501        .id_with_format(format);
502        let mut commit = SliceWriter::new(commit_storage);
503        commit.write_all(b"tree ")?;
504        commit.write_all(&tree_id.to_hex())?;
505        commit.write_all(b"\n")?;
506        write_identity(&mut commit, b"author", options.author)?;
507        write_identity(&mut commit, b"committer", options.committer)?;
508        commit.write_all(b"\n")?;
509        commit.write_all(options.message.as_bytes())?;
510        let commit = commit.finish();
511        let head = Object {
512            kind: ObjectKind::Commit,
513            data: commit,
514        }
515        .id_with_format(format);
516        Ok(Self {
517            input,
518            trees,
519            framed,
520            commit,
521            head,
522            head_ref: "refs/heads/main",
523            refs: &["refs/heads/main"],
524        })
525    }
526
527    /// Configure the default branch and ref aliases for this snapshot's commit.
528    /// Names must be fully qualified, byte-sorted, unique, and free of path
529    /// conflicts (e.g. refs/heads/a and refs/heads/a/b). Include head_ref in
530    /// the slice. Ref configuration does not change any object ID or cache key.
531    pub fn with_refs(mut self, head_ref: &'a str, refs: &'a [&'a str]) -> Result<Self> {
532        self.head_ref = head_ref;
533        self.refs = refs;
534        validate_refs(&self)?;
535        Ok(self)
536    }
537}
538
539fn write_identity(
540    output: &mut SliceWriter<'_>,
541    header: &[u8],
542    identity: CommitIdentity<'_>,
543) -> Result<()> {
544    use core::fmt::Write as _;
545    output.write_all(header)?;
546    output.write_all(b" ")?;
547    output.write_all(identity.name.as_bytes())?;
548    output.write_all(b" <")?;
549    output.write_all(identity.email.as_bytes())?;
550    writeln!(output, "> {} {}", identity.timestamp, identity.timezone)
551        .map_err(|_| Error::BufferTooSmall)
552}
553
554fn write_tree(
555    input: Input<'_>,
556    storage: &mut [u8],
557    depth: usize,
558    framed: bool,
559    format: ObjectFormat,
560) -> Result<usize> {
561    if depth >= MAX_TREE_DEPTH {
562        return Err(Error::Unsupported);
563    }
564    let mut length = 0_usize;
565    let mut previous: Option<Entry<'_>> = None;
566    for (index, entry) in input.entries().enumerate() {
567        validate_name(entry.name.as_bytes())?;
568        if previous.is_some_and(|previous| previous.cmp_name(&entry) != Ordering::Less)
569            || input
570                .entries()
571                .take(index)
572                .any(|previous| previous.name == entry.name)
573        {
574            return Err(Error::UnsortedFiles);
575        }
576        previous = Some(entry);
577        length = length
578            .checked_add(entry.kind.mode().len())
579            .and_then(|length| length.checked_add(entry.name.len()))
580            .and_then(|length| length.checked_add(2 + format.id_length()))
581            .ok_or(Error::BufferTooSmall)?;
582    }
583    let prefix = if framed { 4 } else { 0 };
584    let record_length = length.checked_add(prefix).ok_or(Error::BufferTooSmall)?;
585    if record_length > storage.len() {
586        return Err(Error::BufferTooSmall);
587    }
588    let (record, children_storage) = storage.split_at_mut(record_length);
589    if framed {
590        record[..4].copy_from_slice(
591            &u32::try_from(length)
592                .map_err(|_| Error::BufferTooSmall)?
593                .to_le_bytes(),
594        );
595    }
596    let mut tree = SliceWriter::new(&mut record[prefix..]);
597    let mut children_length = 0;
598    for entry in input.entries() {
599        let object = match entry.kind {
600            EntryKind::Tree(children) => {
601                let storage = &mut children_storage[children_length..];
602                let length =
603                    write_tree(Input::Entries(children), storage, depth + 1, true, format)?;
604                children_length += length;
605                Object {
606                    kind: ObjectKind::Tree,
607                    data: Trees {
608                        bytes: &storage[..length],
609                        framed: true,
610                        done: false,
611                    }
612                    .next()
613                    .ok_or(Error::ObjectNotFound)?,
614                }
615            }
616            EntryKind::File(data) | EntryKind::Executable(data) | EntryKind::Symlink(data) => {
617                Object {
618                    kind: ObjectKind::Blob,
619                    data,
620                }
621            }
622        };
623        tree.write_all(entry.kind.mode())?;
624        tree.write_all(b" ")?;
625        tree.write_all(entry.name.as_bytes())?;
626        tree.write_all(&[0])?;
627        tree.write_all(object.id_with_format(format).as_bytes())?;
628    }
629    Ok(record_length + children_length)
630}
631
632impl Repository for Snapshot<'_> {
633    fn object_format(&self) -> ObjectFormat {
634        self.head.format()
635    }
636    fn head(&self) -> ObjectId {
637        self.head
638    }
639
640    fn head_ref(&self) -> &str {
641        self.head_ref
642    }
643
644    fn reference_count(&self) -> usize {
645        self.refs.len()
646    }
647
648    fn reference_name(&self, index: usize) -> Option<&str> {
649        self.refs.get(index).copied()
650    }
651
652    fn object_count(&self) -> usize {
653        1 + self.unique_trees().count() + self.unique_blobs().count()
654    }
655
656    fn object(&self, index: usize) -> Option<Object<'_>> {
657        if index == 0 {
658            return Some(Object {
659                kind: ObjectKind::Commit,
660                data: self.commit,
661            });
662        }
663        if let Some(data) = self.unique_trees().nth(index - 1) {
664            return Some(Object {
665                kind: ObjectKind::Tree,
666                data,
667            });
668        }
669        self.unique_blobs()
670            .nth(index - 1 - self.unique_trees().count())
671            .map(|data| Object {
672                kind: ObjectKind::Blob,
673                data,
674            })
675    }
676}