Skip to main content

grit_lib/
odb.rs

1//! Loose object database: reading and writing zlib-compressed Git objects.
2//!
3//! Git stores objects as files under `<git-dir>/objects/<xx>/<38-hex-chars>`,
4//! where the path is derived from the SHA-1 digest. Each file is a zlib-
5//! compressed byte sequence whose decompressed form is:
6//!
7//! ```text
8//! "<type> <size>\0<data>"
9//! ```
10//!
11//! # Usage
12//!
13//! ```no_run
14//! use std::path::Path;
15//! use grit_lib::odb::Odb;
16//!
17//! let odb = Odb::new(Path::new(".git/objects"));
18//! ```
19
20use std::fs;
21use std::io::{Read, Write};
22use std::path::{Path, PathBuf};
23use std::sync::{Arc, Mutex, OnceLock};
24
25use flate2::read::ZlibDecoder;
26use flate2::write::ZlibEncoder;
27use flate2::Compression;
28use sha1::{Digest, Sha1};
29use sha2::Sha256;
30
31use crate::config::ConfigSet;
32use crate::error::{Error, Result};
33use crate::midx::{midx_oid_listed_in_tip, try_read_object_via_midx};
34use crate::objects::{HashAlgo, Object, ObjectId, ObjectKind};
35use crate::pack;
36
37/// Decompress a zlib-wrapped loose object payload from an open file.
38///
39/// When the zlib wrapper advertises a preset dictionary (FDICT), `flate2` typically fails with a
40/// generic corrupt-stream error; map that to `"needs dictionary"` so callers match Git's messages
41/// (`t1006-cat-file` zlib-dictionary test).
42fn read_zlib_loose_payload(mut file: fs::File) -> Result<Vec<u8>> {
43    let mut hdr = [0u8; 2];
44    file.read_exact(&mut hdr).map_err(Error::Io)?;
45    let cmf_flg = u16::from(hdr[0]) << 8 | u16::from(hdr[1]);
46    let looks_like_zlib_header = cmf_flg != 0 && cmf_flg % 31 == 0;
47    let preset_dictionary = looks_like_zlib_header && (hdr[1] & 0x20) != 0;
48    let mut decoder = ZlibDecoder::new(hdr.as_slice().chain(file));
49    let mut raw = Vec::new();
50    match decoder.read_to_end(&mut raw) {
51        Ok(_) => Ok(raw),
52        Err(e) => {
53            if preset_dictionary {
54                Err(Error::Zlib("needs dictionary".to_owned()))
55            } else {
56                Err(Error::Zlib(e.to_string()))
57            }
58        }
59    }
60}
61
62/// True when `oid` is stored as a loose object or in a **non-promisor** local pack.
63fn exists_materialized_in_objects_dir(objects_dir: &Path, oid: &ObjectId) -> bool {
64    let loose = objects_dir
65        .join(oid.loose_prefix())
66        .join(oid.loose_suffix());
67    if loose.exists() {
68        return true;
69    }
70    let Ok(indexes) = pack::read_local_pack_indexes_cached(objects_dir) else {
71        return false;
72    };
73    for idx in &indexes {
74        if idx.pack_path.with_extension("promisor").is_file() {
75            continue;
76        }
77        if idx.contains(oid) {
78            return true;
79        }
80    }
81    false
82}
83
84/// A loose-object database rooted at a given `objects/` directory.
85#[derive(Clone)]
86pub struct Odb {
87    objects_dir: PathBuf,
88    /// Work tree root for resolving relative alternate env paths.
89    work_tree: Option<PathBuf>,
90    /// Embedded submodule object stores registered for this read pass (Git `register_all_submodule_sources`).
91    submodule_alternate_dirs: Arc<Mutex<Vec<PathBuf>>>,
92    /// When set, used to read `core.multiPackIndex` (and related) for MIDX-backed object reads.
93    config_git_dir: Option<PathBuf>,
94    /// Cache for `core.multiPackIndex` — populated on first lookup.
95    ///
96    /// Reading this config requires loading the system/global/local config cascade and reparsing
97    /// every file; the value cannot change for a process that has opened a single repository, so
98    /// caching it here avoids re-loading the cascade for every object read.
99    core_multi_pack_index_cache: Arc<OnceLock<bool>>,
100    /// When `Some`, object writes are redirected into this in-memory overlay instead of being
101    /// persisted to the loose store (Git's tmp-objdir). Reads consult the overlay first. This
102    /// mirrors `git merge-tree --quiet`, which performs a full merge but must leave the object
103    /// database untouched (no new loose objects).
104    mem_overlay: Arc<Mutex<Option<std::collections::HashMap<ObjectId, (ObjectKind, Vec<u8>)>>>>,
105    /// The repository's object hash algorithm (`extensions.objectformat`),
106    /// detected lazily from the config and cached. Determines the hash used
107    /// when writing objects. Defaults to SHA-1 when no config is available.
108    hash_algo_cache: Arc<OnceLock<HashAlgo>>,
109}
110
111impl std::fmt::Debug for Odb {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.debug_struct("Odb")
114            .field("objects_dir", &self.objects_dir)
115            .field("work_tree", &self.work_tree)
116            .field("submodule_alternate_dirs", &"<mutex>")
117            .field("config_git_dir", &self.config_git_dir)
118            .finish()
119    }
120}
121
122impl Odb {
123    /// Create an [`Odb`] pointing at the given `objects/` directory.
124    ///
125    /// The directory does not need to exist yet; it will be created on the
126    /// first write operation.
127    #[must_use]
128    pub fn new(objects_dir: &Path) -> Self {
129        Self {
130            objects_dir: objects_dir.to_path_buf(),
131            work_tree: None,
132            submodule_alternate_dirs: Arc::new(Mutex::new(Vec::new())),
133            config_git_dir: None,
134            core_multi_pack_index_cache: Arc::new(OnceLock::new()),
135            mem_overlay: Arc::new(Mutex::new(None)),
136            hash_algo_cache: Arc::new(OnceLock::new()),
137        }
138    }
139
140    /// Create an [`Odb`] with a work tree for resolving relative alternate paths.
141    #[must_use]
142    pub fn with_work_tree(objects_dir: &Path, work_tree: &Path) -> Self {
143        Self {
144            objects_dir: objects_dir.to_path_buf(),
145            work_tree: Some(work_tree.to_path_buf()),
146            submodule_alternate_dirs: Arc::new(Mutex::new(Vec::new())),
147            config_git_dir: None,
148            core_multi_pack_index_cache: Arc::new(OnceLock::new()),
149            mem_overlay: Arc::new(Mutex::new(None)),
150            hash_algo_cache: Arc::new(OnceLock::new()),
151        }
152    }
153
154    /// Enable the in-memory write overlay (Git's tmp-objdir): subsequent [`Self::write`]/
155    /// [`Self::write_local`] calls keep objects only in memory, and [`Self::read`] consults that
156    /// overlay before the on-disk store. Used by `merge-tree --quiet` so a full merge can run
157    /// without persisting any new loose objects.
158    pub fn enable_mem_overlay(&self) {
159        if let Ok(mut guard) = self.mem_overlay.lock() {
160            *guard = Some(std::collections::HashMap::new());
161        }
162    }
163
164    /// Disable the in-memory write overlay, discarding any objects accumulated in it.
165    pub fn disable_mem_overlay(&self) {
166        if let Ok(mut guard) = self.mem_overlay.lock() {
167            *guard = None;
168        }
169    }
170
171    /// If the in-memory overlay is active, store `(kind, data)` under `oid` there and return
172    /// `true`; otherwise return `false` so the caller falls through to the on-disk path.
173    fn overlay_store(&self, oid: ObjectId, kind: ObjectKind, data: &[u8]) -> bool {
174        if let Ok(mut guard) = self.mem_overlay.lock() {
175            if let Some(map) = guard.as_mut() {
176                map.entry(oid).or_insert_with(|| (kind, data.to_vec()));
177                return true;
178            }
179        }
180        false
181    }
182
183    /// Read `oid` from the in-memory overlay, if active and present.
184    fn overlay_read(&self, oid: &ObjectId) -> Option<Object> {
185        if let Ok(guard) = self.mem_overlay.lock() {
186            if let Some(map) = guard.as_ref() {
187                if let Some((kind, data)) = map.get(oid) {
188                    return Some(Object {
189                        kind: *kind,
190                        data: data.clone(),
191                    });
192                }
193            }
194        }
195        None
196    }
197
198    /// Register `<submodule-git-dir>/objects` for every stage-0 gitlink in `index` that has a
199    /// checkout under `work_tree`, so reads can resolve submodule commits stored only in the
200    /// nested repository (matches Git's `odb_add_submodule_source_by_path` / `register_all_submodule_sources`).
201    pub fn register_submodule_object_directories_from_index(
202        &self,
203        work_tree: &Path,
204        index: &crate::index::Index,
205    ) {
206        use crate::diff::submodule_embedded_git_dir;
207
208        let Ok(mut dirs) = self.submodule_alternate_dirs.lock() else {
209            return;
210        };
211        dirs.clear();
212        for e in &index.entries {
213            if e.stage() != 0 || e.mode != crate::index::MODE_GITLINK {
214                continue;
215            }
216            let path_str = String::from_utf8_lossy(&e.path);
217            let abs = work_tree.join(path_str.as_ref());
218            let Some(sub_git) = submodule_embedded_git_dir(&abs) else {
219                continue;
220            };
221            let objects = sub_git.join("objects");
222            if !objects.is_dir() {
223                continue;
224            }
225            let canon = objects.canonicalize().unwrap_or(objects);
226            if !dirs.iter().any(|p| p == &canon) {
227                dirs.push(canon);
228            }
229        }
230    }
231
232    /// Attach a git directory so [`Self::read`] can honor `core.multiPackIndex` when resolving packed objects.
233    #[must_use]
234    pub fn with_config_git_dir(mut self, git_dir: PathBuf) -> Self {
235        self.config_git_dir = Some(git_dir);
236        self
237    }
238
239    /// The repository's object hash algorithm, detected from
240    /// `extensions.objectformat` and cached for the lifetime of this `Odb`.
241    ///
242    /// The git directory is taken from the attached `config_git_dir` when set,
243    /// otherwise inferred as the parent of the `objects/` directory. Defaults
244    /// to [`HashAlgo::Sha1`] when no config can be read.
245    #[must_use]
246    pub fn hash_algo(&self) -> HashAlgo {
247        *self.hash_algo_cache.get_or_init(|| {
248            let git_dir = self
249                .config_git_dir
250                .clone()
251                .or_else(|| self.objects_dir.parent().map(Path::to_path_buf));
252            let Some(git_dir) = git_dir else {
253                return HashAlgo::Sha1;
254            };
255            let cfg = ConfigSet::load(Some(&git_dir), true).unwrap_or_default();
256            cfg.get("extensions.objectformat")
257                .and_then(|v| HashAlgo::from_name(&v))
258                .unwrap_or(HashAlgo::Sha1)
259        })
260    }
261
262    fn core_multi_pack_index_enabled(&self) -> bool {
263        // The system/global/local config cascade is expensive to load (the parser walks every
264        // file from `/etc/gitconfig` through `.git/config`); calling it once per object lookup
265        // dominated `status` runtime. Cache the result for the lifetime of this `Odb`.
266        *self.core_multi_pack_index_cache.get_or_init(|| {
267            let Some(git_dir) = &self.config_git_dir else {
268                return false;
269            };
270            let cfg = ConfigSet::load(Some(git_dir), true).unwrap_or_default();
271            match cfg.get_bool("core.multiPackIndex") {
272                Some(Ok(b)) => b,
273                Some(Err(_)) => true,
274                None => true,
275            }
276        })
277    }
278
279    /// Return the path to the `objects/` directory.
280    #[must_use]
281    pub fn objects_dir(&self) -> &Path {
282        &self.objects_dir
283    }
284
285    /// Return the attached git directory, if one was set with
286    /// [`Self::with_config_git_dir`].
287    ///
288    /// Used by config- and ref-aware operations (delta islands, on-disk delta
289    /// reuse) that need the repository root rather than just the object store.
290    #[must_use]
291    pub fn config_git_dir(&self) -> Option<&Path> {
292        self.config_git_dir.as_deref()
293    }
294
295    /// Return the filesystem path for a given object ID.
296    #[must_use]
297    pub fn object_path(&self, oid: &ObjectId) -> PathBuf {
298        self.objects_dir
299            .join(oid.loose_prefix())
300            .join(oid.loose_suffix())
301    }
302
303    /// Whether the object exists under this database directory only (loose or local packs).
304    ///
305    /// Unlike [`Self::exists`], this ignores `info/alternates` and
306    /// `GIT_ALTERNATE_OBJECT_DIRECTORIES`. Used for partial-clone bookkeeping where
307    /// objects reachable via alternates are still treated as "missing" until copied locally.
308    ///
309    /// Objects stored **only** in promisor packs (sibling `.promisor` marker next to the
310    /// `.pack`) are treated as absent: Git considers them fetchable on demand, and
311    /// `rev-list --missing=print` lists them until materialized as loose objects or a
312    /// non-promisor pack.
313    ///
314    /// The empty tree object is treated as present without a loose file (matches Git).
315    #[must_use]
316    pub fn exists_local(&self, oid: &ObjectId) -> bool {
317        const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
318        if oid.to_hex() == EMPTY_TREE {
319            return true;
320        }
321        exists_materialized_in_objects_dir(&self.objects_dir, oid)
322    }
323
324    /// Check whether an object exists in the loose store or any pack file.
325    #[must_use]
326    pub fn exists(&self, oid: &ObjectId) -> bool {
327        // The empty tree is a well-known object (no on-disk loose file). Git's
328        // canonical SHA-1 is `...8d69288fbee4904`; some harnesses still use the
329        // legacy typo hash `...899d69f7c6948d4` — treat both as present.
330        const EMPTY_TREE_CANON: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
331        const EMPTY_TREE_LEGACY: &str = "4b825dc642cb6eb9a060e54bf899d69f7c6948d4";
332        let hex = oid.to_hex();
333        if hex == EMPTY_TREE_CANON || hex == EMPTY_TREE_LEGACY {
334            return true;
335        }
336        if self.exists_in_dir(&self.objects_dir, oid) {
337            return true;
338        }
339        // Check alternates from info/alternates file.
340        if let Ok(alts) = pack::read_alternates_recursive(&self.objects_dir) {
341            for alt_dir in &alts {
342                if self.exists_in_dir(alt_dir, oid) {
343                    return true;
344                }
345            }
346        }
347        // Check GIT_ALTERNATE_OBJECT_DIRECTORIES env var.
348        for alt_dir in env_alternate_dirs(self.work_tree.as_deref()) {
349            if self.exists_in_dir(&alt_dir, oid) {
350                return true;
351            }
352        }
353        if let Ok(guard) = self.submodule_alternate_dirs.lock() {
354            for alt_dir in guard.iter() {
355                if self.exists_in_dir(alt_dir, oid) {
356                    return true;
357                }
358            }
359        }
360        false
361    }
362
363    /// Check whether an object exists in a specific objects directory.
364    fn exists_in_dir(&self, objects_dir: &Path, oid: &ObjectId) -> bool {
365        let loose = objects_dir
366            .join(oid.loose_prefix())
367            .join(oid.loose_suffix());
368        if loose.exists() {
369            return true;
370        }
371        if let Ok(indexes) = pack::read_local_pack_indexes_cached(objects_dir) {
372            for idx in &indexes {
373                if idx.contains(oid) {
374                    return true;
375                }
376            }
377        }
378        if objects_dir == self.objects_dir.as_path()
379            && self.config_git_dir.is_some()
380            && self.core_multi_pack_index_enabled()
381        {
382            match midx_oid_listed_in_tip(objects_dir, oid) {
383                Ok(Some(true)) => return true,
384                Ok(Some(false)) | Ok(None) => {}
385                Err(_) => return false,
386            }
387        }
388        false
389    }
390
391    /// Touch the loose object file or pack file containing `oid`, matching Git's
392    /// `odb_freshen_object` (updates mtime so age-based prune keeps recently re-referenced objects).
393    ///
394    /// Returns `true` if an on-disk object was found and touched.
395    #[must_use]
396    pub fn freshen_object(&self, oid: &ObjectId) -> bool {
397        const EMPTY_TREE_CANON: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
398        const EMPTY_TREE_LEGACY: &str = "4b825dc642cb6eb9a060e54bf899d69f7c6948d4";
399        let hex = oid.to_hex();
400        if hex == EMPTY_TREE_CANON || hex == EMPTY_TREE_LEGACY {
401            return false;
402        }
403
404        let loose = self.object_path(oid);
405        if loose.is_file() {
406            return touch_path_mtime(&loose);
407        }
408
409        if freshen_object_in_objects_dir(&self.objects_dir, oid) {
410            return true;
411        }
412
413        if let Ok(alts) = pack::read_alternates_recursive(&self.objects_dir) {
414            for alt_dir in &alts {
415                if freshen_object_in_objects_dir(alt_dir, oid) {
416                    return true;
417                }
418            }
419        }
420
421        for alt_dir in env_alternate_dirs(self.work_tree.as_deref()) {
422            if freshen_object_in_objects_dir(&alt_dir, oid) {
423                return true;
424            }
425        }
426
427        if let Ok(guard) = self.submodule_alternate_dirs.lock() {
428            for alt_dir in guard.iter() {
429                if freshen_object_in_objects_dir(alt_dir, oid) {
430                    return true;
431                }
432            }
433        }
434
435        false
436    }
437
438    /// Read a loose object file at `path`, verifying the uncompressed payload hashes to `expected_oid`.
439    ///
440    /// Git stores loose objects under paths derived from the OID; if the file contents hash to a
441    /// different id (for example after a mistaken `mv`), this returns [`Error::LooseHashMismatch`].
442    ///
443    /// # Errors
444    ///
445    /// - [`Error::Zlib`] — decompression failed.
446    /// - [`Error::CorruptObject`] — header is malformed.
447    /// - [`Error::LooseHashMismatch`] — payload OID does not match `expected_oid`.
448    pub fn read_loose_verify_oid(path: &Path, expected_oid: &ObjectId) -> Result<Object> {
449        let file = fs::File::open(path).map_err(Error::Io)?;
450        let raw = read_zlib_loose_payload(file)?;
451        let obj = parse_object_bytes_with_oid(&raw, expected_oid)?;
452        // Verify against the expected OID using its own hash algorithm; a SHA-256
453        // loose object must be re-hashed with SHA-256, not SHA-1.
454        let computed = hash_object_data_with(expected_oid.algo(), obj.kind, &obj.data);
455        if computed != *expected_oid {
456            return Err(Error::LooseHashMismatch {
457                path: path.display().to_string(),
458                real_oid: computed.to_hex(),
459            });
460        }
461        Ok(obj)
462    }
463
464    /// Read and decompress an object from the loose store.
465    ///
466    /// # Errors
467    ///
468    /// - [`Error::ObjectNotFound`] — no file at the expected path.
469    /// - [`Error::Zlib`] — decompression failed.
470    /// - [`Error::CorruptObject`] — header is malformed.
471    pub fn read(&self, oid: &ObjectId) -> Result<Object> {
472        // The empty tree is a well-known virtual object — no storage needed.
473        const EMPTY_TREE_CANON: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
474        const EMPTY_TREE_LEGACY: &str = "4b825dc642cb6eb9a060e54bf899d69f7c6948d4";
475        let hex = oid.to_hex();
476        if hex == EMPTY_TREE_CANON || hex == EMPTY_TREE_LEGACY {
477            return Ok(crate::objects::Object {
478                kind: crate::objects::ObjectKind::Tree,
479                data: Vec::new(),
480            });
481        }
482
483        // Objects written under an active in-memory overlay never hit disk, so they must be
484        // resolved here before any loose/pack lookup.
485        if let Some(obj) = self.overlay_read(oid) {
486            return Ok(obj);
487        }
488
489        // Git prepares the packed object store (registering the packs the MIDX names) before
490        // serving reads; a MIDX-referenced pack whose `.idx` cannot be opened reports
491        // `packfile <pack> index unavailable` even when the requested object turns out to be
492        // loose. Reproduce that once-per-process so `rev-list` over a corrupt idx still warns.
493        if self.config_git_dir.is_some() && self.core_multi_pack_index_enabled() {
494            crate::midx::validate_midx_referenced_packs(&self.objects_dir);
495        }
496
497        let path = self.object_path(oid);
498        match fs::File::open(&path) {
499            Ok(file) => {
500                let raw = read_zlib_loose_payload(file)?;
501                // Match Git: loose objects are read from the path implied by `oid` without
502                // requiring the payload to hash back to that oid (t1006 corrupt-loose / swapped files).
503                return parse_object_bytes(&raw);
504            }
505            Err(_) => {
506                // Loose object not found; try pack files.
507            }
508        }
509
510        if self.config_git_dir.is_some() && self.core_multi_pack_index_enabled() {
511            if let Some(obj) = try_read_object_via_midx(&self.objects_dir, oid)? {
512                return Ok(obj);
513            }
514        }
515
516        // Fall back to pack files.
517        match pack::read_object_from_packs(&self.objects_dir, oid) {
518            Ok(obj) => return Ok(obj),
519            Err(Error::ObjectNotFound(_)) => {}
520            Err(err) => return Err(err),
521        }
522
523        let midx_alt = self.config_git_dir.is_some() && self.core_multi_pack_index_enabled();
524
525        // Check alternates from info/alternates file.
526        if let Ok(alts) = pack::read_alternates_recursive(&self.objects_dir) {
527            for alt_dir in &alts {
528                if let Ok(obj) = Self::read_from_dir(alt_dir, oid, midx_alt) {
529                    return Ok(obj);
530                }
531            }
532        }
533
534        // Check GIT_ALTERNATE_OBJECT_DIRECTORIES env var.
535        for alt_dir in env_alternate_dirs(self.work_tree.as_deref()) {
536            if let Ok(obj) = Self::read_from_dir(&alt_dir, oid, midx_alt) {
537                return Ok(obj);
538            }
539        }
540
541        if let Ok(guard) = self.submodule_alternate_dirs.lock() {
542            for alt_dir in guard.iter() {
543                if let Ok(obj) = Self::read_from_dir(alt_dir, oid, false) {
544                    return Ok(obj);
545                }
546            }
547        }
548
549        Err(Error::ObjectNotFound(oid.to_hex()))
550    }
551
552    /// Try to read an object from a specific objects directory (loose or pack).
553    fn read_from_dir(objects_dir: &Path, oid: &ObjectId, use_midx: bool) -> Result<Object> {
554        let loose = objects_dir
555            .join(oid.loose_prefix())
556            .join(oid.loose_suffix());
557        if let Ok(file) = fs::File::open(&loose) {
558            let raw = read_zlib_loose_payload(file)?;
559            return parse_object_bytes(&raw);
560        }
561        if use_midx {
562            if let Some(obj) = try_read_object_via_midx(objects_dir, oid)? {
563                return Ok(obj);
564            }
565        }
566        match pack::read_object_from_packs(objects_dir, oid) {
567            Ok(obj) => Ok(obj),
568            Err(Error::ObjectNotFound(_)) => Err(Error::ObjectNotFound(oid.to_hex())),
569            Err(err) => Err(err),
570        }
571    }
572
573    /// Hash raw content of a given kind with SHA-1 and return the [`ObjectId`].
574    ///
575    /// This does **not** write anything to disk. Prefer [`Self::hash`] when a
576    /// repository hash algorithm is available, so SHA-256 repositories are
577    /// handled correctly.
578    #[must_use]
579    pub fn hash_object_data(kind: ObjectKind, data: &[u8]) -> ObjectId {
580        hash_object_data_with(HashAlgo::Sha1, kind, data)
581    }
582
583    /// Hash raw content of a given kind using this repository's hash algorithm.
584    ///
585    /// This does **not** write anything to disk.
586    #[must_use]
587    pub fn hash(&self, kind: ObjectKind, data: &[u8]) -> ObjectId {
588        hash_object_data_with(self.hash_algo(), kind, data)
589    }
590
591    /// Write an object to the loose store and return its [`ObjectId`].
592    ///
593    /// If the object already exists it is not overwritten (Git behaviour).
594    ///
595    /// # Errors
596    ///
597    /// - [`Error::Io`] — could not create the directory or write the file.
598    /// - [`Error::Zlib`] — compression failed.
599    pub fn write(&self, kind: ObjectKind, data: &[u8]) -> Result<ObjectId> {
600        let store_bytes = build_store_bytes(kind, data);
601        let oid = hash_bytes_with(self.hash_algo(), &store_bytes);
602
603        // When the in-memory overlay is active, keep the object in memory only (unless it is
604        // already present on disk, in which case nothing new needs to be written anyway).
605        if !self.exists(&oid) && self.overlay_store(oid, kind, data) {
606            return Ok(oid);
607        }
608
609        let path = self.object_path(&oid);
610        if path.exists() {
611            let _ = self.freshen_object(&oid);
612            return Ok(oid);
613        }
614        if self.exists(&oid) {
615            let _ = self.freshen_object(&oid);
616            return Ok(oid);
617        }
618
619        let prefix_dir = path
620            .parent()
621            .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
622        fs::create_dir_all(prefix_dir)?;
623
624        // Write to a temp file in the same directory, then rename atomically.
625        let tmp_path = prefix_dir.join(format!("tmp_{}", oid.loose_suffix()));
626        {
627            let tmp_file = fs::File::create(&tmp_path)?;
628            let mut encoder = ZlibEncoder::new(tmp_file, Compression::default());
629            encoder
630                .write_all(&store_bytes)
631                .map_err(|e| Error::Zlib(e.to_string()))?;
632            encoder.finish().map_err(|e| Error::Zlib(e.to_string()))?;
633        }
634        fs::rename(&tmp_path, &path)?;
635        #[cfg(unix)]
636        {
637            use std::os::unix::fs::PermissionsExt;
638            let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o444));
639        }
640
641        Ok(oid)
642    }
643
644    /// Write an object as a loose file in this object directory only.
645    ///
646    /// Unlike [`Self::write`], this ignores `info/alternates` and
647    /// `GIT_ALTERNATE_OBJECT_DIRECTORIES`: if the object exists only in an
648    /// alternate store, it is still written here. That matches how Git's
649    /// `unpack-objects` materializes every packed object into the receiving
650    /// repository even when the same OID is already reachable via alternates
651    /// (see `t5519-push-alternates`).
652    ///
653    /// The well-known empty tree is still written when no loose file exists yet,
654    /// even though [`Self::exists_local`] treats it as virtually present.
655    ///
656    /// # Errors
657    ///
658    /// Same as [`Self::write`].
659    pub fn write_local(&self, kind: ObjectKind, data: &[u8]) -> Result<ObjectId> {
660        let store_bytes = build_store_bytes(kind, data);
661        let oid = hash_bytes_with(self.hash_algo(), &store_bytes);
662
663        let path = self.object_path(&oid);
664        if path.exists() {
665            let _ = self.freshen_object(&oid);
666            return Ok(oid);
667        }
668        if exists_materialized_in_objects_dir(&self.objects_dir, &oid) {
669            let _ = self.freshen_object(&oid);
670            return Ok(oid);
671        }
672
673        let prefix_dir = path
674            .parent()
675            .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
676        fs::create_dir_all(prefix_dir)?;
677
678        let tmp_path = prefix_dir.join(format!("tmp_{}", oid.loose_suffix()));
679        {
680            let tmp_file = fs::File::create(&tmp_path)?;
681            let mut encoder = ZlibEncoder::new(tmp_file, Compression::default());
682            encoder
683                .write_all(&store_bytes)
684                .map_err(|e| Error::Zlib(e.to_string()))?;
685            encoder.finish().map_err(|e| Error::Zlib(e.to_string()))?;
686        }
687        fs::rename(&tmp_path, &path)?;
688        #[cfg(unix)]
689        {
690            use std::os::unix::fs::PermissionsExt;
691            let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o444));
692        }
693
694        Ok(oid)
695    }
696
697    /// Write a loose object file when it is missing, even if [`Self::exists`] is true because
698    /// the object lives only in a pack.
699    ///
700    /// Used when materializing a partial-clone layout: objects must be duplicated as loose files
701    /// before local packs are removed. Unlike [`Self::write_local`], objects present only in a
702    /// promisor pack are still written because [`Self::exists_local`] treats those as absent.
703    pub fn write_loose_materialize(&self, kind: ObjectKind, data: &[u8]) -> Result<ObjectId> {
704        let store_bytes = build_store_bytes(kind, data);
705        let oid = hash_bytes_with(self.hash_algo(), &store_bytes);
706        let path = self.object_path(&oid);
707        if path.exists() {
708            let _ = self.freshen_object(&oid);
709            return Ok(oid);
710        }
711
712        let prefix_dir = path
713            .parent()
714            .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
715        fs::create_dir_all(prefix_dir)?;
716
717        let tmp_path = prefix_dir.join(format!("tmp_{}", oid.loose_suffix()));
718        {
719            let tmp_file = fs::File::create(&tmp_path)?;
720            let mut encoder = ZlibEncoder::new(tmp_file, Compression::default());
721            encoder
722                .write_all(&store_bytes)
723                .map_err(|e| Error::Zlib(e.to_string()))?;
724            encoder.finish().map_err(|e| Error::Zlib(e.to_string()))?;
725        }
726        fs::rename(&tmp_path, &path)?;
727        #[cfg(unix)]
728        {
729            use std::os::unix::fs::PermissionsExt;
730            let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o444));
731        }
732
733        Ok(oid)
734    }
735
736    /// Write an already-serialized object (header + data) to the loose store.
737    ///
738    /// Useful when the caller has the full store bytes (e.g. from stdin with
739    /// `--literally`).
740    ///
741    /// # Errors
742    ///
743    /// - [`Error::CorruptObject`] — the provided bytes don't form a valid header.
744    /// - [`Error::Io`] / [`Error::Zlib`] — storage errors.
745    pub fn write_raw(&self, store_bytes: &[u8]) -> Result<ObjectId> {
746        // Validate the header before storing
747        parse_object_bytes(store_bytes)?;
748
749        let oid = hash_bytes_with(self.hash_algo(), store_bytes);
750        let path = self.object_path(&oid);
751        if path.exists() {
752            let _ = self.freshen_object(&oid);
753            return Ok(oid);
754        }
755        if self.exists(&oid) {
756            let _ = self.freshen_object(&oid);
757            return Ok(oid);
758        }
759
760        let prefix_dir = path
761            .parent()
762            .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
763        fs::create_dir_all(prefix_dir)?;
764
765        let tmp_path = prefix_dir.join(format!("tmp_{}", oid.loose_suffix()));
766        {
767            let tmp_file = fs::File::create(&tmp_path)?;
768            let mut encoder = ZlibEncoder::new(tmp_file, Compression::default());
769            encoder
770                .write_all(store_bytes)
771                .map_err(|e| Error::Zlib(e.to_string()))?;
772            encoder.finish().map_err(|e| Error::Zlib(e.to_string()))?;
773        }
774        fs::rename(&tmp_path, &path)?;
775        #[cfg(unix)]
776        {
777            use std::os::unix::fs::PermissionsExt;
778            let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o444));
779        }
780
781        Ok(oid)
782    }
783
784    /// Like [`Self::write_raw`] but only consults this object directory, not alternates.
785    ///
786    /// See [`Self::write_local`].
787    ///
788    /// # Errors
789    ///
790    /// Same as [`Self::write_raw`].
791    pub fn write_raw_local(&self, store_bytes: &[u8]) -> Result<ObjectId> {
792        parse_object_bytes(store_bytes)?;
793
794        let oid = hash_bytes_with(self.hash_algo(), store_bytes);
795        let path = self.object_path(&oid);
796        if path.exists() {
797            let _ = self.freshen_object(&oid);
798            return Ok(oid);
799        }
800        if exists_materialized_in_objects_dir(&self.objects_dir, &oid) {
801            let _ = self.freshen_object(&oid);
802            return Ok(oid);
803        }
804
805        let prefix_dir = path
806            .parent()
807            .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
808        fs::create_dir_all(prefix_dir)?;
809
810        let tmp_path = prefix_dir.join(format!("tmp_{}", oid.loose_suffix()));
811        {
812            let tmp_file = fs::File::create(&tmp_path)?;
813            let mut encoder = ZlibEncoder::new(tmp_file, Compression::default());
814            encoder
815                .write_all(store_bytes)
816                .map_err(|e| Error::Zlib(e.to_string()))?;
817            encoder.finish().map_err(|e| Error::Zlib(e.to_string()))?;
818        }
819        fs::rename(&tmp_path, &path)?;
820        #[cfg(unix)]
821        {
822            use std::os::unix::fs::PermissionsExt;
823            let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o444));
824        }
825
826        Ok(oid)
827    }
828
829    /// Returns true when a loose object exists at `oid`'s path and zlib-decompresses to a
830    /// structurally valid `<type> <size>\0<payload>` object (type may be non-standard).
831    ///
832    /// Used for `git cat-file -e`, which succeeds for hand-crafted loose objects that
833    /// [`Self::read`] rejects due to [`Error::UnknownObjectType`].
834    #[must_use]
835    pub fn loose_object_plumbing_ok(&self, oid: &ObjectId) -> bool {
836        let path = self.object_path(oid);
837        let Ok(file) = fs::File::open(&path) else {
838            return false;
839        };
840        let Ok(raw) = read_zlib_loose_payload(file) else {
841            return false;
842        };
843        loose_store_bytes_header_valid(&raw)
844    }
845}
846
847fn loose_store_bytes_header_valid(raw: &[u8]) -> bool {
848    let nul = match raw.iter().position(|&b| b == 0) {
849        Some(i) => i,
850        None => return false,
851    };
852    let header = &raw[..nul];
853    let data = &raw[nul + 1..];
854    let sp = match header.iter().position(|&b| b == b' ') {
855        Some(i) => i,
856        None => return false,
857    };
858    if sp == 0 || sp > 32 {
859        return false;
860    }
861    let size_str = match std::str::from_utf8(&header[sp + 1..]) {
862        Ok(s) => s,
863        Err(_) => return false,
864    };
865    let size: usize = match size_str.parse() {
866        Ok(s) => s,
867        Err(_) => return false,
868    };
869    data.len() == size
870}
871
872/// Update `path`'s mtime to "now" (Git `utime(path, NULL)`), returning whether it succeeded.
873fn touch_path_mtime(path: &Path) -> bool {
874    // `utime(path, NULL)` sets both atime and mtime to the current time.
875    let now = filetime::FileTime::now();
876    filetime::set_file_times(path, now, now).is_ok()
877}
878
879fn freshen_object_in_objects_dir(objects_dir: &Path, oid: &ObjectId) -> bool {
880    let Ok(indexes) = pack::read_local_pack_indexes_cached(objects_dir) else {
881        return false;
882    };
883    for idx in &indexes {
884        if idx.contains(oid) {
885            let touched = touch_path_mtime(&idx.pack_path);
886            if touched {
887                // Keep the cached pack bytes valid: this mtime bump is ours, not a content change.
888                pack::refresh_pack_bytes_signature(&idx.pack_path);
889            }
890            return touched;
891        }
892    }
893    false
894}
895
896/// Hash the canonical store bytes of an object (`"<kind> <len>\0<data>"`) with
897/// the given hash algorithm.
898fn hash_object_data_with(algo: HashAlgo, kind: ObjectKind, data: &[u8]) -> ObjectId {
899    let header = format!("{} {}\0", kind, data.len());
900    match algo {
901        HashAlgo::Sha1 => {
902            let mut hasher = Sha1::new();
903            hasher.update(header.as_bytes());
904            hasher.update(data);
905            ObjectId::from_bytes(hasher.finalize().as_slice())
906                .unwrap_or_else(|_| unreachable!("SHA-1 is 20 bytes"))
907        }
908        HashAlgo::Sha256 => {
909            let mut hasher = Sha256::new();
910            hasher.update(header.as_bytes());
911            hasher.update(data);
912            ObjectId::from_bytes(hasher.finalize().as_slice())
913                .unwrap_or_else(|_| unreachable!("SHA-256 is 32 bytes"))
914        }
915    }
916}
917
918/// Compute the digest of pre-built store bytes with the given hash algorithm.
919fn hash_bytes_with(algo: HashAlgo, data: &[u8]) -> ObjectId {
920    match algo {
921        HashAlgo::Sha1 => {
922            let mut hasher = Sha1::new();
923            hasher.update(data);
924            ObjectId::from_bytes(hasher.finalize().as_slice())
925                .unwrap_or_else(|_| unreachable!("SHA-1 is 20 bytes"))
926        }
927        HashAlgo::Sha256 => {
928            let mut hasher = Sha256::new();
929            hasher.update(data);
930            ObjectId::from_bytes(hasher.finalize().as_slice())
931                .unwrap_or_else(|_| unreachable!("SHA-256 is 32 bytes"))
932        }
933    }
934}
935
936/// Build the canonical store byte sequence: `"<kind> <len>\0<data>"`.
937fn build_store_bytes(kind: ObjectKind, data: &[u8]) -> Vec<u8> {
938    let header = format!("{} {}\0", kind, data.len());
939    let mut out = Vec::with_capacity(header.len() + data.len());
940    out.extend_from_slice(header.as_bytes());
941    out.extend_from_slice(data);
942    out
943}
944
945/// Parse decompressed object bytes (`"<type> <size>\0<data>"`) into an [`Object`].
946pub(crate) fn parse_object_bytes(raw: &[u8]) -> Result<Object> {
947    parse_object_bytes_inner(raw, None)
948}
949
950pub(crate) fn parse_object_bytes_with_oid(raw: &[u8], oid: &ObjectId) -> Result<Object> {
951    parse_object_bytes_inner(raw, Some(oid))
952}
953
954fn parse_object_bytes_inner(raw: &[u8], oid_hint: Option<&ObjectId>) -> Result<Object> {
955    let nul = raw
956        .iter()
957        .position(|&b| b == 0)
958        .ok_or_else(|| Error::CorruptObject("missing NUL in object header".to_owned()))?;
959
960    let header = &raw[..nul];
961    let data = raw[nul + 1..].to_vec();
962
963    let sp = header
964        .iter()
965        .position(|&b| b == b' ')
966        .ok_or_else(|| Error::CorruptObject("missing space in object header".to_owned()))?;
967
968    if sp > 32 {
969        let oid_str = oid_hint
970            .map(|o| o.to_hex())
971            .unwrap_or_else(|| hash_bytes_with(HashAlgo::Sha1, raw).to_hex());
972        return Err(Error::ObjectHeaderTooLong { oid: oid_str });
973    }
974
975    let kind = ObjectKind::from_bytes(&header[..sp])?;
976
977    let size_str = std::str::from_utf8(&header[sp + 1..])
978        .map_err(|_| Error::CorruptObject("non-UTF-8 object size".to_owned()))?;
979    let size: usize = size_str
980        .parse()
981        .map_err(|_| Error::CorruptObject(format!("invalid object size: {size_str}")))?;
982
983    if data.len() != size {
984        return Err(Error::CorruptObject(format!(
985            "object size mismatch: header says {size} but got {}",
986            data.len()
987        )));
988    }
989
990    Ok(Object::new(kind, data))
991}
992
993/// Parse `GIT_ALTERNATE_OBJECT_DIRECTORIES` into a list of paths.
994///
995/// The env var contains colon-separated (`:`-separated on Unix) paths
996/// to additional object directories to search. Supports double-quoted
997/// entries with octal escapes (e.g. `\057` for `/`).
998///
999/// Relative paths are resolved against `resolve_base` (typically the work tree root).
1000fn env_alternate_dirs(resolve_base: Option<&Path>) -> Vec<PathBuf> {
1001    match std::env::var("GIT_ALTERNATE_OBJECT_DIRECTORIES") {
1002        Ok(val) if !val.is_empty() => {
1003            let mut dirs = parse_alternate_env(&val);
1004            if let Some(base) = resolve_base {
1005                for dir in &mut dirs {
1006                    if dir.is_relative() {
1007                        *dir = base.join(&dir);
1008                    }
1009                }
1010            }
1011            dirs
1012        }
1013        _ => Vec::new(),
1014    }
1015}
1016
1017/// Parse a colon-separated alternates string, handling double-quoted entries
1018/// with octal escape sequences.
1019fn parse_alternate_env(val: &str) -> Vec<PathBuf> {
1020    let mut result = Vec::new();
1021    let mut chars = val.chars().peekable();
1022    while chars.peek().is_some() {
1023        if chars.peek() == Some(&':') {
1024            chars.next();
1025            continue;
1026        }
1027        if chars.peek() == Some(&'"') {
1028            // Try quoted parsing; if EOF is hit without closing quote,
1029            // fall back to treating the whole segment as a raw path.
1030            chars.next(); // consume the opening '"'
1031            let saved: Vec<char> = chars.clone().collect();
1032            let mut path = String::new();
1033            let mut properly_closed = false;
1034            loop {
1035                match chars.next() {
1036                    None => break,
1037                    Some('"') => {
1038                        properly_closed = true;
1039                        break;
1040                    }
1041                    Some('\\') => match chars.peek() {
1042                        Some(c) if c.is_ascii_digit() => {
1043                            let mut oct = String::new();
1044                            for _ in 0..3 {
1045                                if let Some(&c) = chars.peek() {
1046                                    if c.is_ascii_digit() {
1047                                        oct.push(c);
1048                                        chars.next();
1049                                    } else {
1050                                        break;
1051                                    }
1052                                } else {
1053                                    break;
1054                                }
1055                            }
1056                            if let Ok(byte) = u8::from_str_radix(&oct, 8) {
1057                                path.push(byte as char);
1058                            }
1059                        }
1060                        Some(_) => {
1061                            if let Some(c) = chars.next() {
1062                                match c {
1063                                    'n' => path.push('\n'),
1064                                    't' => path.push('\t'),
1065                                    'r' => path.push('\r'),
1066                                    _ => path.push(c),
1067                                }
1068                            }
1069                        }
1070                        None => {}
1071                    },
1072                    Some(c) => path.push(c),
1073                }
1074            }
1075            if !properly_closed {
1076                // Broken quoting: fall back to treating raw value (with leading ")
1077                // as a literal path.
1078                let raw: String = std::iter::once('"').chain(saved).collect();
1079                // Extract up to ':' or end
1080                let raw_path = raw.split(':').next().unwrap_or(&raw);
1081                if !raw_path.is_empty() {
1082                    result.push(PathBuf::from(raw_path));
1083                }
1084                // Advance past the ':' in the original chars (we consumed the saved copy)
1085                // Since chars is now at EOF, we need to handle remaining items.
1086                // Actually, we consumed chars fully. Let's reconstruct from raw.
1087                let remainder = &raw[raw_path.len()..];
1088                if let Some(rest) = remainder.strip_prefix(':') {
1089                    // Parse remaining entries
1090                    result.extend(parse_alternate_env(rest));
1091                }
1092                return result;
1093            } else if !path.is_empty() {
1094                result.push(PathBuf::from(path));
1095            }
1096        } else {
1097            let mut path = String::new();
1098            while let Some(&c) = chars.peek() {
1099                if c == ':' {
1100                    break;
1101                }
1102                path.push(c);
1103                chars.next();
1104            }
1105            if !path.is_empty() {
1106                result.push(PathBuf::from(path));
1107            }
1108        }
1109    }
1110    result
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    #![allow(clippy::expect_used, clippy::unwrap_used)]
1116
1117    use super::*;
1118    use tempfile::TempDir;
1119
1120    #[test]
1121    fn round_trip_blob() {
1122        let dir = TempDir::new().unwrap();
1123        let odb = Odb::new(dir.path());
1124        let data = b"hello world";
1125        let oid = odb.write(ObjectKind::Blob, data).unwrap();
1126        let obj = odb.read(&oid).unwrap();
1127        assert_eq!(obj.kind, ObjectKind::Blob);
1128        assert_eq!(obj.data, data);
1129    }
1130
1131    #[test]
1132    fn known_blob_hash() {
1133        // Verified: echo -n "hello" | git hash-object --stdin
1134        //        => b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0
1135        let oid = Odb::hash_object_data(ObjectKind::Blob, b"hello");
1136        assert_eq!(oid.to_hex(), "b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0");
1137    }
1138}