Skip to main content

gix_commitgraph/file/
init.rs

1use gix_error::Result;
2use std::path::{Path, PathBuf};
3
4use gix_error::{ResultExt, bail, corruption, message};
5
6use crate::{
7    File,
8    file::{
9        BASE_GRAPHS_LIST_CHUNK_ID, COMMIT_DATA_CHUNK_ID, COMMIT_DATA_ENTRY_SIZE_SANS_HASH,
10        EXTENDED_EDGES_LIST_CHUNK_ID, FAN_LEN, HEADER_LEN, OID_FAN_CHUNK_ID, OID_LOOKUP_CHUNK_ID, SIGNATURE,
11    },
12};
13
14const MIN_FILE_SIZE: usize = HEADER_LEN
15    + gix_chunk::file::Index::size_for_entries(3 /*OIDF, OIDL, CDAT*/)
16    + FAN_LEN * 4 /* FANOUT TABLE CHUNK OIDF */
17    + gix_hash::Kind::shortest().len_in_bytes();
18
19impl File {
20    /// Try to parse the commit graph file at `path`.
21    pub fn at(path: impl AsRef<Path>) -> Result<File> {
22        Self::try_from(path.as_ref())
23    }
24
25    /// A lower-level constructor which constructs a new instance directly from the mapping in `data`,
26    /// assuming that it originated from `path`.
27    ///
28    /// Note that `path` is only used for verification of the hash its basename contains, but otherwise
29    /// is not of importance.
30    pub fn new(data: memmap2::Mmap, path: PathBuf) -> Result<File> {
31        let data_size = data.len();
32        if data_size < MIN_FILE_SIZE {
33            bail!(corruption("Commit-graph file too small even for an empty graph"));
34        }
35
36        let mut ofs = 0;
37        if &data[ofs..ofs + SIGNATURE.len()] != SIGNATURE {
38            bail!(corruption("Commit-graph file does not start with expected signature"));
39        }
40        ofs += SIGNATURE.len();
41
42        match data[ofs] {
43            1 => (),
44            x => {
45                bail!("Unsupported commit-graph file version: {x}".unsupported());
46            }
47        }
48        ofs += 1;
49
50        let object_hash = gix_hash::Kind::try_from(data[ofs])
51            .map_err(|v| message!("Commit-graph file uses unsupported hash version: {v}").unsupported_error())?;
52        ofs += 1;
53
54        let chunk_count = data[ofs];
55        // Can assert chunk_count >= MIN_CHUNKS here, but later OIDF+OIDL+CDAT presence checks make
56        // it redundant.
57        ofs += 1;
58
59        let base_graph_count = data[ofs];
60        ofs += 1;
61
62        let chunks = gix_chunk::file::Index::from_bytes(&data, ofs, u32::from(chunk_count)).or_raise(|| {
63            message!("Couldn't read commit-graph file with {chunk_count} chunks at offset {ofs}").corrupted()
64        })?;
65
66        let base_graphs_list_offset = chunks
67            .validated_usize_offset_by_id(BASE_GRAPHS_LIST_CHUNK_ID, |chunk_range| -> Result<_> {
68                let chunk_size = chunk_range.len();
69                if chunk_size % object_hash.len_in_bytes() != 0 {
70                    bail!("Commit-graph chunk {BASE_GRAPHS_LIST_CHUNK_ID:?} has invalid size: {msg}".corrupted(),
71                        msg = format!(
72                            "chunk size {} is not a multiple of {}",
73                            chunk_size,
74                            object_hash.len_in_bytes()
75                        ),
76                    );
77                }
78                let chunk_base_graph_count = chunk_size / object_hash.len_in_bytes();
79                if chunk_base_graph_count != usize::from(base_graph_count) {
80                    bail!("Commit-graph {BASE_GRAPHS_LIST_CHUNK_ID:?} chunk contains {chunk_base_graph_count} base graphs, but commit-graph file header claims {base_graph_count} base graphs".corrupted())
81                }
82                Ok(chunk_range.start)
83            })
84            .ok()
85            .transpose()?;
86
87        let (commit_data_offset, commit_data_count): (_, u32) = chunks
88            .validated_usize_offset_by_id(COMMIT_DATA_CHUNK_ID, |chunk_range| -> Result<_> {
89                let chunk_size = chunk_range.len();
90
91                let entry_size = object_hash.len_in_bytes() + COMMIT_DATA_ENTRY_SIZE_SANS_HASH;
92                if chunk_size % entry_size != 0 {
93                    bail!("Commit-graph chunk {COMMIT_DATA_CHUNK_ID:?} has invalid size: chunk size {chunk_size} is not a multiple of {entry_size}".corrupted())
94                }
95                Ok((
96                    chunk_range.start,
97                    commit_count(chunk_size / entry_size)?,
98                ))
99            })
100            .or_raise(|| corruption("Error getting offset for commit data chunk"))??;
101
102        let fan_offset = chunks
103            .validated_usize_offset_by_id(OID_FAN_CHUNK_ID, |chunk_range| -> Result<_> {
104                let chunk_size = chunk_range.len();
105
106                let expected_size = 4 * FAN_LEN;
107                if chunk_size != expected_size {
108                    bail!("Commit-graph chunk {OID_FAN_CHUNK_ID:?} has invalid size: expected chunk length {expected_size}, got {chunk_size}".corrupted())
109                }
110                Ok(chunk_range.start)
111            })
112            .or_raise(|| corruption("Error getting offset for OID fan chunk"))?
113            .or_raise(|| message("Error getting offset for OID fan chunk"))?;
114
115        let (oid_lookup_offset, oid_lookup_count): (_, u32) = chunks
116            .validated_usize_offset_by_id(OID_LOOKUP_CHUNK_ID, |chunk_range| -> Result<_> {
117                let chunk_size = chunk_range.len();
118
119                if chunk_size % object_hash.len_in_bytes() != 0 {
120                    bail!("Commit-graph chunk {OID_LOOKUP_CHUNK_ID:?} has invalid size: chunk size {chunk_size} is not a multiple of {hash_len}".corrupted(), hash_len = object_hash.len_in_bytes())
121                }
122                Ok((
123                    chunk_range.start,
124                    commit_count(chunk_size / object_hash.len_in_bytes())?,
125                ))
126            })
127            .or_raise(|| corruption("Error getting offset for OID lookup chunk"))?
128            .or_raise(|| message("Error getting offset for OID lookup chunk"))?;
129
130        let extra_edges_list_range = chunks
131            .validated_usize_offset_by_id(EXTENDED_EDGES_LIST_CHUNK_ID, |chunk_range| -> Result<_> {
132                if chunk_range.len() % 4 != 0 {
133                    bail!("Commit-graph chunk {EXTENDED_EDGES_LIST_CHUNK_ID:?} has invalid size: chunk size {} is not a multiple of 4".corrupted(), chunk_range.len());
134                }
135                Ok(chunk_range)
136            })
137            .ok()
138            .transpose()?;
139
140        let trailer = &data[chunks.highest_offset() as usize..];
141        if trailer.len() != object_hash.len_in_bytes() {
142            bail!(
143                "Expected commit-graph trailer to contain {} bytes, got {}".corrupted(),
144                object_hash.len_in_bytes(),
145                trailer.len()
146            );
147        }
148
149        if base_graph_count > 0 && base_graphs_list_offset.is_none() {
150            bail!("Chunk named {BASE_GRAPHS_LIST_CHUNK_ID:?} was not found in chunk file index".corrupted());
151        }
152
153        let (fan, _) = read_fan(&data[fan_offset..]);
154        if fan.windows(2).any(|counts| counts[0] > counts[1]) {
155            bail!(corruption("Commit-graph fanout table is not monotonic"));
156        }
157        if oid_lookup_count != fan[255] {
158            bail!(
159                "Commit-graph {OID_FAN_CHUNK_ID:?} chunk contains {chunk1_commits} commits, but {OID_LOOKUP_CHUNK_ID:?} chunk contains {chunk2_commits} commits".corrupted(),
160                chunk1_commits = fan[255],
161                chunk2_commits = oid_lookup_count,
162            );
163        }
164        if commit_data_count != fan[255] {
165            bail!(
166                "Commit-graph {OID_FAN_CHUNK_ID:?} chunk contains {chunk1_commits} commits, but {COMMIT_DATA_CHUNK_ID:?} chunk contains {chunk2_commits} commits".corrupted(),
167                chunk1_commits = fan[255],
168                chunk2_commits = commit_data_count,
169            );
170        }
171        Ok(File {
172            base_graph_count,
173            base_graphs_list_offset,
174            commit_data_offset,
175            data,
176            extra_edges_list_range,
177            fan,
178            oid_lookup_offset,
179            path,
180            hash_len: object_hash.len_in_bytes(),
181            object_hash,
182            parent_position_limit: if base_graph_count == 0 {
183                fan[255]
184            } else {
185                crate::MAX_COMMITS
186            },
187        })
188    }
189}
190
191fn commit_count(count: usize) -> Result<u32> {
192    if count > crate::MAX_COMMITS as usize {
193        bail!(
194            "Commit-graph chunk contains {count} commits, but only {} commits are allowed".corrupted(),
195            crate::MAX_COMMITS
196        );
197    }
198    Ok(count as u32)
199}
200
201impl TryFrom<&Path> for File {
202    type Error = gix_error::Error;
203
204    fn try_from(path: &Path) -> Result<Self> {
205        let data = std::fs::File::open(path)
206            .and_then(|file| {
207                // SAFETY: we have to take the risk of somebody changing the file underneath. Git never writes into the same file.
208                #[expect(unsafe_code)]
209                unsafe {
210                    memmap2::MmapOptions::new().map_copy_read_only(&file)
211                }
212            })
213            .or_raise(|| message!("Could not open commit-graph file at \"{path}\"", path = path.display()))?;
214        Self::new(data, path.to_owned())
215    }
216}
217
218// Copied from gix-odb/pack/index/init.rs
219fn read_fan(d: &[u8]) -> ([u32; FAN_LEN], usize) {
220    assert!(d.len() >= FAN_LEN * 4);
221
222    let mut fan = [0; FAN_LEN];
223    for (c, f) in d.as_chunks::<4>().0.iter().zip(fan.iter_mut()) {
224        *f = u32::from_be_bytes(*c);
225    }
226    (fan, FAN_LEN * 4)
227}
228
229#[cfg(test)]
230mod tests {
231    #[test]
232    fn commit_counts_cannot_exceed_the_format_limit() -> gix_error::TestResult {
233        assert_eq!(
234            super::commit_count(crate::MAX_COMMITS as usize)?,
235            crate::MAX_COMMITS,
236            "the format's maximum commit count is representable"
237        );
238        for count in [crate::MAX_COMMITS as usize + 1, usize::MAX] {
239            let err = super::commit_count(count).expect_err("oversized counts are rejected before narrowing");
240            assert!(
241                err.is_corrupted(),
242                "unrepresentable stored commit counts are corruption"
243            );
244        }
245        Ok(())
246    }
247}