mkit_core/store/source.rs
1//! Read-side object sources and adapters over the durable [`ObjectStore`].
2//!
3//! [`ObjectSource`] is the read trait shared by the store and its overlays;
4//! [`EphemeralSink`] is the in-memory snapshot overlay for query commands;
5//! [`DisplaySource`] is the display-only adapter that opts a render path out of
6//! per-read hash verification (#625). Extracted from `store.rs` (#633): these are
7//! consumers of the store's read API, distinct from its on-disk write/fsync
8//! machinery.
9
10use crate::hash::Hash;
11use crate::object::{Object, object_id_from_parts};
12use crate::serialize;
13
14use super::{MAX_RAW_OBJECT_SIZE, ObjectSink, ObjectStore, StoreError, StoreResult};
15
16/// Read source shared by [`ObjectStore`] and snapshot overlays, so
17/// tree-diff code can resolve objects from either the durable store or
18/// an in-memory [`EphemeralSink`].
19pub trait ObjectSource {
20 /// Read and integrity-verify the raw bytes of `h`.
21 ///
22 /// # Errors
23 /// [`StoreError::ObjectNotFound`] / [`StoreError::HashMismatch`] /
24 /// I/O errors, as for [`ObjectStore::read`].
25 fn read(&self, h: &Hash) -> StoreResult<Vec<u8>>;
26
27 /// Read and decode `h` into a typed [`Object`].
28 ///
29 /// # Errors
30 /// As [`Self::read`], plus decode errors.
31 fn read_object(&self, h: &Hash) -> StoreResult<Object> {
32 Ok(serialize::deserialize(&self.read(h)?)?)
33 }
34
35 /// Read `h` without integrity verification, for display-only
36 /// rendering — see [`ObjectStore::read_unverified`] for the full
37 /// policy (#625). Defaults to the fully-verifying [`Self::read`], so
38 /// every existing and third-party [`ObjectSource`] stays verifying
39 /// unless it explicitly opts in by overriding this method.
40 ///
41 /// # Errors
42 /// As [`Self::read`] (an opting-in override may narrow this to never
43 /// return [`StoreError::HashMismatch`]).
44 fn read_unverified(&self, h: &Hash) -> StoreResult<Vec<u8>> {
45 self.read(h)
46 }
47}
48
49impl ObjectSource for ObjectStore {
50 fn read(&self, h: &Hash) -> StoreResult<Vec<u8>> {
51 ObjectStore::read(self, h)
52 }
53
54 fn read_unverified(&self, h: &Hash) -> StoreResult<Vec<u8>> {
55 ObjectStore::read_unverified(self, h)
56 }
57}
58
59/// In-memory object overlay for **ephemeral worktree snapshots**
60/// (`status`, `diff`, conflict/restore safety checks).
61///
62/// Writes never touch the durable store: objects whose hash already
63/// exists on disk are deduplicated against it (the store's
64/// visible-implies-durable invariant makes that safe), everything else
65/// lives in a private map that vanishes with the sink. This is what
66/// keeps query commands from (a) paying any durability cost, (b)
67/// growing `objects/` with throwaway snapshot trees, and (c) ever
68/// making a non-durable object *visible* where another writer's dedup
69/// could durably reference it.
70///
71/// Reads fall through to the underlying store, so diff walkers can
72/// resolve a snapshot tree that references committed objects.
73#[derive(Debug)]
74pub struct EphemeralSink<'s> {
75 store: &'s ObjectStore,
76 objects: std::sync::Mutex<std::collections::HashMap<Hash, Vec<u8>>>,
77}
78
79impl<'s> EphemeralSink<'s> {
80 /// Create an empty overlay over `store`.
81 #[must_use]
82 pub fn new(store: &'s ObjectStore) -> Self {
83 Self {
84 store,
85 objects: std::sync::Mutex::new(std::collections::HashMap::new()),
86 }
87 }
88}
89
90impl ObjectSink for EphemeralSink<'_> {
91 fn put(&self, bytes: &[u8]) -> StoreResult<Hash> {
92 self.put_parts(&[bytes])
93 }
94
95 fn put_parts(&self, parts: &[&[u8]]) -> StoreResult<Hash> {
96 let mut total: usize = 0;
97 for p in parts {
98 total = total
99 .checked_add(p.len())
100 .ok_or(StoreError::ObjectTooLarge)?;
101 }
102 if total > MAX_RAW_OBJECT_SIZE {
103 return Err(StoreError::ObjectTooLarge);
104 }
105 let h = object_id_from_parts(parts);
106 // Dedup against the durable store: visible store objects are durable
107 // by invariant, and skipping them keeps the overlay's memory bounded
108 // by the *changed* content, not the worktree. The overlay only ever
109 // materialises the bytes on a dedup miss.
110 if self.store.contains(&h) {
111 return Ok(h);
112 }
113 self.objects
114 .lock()
115 .expect("ephemeral sink mutex")
116 .entry(h)
117 .or_insert_with(|| {
118 let mut buf = Vec::with_capacity(total);
119 for p in parts {
120 buf.extend_from_slice(p);
121 }
122 buf
123 });
124 Ok(h)
125 }
126
127 fn has(&self, h: &Hash) -> bool {
128 self.objects
129 .lock()
130 .expect("ephemeral sink mutex")
131 .contains_key(h)
132 || self.store.contains(h)
133 }
134}
135
136impl EphemeralSink<'_> {
137 /// The private-map half of a read: bytes this process already built
138 /// (via `put`/`put_parts`) and never persisted anywhere else to be
139 /// corrupted, shared by both [`ObjectSource::read`] and
140 /// [`ObjectSource::read_unverified`] below — the two differ only in
141 /// what they do on a miss.
142 fn overlay_get(&self, h: &Hash) -> Option<Vec<u8>> {
143 self.objects
144 .lock()
145 .expect("ephemeral sink mutex")
146 .get(h)
147 .cloned()
148 }
149}
150
151impl ObjectSource for EphemeralSink<'_> {
152 fn read(&self, h: &Hash) -> StoreResult<Vec<u8>> {
153 match self.overlay_get(h) {
154 Some(bytes) => Ok(bytes),
155 None => self.store.read(h),
156 }
157 }
158
159 fn read_unverified(&self, h: &Hash) -> StoreResult<Vec<u8>> {
160 // Private-map hits are already trusted, same as the verifying
161 // `read` path above. Only the store fall-through actually skips a
162 // hash check.
163 match self.overlay_get(h) {
164 Some(bytes) => Ok(bytes),
165 None => self.store.read_unverified(h),
166 }
167 }
168}
169
170/// Adapter that makes any [`ObjectSource`] read without BLAKE3
171/// verification, for display-only rendering (`diff`, `show`, and the
172/// commit/merge/pull post-op summaries). Wrap the source once at the
173/// render call site — `DisplaySource::new(&store)` — and pass the
174/// wrapper wherever a generic `S: ObjectSource` render function expects
175/// its source. Every existing render function (`render_stat`,
176/// `emit_entry_patch`, `LoadedBlob::load`/`prefix`/`into_content`) works
177/// unchanged: the verify/no-verify policy lives entirely in which source
178/// gets passed in, never in a flag threaded through render code.
179///
180/// # When to use
181/// mkit never feeds unverified bytes into state that mkit itself writes,
182/// or into output that mkit's own tooling round-trips (format-patch →
183/// `git am`) — a diffstat or patch preview that only a human reads and
184/// discards is neither, so it's fair game. NEVER wrap a source feeding
185/// publication (commit/merge/rebase tree writes), dedup, fetch/apply, or
186/// any path whose output becomes durable state or gets applied elsewhere
187/// (e.g. the format-patch body in `git_tools.rs`, which is deliberately
188/// NOT wrapped because `git am` applies it into new commits). See
189/// [`ObjectStore::read_unverified`] for the full policy this wrapper
190/// exists to apply consistently (#625).
191pub struct DisplaySource<'a, S: ObjectSource + ?Sized> {
192 inner: &'a S,
193}
194
195impl<'a, S: ObjectSource + ?Sized> DisplaySource<'a, S> {
196 /// Wrap `inner` so reads through this adapter skip verification.
197 #[must_use]
198 pub fn new(inner: &'a S) -> Self {
199 Self { inner }
200 }
201}
202
203// Manual `Debug` (rather than `derive`) so this doesn't force an
204// unnecessary `S: Debug` bound on every generic render fn that only
205// needs `S: ObjectSource`.
206impl<S: ObjectSource + ?Sized> std::fmt::Debug for DisplaySource<'_, S> {
207 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208 f.debug_struct("DisplaySource").finish_non_exhaustive()
209 }
210}
211
212impl<S: ObjectSource + ?Sized> ObjectSource for DisplaySource<'_, S> {
213 fn read(&self, h: &Hash) -> StoreResult<Vec<u8>> {
214 self.inner.read_unverified(h)
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use crate::layout::RepoLayout;
222 use std::fs::OpenOptions;
223 use std::io::{self, Seek, Write};
224 use tempfile::TempDir;
225
226 fn fresh_store() -> (TempDir, ObjectStore) {
227 let dir = TempDir::new().expect("tempdir");
228 let store = ObjectStore::init(&RepoLayout::single(dir.path())).expect("init");
229 (dir, store)
230 }
231
232 /// Flip the first byte of the on-disk object file for `h`, in place.
233 /// Shared by the `DisplaySource` corruption tests below — the same
234 /// "the bytes on disk no longer match `h`" setup the corruption tests
235 /// in `store.rs` use (deliberately duplicated there rather than
236 /// exposing a test-support seam).
237 fn corrupt_first_byte(store: &ObjectStore, h: &Hash, first_byte: u8) {
238 let path = store.path_for(h);
239 let mut f = OpenOptions::new()
240 .read(true)
241 .write(true)
242 .open(&path)
243 .unwrap();
244 f.seek(io::SeekFrom::Start(0)).unwrap();
245 f.write_all(&[first_byte ^ 0xFF]).unwrap();
246 f.sync_all().unwrap();
247 }
248
249 #[test]
250 fn display_source_delegates_to_unverified() {
251 // `DisplaySource` is the intended call-site adapter: wrapping the
252 // store must make `ObjectSource::read` succeed on an object that
253 // `ObjectStore::read` itself would reject.
254 let (_dir, store) = fresh_store();
255 let bytes = b"trustworthy".to_vec();
256 let h = store.write(&bytes).unwrap();
257 corrupt_first_byte(&store, &h, bytes[0]);
258 assert!(matches!(
259 store.read(&h).unwrap_err(),
260 StoreError::HashMismatch { .. }
261 ));
262
263 let display = DisplaySource::new(&store);
264 let mut corrupted = bytes.clone();
265 corrupted[0] ^= 0xFF;
266 assert_eq!(
267 display.read(&h).unwrap(),
268 corrupted,
269 "DisplaySource::read must delegate to the store's unverified read"
270 );
271 }
272
273 #[test]
274 fn ephemeral_sink_unverified_falls_through() {
275 // Two shapes an `EphemeralSink` reader can hit: a private-map
276 // object built by this snapshot (never touches disk, so nothing to
277 // corrupt) and a store-backed object corrupted on disk. Both must
278 // read fine through `DisplaySource<EphemeralSink>`.
279 let (_dir, store) = fresh_store();
280 let durable_bytes = b"trustworthy".to_vec();
281 let durable_h = store.write(&durable_bytes).unwrap();
282 corrupt_first_byte(&store, &durable_h, durable_bytes[0]);
283
284 let sink = EphemeralSink::new(&store);
285 let private_h = sink.put(b"snapshot-only").unwrap();
286
287 let display = DisplaySource::new(&sink);
288 assert_eq!(display.read(&private_h).unwrap(), b"snapshot-only");
289
290 let mut corrupted = durable_bytes.clone();
291 corrupted[0] ^= 0xFF;
292 assert_eq!(display.read(&durable_h).unwrap(), corrupted);
293 }
294
295 #[test]
296 fn read_unverified_default_is_verifying_read() {
297 // A minimal `ObjectSource` impl that only defines `read` must get
298 // fully-verifying behaviour from `read_unverified`'s default body
299 // — the whole safety net for third-party/future implementors that
300 // haven't opted in to the unverified path.
301 struct OnlyReadImpl<'s>(&'s ObjectStore);
302 impl ObjectSource for OnlyReadImpl<'_> {
303 fn read(&self, h: &Hash) -> StoreResult<Vec<u8>> {
304 self.0.read(h)
305 }
306 }
307
308 let (_dir, store) = fresh_store();
309 let bytes = b"trustworthy".to_vec();
310 let h = store.write(&bytes).unwrap();
311 corrupt_first_byte(&store, &h, bytes[0]);
312
313 let only_read = OnlyReadImpl(&store);
314 assert!(matches!(
315 only_read.read_unverified(&h).unwrap_err(),
316 StoreError::HashMismatch { .. }
317 ));
318 }
319}