Skip to main content

amiss_git/
repo.rs

1use std::fs::File;
2use std::io::Read as _;
3use std::path::Path;
4use std::sync::OnceLock;
5
6use amiss_wire::controls::ResourceName;
7use amiss_wire::model::{ObjectFormat, Oid};
8
9use crate::Error;
10use crate::handle::{open_dir, open_file, open_root};
11use crate::object::{Object, ObjectKind, decode_loose, discard_to_unreadable, hex, verify_oid};
12use crate::pack::{
13    self, PackSet, apply_delta, inflate_exact, kind_of, parse_entry_header, parse_ofs_distance,
14};
15use crate::resources::{GitResources, ValueCap, crossing};
16
17#[derive(Debug)]
18pub struct Repository {
19    git_dir: File,
20    objects: File,
21    object_format: ObjectFormat,
22    packs: OnceLock<Result<PackSet, Error>>,
23    repacked: OnceLock<Result<PackSet, Error>>,
24}
25
26impl Repository {
27    /// Opens the primary non-bare form: the root's final entry and its direct
28    /// `.git` child as no-follow directory handles; every later object access
29    /// is relative to those handles.
30    ///
31    /// # Errors
32    ///
33    /// `RepositoryUnavailable` for a bare repository, `.git` file, symlink,
34    /// or missing primary object database.
35    pub fn open(root: &Path, object_format: ObjectFormat) -> Result<Self, Error> {
36        let root_dir = open_root(root)?;
37        let git_dir =
38            open_dir(&root_dir, ".git").map_err(|_defect| Error::RepositoryUnavailable)?;
39        let objects =
40            open_dir(&git_dir, "objects").map_err(|_defect| Error::RepositoryUnavailable)?;
41        Ok(Self {
42            git_dir,
43            objects,
44            object_format,
45            packs: OnceLock::new(),
46            repacked: OnceLock::new(),
47        })
48    }
49
50    /// Total loose-first lookup for one full OID in the declared namespace.
51    ///
52    /// # Errors
53    ///
54    /// `ObjectMissing` when no loose or validated pack row holds the OID,
55    /// `ObjectUnreadable` for any corruption or non-ordinary entry, and
56    /// `ResourceLimit` for cap crossings.
57    pub fn read_object(&self, resources: &mut GitResources, oid: &Oid) -> Result<Object, Error> {
58        self.read_full(resources, oid, 1, None)
59    }
60
61    /// # Errors
62    ///
63    /// Everything `read_object` fails with, plus `ObjectWrongKind` when the
64    /// reconstructed type differs from `expected`.
65    pub fn read_expected(
66        &self,
67        resources: &mut GitResources,
68        oid: &Oid,
69        expected: ObjectKind,
70    ) -> Result<Object, Error> {
71        let object = self.read_object(resources, oid)?;
72        if object.kind == expected {
73            Ok(object)
74        } else {
75            Err(Error::ObjectWrongKind)
76        }
77    }
78
79    /// Reads one object under a smaller contextual inflated cap, which applies
80    /// before the general Git object cap when a header declares a larger
81    /// value. The cap binds the requested object only, never a delta base.
82    ///
83    /// # Errors
84    ///
85    /// Everything `read_expected` fails with; a declared size past the cap is
86    /// a `ResourceLimit` carrying the cap's own resource.
87    pub fn read_expected_capped(
88        &self,
89        resources: &mut GitResources,
90        oid: &Oid,
91        expected: ObjectKind,
92        cap: ValueCap,
93    ) -> Result<Object, Error> {
94        let object = self.read_full(resources, oid, 1, Some(&cap))?;
95        if object.kind == expected {
96            Ok(object)
97        } else {
98            Err(Error::ObjectWrongKind)
99        }
100    }
101
102    fn read_full(
103        &self,
104        resources: &mut GitResources,
105        oid: &Oid,
106        depth: u64,
107        value_cap: Option<&ValueCap>,
108    ) -> Result<Object, Error> {
109        let limit = resources.limits().delta_depth;
110        if depth > limit {
111            return Err(crossing(ResourceName::GitDeltaDepth, limit, depth));
112        }
113        let hex_text = oid.as_str();
114        let fan = hex_text.get(..2).ok_or(Error::ObjectUnreadable)?;
115        let rest = hex_text.get(2..).ok_or(Error::ObjectUnreadable)?;
116        let Some(fan_dir) = absent_to_none(open_dir(&self.objects, fan))? else {
117            return self.read_packed(resources, oid, depth, value_cap);
118        };
119        let Some(loose) = absent_to_none(open_file(&fan_dir, rest))? else {
120            return self.read_packed(resources, oid, depth, value_cap);
121        };
122        self.decode(resources, oid, loose, value_cap)
123    }
124
125    /// One pack enumeration, with its index sizes charged. Charging is by pack
126    /// name and idempotent, so a second enumeration pays only for the packs the
127    /// first one never saw.
128    fn enumeration<'set>(
129        &'set self,
130        cell: &'set OnceLock<Result<PackSet, Error>>,
131        resources: &mut GitResources,
132    ) -> Result<&'set PackSet, Error> {
133        let built = cell.get_or_init(|| pack::build(&self.objects, self.object_format, resources));
134        match built {
135            Ok(set) => {
136                for (name, size) in &set.index_sizes {
137                    resources.charge_index(name, *size)?;
138                }
139                Ok(set)
140            }
141            Err(defect) => Err(defect.clone()),
142        }
143    }
144
145    /// Locates a packed OID, re-reading the pack directory once when the first
146    /// enumeration misses.
147    ///
148    /// A concurrent repack moves an object out of the loose store and into a
149    /// pack that did not exist when the first enumeration ran, which leaves a
150    /// present object looking absent. Git re-reads its pack directory on a miss
151    /// for exactly this reason, and so does this. The re-read goes through the
152    /// same held objects handle, so the no-follow boundary is unchanged, and
153    /// anything it finds is still content-addressed before use. A race can
154    /// therefore cost one extra directory read, and can never yield another
155    /// object's bytes. Exactly one re-read happens per repository, so a store
156    /// that is genuinely missing an object still settles as missing.
157    fn locate_packed(
158        &self,
159        resources: &mut GitResources,
160        raw: &[u8],
161    ) -> Result<Option<(&PackSet, usize, u64)>, Error> {
162        let enumerated = self.enumeration(&self.packs, resources)?;
163        if let Some((pack_index, offset)) = enumerated.locate(raw) {
164            return Ok(Some((enumerated, pack_index, offset)));
165        }
166        let repacked = self.enumeration(&self.repacked, resources)?;
167        Ok(repacked
168            .locate(raw)
169            .map(|(pack_index, offset)| (repacked, pack_index, offset)))
170    }
171
172    fn read_packed(
173        &self,
174        resources: &mut GitResources,
175        oid: &Oid,
176        depth: u64,
177        value_cap: Option<&ValueCap>,
178    ) -> Result<Object, Error> {
179        let raw = oid_raw(oid).ok_or(Error::ObjectUnreadable)?;
180        let Some((set, pack_index, offset)) = self.locate_packed(resources, &raw)? else {
181            return Err(Error::ObjectMissing);
182        };
183        let (kind, body) =
184            self.read_pack_at(resources, set, pack_index, offset, depth, value_cap)?;
185        let raw_header = format!("{} {}\0", kind.as_str(), body.len()).into_bytes();
186        verify_oid(self.object_format, oid, &raw_header, &body)?;
187        Ok(Object { kind, body })
188    }
189
190    fn read_pack_at(
191        &self,
192        resources: &mut GitResources,
193        set: &PackSet,
194        pack_index: usize,
195        offset: u64,
196        depth: u64,
197        value_cap: Option<&ValueCap>,
198    ) -> Result<(ObjectKind, Vec<u8>), Error> {
199        let limit = resources.limits().delta_depth;
200        if depth > limit {
201            return Err(crossing(ResourceName::GitDeltaDepth, limit, depth));
202        }
203        let inflated_cap = resources.limits().inflated_object_bytes;
204        let entry = {
205            let pack = set.packs.get(pack_index).ok_or(Error::ObjectUnreadable)?;
206            pack.read_interval(resources, offset)?
207        };
208        let header = parse_entry_header(&entry)?;
209        let after_header = entry
210            .get(header.header_len..)
211            .ok_or(Error::ObjectUnreadable)?;
212
213        match header.type_code {
214            1..=4 => {
215                if let Some(value) = value_cap
216                    && header.size > value.limit
217                {
218                    return Err(crossing(value.resource, value.limit, header.size));
219                }
220                let body = inflate_exact(after_header, header.size, inflated_cap)?;
221                Ok((kind_of(header.type_code)?, body))
222            }
223            6 => {
224                let (distance, used) = parse_ofs_distance(after_header)?;
225                let base_offset = offset
226                    .checked_sub(distance)
227                    .ok_or(Error::ObjectUnreadable)?;
228                let base_known = {
229                    let pack = set.packs.get(pack_index).ok_or(Error::ObjectUnreadable)?;
230                    pack.row_at(base_offset).is_some()
231                };
232                if !base_known {
233                    return Err(Error::ObjectUnreadable);
234                }
235                let (kind, base) = self.read_pack_at(
236                    resources,
237                    set,
238                    pack_index,
239                    base_offset,
240                    depth.saturating_add(1),
241                    None,
242                )?;
243                let script_bytes = after_header.get(used..).ok_or(Error::ObjectUnreadable)?;
244                let script = inflate_exact(script_bytes, header.size, inflated_cap)?;
245                Ok((kind, apply_delta(&base, &script, inflated_cap, value_cap)?))
246            }
247            7 => {
248                let width = self.oid_width();
249                let base_raw = after_header.get(..width).ok_or(Error::ObjectUnreadable)?;
250                let base_oid =
251                    Oid::new(self.object_format, hex(base_raw)).ok_or(Error::ObjectUnreadable)?;
252                let base = self.read_full(resources, &base_oid, depth.saturating_add(1), None)?;
253                let script_bytes = after_header.get(width..).ok_or(Error::ObjectUnreadable)?;
254                let script = inflate_exact(script_bytes, header.size, inflated_cap)?;
255                Ok((
256                    base.kind,
257                    apply_delta(&base.body, &script, inflated_cap, value_cap)?,
258                ))
259            }
260            _ => Err(Error::ObjectUnreadable),
261        }
262    }
263
264    #[must_use]
265    pub const fn object_format(&self) -> ObjectFormat {
266        self.object_format
267    }
268
269    /// Reads the current raw `.git/index` bytes through the retained handle:
270    /// an ordinary no-follow entry, bounded by the raw staged-index cap with
271    /// the exact declared length observed.
272    ///
273    /// # Errors
274    ///
275    /// `IndexInvalid` for a missing or non-ordinary entry, and the
276    /// `git-index-bytes` crossing for an oversized one.
277    pub fn read_index_bytes(&self, resources: &mut GitResources) -> Result<Vec<u8>, Error> {
278        let file = open_file(&self.git_dir, "index").map_err(|_defect| Error::IndexInvalid)?;
279        let metadata = file.metadata().map_err(|_defect| Error::IndexInvalid)?;
280        let cap = resources.limits().index_bytes;
281        if metadata.len() > cap {
282            return Err(crossing(ResourceName::GitIndexBytes, cap, metadata.len()));
283        }
284        let mut bytes = Vec::new();
285        let read = file
286            .take(cap.saturating_add(1))
287            .read_to_end(&mut bytes)
288            .map_err(|_defect| Error::IndexInvalid)?;
289        if u64::try_from(read).unwrap_or(u64::MAX) > cap {
290            return Err(crossing(
291                ResourceName::GitIndexBytes,
292                cap,
293                cap.saturating_add(1),
294            ));
295        }
296        Ok(bytes)
297    }
298
299    /// Whether the primary object database holds the OID, without reading or
300    /// reconstructing the object.
301    ///
302    /// # Errors
303    ///
304    /// Pack enumeration defects and their resource crossings.
305    pub fn has_object(&self, resources: &mut GitResources, oid: &Oid) -> Result<bool, Error> {
306        let hex_text = oid.as_str();
307        let fan = hex_text.get(..2).ok_or(Error::ObjectUnreadable)?;
308        let rest = hex_text.get(2..).ok_or(Error::ObjectUnreadable)?;
309        if let Ok(fan_dir) = open_dir(&self.objects, fan)
310            && open_file(&fan_dir, rest).is_ok()
311        {
312            return Ok(true);
313        }
314        let Some(raw) = oid_raw(oid) else {
315            return Err(Error::ObjectUnreadable);
316        };
317        Ok(self.locate_packed(resources, &raw)?.is_some())
318    }
319
320    /// The end-of-scan race check: reopens the current index entry, rereads
321    /// it boundedly, and accepts byte identity or an equal reparsed logical
322    /// projection. Anything else is solely a snapshot change.
323    ///
324    /// # Errors
325    ///
326    /// `SnapshotChanged`, or the index byte crossing during the reread.
327    pub fn verify_index_unchanged(
328        &self,
329        resources: &mut GitResources,
330        initial: &[u8],
331    ) -> Result<(), Error> {
332        let current = match self.read_index_bytes(resources) {
333            Ok(bytes) => bytes,
334            Err(Error::ResourceLimit {
335                resource,
336                configured_limit,
337                observed_lower_bound,
338            }) => {
339                return Err(Error::ResourceLimit {
340                    resource,
341                    configured_limit,
342                    observed_lower_bound,
343                });
344            }
345            Err(
346                Error::RepositoryUnavailable
347                | Error::ObjectMissing
348                | Error::ObjectWrongKind
349                | Error::ObjectUnreadable
350                | Error::IndexInvalid
351                | Error::IndexUnmerged
352                | Error::IntentToAdd
353                | Error::SnapshotChanged,
354            ) => return Err(Error::SnapshotChanged),
355        };
356        if current == initial {
357            return Ok(());
358        }
359        let before = crate::index::parse_index_file(self.object_format, initial)
360            .map_err(|_defect| Error::SnapshotChanged)?;
361        let after = crate::index::parse_index_file(self.object_format, &current)
362            .map_err(|_defect| Error::SnapshotChanged)?;
363        if before == after {
364            return Ok(());
365        }
366        Err(Error::SnapshotChanged)
367    }
368
369    const fn oid_width(&self) -> usize {
370        match self.object_format {
371            ObjectFormat::Sha1 => 20,
372            ObjectFormat::Sha256 => 32,
373        }
374    }
375
376    fn decode(
377        &self,
378        resources: &mut GitResources,
379        oid: &Oid,
380        file: File,
381        value_cap: Option<&ValueCap>,
382    ) -> Result<Object, Error> {
383        let metadata = file.metadata().map_err(discard_to_unreadable)?;
384        resources.charge_compressed(oid.as_str(), metadata.len())?;
385
386        let stream_cap = resources.limits().compressed_stream_bytes;
387        let mut compressed = Vec::new();
388        let read = file
389            .take(stream_cap.saturating_add(1))
390            .read_to_end(&mut compressed)
391            .map_err(discard_to_unreadable)?;
392        if u64::try_from(read).unwrap_or(u64::MAX) > stream_cap {
393            return Err(crossing(
394                ResourceName::GitCompressedObjectBytes,
395                stream_cap,
396                stream_cap.saturating_add(1),
397            ));
398        }
399        decode_loose(
400            &compressed,
401            self.object_format,
402            oid,
403            resources.limits().inflated_object_bytes,
404            value_cap,
405        )
406    }
407}
408
409/// An entry that is simply absent falls through to the pack lookup; a
410/// present but refused entry (a symlink, a directory where a file belongs) is
411/// an unreadable object, never a silent miss.
412fn absent_to_none(opened: std::io::Result<File>) -> Result<Option<File>, Error> {
413    match opened {
414        Ok(file) => Ok(Some(file)),
415        Err(defect) if defect.kind() == std::io::ErrorKind::NotFound => Ok(None),
416        Err(_defect) => Err(Error::ObjectUnreadable),
417    }
418}
419
420fn oid_raw(oid: &Oid) -> Option<Vec<u8>> {
421    let text = oid.as_str();
422    if !text.len().is_multiple_of(2) {
423        return None;
424    }
425    let mut out = Vec::with_capacity(text.len().checked_div(2)?);
426    for pair in text.as_bytes().chunks_exact(2) {
427        let [high, low] = pair else { return None };
428        let value = |byte: u8| match byte {
429            b'0'..=b'9' => Some(byte.wrapping_sub(b'0')),
430            b'a'..=b'f' => Some(byte.wrapping_sub(b'a').wrapping_add(10)),
431            _ => None,
432        };
433        out.push(value(*high)?.wrapping_shl(4) | value(*low)?);
434    }
435    Some(out)
436}