Skip to main content

mkit_core/worktree/
blob.rs

1//! Blob-content I/O: reading back what the worktree walker stored.
2//!
3//! A file's content lives either in a single inline [`Blob`](crate::object::Blob)
4//! or behind a [`ChunkedBlob`](crate::object::ChunkedBlob) manifest. [`LoadedBlob`]
5//! is the one-read-per-object view over both shapes; [`read_blob`] is the one-shot
6//! full-content convenience on top of it. Extracted from `worktree.rs` (#633) —
7//! this cluster reconstructs content and has nothing to do with walking a worktree.
8
9use std::borrow::Cow;
10use std::io;
11
12use crate::hash::Hash;
13use crate::object::{ChunkedBlob, Object};
14
15use super::{WorktreeError, WorktreeResult};
16
17/// Reassemble the full byte content of a `Blob` or `ChunkedBlob` object
18/// addressed by `hash`.
19///
20/// A plain [`Blob`](crate::object::Blob) returns its bytes directly. A
21/// [`ChunkedBlob`] manifest is reassembled
22/// by concatenating each referenced chunk (every chunk must itself be a
23/// `Blob`). This is the shared counterpart to [`store_file_object`](super::store_file_object) and
24/// backs `mkit cat`, `mkit diff`, conflict rendering, and blame so they
25/// all reconstruct large-file content the same way.
26///
27/// # Errors
28/// - [`WorktreeError::Store`] if `hash` or any chunk is missing.
29/// - [`WorktreeError::Io`] if `hash` (or a chunk) resolves to an object
30///   that is neither a `Blob` nor a `ChunkedBlob` of `Blob`s.
31/// - [`WorktreeError::Object`] if the concatenated chunks do not total
32///   the manifest's `total_size` (SPEC-OBJECTS §7).
33pub fn read_blob<S: crate::store::ObjectSource + ?Sized>(
34    store: &S,
35    hash: &Hash,
36) -> WorktreeResult<Vec<u8>> {
37    LoadedBlob::load(store, hash)?.into_content(store)
38}
39
40/// A blob's top-level object, read from the store exactly once and held so
41/// a caller needing several views of the same blob — byte length, bounded
42/// prefix, full content — pays a single top-level `read_object` instead of
43/// one per view. `diff --stat` is the motivating caller: its text-vs-binary
44/// sniff needs a prefix, then either the length (binary row) or the full
45/// content (text row), and taking each view through a separate store-level
46/// read re-read (and re-hash-verified) the same object two times per
47/// changed file, a per-entry cost that dominates a many-small-files
48/// diffstat (#624).
49///
50/// Chunk objects are still read on demand: [`Self::len`] reads none,
51/// [`Self::prefix`] reads only leading chunks, [`Self::into_content`]
52/// reads them all.
53///
54/// One read stays undeduped by design: [`Self::prefix`] on a chunked blob
55/// reads the leading chunk(s) to sniff, and [`Self::into_content`] reads
56/// every chunk (including that same leading one) to reassemble — a caller
57/// doing both, as `diff --stat` does, reads the first chunk twice. Caching
58/// it would need either interior mutability or a consuming-prefix API, to
59/// save an O(1) read on an O(n-chunks) path; not worth it (#624).
60#[derive(Debug)]
61pub enum LoadedBlob {
62    /// An inline [`Blob`](crate::object::Blob): its full content, already
63    /// in hand.
64    Inline(Vec<u8>),
65    /// A [`ChunkedBlob`] manifest: content lives in chunk objects, read
66    /// only when a view needs them.
67    Chunked(ChunkedBlob),
68}
69
70impl LoadedBlob {
71    /// Read the top-level object addressed by `hash` — one store read.
72    ///
73    /// # Errors
74    /// - [`WorktreeError::Store`] if `hash` is missing.
75    /// - [`WorktreeError::Io`] if `hash` resolves to an object that is
76    ///   neither a `Blob` nor a `ChunkedBlob`.
77    pub fn load<S: crate::store::ObjectSource + ?Sized>(
78        store: &S,
79        hash: &Hash,
80    ) -> WorktreeResult<Self> {
81        match store.read_object(hash)? {
82            Object::Blob(b) => Ok(Self::Inline(b.data)),
83            Object::ChunkedBlob(manifest) => Ok(Self::Chunked(manifest)),
84            other => Err(not_a_blob("object", hash, &other)),
85        }
86    }
87
88    /// Content byte length from what is already in hand — an inline blob's
89    /// data length, a chunked blob's manifest `total_size` — no chunk
90    /// reads. `total_size` is trustworthy without re-verifying against the
91    /// chunks: every reassembly path enforces it via
92    /// [`ChunkedBlob::check_reassembled_size`], so a manifest with a wrong
93    /// `total_size` cannot have been durably written (#550).
94    #[must_use]
95    pub fn len(&self) -> u64 {
96        match self {
97            Self::Inline(data) => data.len() as u64,
98            Self::Chunked(manifest) => manifest.total_size,
99        }
100    }
101
102    /// Whether the content is zero bytes long.
103    #[must_use]
104    pub fn is_empty(&self) -> bool {
105        self.len() == 0
106    }
107
108    /// Up to `max_len` leading content bytes. Inline data is borrowed (no
109    /// copy, no reads); a chunked blob reads only as many leading chunks
110    /// as it takes to cover `max_len`, then stops — one chunk in practice,
111    /// since every chunk but possibly the last is at least
112    /// [`crate::chunker::MIN_SIZE`] bytes, well above the sniff windows
113    /// callers use (e.g. [`crate::ops::diff::BINARY_SNIFF_LEN`]).
114    ///
115    /// # Errors
116    /// - [`WorktreeError::Store`] if a needed chunk is missing.
117    /// - [`WorktreeError::Io`] if a needed chunk is not a `Blob`.
118    pub fn prefix<S: crate::store::ObjectSource + ?Sized>(
119        &self,
120        store: &S,
121        max_len: usize,
122    ) -> WorktreeResult<Cow<'_, [u8]>> {
123        match self {
124            Self::Inline(data) => Ok(Cow::Borrowed(&data[..data.len().min(max_len)])),
125            Self::Chunked(manifest) => {
126                let cap = usize::try_from(manifest.total_size)
127                    .unwrap_or(max_len)
128                    .min(max_len);
129                let mut data = Vec::with_capacity(cap);
130                for chunk in &manifest.chunks {
131                    if data.len() >= max_len {
132                        break;
133                    }
134                    data.extend_from_slice(&read_chunk(store, chunk)?);
135                }
136                data.truncate(max_len);
137                Ok(Cow::Owned(data))
138            }
139        }
140    }
141
142    /// The full content: inline data as-is (no further reads), a chunked
143    /// blob reassembled by reading every chunk, with the result length
144    /// enforced against the manifest's `total_size` (SPEC-OBJECTS §7).
145    ///
146    /// # Errors
147    /// - [`WorktreeError::Store`] if a chunk is missing.
148    /// - [`WorktreeError::Io`] if a chunk is not a `Blob`.
149    /// - [`WorktreeError::Object`] if the concatenated chunks do not total
150    ///   the manifest's `total_size`.
151    pub fn into_content<S: crate::store::ObjectSource + ?Sized>(
152        self,
153        store: &S,
154    ) -> WorktreeResult<Vec<u8>> {
155        match self {
156            Self::Inline(data) => Ok(data),
157            Self::Chunked(manifest) => {
158                let mut data =
159                    Vec::with_capacity(usize::try_from(manifest.total_size).unwrap_or(0));
160                for chunk in &manifest.chunks {
161                    data.extend_from_slice(&read_chunk(store, chunk)?);
162                }
163                manifest.check_reassembled_size(data.len())?;
164                Ok(data)
165            }
166        }
167    }
168
169    /// The empty blob: what a diff side with no object (an add's old side,
170    /// a delete's new side) loads as. Zero length, empty prefix, empty
171    /// content — no store reads.
172    #[must_use]
173    pub fn empty() -> Self {
174        Self::Inline(Vec::new())
175    }
176}
177
178/// One chunk of a [`ChunkedBlob`]: must deserialize to an inline `Blob`.
179fn read_chunk<S: crate::store::ObjectSource + ?Sized>(
180    store: &S,
181    chunk: &Hash,
182) -> WorktreeResult<Vec<u8>> {
183    match store.read_object(chunk)? {
184        Object::Blob(b) => Ok(b.data),
185        other => Err(not_a_blob("chunk", chunk, &other)),
186    }
187}
188
189/// The "expected a blob, found something else" error shared by every
190/// [`LoadedBlob`] read path; `what` is `"object"` for a top-level hash and
191/// `"chunk"` for a manifest chunk, preserving the historical wording of
192/// both messages.
193fn not_a_blob(what: &str, hash: &Hash, got: &Object) -> WorktreeError {
194    WorktreeError::Io(io::Error::other(format!(
195        "{what} {} is not a blob (got {})",
196        crate::hash::to_hex(hash),
197        got.object_type().name()
198    )))
199}