1use std::collections::{HashMap, HashSet};
4use std::fs::{self, File};
5use std::io::{self, Read, Write};
6use std::ops::ControlFlow;
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::SystemTime;
11
12use flate2::write::ZlibEncoder;
13use flate2::Compression;
14use flate2::{Decompress, FlushDecompress, Status};
15
16use super::{BoundedRead, ObjectStore, ObjectStream, WritableObjectStore};
17use crate::error::{Error, Result};
18use crate::git_dir::io_error::{map_objects_dir_io, GitDirPathOperation};
19use crate::hash::{self, ObjectPayloadHasher};
20use crate::objects::{HashAlgo, Object, ObjectId, ObjectIdPrefix, ObjectInfo, ObjectKind};
21use crate::odb::WriteOptions;
22use crate::zlib_inflate::{
23 with_loose_scratch, LooseFileReadBudget, LOOSE_INFLATE_INPUT_CHUNK, LOOSE_INFLATE_OUTPUT_CHUNK,
24};
25const STREAM_CHUNK: usize = 128 * 1024;
27
28fn objects_dir_mtime(objects_dir: &Path) -> SystemTime {
29 #[cfg(test)]
30 crate::hot_path_test_metrics::record_loose_objects_root_metadata_for_active_scope();
31 fs::metadata(objects_dir)
32 .and_then(|m| m.modified())
33 .unwrap_or(SystemTime::UNIX_EPOCH)
34}
35
36fn fanout_dir_mtime(objects_dir: &Path, prefix: &str) -> SystemTime {
37 #[cfg(test)]
38 crate::hot_path_test_metrics::record_loose_fanout_metadata_for_active_scope();
39 fs::metadata(objects_dir.join(prefix))
40 .and_then(|m| m.modified())
41 .unwrap_or(SystemTime::UNIX_EPOCH)
42}
43
44fn loose_fanout_is_dir(objects_dir: &Path, prefix: &str) -> bool {
45 #[cfg(test)]
46 crate::hot_path_test_metrics::record_loose_path_exists_probe_for_active_scope();
47 objects_dir.join(prefix).is_dir()
48}
49
50fn loose_fanout_prefix(objects_dir: &Path, path: &Path) -> Option<String> {
51 path.strip_prefix(objects_dir)
52 .ok()
53 .and_then(|rel| rel.components().next())
54 .and_then(|c| c.as_os_str().to_str())
55 .filter(|s| s.len() == 2 && s.chars().all(|ch| ch.is_ascii_hexdigit()))
56 .map(str::to_owned)
57}
58
59#[derive(Debug)]
61pub(crate) struct LooseNegativeCache {
62 root_mtime: SystemTime,
63 fanout_mtime: HashMap<String, SystemTime>,
64 absent_paths: HashSet<PathBuf>,
65 absent_fanout: HashSet<String>,
66}
67
68impl Default for LooseNegativeCache {
69 fn default() -> Self {
70 Self {
71 root_mtime: SystemTime::UNIX_EPOCH,
72 fanout_mtime: HashMap::new(),
73 absent_paths: HashSet::new(),
74 absent_fanout: HashSet::new(),
75 }
76 }
77}
78
79impl LooseNegativeCache {
80 fn sync_root(&mut self, objects_dir: &Path) {
81 let mt = objects_dir_mtime(objects_dir);
82 if self.root_mtime != mt {
83 *self = Self {
84 root_mtime: mt,
85 ..Self::default()
86 };
87 }
88 }
89
90 fn sync_fanout(&mut self, objects_dir: &Path, prefix: &str) {
91 let mt = fanout_dir_mtime(objects_dir, prefix);
92 if self.fanout_mtime.get(prefix) != Some(&mt) {
93 self.fanout_mtime.insert(prefix.to_owned(), mt);
94 let fanout_dir = objects_dir.join(prefix);
95 self.absent_paths.retain(|p| !p.starts_with(&fanout_dir));
96 self.absent_fanout.remove(prefix);
97 }
98 }
99
100 fn peek_known_absent(&self, objects_dir: &Path, path: &Path) -> bool {
102 if self.absent_paths.contains(path) {
103 return true;
104 }
105 if let Some(prefix) = loose_fanout_prefix(objects_dir, path) {
106 if self.absent_fanout.contains(&prefix) {
107 return true;
108 }
109 }
110 false
111 }
112
113 fn may_skip_open(&mut self, objects_dir: &Path, path: &Path) -> bool {
114 if self.peek_known_absent(objects_dir, path) {
115 return true;
116 }
117 self.sync_root(objects_dir);
118 if let Some(prefix) = loose_fanout_prefix(objects_dir, path) {
119 self.sync_fanout(objects_dir, &prefix);
120 if self.absent_fanout.contains(&prefix) {
121 return true;
122 }
123 }
124 self.absent_paths.contains(path)
125 }
126
127 fn record_absent(&mut self, objects_dir: &Path, path: &Path) {
128 self.sync_root(objects_dir);
129 if let Some(prefix) = loose_fanout_prefix(objects_dir, path) {
130 self.sync_fanout(objects_dir, &prefix);
131 }
132 self.absent_paths.insert(path.to_path_buf());
133 if let Some(prefix) = loose_fanout_prefix(objects_dir, path) {
134 if !loose_fanout_is_dir(objects_dir, &prefix) {
135 self.absent_fanout.insert(prefix);
136 }
137 }
138 }
139}
140
141pub(crate) type SharedLooseNegativeCache = Arc<Mutex<LooseNegativeCache>>;
142
143#[derive(Debug, Clone)]
145pub struct LooseStore {
146 objects_dir: PathBuf,
147 hash_algo: HashAlgo,
148 compression: Compression,
149 negative_cache: SharedLooseNegativeCache,
150}
151
152impl LooseStore {
153 #[must_use]
155 pub fn new(objects_dir: PathBuf, hash_algo: HashAlgo, compression: Compression) -> Self {
156 Self::new_with_negative_cache(
157 objects_dir,
158 hash_algo,
159 compression,
160 Arc::new(Mutex::new(LooseNegativeCache::default())),
161 )
162 }
163
164 pub(crate) fn new_with_negative_cache(
165 objects_dir: PathBuf,
166 hash_algo: HashAlgo,
167 compression: Compression,
168 negative_cache: SharedLooseNegativeCache,
169 ) -> Self {
170 Self {
171 objects_dir,
172 hash_algo,
173 compression,
174 negative_cache,
175 }
176 }
177
178 fn invalidate_negative_cache(&self) {
179 if let Ok(mut guard) = self.negative_cache.lock() {
180 *guard = LooseNegativeCache::default();
181 }
182 }
183
184 fn open_may_be_absent(&self, path: &Path) -> bool {
185 self.negative_cache
186 .lock()
187 .ok()
188 .is_some_and(|mut guard| guard.may_skip_open(&self.objects_dir, path))
189 }
190
191 pub(crate) fn cached_absent_loose_path(&self, oid: &ObjectId) -> bool {
193 let path = self.object_path(oid);
194 self.negative_cache
195 .lock()
196 .ok()
197 .is_some_and(|guard| guard.peek_known_absent(&self.objects_dir, &path))
198 }
199
200 fn record_absent_path(&self, path: &Path) {
201 if let Ok(mut guard) = self.negative_cache.lock() {
202 guard.record_absent(&self.objects_dir, path);
203 }
204 }
205
206 #[must_use]
208 pub fn objects_dir(&self) -> &Path {
209 &self.objects_dir
210 }
211
212 #[must_use]
214 pub fn object_path(&self, oid: &ObjectId) -> PathBuf {
215 oid.loose_path_in(&self.objects_dir)
216 }
217
218 pub fn ensure_all_loose_prefix_dirs(&self) -> Result<()> {
224 for i in 0u8..=255 {
225 let prefix = self.objects_dir.join(format!("{i:02x}"));
226 fs::create_dir_all(&prefix).map_err(|source| {
227 map_objects_dir_io(
228 &self.objects_dir,
229 &prefix,
230 GitDirPathOperation::CreateDir,
231 source,
232 )
233 })?;
234 }
235 Ok(())
236 }
237
238 pub fn zlib_compress_store_bytes(&self, store_bytes: &[u8]) -> Result<Vec<u8>> {
244 zlib_compress_store_bytes(store_bytes, self.compression)
245 }
246
247 pub(crate) fn try_finish_if_loose_present(
249 &self,
250 oid: &ObjectId,
251 options: WriteOptions,
252 ) -> Result<Option<ObjectId>> {
253 let path = self.object_path(oid);
254 if path.is_file() {
255 if !options.silent {
256 let _ = self.freshen(oid)?;
257 }
258 return Ok(Some(*oid));
259 }
260 Ok(None)
261 }
262
263 pub fn write_store_prehashed(
269 &self,
270 oid: &ObjectId,
271 store_bytes: &[u8],
272 options: WriteOptions,
273 ) -> Result<ObjectId> {
274 parse_object_bytes(store_bytes)?;
275 if oid.algo() != self.hash_algo {
276 return Err(Error::InvalidObjectId(oid.to_hex()));
277 }
278 if let Some(existing) = self.try_finish_if_loose_present(oid, options)? {
279 return Ok(existing);
280 }
281 let path = self.object_path(oid);
282 let prefix_dir = path
283 .parent()
284 .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
285 let zlib = zlib_compress_store_bytes(store_bytes, self.compression)?;
286 publish_loose_object(&self.objects_dir, &path, prefix_dir, false, |mut file| {
287 file.write_all(&zlib).map_err(|source| {
288 map_objects_dir_io(
289 &self.objects_dir,
290 &path,
291 GitDirPathOperation::WriteObject,
292 source,
293 )
294 })
295 })?;
296 self.invalidate_negative_cache();
297 Ok(*oid)
298 }
299
300 pub fn write_zlib_prehashed(
306 &self,
307 oid: &ObjectId,
308 zlib_store: &[u8],
309 options: WriteOptions,
310 ) -> Result<ObjectId> {
311 if oid.algo() != self.hash_algo {
312 return Err(Error::InvalidObjectId(oid.to_hex()));
313 }
314 if let Some(existing) = self.try_finish_if_loose_present(oid, options)? {
315 return Ok(existing);
316 }
317 let path = self.object_path(oid);
318 let prefix_dir = path
319 .parent()
320 .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
321 publish_loose_object(
322 &self.objects_dir,
323 &path,
324 prefix_dir,
325 options.loose_prefix_dirs_ready,
326 |mut file| {
327 file.write_all(zlib_store).map_err(|source| {
328 map_objects_dir_io(
329 &self.objects_dir,
330 &path,
331 GitDirPathOperation::WriteObject,
332 source,
333 )
334 })
335 },
336 )?;
337 self.invalidate_negative_cache();
338 Ok(*oid)
339 }
340
341 pub(crate) fn write_prehashed(
349 &self,
350 oid: &ObjectId,
351 kind: ObjectKind,
352 data: &[u8],
353 options: WriteOptions,
354 ) -> Result<ObjectId> {
355 if oid.algo() != self.hash_algo {
356 return Err(Error::InvalidObjectId(oid.to_hex()));
357 }
358 if let Some(existing) = self.try_finish_if_loose_present(oid, options)? {
359 return Ok(existing);
360 }
361 let path = self.object_path(oid);
362 let prefix_dir = path
363 .parent()
364 .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
365 publish_loose_object(
366 &self.objects_dir,
367 &path,
368 prefix_dir,
369 options.loose_prefix_dirs_ready,
370 |file| deflate_object_into(file, kind, data, self.compression),
371 )?;
372 self.invalidate_negative_cache();
373 Ok(*oid)
374 }
375
376 pub fn read_verify_oid(path: &Path, expected_oid: &ObjectId) -> Result<Object> {
384 let file = fs::File::open(path).map_err(Error::Io)?;
385 let raw = match read_zlib_loose_payload(file) {
386 Ok(raw) => raw,
387 Err(err) => {
388 return Err(Error::wrap_loose_read_failure(
389 *expected_oid,
390 path.to_path_buf(),
391 err,
392 ));
393 }
394 };
395 let obj = match parse_object_bytes_with_oid(&raw, expected_oid) {
396 Ok(obj) => obj,
397 Err(err) => {
398 return Err(Error::wrap_loose_read_failure(
399 *expected_oid,
400 path.to_path_buf(),
401 err,
402 ));
403 }
404 };
405 let computed = hash::hash_object(expected_oid.algo(), obj.kind, &obj.data)?;
406 if computed != *expected_oid {
407 return Err(Error::LooseHashMismatch {
408 path: path.display().to_string(),
409 real_oid: computed.to_hex(),
410 });
411 }
412 Ok(obj)
413 }
414
415 pub(crate) fn read_unless_larger(
425 &self,
426 oid: &ObjectId,
427 limit: u64,
428 ) -> Result<Option<BoundedRead>> {
429 let mut file = match fs::File::open(self.object_path(oid)) {
430 Ok(f) => f,
431 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
432 Err(err) => return Err(Error::Io(err)),
433 };
434 let mut buf = Vec::new();
435 let complete =
436 with_loose_scratch(|s| s.inflate_loose_file_unless_larger(&mut file, &mut buf, limit))?;
437 if !complete {
438 return Ok(Some(BoundedRead::Larger));
439 }
440 let kind = strip_decompressed_object_header(&mut buf, Some(oid))?;
441 Ok(Some(BoundedRead::Object(Object::new(kind, buf))))
442 }
443
444 fn touch_mtime(path: &Path) -> bool {
445 crate::fs_readonly::set_file_times_now(path).is_some()
446 }
447}
448
449impl ObjectStore for LooseStore {
450 fn hash_algo(&self) -> HashAlgo {
451 self.hash_algo
452 }
453
454 fn read(&self, oid: &ObjectId) -> Result<Option<Object>> {
455 let mut data = Vec::new();
456 Ok(self
457 .read_into(oid, &mut data)?
458 .map(|kind| Object::new(kind, data)))
459 }
460
461 fn read_into(&self, oid: &ObjectId, buf: &mut Vec<u8>) -> Result<Option<ObjectKind>> {
462 let path = self.object_path(oid);
463 if self.open_may_be_absent(&path) {
464 return Ok(None);
465 }
466 let file = match fs::File::open(&path) {
467 Ok(f) => f,
468 Err(err) if err.kind() == io::ErrorKind::NotFound => {
469 self.record_absent_path(&path);
470 return Ok(None);
471 }
472 Err(err) => return Err(Error::Io(err)),
473 };
474 if let Err(err) = read_zlib_loose_payload_into(file, buf) {
475 return Err(Error::wrap_loose_read_failure(*oid, path, err));
476 }
477 let kind = match strip_decompressed_object_header(buf, Some(oid)) {
478 Ok(kind) => kind,
479 Err(err) => return Err(Error::wrap_loose_read_failure(*oid, path, err)),
480 };
481 Ok(Some(kind))
482 }
483
484 fn read_info(&self, oid: &ObjectId) -> Result<Option<ObjectInfo>> {
485 let path = self.object_path(oid);
486 if self.open_may_be_absent(&path) {
487 return Ok(None);
488 }
489 let mut file = match fs::File::open(&path) {
490 Ok(f) => f,
491 Err(err) if err.kind() == io::ErrorKind::NotFound => {
492 self.record_absent_path(&path);
493 return Ok(None);
494 }
495 Err(err) => return Err(Error::Io(err)),
496 };
497 match loose_object_info_from_file(&mut file) {
498 Err(Error::Io(err)) if err.kind() == io::ErrorKind::IsADirectory => Ok(None),
499 Err(err) => Err(Error::wrap_loose_read_failure(*oid, path, err)),
500 Ok(info) => Ok(Some(info)),
501 }
502 }
503
504 fn open_stream(&self, oid: &ObjectId) -> Result<Option<ObjectStream<'_>>> {
505 let path = self.object_path(oid);
506 if self.open_may_be_absent(&path) {
507 return Ok(None);
508 }
509 let file = match fs::File::open(&path) {
510 Ok(f) => f,
511 Err(err) if err.kind() == io::ErrorKind::NotFound => {
512 self.record_absent_path(&path);
513 return Ok(None);
514 }
515 Err(err) => return Err(Error::Io(err)),
516 };
517 let reader = match LoosePayloadReader::open(file) {
518 Ok(reader) => reader,
519 Err(err) => return Err(Error::wrap_loose_read_failure(*oid, path, err)),
520 };
521 Ok(Some(ObjectStream {
522 kind: reader.kind,
523 size: reader.payload_size,
524 reader: Box::new(reader),
525 }))
526 }
527
528 fn for_each_object(&self, f: &mut dyn FnMut(&ObjectId) -> ControlFlow<()>) -> Result<()> {
529 for_each_loose_object_id(&self.objects_dir, self.hash_algo, f)
530 }
531
532 fn lookup_prefix(&self, prefix: &str, limit: usize, out: &mut Vec<ObjectId>) -> Result<()> {
533 let prefix = ObjectIdPrefix::from_hex(prefix, self.hash_algo)?;
534 let start_len = out.len();
535 let mut collect = |oid: &ObjectId| {
536 if prefix.matches(oid) {
537 out.push(*oid);
538 if limit != 0 && out.len() - start_len >= limit {
539 return ControlFlow::Break(());
540 }
541 }
542 ControlFlow::Continue(())
543 };
544 if prefix.nibbles() < 2 {
545 return for_each_loose_object_id(&self.objects_dir, self.hash_algo, &mut collect);
546 }
547 let fanout = format!("{:02x}", prefix.lowest().as_bytes()[0]);
548 let fanout_dir = self.objects_dir.join(&fanout);
549 for_each_object_id_in_fanout_dir(&fanout_dir, &fanout, self.hash_algo, &mut collect)
550 .map(drop)
551 }
552}
553
554impl WritableObjectStore for LooseStore {
555 fn write(&self, kind: ObjectKind, data: &[u8], options: WriteOptions) -> Result<ObjectId> {
556 let oid = hash::hash_object(self.hash_algo, kind, data)?;
557 if let Some(existing) = self.try_finish_if_loose_present(&oid, options)? {
558 return Ok(existing);
559 }
560 let path = self.object_path(&oid);
561 let prefix_dir = path
562 .parent()
563 .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
564 publish_loose_object(&self.objects_dir, &path, prefix_dir, false, |file| {
565 deflate_object_into(file, kind, data, self.compression)
566 })?;
567 self.invalidate_negative_cache();
568 Ok(oid)
569 }
570
571 fn write_stream(
572 &self,
573 kind: ObjectKind,
574 size: u64,
575 reader: &mut dyn Read,
576 options: WriteOptions,
577 ) -> Result<ObjectId> {
578 self.write_payload_stream(kind, size, reader, options)
579 }
580
581 fn freshen(&self, oid: &ObjectId) -> Result<bool> {
582 let path = self.object_path(oid);
583 if path.is_file() {
584 return Ok(LooseStore::touch_mtime(&path));
585 }
586 Ok(false)
587 }
588}
589
590pub fn enumerate_loose_objects(
592 objects_dir: &Path,
593 hash_algo: HashAlgo,
594) -> Result<Vec<(ObjectId, PathBuf)>> {
595 let mut out = Vec::new();
596 for_each_loose_object_id(objects_dir, hash_algo, &mut |oid| {
597 let path = oid.loose_path_in(objects_dir);
598 out.push((*oid, path));
599 ControlFlow::Continue(())
600 })?;
601 Ok(out)
602}
603
604pub fn for_each_loose_object_id(
606 objects_dir: &Path,
607 hash_algo: HashAlgo,
608 f: &mut dyn FnMut(&ObjectId) -> ControlFlow<()>,
609) -> Result<()> {
610 let top = match fs::read_dir(objects_dir) {
611 Ok(rd) => rd,
612 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
613 Err(e) => return Err(Error::Io(e)),
614 };
615
616 for top_entry in top {
617 let top_entry = top_entry.map_err(Error::Io)?;
618 let name = top_entry.file_name();
619 let Some(prefix) = name.to_str() else {
620 continue;
621 };
622 if prefix.len() != 2 || !prefix.bytes().all(|b| b.is_ascii_hexdigit()) {
623 continue;
624 }
625 if !top_entry.file_type().map_err(Error::Io)?.is_dir() {
626 continue;
627 }
628 if for_each_object_id_in_fanout_dir(&top_entry.path(), prefix, hash_algo, f)?.is_break() {
629 return Ok(());
630 }
631 }
632 Ok(())
633}
634
635pub(crate) fn for_each_object_id_in_fanout_dir(
638 fanout_dir: &Path,
639 fanout: &str,
640 hash_algo: HashAlgo,
641 f: &mut dyn FnMut(&ObjectId) -> ControlFlow<()>,
642) -> Result<ControlFlow<()>> {
643 let sub = match fs::read_dir(fanout_dir) {
644 Ok(rd) => rd,
645 Err(e)
646 if matches!(
647 e.kind(),
648 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
649 ) =>
650 {
651 return Ok(ControlFlow::Continue(()));
652 }
653 Err(e) => return Err(Error::Io(e)),
654 };
655 for sub_entry in sub {
656 let sub_entry = sub_entry.map_err(Error::Io)?;
657 if !sub_entry.file_type().map_err(Error::Io)?.is_file() {
658 continue;
659 }
660 let suffix_name = sub_entry.file_name();
661 let Some(suffix) = suffix_name.to_str() else {
662 continue;
663 };
664 if !is_loose_suffix_for_algo(suffix, hash_algo) {
665 continue;
666 }
667 let hex = format!("{fanout}{suffix}");
668 let Ok(oid) = ObjectId::from_hex(&hex) else {
669 continue;
670 };
671 if f(&oid).is_break() {
672 return Ok(ControlFlow::Break(()));
673 }
674 }
675 Ok(ControlFlow::Continue(()))
676}
677
678pub fn read_zlib_loose_payload(file: File) -> Result<Vec<u8>> {
680 let mut out = Vec::new();
681 read_zlib_loose_payload_into(file, &mut out)?;
682 Ok(out)
683}
684
685pub(crate) fn read_zlib_loose_payload_into(mut file: File, buf: &mut Vec<u8>) -> Result<()> {
687 with_loose_scratch(|scratch| scratch.inflate_loose_file(&mut file, buf))
688}
689
690pub(crate) fn strip_decompressed_object_header(
692 buf: &mut Vec<u8>,
693 oid_hint: Option<&ObjectId>,
694) -> Result<ObjectKind> {
695 let nul = buf
696 .iter()
697 .position(|&b| b == 0)
698 .ok_or_else(|| Error::CorruptObject("missing NUL in object header".to_owned()))?;
699 let (kind, size) = parse_object_header_prefix(buf)?;
700 let payload_start = nul + 1;
701 let size_usize = usize::try_from(size)
702 .map_err(|_| Error::CorruptObject(format!("invalid object size: {size}")))?;
703 if buf.len().saturating_sub(payload_start) != size_usize {
704 let oid_str = oid_hint.map(ObjectId::to_hex).unwrap_or_else(|| {
705 hash_algo_digest_hex(HashAlgo::Sha1, buf).unwrap_or_else(|_| "?".to_owned())
706 });
707 return Err(Error::CorruptObject(format!(
708 "object {oid_str} size mismatch: header says {size}, got {}",
709 buf.len().saturating_sub(payload_start)
710 )));
711 }
712 if payload_start > 0 {
713 buf.copy_within(payload_start.., 0);
714 }
715 buf.truncate(size_usize);
716 Ok(kind)
717}
718
719pub fn read_loose_object_info(path: &Path) -> Result<ObjectInfo> {
721 let mut file = fs::File::open(path).map_err(Error::Io)?;
722 match loose_object_info_from_file(&mut file) {
723 Ok(info) => Ok(info),
724 Err(err) => {
725 if let Some(oid) = oid_from_loose_object_path(path) {
726 Err(Error::wrap_loose_read_failure(oid, path.to_path_buf(), err))
727 } else {
728 Err(err)
729 }
730 }
731 }
732}
733
734fn oid_from_loose_object_path(path: &Path) -> Option<ObjectId> {
735 let file_name = path.file_name()?.to_str()?;
736 if !ObjectId::is_loose_suffix_len(file_name.len()) {
737 return None;
738 }
739 let parent = path.parent()?.file_name()?.to_str()?;
740 if parent.len() != 2 {
741 return None;
742 }
743 let hex = format!("{parent}{file_name}");
744 ObjectId::from_hex(&hex).ok()
745}
746
747fn loose_object_info_from_file(file: &mut File) -> Result<ObjectInfo> {
748 let prefix = with_loose_scratch(|scratch| scratch.inflate_loose_header(file))?;
749 let (kind, size) = parse_object_header_prefix(&prefix)?;
750 Ok(ObjectInfo { kind, size })
751}
752
753pub(crate) fn parse_object_bytes(raw: &[u8]) -> Result<Object> {
755 parse_object_bytes_inner(raw, None)
756}
757
758pub(crate) fn parse_object_bytes_with_oid(raw: &[u8], oid: &ObjectId) -> Result<Object> {
759 parse_object_bytes_inner(raw, Some(oid))
760}
761
762pub(crate) fn zlib_compress_store_bytes(
763 store_bytes: &[u8],
764 compression: Compression,
765) -> Result<Vec<u8>> {
766 let mut out = Vec::new();
767 let mut encoder = ZlibEncoder::new(&mut out, compression);
768 encoder
769 .write_all(store_bytes)
770 .map_err(|e| Error::Zlib(e.to_string()))?;
771 encoder.finish().map_err(|e| Error::Zlib(e.to_string()))?;
772 Ok(out)
773}
774
775pub(crate) fn decompress_zlib_loose_bytes(zlib: &[u8]) -> Result<Vec<u8>> {
776 let mut decoder = flate2::read::ZlibDecoder::new(zlib);
777 let mut raw = Vec::new();
778 decoder
779 .read_to_end(&mut raw)
780 .map_err(|e| Error::Zlib(e.to_string()))?;
781 Ok(raw)
782}
783
784pub(crate) fn parse_object_header_prefix(raw: &[u8]) -> Result<(ObjectKind, u64)> {
786 let nul = raw
787 .iter()
788 .position(|&b| b == 0)
789 .ok_or_else(|| Error::CorruptObject("missing NUL in object header".to_owned()))?;
790
791 let header = &raw[..nul];
792 let sp = header
793 .iter()
794 .position(|&b| b == b' ')
795 .ok_or_else(|| Error::CorruptObject("missing space in object header".to_owned()))?;
796
797 if sp > 32 {
798 return Err(Error::ObjectHeaderTooLong {
799 oid: hash_algo_digest_hex(HashAlgo::Sha1, raw).unwrap_or_else(|_| "?".to_owned()),
800 });
801 }
802
803 let kind = ObjectKind::from_bytes(&header[..sp])?;
804 let size_str = std::str::from_utf8(&header[sp + 1..])
805 .map_err(|_| Error::CorruptObject("non-UTF-8 object size".to_owned()))?;
806 let size: u64 = size_str
807 .parse()
808 .map_err(|_| Error::CorruptObject(format!("invalid object size: {size_str}")))?;
809 Ok((kind, size))
810}
811
812fn parse_object_bytes_inner(raw: &[u8], oid_hint: Option<&ObjectId>) -> Result<Object> {
813 let nul = raw
814 .iter()
815 .position(|&b| b == 0)
816 .ok_or_else(|| Error::CorruptObject("missing NUL in object header".to_owned()))?;
817
818 let header = &raw[..nul];
819 let data = raw[nul + 1..].to_vec();
820
821 let sp = header
822 .iter()
823 .position(|&b| b == b' ')
824 .ok_or_else(|| Error::CorruptObject("missing space in object header".to_owned()))?;
825
826 if sp > 32 {
827 let oid_str = oid_hint.map(|o| o.to_hex()).unwrap_or_else(|| {
828 hash_algo_digest_hex(HashAlgo::Sha1, raw).unwrap_or_else(|_| "?".to_owned())
829 });
830 return Err(Error::ObjectHeaderTooLong { oid: oid_str });
831 }
832
833 let kind = ObjectKind::from_bytes(&header[..sp])?;
834
835 let size_str = std::str::from_utf8(&header[sp + 1..])
836 .map_err(|_| Error::CorruptObject("non-UTF-8 object size".to_owned()))?;
837 let size: usize = size_str
838 .parse()
839 .map_err(|_| Error::CorruptObject(format!("invalid object size: {size_str}")))?;
840
841 if data.len() != size {
842 return Err(Error::CorruptObject(format!(
843 "object size mismatch: header says {size} but got {}",
844 data.len()
845 )));
846 }
847
848 Ok(Object::new(kind, data))
849}
850
851fn hash_algo_digest_hex(algo: HashAlgo, data: &[u8]) -> Result<String> {
852 Ok(algo.digest(data)?.to_hex())
853}
854
855static LOOSE_TMP_SEQ: AtomicU64 = AtomicU64::new(0);
858
859fn unique_loose_tmp_path(prefix_dir: &Path) -> PathBuf {
862 let seq = LOOSE_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
863 prefix_dir.join(format!("tmp_obj_{}_{seq}", std::process::id()))
864}
865
866fn deflate_object_into(
868 file: File,
869 kind: ObjectKind,
870 data: &[u8],
871 compression: Compression,
872) -> Result<()> {
873 let zlib_err = |e: io::Error| Error::Zlib(e.to_string());
874 let mut encoder = ZlibEncoder::new(file, compression);
875 write!(encoder, "{} {}\0", kind, data.len()).map_err(zlib_err)?;
876 encoder.write_all(data).map_err(zlib_err)?;
877 encoder.finish().map_err(zlib_err)?;
878 Ok(())
879}
880
881fn read_payload_chunks(
882 reader: &mut dyn Read,
883 size: u64,
884 mut sink: impl FnMut(&[u8]) -> Result<()>,
885) -> Result<()> {
886 let mut remaining = size;
887 let mut buf = [0u8; STREAM_CHUNK];
888 while remaining > 0 {
889 let want = usize::try_from(remaining.min(STREAM_CHUNK as u64)).map_err(|_| {
890 Error::CorruptObject(format!("object size {size} exceeds address space"))
891 })?;
892 let n = match reader.read(&mut buf[..want]) {
893 Ok(0) => {
894 return Err(Error::ObjectStreamTruncated {
895 expected: size,
896 actual: size - remaining,
897 })
898 }
899 Ok(n) => n,
900 Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
901 Err(e) => return Err(Error::Io(e)),
902 };
903 sink(&buf[..n])?;
904 remaining -= n as u64;
905 }
906 Ok(())
907}
908
909impl LooseStore {
910 pub(crate) fn write_payload_stream(
914 &self,
915 kind: ObjectKind,
916 size: u64,
917 reader: &mut dyn Read,
918 options: WriteOptions,
919 ) -> Result<ObjectId> {
920 let (first, tail) = crate::odb::stream::read_first_payload_chunk(reader, size)?;
921 let compression =
922 crate::odb::stream::compression_after_first_chunk(&first, self.compression);
923 self.write_payload_stream_with(kind, size, options, compression, |chunk_sink| {
924 chunk_sink(&first)?;
925 if tail > 0 {
926 read_payload_chunks(reader, tail, chunk_sink)
927 } else {
928 Ok(())
929 }
930 })
931 }
932
933 pub(crate) fn write_payload_stream_with(
935 &self,
936 kind: ObjectKind,
937 size: u64,
938 options: WriteOptions,
939 compression: Compression,
940 mut supply: impl FnMut(&mut dyn FnMut(&[u8]) -> Result<()>) -> Result<()>,
941 ) -> Result<ObjectId> {
942 let prefix_dir = &self.objects_dir;
943 let mut hasher = ObjectPayloadHasher::new(self.hash_algo, kind, size);
944 let tmp_path = unique_loose_tmp_path(prefix_dir);
945 fs::create_dir_all(prefix_dir).map_err(Error::Io)?;
946 let file = fs::OpenOptions::new()
947 .write(true)
948 .create_new(true)
949 .open(&tmp_path)
950 .map_err(Error::Io)?;
951 let zlib_err = |e: io::Error| Error::Zlib(e.to_string());
952 let mut encoder = ZlibEncoder::new(file, compression);
953 if let Err(e) = (|| {
954 write!(encoder, "{} {}\0", kind, size).map_err(zlib_err)?;
955 supply(&mut |chunk| {
956 hasher.update(chunk);
957 encoder.write_all(chunk).map_err(zlib_err)
958 })?;
959 encoder.finish().map_err(zlib_err)?;
960 Ok::<(), Error>(())
961 })() {
962 let _ = fs::remove_file(&tmp_path);
963 return Err(e);
964 }
965 let oid = match hasher.finish() {
966 Ok(oid) => oid,
967 Err(e) => {
968 let _ = fs::remove_file(&tmp_path);
969 return Err(e);
970 }
971 };
972 if let Some(existing) = self.try_finish_if_loose_present(&oid, options)? {
973 let _ = fs::remove_file(&tmp_path);
974 return Ok(existing);
975 }
976 let path = self.object_path(&oid);
977 let dest_prefix = path
978 .parent()
979 .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
980 if let Err(e) = fs::create_dir_all(dest_prefix) {
981 let _ = fs::remove_file(&tmp_path);
982 return Err(Error::Io(e));
983 }
984 match fs::rename(&tmp_path, &path) {
985 Ok(()) => {
986 set_loose_object_mode(&path);
987 Ok(oid)
988 }
989 Err(e) => {
990 let _ = fs::remove_file(&tmp_path);
991 if path.is_file() {
992 return Ok(oid);
993 }
994 Err(Error::Io(e))
995 }
996 }
997 }
998}
999
1000fn publish_loose_object(
1013 objects_dir: &Path,
1014 path: &Path,
1015 prefix_dir: &Path,
1016 prefix_dir_ready: bool,
1017 mut fill: impl FnMut(File) -> Result<()>,
1018) -> Result<()> {
1019 const MAX_ATTEMPTS: u32 = 4;
1020 let mut attempt = 0;
1021 loop {
1022 attempt += 1;
1023 let can_retry = attempt < MAX_ATTEMPTS;
1024 if !prefix_dir_ready || attempt > 1 {
1025 fs::create_dir_all(prefix_dir).map_err(|source| {
1026 map_objects_dir_io(
1027 objects_dir,
1028 prefix_dir,
1029 GitDirPathOperation::CreateDir,
1030 source,
1031 )
1032 })?;
1033 }
1034 let tmp_path = unique_loose_tmp_path(prefix_dir);
1035 let file = match fs::OpenOptions::new()
1036 .write(true)
1037 .create_new(true)
1038 .open(&tmp_path)
1039 {
1040 Ok(file) => file,
1041 Err(e)
1042 if can_retry
1043 && matches!(
1044 e.kind(),
1045 io::ErrorKind::NotFound | io::ErrorKind::AlreadyExists
1046 ) =>
1047 {
1048 continue;
1049 }
1050 Err(e) => {
1051 return Err(map_objects_dir_io(
1052 objects_dir,
1053 &tmp_path,
1054 GitDirPathOperation::WriteObject,
1055 e,
1056 ));
1057 }
1058 };
1059 if let Err(e) = fill(file) {
1060 let _ = fs::remove_file(&tmp_path);
1061 return Err(e);
1062 }
1063 match fs::rename(&tmp_path, path) {
1064 Ok(()) => {
1065 set_loose_object_mode(path);
1066 return Ok(());
1067 }
1068 Err(e) => {
1069 let _ = fs::remove_file(&tmp_path);
1070 if path.is_file() {
1071 return Ok(());
1072 }
1073 if can_retry && e.kind() == io::ErrorKind::NotFound {
1074 continue;
1075 }
1076 return Err(map_objects_dir_io(
1077 objects_dir,
1078 path,
1079 GitDirPathOperation::WriteObject,
1080 e,
1081 ));
1082 }
1083 }
1084 }
1085}
1086
1087fn set_loose_object_mode(path: &Path) {
1088 #[cfg(unix)]
1089 {
1090 use std::os::unix::fs::PermissionsExt;
1091 let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o444));
1092 }
1093 let _ = path;
1094}
1095
1096fn is_loose_suffix_for_algo(suffix: &str, algo: HashAlgo) -> bool {
1097 suffix.len() == algo.hex_len() - 2 && suffix.bytes().all(|b| b.is_ascii_hexdigit())
1098}
1099
1100struct LoosePayloadReader {
1102 file: File,
1103 read_budget: LooseFileReadBudget,
1104 dec: Decompress,
1105 pending: Vec<u8>,
1107 pending_pos: usize,
1108 input_exhausted: bool,
1110 zlib_complete: bool,
1112 preset_dictionary: bool,
1113 kind: ObjectKind,
1114 payload_size: u64,
1115 payload_remaining: u64,
1116 phase: LooseReadPhase,
1117 header_buf: Vec<u8>,
1118 extra_payload: Vec<u8>,
1120}
1121
1122enum LooseReadPhase {
1123 Header,
1124 Payload,
1125}
1126
1127impl LoosePayloadReader {
1128 fn open(mut file: File) -> Result<Self> {
1129 let read_budget = LooseFileReadBudget::new(&file)?;
1130 let mut hdr = [0u8; 2];
1131 file.read_exact(&mut hdr).map_err(Error::Io)?;
1132 let cmf_flg = u16::from(hdr[0]) << 8 | u16::from(hdr[1]);
1133 let looks_like_zlib_header = cmf_flg != 0 && cmf_flg % 31 == 0;
1134 let preset_dictionary = looks_like_zlib_header && (hdr[1] & 0x20) != 0;
1135 let mut read_budget = read_budget;
1136 read_budget.note_consumed(2);
1137 let mut reader = Self {
1138 file,
1139 read_budget,
1140 dec: Decompress::new(true),
1141 pending: hdr.to_vec(),
1142 pending_pos: 0,
1143 input_exhausted: false,
1144 zlib_complete: false,
1145 preset_dictionary,
1146 kind: ObjectKind::Blob,
1147 payload_size: 0,
1148 payload_remaining: 0,
1149 phase: LooseReadPhase::Header,
1150 header_buf: Vec::with_capacity(64),
1151 extra_payload: Vec::new(),
1152 };
1153 reader.parse_header()?;
1154 Ok(reader)
1155 }
1156
1157 fn parse_header(&mut self) -> Result<()> {
1158 let mut header_buf = std::mem::take(&mut self.header_buf);
1159 loop {
1160 self.pump_zlib(&mut |bytes| {
1161 header_buf.extend_from_slice(bytes);
1162 })?;
1163 if header_buf.contains(&0) || header_buf.len() >= 128 {
1164 break;
1165 }
1166 if self.input_exhausted && self.pending_pos == self.pending.len() {
1167 break;
1168 }
1169 }
1170 let nul = header_buf
1171 .iter()
1172 .position(|&b| b == 0)
1173 .ok_or_else(|| Error::CorruptObject("missing NUL in object header".to_owned()))?;
1174 let (kind, size) = parse_object_header_prefix(&header_buf[..=nul])?;
1175 self.extra_payload = header_buf[nul + 1..].to_vec();
1176 self.header_buf = header_buf;
1177 self.kind = kind;
1178 self.payload_size = size;
1179 self.payload_remaining = size;
1180 self.phase = LooseReadPhase::Payload;
1181 Ok(())
1182 }
1183
1184 fn pump_zlib(&mut self, on_out: &mut dyn FnMut(&[u8])) -> Result<()> {
1185 let mut scratch = [0u8; LOOSE_INFLATE_OUTPUT_CHUNK];
1186 let produced = self.inflate_into(&mut scratch)?;
1187 if produced > 0 {
1188 on_out(&scratch[..produced]);
1189 }
1190 Ok(())
1191 }
1192
1193 fn inflate_into(&mut self, out: &mut [u8]) -> Result<usize> {
1197 if self.zlib_complete {
1198 return Ok(0);
1199 }
1200 let cap = out.len().min(LOOSE_INFLATE_OUTPUT_CHUNK);
1201 let out = &mut out[..cap];
1202 if self.pending_pos == self.pending.len() && !self.input_exhausted {
1203 let mut buf = [0u8; LOOSE_INFLATE_INPUT_CHUNK];
1204 let n = self.read_budget.read(&mut self.file, &mut buf)?;
1205 self.pending.clear();
1206 self.pending.extend_from_slice(&buf[..n]);
1207 self.pending_pos = 0;
1208 self.input_exhausted = n == 0;
1209 }
1210 let flush = if self.input_exhausted && self.pending_pos == self.pending.len() {
1211 FlushDecompress::Finish
1212 } else {
1213 FlushDecompress::None
1214 };
1215 let before_in = self.dec.total_in();
1216 let before_out = self.dec.total_out();
1217 let status = match self
1218 .dec
1219 .decompress(&self.pending[self.pending_pos..], out, flush)
1220 {
1221 Ok(s) => s,
1222 Err(e) => {
1223 if self.preset_dictionary {
1224 return Err(Error::Zlib("needs dictionary".to_owned()));
1225 }
1226 return Err(Error::Zlib(e.to_string()));
1227 }
1228 };
1229 self.pending_pos += (self.dec.total_in() - before_in) as usize;
1231 let produced = (self.dec.total_out() - before_out) as usize;
1232 if matches!(status, Status::StreamEnd) {
1233 self.zlib_complete = true;
1234 }
1235 Ok(produced)
1236 }
1237
1238 fn ensure_zlib_complete(&mut self) -> io::Result<()> {
1240 if self.zlib_complete {
1241 return Ok(());
1242 }
1243 if !self.extra_payload.is_empty() {
1244 return Err(io::Error::other(
1245 "corrupt object: inflated bytes remain after declared payload size",
1246 ));
1247 }
1248 loop {
1249 let mut excess = 0usize;
1250 self.pump_zlib(&mut |bytes| {
1251 excess += bytes.len();
1252 })
1253 .map_err(|e| io::Error::other(e.to_string()))?;
1254 if excess > 0 {
1255 return Err(io::Error::other(
1256 "corrupt object: excess decompressed data after declared payload size",
1257 ));
1258 }
1259 if self.zlib_complete {
1260 return Ok(());
1261 }
1262 if self.input_exhausted && self.pending_pos == self.pending.len() {
1263 return Err(io::Error::other(
1264 "corrupt object: zlib stream ended before trailer validation",
1265 ));
1266 }
1267 }
1268 }
1269}
1270
1271impl Read for LoosePayloadReader {
1272 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1273 if !matches!(self.phase, LooseReadPhase::Payload) {
1274 return Ok(0);
1275 }
1276 if self.payload_remaining == 0 {
1277 self.ensure_zlib_complete()?;
1278 return Ok(0);
1279 }
1280 if buf.is_empty() {
1281 return Ok(0);
1282 }
1283 let mut delivered = 0usize;
1284 while delivered < buf.len() && self.payload_remaining > 0 {
1285 if !self.extra_payload.is_empty() {
1286 let take = self
1287 .extra_payload
1288 .len()
1289 .min(buf.len() - delivered)
1290 .min(self.payload_remaining as usize);
1291 buf[delivered..delivered + take].copy_from_slice(&self.extra_payload[..take]);
1292 self.extra_payload.drain(..take);
1293 delivered += take;
1294 self.payload_remaining -= take as u64;
1295 continue;
1296 }
1297 let want = usize::try_from(self.payload_remaining)
1298 .map_or(buf.len() - delivered, |r| r.min(buf.len() - delivered));
1299 let produced = self
1300 .inflate_into(&mut buf[delivered..delivered + want])
1301 .map_err(|e| io::Error::other(e.to_string()))?;
1302 if produced == 0 {
1303 if self.zlib_complete
1304 || (self.input_exhausted && self.pending_pos == self.pending.len())
1305 {
1306 return Err(io::Error::other(format!(
1307 "corrupt object: zlib stream ended with {} payload bytes remaining",
1308 self.payload_remaining
1309 )));
1310 }
1311 continue;
1312 }
1313 delivered += produced;
1314 self.payload_remaining -= produced as u64;
1315 }
1316 if self.payload_remaining == 0 {
1317 self.ensure_zlib_complete()?;
1318 }
1319 Ok(delivered)
1320 }
1321}
1322
1323#[cfg(test)]
1324mod tests {
1325 use super::*;
1326 use std::io::Read;
1327
1328 use super::super::{ObjectStore, WritableObjectStore};
1329
1330 const READ_CHUNK: usize = LOOSE_INFLATE_INPUT_CHUNK;
1331
1332 #[test]
1333 fn lookup_prefix_scans_single_fanout_dir() {
1334 let dir = tempfile::tempdir().unwrap();
1335 let store = LooseStore::new(
1336 dir.path().join("objects"),
1337 HashAlgo::Sha1,
1338 Compression::default(),
1339 );
1340 fs::create_dir_all(store.objects_dir()).unwrap();
1341 let oid =
1342 WritableObjectStore::write(&store, ObjectKind::Blob, b"x", WriteOptions::default())
1343 .unwrap();
1344 let mut out = Vec::new();
1345 ObjectStore::lookup_prefix(&store, &oid.to_hex()[..4], 0, &mut out).unwrap();
1346 assert_eq!(out, vec![oid]);
1347 }
1348
1349 fn temp_store() -> (tempfile::TempDir, LooseStore) {
1350 let dir = tempfile::tempdir().unwrap();
1351 let store = LooseStore::new(
1352 dir.path().join("objects"),
1353 HashAlgo::Sha1,
1354 Compression::default(),
1355 );
1356 fs::create_dir_all(store.objects_dir()).unwrap();
1357 (dir, store)
1358 }
1359
1360 fn tmp_entries(store: &LooseStore) -> Vec<PathBuf> {
1361 fs::read_dir(store.objects_dir())
1362 .unwrap()
1363 .flatten()
1364 .filter(|shard| shard.file_type().is_ok_and(|t| t.is_dir()))
1365 .flat_map(|shard| fs::read_dir(shard.path()).unwrap().flatten())
1366 .map(|entry| entry.path())
1367 .filter(|path| {
1368 path.file_name()
1369 .is_some_and(|n| n.to_string_lossy().starts_with("tmp_"))
1370 })
1371 .collect()
1372 }
1373
1374 #[test]
1375 fn identical_writes_through_separate_handles_all_succeed() {
1376 let (_dir, store) = temp_store();
1377 let threads = 8;
1378 let barrier = std::sync::Arc::new(std::sync::Barrier::new(threads));
1379 let payloads: Vec<Vec<u8>> = (0..200)
1380 .map(|j| format!("same {j}").into_bytes())
1381 .chain(std::iter::once(vec![0x5a; 1024 * 1024]))
1382 .collect();
1383 let payloads = std::sync::Arc::new(payloads);
1384 let handles: Vec<_> = (0..threads)
1385 .map(|_| {
1386 let store = store.clone();
1387 let barrier = std::sync::Arc::clone(&barrier);
1388 let payloads = std::sync::Arc::clone(&payloads);
1389 std::thread::spawn(move || {
1390 barrier.wait();
1391 payloads
1392 .iter()
1393 .map(|data| {
1394 WritableObjectStore::write(
1395 &store,
1396 ObjectKind::Blob,
1397 data,
1398 WriteOptions::default(),
1399 )
1400 })
1401 .collect::<Vec<_>>()
1402 })
1403 })
1404 .collect();
1405 for handle in handles {
1406 for (data, result) in payloads.iter().zip(handle.join().unwrap()) {
1407 let oid = result.expect("concurrent identical write succeeds");
1408 let obj = ObjectStore::read(&store, &oid).unwrap().expect("object");
1409 assert!(obj.data == *data);
1410 }
1411 }
1412 assert!(tmp_entries(&store).is_empty());
1413 }
1414
1415 #[test]
1416 fn prehashed_write_recreates_pruned_fanout_dir_marked_ready() {
1417 let (_dir, store) = temp_store();
1418 let data = b"fanout pruned under a ready writer";
1419 let oid = hash::hash_object(HashAlgo::Sha1, ObjectKind::Blob, data).unwrap();
1420 let zlib = crate::odb::zlib_compress_blob_loose(data, Compression::default()).unwrap();
1421 let options = WriteOptions {
1422 loose_prefix_dirs_ready: true,
1423 ..WriteOptions::default()
1424 };
1425 assert!(!store.object_path(&oid).parent().unwrap().exists());
1426 let written = store.write_zlib_prehashed(&oid, &zlib, options).unwrap();
1427 assert_eq!(written, oid);
1428 assert_eq!(ObjectStore::read(&store, &oid).unwrap().unwrap().data, data);
1429 assert!(tmp_entries(&store).is_empty());
1430 }
1431
1432 #[test]
1433 fn unique_tmp_paths_differ_and_are_not_loose_names() {
1434 let prefix = Path::new("objects/ab");
1435 let a = unique_loose_tmp_path(prefix);
1436 let b = unique_loose_tmp_path(prefix);
1437 assert_ne!(a, b);
1438 for path in [a, b] {
1439 let name = path.file_name().unwrap().to_str().unwrap();
1440 assert!(name.starts_with("tmp_obj_"), "{name}");
1441 assert!(!is_loose_suffix_for_algo(name, HashAlgo::Sha1));
1442 assert!(!is_loose_suffix_for_algo(name, HashAlgo::Sha256));
1443 }
1444 }
1445
1446 #[test]
1447 fn read_info_and_read_agree_for_object_spanning_read_chunks() {
1448 let (_dir, store) = temp_store();
1449 let data: Vec<u8> = (0..3 * READ_CHUNK as u32)
1450 .map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8)
1451 .collect();
1452 let oid =
1453 WritableObjectStore::write(&store, ObjectKind::Blob, &data, WriteOptions::default())
1454 .unwrap();
1455 let info = ObjectStore::read_info(&store, &oid).unwrap().expect("info");
1456 assert_eq!(info.kind, ObjectKind::Blob);
1457 assert_eq!(info.size, data.len() as u64);
1458 let obj = ObjectStore::read(&store, &oid).unwrap().expect("object");
1459 assert_eq!(obj.kind, ObjectKind::Blob);
1460 assert_eq!(obj.data, data);
1461 let mut buf = Vec::new();
1462 let kind = ObjectStore::read_into(&store, &oid, &mut buf).unwrap();
1463 assert_eq!(kind, Some(ObjectKind::Blob));
1464 assert_eq!(buf, data);
1465 }
1466
1467 #[test]
1468 fn read_unless_larger_reads_small_and_skips_large_objects() {
1469 let (_dir, store) = temp_store();
1470 let data = vec![b'x'; 3 * READ_CHUNK];
1471 let oid =
1472 WritableObjectStore::write(&store, ObjectKind::Blob, &data, WriteOptions::default())
1473 .unwrap();
1474 match store.read_unless_larger(&oid, data.len() as u64).unwrap() {
1475 Some(BoundedRead::Object(obj)) => {
1476 assert_eq!(obj.kind, ObjectKind::Blob);
1477 assert_eq!(obj.data, data);
1478 }
1479 other => panic!("expected the object, got {other:?}"),
1480 }
1481 assert!(matches!(
1482 store
1483 .read_unless_larger(&oid, data.len() as u64 - 1)
1484 .unwrap(),
1485 Some(BoundedRead::Larger)
1486 ));
1487 let missing = ObjectId::from_hex("1234567890123456789012345678901234567890").unwrap();
1488 assert!(store.read_unless_larger(&missing, 0).unwrap().is_none());
1489 }
1490
1491 #[test]
1492 fn stream_reader_delivers_payload_through_tiny_and_huge_buffers() {
1493 let (_dir, store) = temp_store();
1494 let data: Vec<u8> = (0..5 * READ_CHUNK as u32)
1495 .map(|i| (i.wrapping_mul(2_654_435_761) >> 11) as u8)
1496 .collect();
1497 let oid =
1498 WritableObjectStore::write(&store, ObjectKind::Blob, &data, WriteOptions::default())
1499 .unwrap();
1500 for chunk_len in [1usize, 7, 4096, 10 * READ_CHUNK] {
1501 let mut stream = ObjectStore::open_stream(&store, &oid)
1502 .unwrap()
1503 .expect("stream");
1504 let mut out = Vec::new();
1505 let mut chunk = vec![0u8; chunk_len];
1506 loop {
1507 let n = stream.reader.read(&mut chunk).unwrap();
1508 if n == 0 {
1509 break;
1510 }
1511 out.extend_from_slice(&chunk[..n]);
1512 }
1513 assert!(out == data, "chunk length {chunk_len}");
1514 }
1515 }
1516
1517 #[test]
1518 fn read_info_is_none_for_missing_object_or_directory_at_its_path() {
1519 let (_dir, store) = temp_store();
1520 let missing = ObjectId::from_hex("1234567890123456789012345678901234567890").unwrap();
1521 assert!(ObjectStore::read_info(&store, &missing).unwrap().is_none());
1522 fs::create_dir_all(store.object_path(&missing)).unwrap();
1523 assert!(ObjectStore::read_info(&store, &missing).unwrap().is_none());
1524 }
1525
1526 #[test]
1527 fn lookup_prefix_one_hex_char_does_not_overflow() {
1528 let dir = tempfile::tempdir().unwrap();
1529 let store = LooseStore::new(
1530 dir.path().join("objects"),
1531 HashAlgo::Sha1,
1532 Compression::default(),
1533 );
1534 fs::create_dir_all(store.objects_dir()).unwrap();
1535 let oid =
1536 WritableObjectStore::write(&store, ObjectKind::Blob, b"x", WriteOptions::default())
1537 .unwrap();
1538 let mut out = Vec::new();
1539 ObjectStore::lookup_prefix(&store, &oid.to_hex()[..1], 0, &mut out).unwrap();
1540 assert!(out.contains(&oid));
1541 }
1542
1543 #[test]
1544 fn truncated_zlib_stream_errors_on_read() {
1545 let dir = tempfile::tempdir().unwrap();
1546 let store = LooseStore::new(
1547 dir.path().join("objects"),
1548 HashAlgo::Sha1,
1549 Compression::default(),
1550 );
1551 fs::create_dir_all(store.objects_dir()).unwrap();
1552 let payload = vec![0u8; 100_000];
1553 let oid =
1554 WritableObjectStore::write(&store, ObjectKind::Blob, &payload, WriteOptions::default())
1555 .unwrap();
1556 let path = store.object_path(&oid);
1557 let mut bytes = fs::read(&path).unwrap();
1558 bytes.truncate(bytes.len() / 2);
1559 #[cfg(unix)]
1560 {
1561 use std::os::unix::fs::PermissionsExt;
1562 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1563 }
1564 fs::write(&path, &bytes).unwrap();
1565 let mut stream = ObjectStore::open_stream(&store, &oid)
1566 .unwrap()
1567 .expect("stream");
1568 let mut out = Vec::new();
1569 let err = stream.reader.read_to_end(&mut out).unwrap_err();
1570 assert!(
1571 err.to_string().contains("payload bytes remaining")
1572 || err.to_string().contains("corrupt object"),
1573 "unexpected error: {err}"
1574 );
1575 assert!(out.len() < payload.len());
1576 }
1577
1578 #[test]
1579 fn truncated_zlib_checksum_only_errors_on_read() {
1580 let dir = tempfile::tempdir().unwrap();
1581 let store = LooseStore::new(
1582 dir.path().join("objects"),
1583 HashAlgo::Sha1,
1584 Compression::default(),
1585 );
1586 fs::create_dir_all(store.objects_dir()).unwrap();
1587 let payload = vec![0u8; 100_000];
1588 let oid =
1589 WritableObjectStore::write(&store, ObjectKind::Blob, &payload, WriteOptions::default())
1590 .unwrap();
1591 let path = store.object_path(&oid);
1592 let mut bytes = fs::read(&path).unwrap();
1593 assert!(bytes.len() > 1);
1594 bytes.pop();
1595 #[cfg(unix)]
1596 {
1597 use std::os::unix::fs::PermissionsExt;
1598 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1599 }
1600 fs::write(&path, &bytes).unwrap();
1601 match ObjectStore::read(&store, &oid) {
1602 Ok(None) => {}
1603 Ok(Some(_)) => panic!("full read must not succeed on checksum-truncated loose object"),
1604 Err(_) => {}
1605 }
1606 let mut stream = ObjectStore::open_stream(&store, &oid)
1607 .expect("open_stream")
1608 .expect("stream hit");
1609 let mut out = Vec::new();
1610 let err = stream.reader.read_to_end(&mut out).unwrap_err();
1611 assert!(
1612 err.to_string().contains("trailer validation")
1613 || err.to_string().contains("corrupt object"),
1614 "unexpected error: {err}"
1615 );
1616 }
1617
1618 #[test]
1619 fn stream_sixteen_mib_peak_buffer_under_one_mib() {
1620 let dir = tempfile::tempdir().unwrap();
1621 let store = LooseStore::new(
1622 dir.path().join("objects"),
1623 HashAlgo::Sha1,
1624 Compression::default(),
1625 );
1626 fs::create_dir_all(store.objects_dir()).unwrap();
1627 let payload = vec![0xCDu8; 16 * 1024 * 1024];
1628 let oid =
1629 WritableObjectStore::write(&store, ObjectKind::Blob, &payload, WriteOptions::default())
1630 .unwrap();
1631 let mut stream = ObjectStore::open_stream(&store, &oid)
1632 .unwrap()
1633 .expect("stream");
1634 assert_eq!(stream.size, payload.len() as u64);
1635 let mut out = Vec::new();
1636 let mut chunk = [0u8; 64 * 1024];
1637 let mut peak = 0usize;
1638 loop {
1639 let n = stream.reader.read(&mut chunk).unwrap();
1640 if n == 0 {
1641 break;
1642 }
1643 peak = peak.max(n);
1644 out.extend_from_slice(&chunk[..n]);
1645 }
1646 assert_eq!(out, payload);
1647 assert!(
1648 peak < 1024 * 1024,
1649 "peak in-flight buffer {peak} must stay under 1 MiB"
1650 );
1651 }
1652
1653 #[test]
1654 fn external_loose_write_in_fanout_visible_after_negative_cache() {
1655 use std::process::{Command, Stdio};
1656
1657 use crate::odb::Odb;
1658
1659 let dir = tempfile::tempdir().unwrap();
1660 let objects = dir.path().join("objects");
1661 fs::create_dir_all(&objects).unwrap();
1662 let odb = Odb::new(&objects);
1663 let payload = b"written externally into an existing fanout dir\n";
1664 let oid = odb.hash(ObjectKind::Blob, payload).unwrap();
1665 assert!(matches!(odb.read(&oid), Err(Error::ObjectNotFound(_))));
1666
1667 let mut child = Command::new("git")
1668 .args(["hash-object", "-w", "--stdin"])
1669 .stdin(Stdio::piped())
1670 .stdout(Stdio::piped())
1671 .env("GIT_OBJECT_DIRECTORY", &objects)
1672 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1673 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1674 .spawn()
1675 .expect("git hash-object");
1676 if let Some(mut stdin) = child.stdin.take() {
1677 use std::io::Write;
1678 stdin.write_all(payload).unwrap();
1679 }
1680 let out = child.wait_with_output().expect("git wait");
1681 assert!(
1682 out.status.success(),
1683 "git hash-object: {}",
1684 String::from_utf8_lossy(&out.stderr)
1685 );
1686 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), oid.to_hex());
1687
1688 let obj = odb
1689 .read(&oid)
1690 .expect("external loose write must be visible");
1691 assert_eq!(obj.data, payload);
1692 }
1693}