1use std::fs::{self, File};
4use std::io::{self, Read, Write};
5use std::ops::ControlFlow;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use flate2::write::ZlibEncoder;
10use flate2::Compression;
11use flate2::{Decompress, FlushDecompress, Status};
12
13use super::{BoundedRead, ObjectStore, ObjectStream, WritableObjectStore};
14use crate::error::{Error, Result};
15use crate::git_dir::io_error::{map_objects_dir_io, GitDirPathOperation};
16use crate::hash::{self, ObjectPayloadHasher};
17use crate::objects::{HashAlgo, Object, ObjectId, ObjectIdPrefix, ObjectInfo, ObjectKind};
18use crate::odb::WriteOptions;
19use crate::zlib_inflate::{
20 with_loose_scratch, LooseFileReadBudget, LOOSE_INFLATE_INPUT_CHUNK, LOOSE_INFLATE_OUTPUT_CHUNK,
21};
22const STREAM_CHUNK: usize = 128 * 1024;
24
25#[derive(Debug, Clone)]
27pub struct LooseStore {
28 objects_dir: PathBuf,
29 hash_algo: HashAlgo,
30 compression: Compression,
31}
32
33impl LooseStore {
34 #[must_use]
36 pub fn new(objects_dir: PathBuf, hash_algo: HashAlgo, compression: Compression) -> Self {
37 Self {
38 objects_dir,
39 hash_algo,
40 compression,
41 }
42 }
43
44 #[must_use]
46 pub fn objects_dir(&self) -> &Path {
47 &self.objects_dir
48 }
49
50 #[must_use]
52 pub fn object_path(&self, oid: &ObjectId) -> PathBuf {
53 oid.loose_path_in(&self.objects_dir)
54 }
55
56 pub fn ensure_all_loose_prefix_dirs(&self) -> Result<()> {
62 for i in 0u8..=255 {
63 let prefix = self.objects_dir.join(format!("{i:02x}"));
64 fs::create_dir_all(&prefix).map_err(|source| {
65 map_objects_dir_io(
66 &self.objects_dir,
67 &prefix,
68 GitDirPathOperation::CreateDir,
69 source,
70 )
71 })?;
72 }
73 Ok(())
74 }
75
76 pub fn zlib_compress_store_bytes(&self, store_bytes: &[u8]) -> Result<Vec<u8>> {
82 zlib_compress_store_bytes(store_bytes, self.compression)
83 }
84
85 pub(crate) fn try_finish_if_loose_present(
87 &self,
88 oid: &ObjectId,
89 options: WriteOptions,
90 ) -> Result<Option<ObjectId>> {
91 let path = self.object_path(oid);
92 if path.is_file() {
93 if !options.silent {
94 let _ = self.freshen(oid)?;
95 }
96 return Ok(Some(*oid));
97 }
98 Ok(None)
99 }
100
101 pub fn write_store_prehashed(
107 &self,
108 oid: &ObjectId,
109 store_bytes: &[u8],
110 options: WriteOptions,
111 ) -> Result<ObjectId> {
112 parse_object_bytes(store_bytes)?;
113 if oid.algo() != self.hash_algo {
114 return Err(Error::InvalidObjectId(oid.to_hex()));
115 }
116 if let Some(existing) = self.try_finish_if_loose_present(oid, options)? {
117 return Ok(existing);
118 }
119 let path = self.object_path(oid);
120 let prefix_dir = path
121 .parent()
122 .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
123 let zlib = zlib_compress_store_bytes(store_bytes, self.compression)?;
124 publish_loose_object(&self.objects_dir, &path, prefix_dir, false, |mut file| {
125 file.write_all(&zlib).map_err(|source| {
126 map_objects_dir_io(
127 &self.objects_dir,
128 &path,
129 GitDirPathOperation::WriteObject,
130 source,
131 )
132 })
133 })?;
134 Ok(*oid)
135 }
136
137 pub fn write_zlib_prehashed(
143 &self,
144 oid: &ObjectId,
145 zlib_store: &[u8],
146 options: WriteOptions,
147 ) -> Result<ObjectId> {
148 if oid.algo() != self.hash_algo {
149 return Err(Error::InvalidObjectId(oid.to_hex()));
150 }
151 if let Some(existing) = self.try_finish_if_loose_present(oid, options)? {
152 return Ok(existing);
153 }
154 let path = self.object_path(oid);
155 let prefix_dir = path
156 .parent()
157 .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
158 publish_loose_object(
159 &self.objects_dir,
160 &path,
161 prefix_dir,
162 options.loose_prefix_dirs_ready,
163 |mut file| {
164 file.write_all(zlib_store).map_err(|source| {
165 map_objects_dir_io(
166 &self.objects_dir,
167 &path,
168 GitDirPathOperation::WriteObject,
169 source,
170 )
171 })
172 },
173 )?;
174 Ok(*oid)
175 }
176
177 pub(crate) fn write_prehashed(
185 &self,
186 oid: &ObjectId,
187 kind: ObjectKind,
188 data: &[u8],
189 options: WriteOptions,
190 ) -> Result<ObjectId> {
191 if oid.algo() != self.hash_algo {
192 return Err(Error::InvalidObjectId(oid.to_hex()));
193 }
194 if let Some(existing) = self.try_finish_if_loose_present(oid, options)? {
195 return Ok(existing);
196 }
197 let path = self.object_path(oid);
198 let prefix_dir = path
199 .parent()
200 .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
201 publish_loose_object(
202 &self.objects_dir,
203 &path,
204 prefix_dir,
205 options.loose_prefix_dirs_ready,
206 |file| deflate_object_into(file, kind, data, self.compression),
207 )?;
208 Ok(*oid)
209 }
210
211 pub fn read_verify_oid(path: &Path, expected_oid: &ObjectId) -> Result<Object> {
219 let file = fs::File::open(path).map_err(Error::Io)?;
220 let raw = read_zlib_loose_payload(file)?;
221 let obj = parse_object_bytes_with_oid(&raw, expected_oid)?;
222 let computed = hash::hash_object(expected_oid.algo(), obj.kind, &obj.data)?;
223 if computed != *expected_oid {
224 return Err(Error::LooseHashMismatch {
225 path: path.display().to_string(),
226 real_oid: computed.to_hex(),
227 });
228 }
229 Ok(obj)
230 }
231
232 pub(crate) fn read_unless_larger(
242 &self,
243 oid: &ObjectId,
244 limit: u64,
245 ) -> Result<Option<BoundedRead>> {
246 let mut file = match fs::File::open(self.object_path(oid)) {
247 Ok(f) => f,
248 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
249 Err(err) => return Err(Error::Io(err)),
250 };
251 let mut buf = Vec::new();
252 let complete =
253 with_loose_scratch(|s| s.inflate_loose_file_unless_larger(&mut file, &mut buf, limit))?;
254 if !complete {
255 return Ok(Some(BoundedRead::Larger));
256 }
257 let kind = strip_decompressed_object_header(&mut buf, Some(oid))?;
258 Ok(Some(BoundedRead::Object(Object::new(kind, buf))))
259 }
260
261 fn touch_mtime(path: &Path) -> bool {
262 crate::fs_readonly::set_file_times_now(path).is_some()
263 }
264}
265
266impl ObjectStore for LooseStore {
267 fn hash_algo(&self) -> HashAlgo {
268 self.hash_algo
269 }
270
271 fn read(&self, oid: &ObjectId) -> Result<Option<Object>> {
272 let mut data = Vec::new();
273 Ok(self
274 .read_into(oid, &mut data)?
275 .map(|kind| Object::new(kind, data)))
276 }
277
278 fn read_into(&self, oid: &ObjectId, buf: &mut Vec<u8>) -> Result<Option<ObjectKind>> {
279 let path = self.object_path(oid);
280 let file = match fs::File::open(&path) {
281 Ok(f) => f,
282 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
283 Err(err) => return Err(Error::Io(err)),
284 };
285 read_zlib_loose_payload_into(file, buf)?;
286 let kind = strip_decompressed_object_header(buf, Some(oid))?;
287 Ok(Some(kind))
288 }
289
290 fn read_info(&self, oid: &ObjectId) -> Result<Option<ObjectInfo>> {
291 let path = self.object_path(oid);
292 let mut file = match fs::File::open(&path) {
293 Ok(f) => f,
294 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
295 Err(err) => return Err(Error::Io(err)),
296 };
297 match loose_object_info_from_file(&mut file) {
298 Err(Error::Io(err)) if err.kind() == io::ErrorKind::IsADirectory => Ok(None),
299 other => other.map(Some),
300 }
301 }
302
303 fn open_stream(&self, oid: &ObjectId) -> Result<Option<ObjectStream<'_>>> {
304 let path = self.object_path(oid);
305 let file = match fs::File::open(&path) {
306 Ok(f) => f,
307 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
308 Err(err) => return Err(Error::Io(err)),
309 };
310 let reader = LoosePayloadReader::open(file)?;
311 Ok(Some(ObjectStream {
312 kind: reader.kind,
313 size: reader.payload_size,
314 reader: Box::new(reader),
315 }))
316 }
317
318 fn for_each_object(&self, f: &mut dyn FnMut(&ObjectId) -> ControlFlow<()>) -> Result<()> {
319 for_each_loose_object_id(&self.objects_dir, self.hash_algo, f)
320 }
321
322 fn lookup_prefix(&self, prefix: &str, limit: usize, out: &mut Vec<ObjectId>) -> Result<()> {
323 let prefix = ObjectIdPrefix::from_hex(prefix, self.hash_algo)?;
324 let start_len = out.len();
325 let mut collect = |oid: &ObjectId| {
326 if prefix.matches(oid) {
327 out.push(*oid);
328 if limit != 0 && out.len() - start_len >= limit {
329 return ControlFlow::Break(());
330 }
331 }
332 ControlFlow::Continue(())
333 };
334 if prefix.nibbles() < 2 {
335 return for_each_loose_object_id(&self.objects_dir, self.hash_algo, &mut collect);
336 }
337 let fanout = format!("{:02x}", prefix.lowest().as_bytes()[0]);
338 let fanout_dir = self.objects_dir.join(&fanout);
339 for_each_object_id_in_fanout_dir(&fanout_dir, &fanout, self.hash_algo, &mut collect)
340 .map(drop)
341 }
342}
343
344impl WritableObjectStore for LooseStore {
345 fn write(&self, kind: ObjectKind, data: &[u8], options: WriteOptions) -> Result<ObjectId> {
346 let oid = hash::hash_object(self.hash_algo, kind, data)?;
347 if let Some(existing) = self.try_finish_if_loose_present(&oid, options)? {
348 return Ok(existing);
349 }
350 let path = self.object_path(&oid);
351 let prefix_dir = path
352 .parent()
353 .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
354 publish_loose_object(&self.objects_dir, &path, prefix_dir, false, |file| {
355 deflate_object_into(file, kind, data, self.compression)
356 })?;
357 Ok(oid)
358 }
359
360 fn write_stream(
361 &self,
362 kind: ObjectKind,
363 size: u64,
364 reader: &mut dyn Read,
365 options: WriteOptions,
366 ) -> Result<ObjectId> {
367 self.write_payload_stream(kind, size, reader, options)
368 }
369
370 fn freshen(&self, oid: &ObjectId) -> Result<bool> {
371 let path = self.object_path(oid);
372 if path.is_file() {
373 return Ok(LooseStore::touch_mtime(&path));
374 }
375 Ok(false)
376 }
377}
378
379pub fn enumerate_loose_objects(
381 objects_dir: &Path,
382 hash_algo: HashAlgo,
383) -> Result<Vec<(ObjectId, PathBuf)>> {
384 let mut out = Vec::new();
385 for_each_loose_object_id(objects_dir, hash_algo, &mut |oid| {
386 let path = oid.loose_path_in(objects_dir);
387 out.push((*oid, path));
388 ControlFlow::Continue(())
389 })?;
390 Ok(out)
391}
392
393pub fn for_each_loose_object_id(
395 objects_dir: &Path,
396 hash_algo: HashAlgo,
397 f: &mut dyn FnMut(&ObjectId) -> ControlFlow<()>,
398) -> Result<()> {
399 let top = match fs::read_dir(objects_dir) {
400 Ok(rd) => rd,
401 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
402 Err(e) => return Err(Error::Io(e)),
403 };
404
405 for top_entry in top {
406 let top_entry = top_entry.map_err(Error::Io)?;
407 let name = top_entry.file_name();
408 let Some(prefix) = name.to_str() else {
409 continue;
410 };
411 if prefix.len() != 2 || !prefix.bytes().all(|b| b.is_ascii_hexdigit()) {
412 continue;
413 }
414 if !top_entry.file_type().map_err(Error::Io)?.is_dir() {
415 continue;
416 }
417 if for_each_object_id_in_fanout_dir(&top_entry.path(), prefix, hash_algo, f)?.is_break() {
418 return Ok(());
419 }
420 }
421 Ok(())
422}
423
424fn for_each_object_id_in_fanout_dir(
427 fanout_dir: &Path,
428 fanout: &str,
429 hash_algo: HashAlgo,
430 f: &mut dyn FnMut(&ObjectId) -> ControlFlow<()>,
431) -> Result<ControlFlow<()>> {
432 let sub = match fs::read_dir(fanout_dir) {
433 Ok(rd) => rd,
434 Err(e)
435 if matches!(
436 e.kind(),
437 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
438 ) =>
439 {
440 return Ok(ControlFlow::Continue(()));
441 }
442 Err(e) => return Err(Error::Io(e)),
443 };
444 for sub_entry in sub {
445 let sub_entry = sub_entry.map_err(Error::Io)?;
446 if !sub_entry.file_type().map_err(Error::Io)?.is_file() {
447 continue;
448 }
449 let suffix_name = sub_entry.file_name();
450 let Some(suffix) = suffix_name.to_str() else {
451 continue;
452 };
453 if !is_loose_suffix_for_algo(suffix, hash_algo) {
454 continue;
455 }
456 let hex = format!("{fanout}{suffix}");
457 let Ok(oid) = ObjectId::from_hex(&hex) else {
458 continue;
459 };
460 if f(&oid).is_break() {
461 return Ok(ControlFlow::Break(()));
462 }
463 }
464 Ok(ControlFlow::Continue(()))
465}
466
467pub fn read_zlib_loose_payload(file: File) -> Result<Vec<u8>> {
469 let mut out = Vec::new();
470 read_zlib_loose_payload_into(file, &mut out)?;
471 Ok(out)
472}
473
474pub(crate) fn read_zlib_loose_payload_into(mut file: File, buf: &mut Vec<u8>) -> Result<()> {
476 with_loose_scratch(|scratch| scratch.inflate_loose_file(&mut file, buf))
477}
478
479pub(crate) fn strip_decompressed_object_header(
481 buf: &mut Vec<u8>,
482 oid_hint: Option<&ObjectId>,
483) -> Result<ObjectKind> {
484 let nul = buf
485 .iter()
486 .position(|&b| b == 0)
487 .ok_or_else(|| Error::CorruptObject("missing NUL in object header".to_owned()))?;
488 let (kind, size) = parse_object_header_prefix(buf)?;
489 let payload_start = nul + 1;
490 let size_usize = usize::try_from(size)
491 .map_err(|_| Error::CorruptObject(format!("invalid object size: {size}")))?;
492 if buf.len().saturating_sub(payload_start) != size_usize {
493 let oid_str = oid_hint.map(ObjectId::to_hex).unwrap_or_else(|| {
494 hash_algo_digest_hex(HashAlgo::Sha1, buf).unwrap_or_else(|_| "?".to_owned())
495 });
496 return Err(Error::CorruptObject(format!(
497 "object {oid_str} size mismatch: header says {size}, got {}",
498 buf.len().saturating_sub(payload_start)
499 )));
500 }
501 if payload_start > 0 {
502 buf.copy_within(payload_start.., 0);
503 }
504 buf.truncate(size_usize);
505 Ok(kind)
506}
507
508pub fn read_loose_object_info(path: &Path) -> Result<ObjectInfo> {
510 let mut file = fs::File::open(path).map_err(Error::Io)?;
511 loose_object_info_from_file(&mut file)
512}
513
514fn loose_object_info_from_file(file: &mut File) -> Result<ObjectInfo> {
515 let prefix = with_loose_scratch(|scratch| scratch.inflate_loose_header(file))?;
516 let (kind, size) = parse_object_header_prefix(&prefix)?;
517 Ok(ObjectInfo { kind, size })
518}
519
520pub(crate) fn parse_object_bytes(raw: &[u8]) -> Result<Object> {
522 parse_object_bytes_inner(raw, None)
523}
524
525pub(crate) fn parse_object_bytes_with_oid(raw: &[u8], oid: &ObjectId) -> Result<Object> {
526 parse_object_bytes_inner(raw, Some(oid))
527}
528
529pub(crate) fn zlib_compress_store_bytes(
530 store_bytes: &[u8],
531 compression: Compression,
532) -> Result<Vec<u8>> {
533 let mut out = Vec::new();
534 let mut encoder = ZlibEncoder::new(&mut out, compression);
535 encoder
536 .write_all(store_bytes)
537 .map_err(|e| Error::Zlib(e.to_string()))?;
538 encoder.finish().map_err(|e| Error::Zlib(e.to_string()))?;
539 Ok(out)
540}
541
542pub(crate) fn decompress_zlib_loose_bytes(zlib: &[u8]) -> Result<Vec<u8>> {
543 let mut decoder = flate2::read::ZlibDecoder::new(zlib);
544 let mut raw = Vec::new();
545 decoder
546 .read_to_end(&mut raw)
547 .map_err(|e| Error::Zlib(e.to_string()))?;
548 Ok(raw)
549}
550
551pub(crate) fn parse_object_header_prefix(raw: &[u8]) -> Result<(ObjectKind, u64)> {
553 let nul = raw
554 .iter()
555 .position(|&b| b == 0)
556 .ok_or_else(|| Error::CorruptObject("missing NUL in object header".to_owned()))?;
557
558 let header = &raw[..nul];
559 let sp = header
560 .iter()
561 .position(|&b| b == b' ')
562 .ok_or_else(|| Error::CorruptObject("missing space in object header".to_owned()))?;
563
564 if sp > 32 {
565 return Err(Error::ObjectHeaderTooLong {
566 oid: hash_algo_digest_hex(HashAlgo::Sha1, raw).unwrap_or_else(|_| "?".to_owned()),
567 });
568 }
569
570 let kind = ObjectKind::from_bytes(&header[..sp])?;
571 let size_str = std::str::from_utf8(&header[sp + 1..])
572 .map_err(|_| Error::CorruptObject("non-UTF-8 object size".to_owned()))?;
573 let size: u64 = size_str
574 .parse()
575 .map_err(|_| Error::CorruptObject(format!("invalid object size: {size_str}")))?;
576 Ok((kind, size))
577}
578
579fn parse_object_bytes_inner(raw: &[u8], oid_hint: Option<&ObjectId>) -> Result<Object> {
580 let nul = raw
581 .iter()
582 .position(|&b| b == 0)
583 .ok_or_else(|| Error::CorruptObject("missing NUL in object header".to_owned()))?;
584
585 let header = &raw[..nul];
586 let data = raw[nul + 1..].to_vec();
587
588 let sp = header
589 .iter()
590 .position(|&b| b == b' ')
591 .ok_or_else(|| Error::CorruptObject("missing space in object header".to_owned()))?;
592
593 if sp > 32 {
594 let oid_str = oid_hint.map(|o| o.to_hex()).unwrap_or_else(|| {
595 hash_algo_digest_hex(HashAlgo::Sha1, raw).unwrap_or_else(|_| "?".to_owned())
596 });
597 return Err(Error::ObjectHeaderTooLong { oid: oid_str });
598 }
599
600 let kind = ObjectKind::from_bytes(&header[..sp])?;
601
602 let size_str = std::str::from_utf8(&header[sp + 1..])
603 .map_err(|_| Error::CorruptObject("non-UTF-8 object size".to_owned()))?;
604 let size: usize = size_str
605 .parse()
606 .map_err(|_| Error::CorruptObject(format!("invalid object size: {size_str}")))?;
607
608 if data.len() != size {
609 return Err(Error::CorruptObject(format!(
610 "object size mismatch: header says {size} but got {}",
611 data.len()
612 )));
613 }
614
615 Ok(Object::new(kind, data))
616}
617
618fn hash_algo_digest_hex(algo: HashAlgo, data: &[u8]) -> Result<String> {
619 Ok(algo.digest(data)?.to_hex())
620}
621
622static LOOSE_TMP_SEQ: AtomicU64 = AtomicU64::new(0);
625
626fn unique_loose_tmp_path(prefix_dir: &Path) -> PathBuf {
629 let seq = LOOSE_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
630 prefix_dir.join(format!("tmp_obj_{}_{seq}", std::process::id()))
631}
632
633fn deflate_object_into(
635 file: File,
636 kind: ObjectKind,
637 data: &[u8],
638 compression: Compression,
639) -> Result<()> {
640 let zlib_err = |e: io::Error| Error::Zlib(e.to_string());
641 let mut encoder = ZlibEncoder::new(file, compression);
642 write!(encoder, "{} {}\0", kind, data.len()).map_err(zlib_err)?;
643 encoder.write_all(data).map_err(zlib_err)?;
644 encoder.finish().map_err(zlib_err)?;
645 Ok(())
646}
647
648fn read_payload_chunks(
649 reader: &mut dyn Read,
650 size: u64,
651 mut sink: impl FnMut(&[u8]) -> Result<()>,
652) -> Result<()> {
653 let mut remaining = size;
654 let mut buf = [0u8; STREAM_CHUNK];
655 while remaining > 0 {
656 let want = usize::try_from(remaining.min(STREAM_CHUNK as u64)).map_err(|_| {
657 Error::CorruptObject(format!("object size {size} exceeds address space"))
658 })?;
659 let n = match reader.read(&mut buf[..want]) {
660 Ok(0) => {
661 return Err(Error::ObjectStreamTruncated {
662 expected: size,
663 actual: size - remaining,
664 })
665 }
666 Ok(n) => n,
667 Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
668 Err(e) => return Err(Error::Io(e)),
669 };
670 sink(&buf[..n])?;
671 remaining -= n as u64;
672 }
673 Ok(())
674}
675
676impl LooseStore {
677 pub(crate) fn write_payload_stream(
681 &self,
682 kind: ObjectKind,
683 size: u64,
684 reader: &mut dyn Read,
685 options: WriteOptions,
686 ) -> Result<ObjectId> {
687 let (first, tail) = crate::odb::stream::read_first_payload_chunk(reader, size)?;
688 let compression =
689 crate::odb::stream::compression_after_first_chunk(&first, self.compression);
690 self.write_payload_stream_with(kind, size, options, compression, |chunk_sink| {
691 chunk_sink(&first)?;
692 if tail > 0 {
693 read_payload_chunks(reader, tail, chunk_sink)
694 } else {
695 Ok(())
696 }
697 })
698 }
699
700 pub(crate) fn write_payload_stream_with(
702 &self,
703 kind: ObjectKind,
704 size: u64,
705 options: WriteOptions,
706 compression: Compression,
707 mut supply: impl FnMut(&mut dyn FnMut(&[u8]) -> Result<()>) -> Result<()>,
708 ) -> Result<ObjectId> {
709 let prefix_dir = &self.objects_dir;
710 let mut hasher = ObjectPayloadHasher::new(self.hash_algo, kind, size);
711 let tmp_path = unique_loose_tmp_path(prefix_dir);
712 fs::create_dir_all(prefix_dir).map_err(Error::Io)?;
713 let file = fs::OpenOptions::new()
714 .write(true)
715 .create_new(true)
716 .open(&tmp_path)
717 .map_err(Error::Io)?;
718 let zlib_err = |e: io::Error| Error::Zlib(e.to_string());
719 let mut encoder = ZlibEncoder::new(file, compression);
720 if let Err(e) = (|| {
721 write!(encoder, "{} {}\0", kind, size).map_err(zlib_err)?;
722 supply(&mut |chunk| {
723 hasher.update(chunk);
724 encoder.write_all(chunk).map_err(zlib_err)
725 })?;
726 encoder.finish().map_err(zlib_err)?;
727 Ok::<(), Error>(())
728 })() {
729 let _ = fs::remove_file(&tmp_path);
730 return Err(e);
731 }
732 let oid = match hasher.finish() {
733 Ok(oid) => oid,
734 Err(e) => {
735 let _ = fs::remove_file(&tmp_path);
736 return Err(e);
737 }
738 };
739 if let Some(existing) = self.try_finish_if_loose_present(&oid, options)? {
740 let _ = fs::remove_file(&tmp_path);
741 return Ok(existing);
742 }
743 let path = self.object_path(&oid);
744 let dest_prefix = path
745 .parent()
746 .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
747 if let Err(e) = fs::create_dir_all(dest_prefix) {
748 let _ = fs::remove_file(&tmp_path);
749 return Err(Error::Io(e));
750 }
751 match fs::rename(&tmp_path, &path) {
752 Ok(()) => {
753 set_loose_object_mode(&path);
754 Ok(oid)
755 }
756 Err(e) => {
757 let _ = fs::remove_file(&tmp_path);
758 if path.is_file() {
759 return Ok(oid);
760 }
761 Err(Error::Io(e))
762 }
763 }
764 }
765}
766
767fn publish_loose_object(
780 objects_dir: &Path,
781 path: &Path,
782 prefix_dir: &Path,
783 prefix_dir_ready: bool,
784 mut fill: impl FnMut(File) -> Result<()>,
785) -> Result<()> {
786 const MAX_ATTEMPTS: u32 = 4;
787 let mut attempt = 0;
788 loop {
789 attempt += 1;
790 let can_retry = attempt < MAX_ATTEMPTS;
791 if !prefix_dir_ready || attempt > 1 {
792 fs::create_dir_all(prefix_dir).map_err(|source| {
793 map_objects_dir_io(
794 objects_dir,
795 prefix_dir,
796 GitDirPathOperation::CreateDir,
797 source,
798 )
799 })?;
800 }
801 let tmp_path = unique_loose_tmp_path(prefix_dir);
802 let file = match fs::OpenOptions::new()
803 .write(true)
804 .create_new(true)
805 .open(&tmp_path)
806 {
807 Ok(file) => file,
808 Err(e)
809 if can_retry
810 && matches!(
811 e.kind(),
812 io::ErrorKind::NotFound | io::ErrorKind::AlreadyExists
813 ) =>
814 {
815 continue;
816 }
817 Err(e) => {
818 return Err(map_objects_dir_io(
819 objects_dir,
820 &tmp_path,
821 GitDirPathOperation::WriteObject,
822 e,
823 ));
824 }
825 };
826 if let Err(e) = fill(file) {
827 let _ = fs::remove_file(&tmp_path);
828 return Err(e);
829 }
830 match fs::rename(&tmp_path, path) {
831 Ok(()) => {
832 set_loose_object_mode(path);
833 return Ok(());
834 }
835 Err(e) => {
836 let _ = fs::remove_file(&tmp_path);
837 if path.is_file() {
838 return Ok(());
839 }
840 if can_retry && e.kind() == io::ErrorKind::NotFound {
841 continue;
842 }
843 return Err(map_objects_dir_io(
844 objects_dir,
845 path,
846 GitDirPathOperation::WriteObject,
847 e,
848 ));
849 }
850 }
851 }
852}
853
854fn set_loose_object_mode(path: &Path) {
855 #[cfg(unix)]
856 {
857 use std::os::unix::fs::PermissionsExt;
858 let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o444));
859 }
860 let _ = path;
861}
862
863fn is_loose_suffix_for_algo(suffix: &str, algo: HashAlgo) -> bool {
864 suffix.len() == algo.hex_len() - 2 && suffix.bytes().all(|b| b.is_ascii_hexdigit())
865}
866
867struct LoosePayloadReader {
869 file: File,
870 read_budget: LooseFileReadBudget,
871 dec: Decompress,
872 pending: Vec<u8>,
874 pending_pos: usize,
875 input_exhausted: bool,
877 zlib_complete: bool,
879 preset_dictionary: bool,
880 kind: ObjectKind,
881 payload_size: u64,
882 payload_remaining: u64,
883 phase: LooseReadPhase,
884 header_buf: Vec<u8>,
885 extra_payload: Vec<u8>,
887}
888
889enum LooseReadPhase {
890 Header,
891 Payload,
892}
893
894impl LoosePayloadReader {
895 fn open(mut file: File) -> Result<Self> {
896 let read_budget = LooseFileReadBudget::new(&file)?;
897 let mut hdr = [0u8; 2];
898 file.read_exact(&mut hdr).map_err(Error::Io)?;
899 let cmf_flg = u16::from(hdr[0]) << 8 | u16::from(hdr[1]);
900 let looks_like_zlib_header = cmf_flg != 0 && cmf_flg % 31 == 0;
901 let preset_dictionary = looks_like_zlib_header && (hdr[1] & 0x20) != 0;
902 let mut read_budget = read_budget;
903 read_budget.note_consumed(2);
904 let mut reader = Self {
905 file,
906 read_budget,
907 dec: Decompress::new(true),
908 pending: hdr.to_vec(),
909 pending_pos: 0,
910 input_exhausted: false,
911 zlib_complete: false,
912 preset_dictionary,
913 kind: ObjectKind::Blob,
914 payload_size: 0,
915 payload_remaining: 0,
916 phase: LooseReadPhase::Header,
917 header_buf: Vec::with_capacity(64),
918 extra_payload: Vec::new(),
919 };
920 reader.parse_header()?;
921 Ok(reader)
922 }
923
924 fn parse_header(&mut self) -> Result<()> {
925 let mut header_buf = std::mem::take(&mut self.header_buf);
926 loop {
927 self.pump_zlib(&mut |bytes| {
928 header_buf.extend_from_slice(bytes);
929 })?;
930 if header_buf.contains(&0) || header_buf.len() >= 128 {
931 break;
932 }
933 if self.input_exhausted && self.pending_pos == self.pending.len() {
934 break;
935 }
936 }
937 let nul = header_buf
938 .iter()
939 .position(|&b| b == 0)
940 .ok_or_else(|| Error::CorruptObject("missing NUL in object header".to_owned()))?;
941 let (kind, size) = parse_object_header_prefix(&header_buf[..=nul])?;
942 self.extra_payload = header_buf[nul + 1..].to_vec();
943 self.header_buf = header_buf;
944 self.kind = kind;
945 self.payload_size = size;
946 self.payload_remaining = size;
947 self.phase = LooseReadPhase::Payload;
948 Ok(())
949 }
950
951 fn pump_zlib(&mut self, on_out: &mut dyn FnMut(&[u8])) -> Result<()> {
952 let mut scratch = [0u8; LOOSE_INFLATE_OUTPUT_CHUNK];
953 let produced = self.inflate_into(&mut scratch)?;
954 if produced > 0 {
955 on_out(&scratch[..produced]);
956 }
957 Ok(())
958 }
959
960 fn inflate_into(&mut self, out: &mut [u8]) -> Result<usize> {
964 if self.zlib_complete {
965 return Ok(0);
966 }
967 let cap = out.len().min(LOOSE_INFLATE_OUTPUT_CHUNK);
968 let out = &mut out[..cap];
969 if self.pending_pos == self.pending.len() && !self.input_exhausted {
970 let mut buf = [0u8; LOOSE_INFLATE_INPUT_CHUNK];
971 let n = self.read_budget.read(&mut self.file, &mut buf)?;
972 self.pending.clear();
973 self.pending.extend_from_slice(&buf[..n]);
974 self.pending_pos = 0;
975 self.input_exhausted = n == 0;
976 }
977 let flush = if self.input_exhausted && self.pending_pos == self.pending.len() {
978 FlushDecompress::Finish
979 } else {
980 FlushDecompress::None
981 };
982 let before_in = self.dec.total_in();
983 let before_out = self.dec.total_out();
984 let status = match self
985 .dec
986 .decompress(&self.pending[self.pending_pos..], out, flush)
987 {
988 Ok(s) => s,
989 Err(e) => {
990 if self.preset_dictionary {
991 return Err(Error::Zlib("needs dictionary".to_owned()));
992 }
993 return Err(Error::Zlib(e.to_string()));
994 }
995 };
996 self.pending_pos += (self.dec.total_in() - before_in) as usize;
998 let produced = (self.dec.total_out() - before_out) as usize;
999 if matches!(status, Status::StreamEnd) {
1000 self.zlib_complete = true;
1001 }
1002 Ok(produced)
1003 }
1004
1005 fn ensure_zlib_complete(&mut self) -> io::Result<()> {
1007 if self.zlib_complete {
1008 return Ok(());
1009 }
1010 if !self.extra_payload.is_empty() {
1011 return Err(io::Error::other(
1012 "corrupt object: inflated bytes remain after declared payload size",
1013 ));
1014 }
1015 loop {
1016 let mut excess = 0usize;
1017 self.pump_zlib(&mut |bytes| {
1018 excess += bytes.len();
1019 })
1020 .map_err(|e| io::Error::other(e.to_string()))?;
1021 if excess > 0 {
1022 return Err(io::Error::other(
1023 "corrupt object: excess decompressed data after declared payload size",
1024 ));
1025 }
1026 if self.zlib_complete {
1027 return Ok(());
1028 }
1029 if self.input_exhausted && self.pending_pos == self.pending.len() {
1030 return Err(io::Error::other(
1031 "corrupt object: zlib stream ended before trailer validation",
1032 ));
1033 }
1034 }
1035 }
1036}
1037
1038impl Read for LoosePayloadReader {
1039 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1040 if !matches!(self.phase, LooseReadPhase::Payload) {
1041 return Ok(0);
1042 }
1043 if self.payload_remaining == 0 {
1044 self.ensure_zlib_complete()?;
1045 return Ok(0);
1046 }
1047 if buf.is_empty() {
1048 return Ok(0);
1049 }
1050 let mut delivered = 0usize;
1051 while delivered < buf.len() && self.payload_remaining > 0 {
1052 if !self.extra_payload.is_empty() {
1053 let take = self
1054 .extra_payload
1055 .len()
1056 .min(buf.len() - delivered)
1057 .min(self.payload_remaining as usize);
1058 buf[delivered..delivered + take].copy_from_slice(&self.extra_payload[..take]);
1059 self.extra_payload.drain(..take);
1060 delivered += take;
1061 self.payload_remaining -= take as u64;
1062 continue;
1063 }
1064 let want = usize::try_from(self.payload_remaining)
1065 .map_or(buf.len() - delivered, |r| r.min(buf.len() - delivered));
1066 let produced = self
1067 .inflate_into(&mut buf[delivered..delivered + want])
1068 .map_err(|e| io::Error::other(e.to_string()))?;
1069 if produced == 0 {
1070 if self.zlib_complete
1071 || (self.input_exhausted && self.pending_pos == self.pending.len())
1072 {
1073 return Err(io::Error::other(format!(
1074 "corrupt object: zlib stream ended with {} payload bytes remaining",
1075 self.payload_remaining
1076 )));
1077 }
1078 continue;
1079 }
1080 delivered += produced;
1081 self.payload_remaining -= produced as u64;
1082 }
1083 if self.payload_remaining == 0 {
1084 self.ensure_zlib_complete()?;
1085 }
1086 Ok(delivered)
1087 }
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093 use std::io::Read;
1094
1095 use super::super::{ObjectStore, WritableObjectStore};
1096
1097 const READ_CHUNK: usize = LOOSE_INFLATE_INPUT_CHUNK;
1098
1099 #[test]
1100 fn lookup_prefix_scans_single_fanout_dir() {
1101 let dir = tempfile::tempdir().unwrap();
1102 let store = LooseStore::new(
1103 dir.path().join("objects"),
1104 HashAlgo::Sha1,
1105 Compression::default(),
1106 );
1107 fs::create_dir_all(store.objects_dir()).unwrap();
1108 let oid =
1109 WritableObjectStore::write(&store, ObjectKind::Blob, b"x", WriteOptions::default())
1110 .unwrap();
1111 let mut out = Vec::new();
1112 ObjectStore::lookup_prefix(&store, &oid.to_hex()[..4], 0, &mut out).unwrap();
1113 assert_eq!(out, vec![oid]);
1114 }
1115
1116 fn temp_store() -> (tempfile::TempDir, LooseStore) {
1117 let dir = tempfile::tempdir().unwrap();
1118 let store = LooseStore::new(
1119 dir.path().join("objects"),
1120 HashAlgo::Sha1,
1121 Compression::default(),
1122 );
1123 fs::create_dir_all(store.objects_dir()).unwrap();
1124 (dir, store)
1125 }
1126
1127 fn tmp_entries(store: &LooseStore) -> Vec<PathBuf> {
1128 fs::read_dir(store.objects_dir())
1129 .unwrap()
1130 .flatten()
1131 .filter(|shard| shard.file_type().is_ok_and(|t| t.is_dir()))
1132 .flat_map(|shard| fs::read_dir(shard.path()).unwrap().flatten())
1133 .map(|entry| entry.path())
1134 .filter(|path| {
1135 path.file_name()
1136 .is_some_and(|n| n.to_string_lossy().starts_with("tmp_"))
1137 })
1138 .collect()
1139 }
1140
1141 #[test]
1142 fn identical_writes_through_separate_handles_all_succeed() {
1143 let (_dir, store) = temp_store();
1144 let threads = 8;
1145 let barrier = std::sync::Arc::new(std::sync::Barrier::new(threads));
1146 let payloads: Vec<Vec<u8>> = (0..200)
1147 .map(|j| format!("same {j}").into_bytes())
1148 .chain(std::iter::once(vec![0x5a; 1024 * 1024]))
1149 .collect();
1150 let payloads = std::sync::Arc::new(payloads);
1151 let handles: Vec<_> = (0..threads)
1152 .map(|_| {
1153 let store = store.clone();
1154 let barrier = std::sync::Arc::clone(&barrier);
1155 let payloads = std::sync::Arc::clone(&payloads);
1156 std::thread::spawn(move || {
1157 barrier.wait();
1158 payloads
1159 .iter()
1160 .map(|data| {
1161 WritableObjectStore::write(
1162 &store,
1163 ObjectKind::Blob,
1164 data,
1165 WriteOptions::default(),
1166 )
1167 })
1168 .collect::<Vec<_>>()
1169 })
1170 })
1171 .collect();
1172 for handle in handles {
1173 for (data, result) in payloads.iter().zip(handle.join().unwrap()) {
1174 let oid = result.expect("concurrent identical write succeeds");
1175 let obj = ObjectStore::read(&store, &oid).unwrap().expect("object");
1176 assert!(obj.data == *data);
1177 }
1178 }
1179 assert!(tmp_entries(&store).is_empty());
1180 }
1181
1182 #[test]
1183 fn prehashed_write_recreates_pruned_fanout_dir_marked_ready() {
1184 let (_dir, store) = temp_store();
1185 let data = b"fanout pruned under a ready writer";
1186 let oid = hash::hash_object(HashAlgo::Sha1, ObjectKind::Blob, data).unwrap();
1187 let zlib = crate::odb::zlib_compress_blob_loose(data, Compression::default()).unwrap();
1188 let options = WriteOptions {
1189 loose_prefix_dirs_ready: true,
1190 ..WriteOptions::default()
1191 };
1192 assert!(!store.object_path(&oid).parent().unwrap().exists());
1193 let written = store.write_zlib_prehashed(&oid, &zlib, options).unwrap();
1194 assert_eq!(written, oid);
1195 assert_eq!(ObjectStore::read(&store, &oid).unwrap().unwrap().data, data);
1196 assert!(tmp_entries(&store).is_empty());
1197 }
1198
1199 #[test]
1200 fn unique_tmp_paths_differ_and_are_not_loose_names() {
1201 let prefix = Path::new("objects/ab");
1202 let a = unique_loose_tmp_path(prefix);
1203 let b = unique_loose_tmp_path(prefix);
1204 assert_ne!(a, b);
1205 for path in [a, b] {
1206 let name = path.file_name().unwrap().to_str().unwrap();
1207 assert!(name.starts_with("tmp_obj_"), "{name}");
1208 assert!(!is_loose_suffix_for_algo(name, HashAlgo::Sha1));
1209 assert!(!is_loose_suffix_for_algo(name, HashAlgo::Sha256));
1210 }
1211 }
1212
1213 #[test]
1214 fn read_info_and_read_agree_for_object_spanning_read_chunks() {
1215 let (_dir, store) = temp_store();
1216 let data: Vec<u8> = (0..3 * READ_CHUNK as u32)
1217 .map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8)
1218 .collect();
1219 let oid =
1220 WritableObjectStore::write(&store, ObjectKind::Blob, &data, WriteOptions::default())
1221 .unwrap();
1222 let info = ObjectStore::read_info(&store, &oid).unwrap().expect("info");
1223 assert_eq!(info.kind, ObjectKind::Blob);
1224 assert_eq!(info.size, data.len() as u64);
1225 let obj = ObjectStore::read(&store, &oid).unwrap().expect("object");
1226 assert_eq!(obj.kind, ObjectKind::Blob);
1227 assert_eq!(obj.data, data);
1228 let mut buf = Vec::new();
1229 let kind = ObjectStore::read_into(&store, &oid, &mut buf).unwrap();
1230 assert_eq!(kind, Some(ObjectKind::Blob));
1231 assert_eq!(buf, data);
1232 }
1233
1234 #[test]
1235 fn read_unless_larger_reads_small_and_skips_large_objects() {
1236 let (_dir, store) = temp_store();
1237 let data = vec![b'x'; 3 * READ_CHUNK];
1238 let oid =
1239 WritableObjectStore::write(&store, ObjectKind::Blob, &data, WriteOptions::default())
1240 .unwrap();
1241 match store.read_unless_larger(&oid, data.len() as u64).unwrap() {
1242 Some(BoundedRead::Object(obj)) => {
1243 assert_eq!(obj.kind, ObjectKind::Blob);
1244 assert_eq!(obj.data, data);
1245 }
1246 other => panic!("expected the object, got {other:?}"),
1247 }
1248 assert!(matches!(
1249 store
1250 .read_unless_larger(&oid, data.len() as u64 - 1)
1251 .unwrap(),
1252 Some(BoundedRead::Larger)
1253 ));
1254 let missing = ObjectId::from_hex("1234567890123456789012345678901234567890").unwrap();
1255 assert!(store.read_unless_larger(&missing, 0).unwrap().is_none());
1256 }
1257
1258 #[test]
1259 fn stream_reader_delivers_payload_through_tiny_and_huge_buffers() {
1260 let (_dir, store) = temp_store();
1261 let data: Vec<u8> = (0..5 * READ_CHUNK as u32)
1262 .map(|i| (i.wrapping_mul(2_654_435_761) >> 11) as u8)
1263 .collect();
1264 let oid =
1265 WritableObjectStore::write(&store, ObjectKind::Blob, &data, WriteOptions::default())
1266 .unwrap();
1267 for chunk_len in [1usize, 7, 4096, 10 * READ_CHUNK] {
1268 let mut stream = ObjectStore::open_stream(&store, &oid)
1269 .unwrap()
1270 .expect("stream");
1271 let mut out = Vec::new();
1272 let mut chunk = vec![0u8; chunk_len];
1273 loop {
1274 let n = stream.reader.read(&mut chunk).unwrap();
1275 if n == 0 {
1276 break;
1277 }
1278 out.extend_from_slice(&chunk[..n]);
1279 }
1280 assert!(out == data, "chunk length {chunk_len}");
1281 }
1282 }
1283
1284 #[test]
1285 fn read_info_is_none_for_missing_object_or_directory_at_its_path() {
1286 let (_dir, store) = temp_store();
1287 let missing = ObjectId::from_hex("1234567890123456789012345678901234567890").unwrap();
1288 assert!(ObjectStore::read_info(&store, &missing).unwrap().is_none());
1289 fs::create_dir_all(store.object_path(&missing)).unwrap();
1290 assert!(ObjectStore::read_info(&store, &missing).unwrap().is_none());
1291 }
1292
1293 #[test]
1294 fn lookup_prefix_one_hex_char_does_not_overflow() {
1295 let dir = tempfile::tempdir().unwrap();
1296 let store = LooseStore::new(
1297 dir.path().join("objects"),
1298 HashAlgo::Sha1,
1299 Compression::default(),
1300 );
1301 fs::create_dir_all(store.objects_dir()).unwrap();
1302 let oid =
1303 WritableObjectStore::write(&store, ObjectKind::Blob, b"x", WriteOptions::default())
1304 .unwrap();
1305 let mut out = Vec::new();
1306 ObjectStore::lookup_prefix(&store, &oid.to_hex()[..1], 0, &mut out).unwrap();
1307 assert!(out.contains(&oid));
1308 }
1309
1310 #[test]
1311 fn truncated_zlib_stream_errors_on_read() {
1312 let dir = tempfile::tempdir().unwrap();
1313 let store = LooseStore::new(
1314 dir.path().join("objects"),
1315 HashAlgo::Sha1,
1316 Compression::default(),
1317 );
1318 fs::create_dir_all(store.objects_dir()).unwrap();
1319 let payload = vec![0u8; 100_000];
1320 let oid =
1321 WritableObjectStore::write(&store, ObjectKind::Blob, &payload, WriteOptions::default())
1322 .unwrap();
1323 let path = store.object_path(&oid);
1324 let mut bytes = fs::read(&path).unwrap();
1325 bytes.truncate(bytes.len() / 2);
1326 #[cfg(unix)]
1327 {
1328 use std::os::unix::fs::PermissionsExt;
1329 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1330 }
1331 fs::write(&path, &bytes).unwrap();
1332 let mut stream = ObjectStore::open_stream(&store, &oid)
1333 .unwrap()
1334 .expect("stream");
1335 let mut out = Vec::new();
1336 let err = stream.reader.read_to_end(&mut out).unwrap_err();
1337 assert!(
1338 err.to_string().contains("payload bytes remaining")
1339 || err.to_string().contains("corrupt object"),
1340 "unexpected error: {err}"
1341 );
1342 assert!(out.len() < payload.len());
1343 }
1344
1345 #[test]
1346 fn truncated_zlib_checksum_only_errors_on_read() {
1347 let dir = tempfile::tempdir().unwrap();
1348 let store = LooseStore::new(
1349 dir.path().join("objects"),
1350 HashAlgo::Sha1,
1351 Compression::default(),
1352 );
1353 fs::create_dir_all(store.objects_dir()).unwrap();
1354 let payload = vec![0u8; 100_000];
1355 let oid =
1356 WritableObjectStore::write(&store, ObjectKind::Blob, &payload, WriteOptions::default())
1357 .unwrap();
1358 let path = store.object_path(&oid);
1359 let mut bytes = fs::read(&path).unwrap();
1360 assert!(bytes.len() > 1);
1361 bytes.pop();
1362 #[cfg(unix)]
1363 {
1364 use std::os::unix::fs::PermissionsExt;
1365 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1366 }
1367 fs::write(&path, &bytes).unwrap();
1368 match ObjectStore::read(&store, &oid) {
1369 Ok(None) => {}
1370 Ok(Some(_)) => panic!("full read must not succeed on checksum-truncated loose object"),
1371 Err(_) => {}
1372 }
1373 let mut stream = ObjectStore::open_stream(&store, &oid)
1374 .expect("open_stream")
1375 .expect("stream hit");
1376 let mut out = Vec::new();
1377 let err = stream.reader.read_to_end(&mut out).unwrap_err();
1378 assert!(
1379 err.to_string().contains("trailer validation")
1380 || err.to_string().contains("corrupt object"),
1381 "unexpected error: {err}"
1382 );
1383 }
1384
1385 #[test]
1386 fn stream_sixteen_mib_peak_buffer_under_one_mib() {
1387 let dir = tempfile::tempdir().unwrap();
1388 let store = LooseStore::new(
1389 dir.path().join("objects"),
1390 HashAlgo::Sha1,
1391 Compression::default(),
1392 );
1393 fs::create_dir_all(store.objects_dir()).unwrap();
1394 let payload = vec![0xCDu8; 16 * 1024 * 1024];
1395 let oid =
1396 WritableObjectStore::write(&store, ObjectKind::Blob, &payload, WriteOptions::default())
1397 .unwrap();
1398 let mut stream = ObjectStore::open_stream(&store, &oid)
1399 .unwrap()
1400 .expect("stream");
1401 assert_eq!(stream.size, payload.len() as u64);
1402 let mut out = Vec::new();
1403 let mut chunk = [0u8; 64 * 1024];
1404 let mut peak = 0usize;
1405 loop {
1406 let n = stream.reader.read(&mut chunk).unwrap();
1407 if n == 0 {
1408 break;
1409 }
1410 peak = peak.max(n);
1411 out.extend_from_slice(&chunk[..n]);
1412 }
1413 assert_eq!(out, payload);
1414 assert!(
1415 peak < 1024 * 1024,
1416 "peak in-flight buffer {peak} must stay under 1 MiB"
1417 );
1418 }
1419}