Skip to main content

gix_commitgraph/
init.rs

1use crate::{File, Graph, MAX_COMMITS, Parent};
2use gix_error::Result;
3use gix_error::{
4    OptionExt, ResourceExhaustionKind, ResultExt, bail, corruption, message, resource_exhaustion, validation,
5};
6use std::{
7    io::{BufRead, BufReader, Read},
8    path::Path,
9};
10
11/// Instantiate a `Graph` from various sources.
12impl Graph {
13    /// Instantiate a commit graph from `path` which may be a directory containing graph files or the graph file itself.
14    ///
15    /// Chain entries and graph files must use `object_hash`, typically the repository's hash kind.
16    /// Mismatches are reported as corruption.
17    ///
18    /// `alloc_limit_bytes` bounds a conservative capacity estimate for parent buffers derived from
19    /// each file's EDGE chunk, including geometric buffer growth. `None` disables the limit.
20    /// The mapped graph data itself is not allocated on the heap.
21    ///
22    /// Filesystem errors retain their [`std::io::Error`] source, including when `path` does not exist.
23    pub fn at(path: &Path, object_hash: gix_hash::Kind, alloc_limit_bytes: Option<usize>) -> Result<Self> {
24        let metadata = path
25            .metadata()
26            .or_raise(|| message!("Could not access commit-graph path at \"{}\"", path.display()))?;
27        let graph = if metadata.is_file() {
28            Self::from_file(path, object_hash)
29        } else if metadata.is_dir() {
30            if path.join("commit-graph-chain").is_file() {
31                Self::from_commit_graphs_dir(path, object_hash)
32            } else {
33                Self::from_info_dir(path, object_hash)
34            }
35        } else {
36            Err(message!(
37                "Did not find any files that look like commit graphs at \"{}\"",
38                path.display()
39            )
40            .validation_error())
41        }?;
42        if let Some(limit) = alloc_limit_bytes {
43            for file in &graph.files {
44                if let Some(edges) = file.extra_edges_data() {
45                    // shortcut: estimate from the whole EDGE chunk, scan individual lists if valid graphs hit the limit.
46                    // Include the inline parent and spare capacity for traversal buffers that double when growing.
47                    let parent_bytes = (edges.len() / 4 + 1).checked_mul(2 * std::mem::size_of::<Result<Parent>>());
48                    if parent_bytes.is_none_or(|bytes| bytes > limit) {
49                        bail!(resource_exhaustion(
50                            ResourceExhaustionKind::AllocationLimit,
51                            "Commit-graph parents require more memory than allowed",
52                        ));
53                    }
54                }
55            }
56        }
57        Ok(graph)
58    }
59
60    /// Instantiate a commit graph from the directory containing all of its files.
61    ///
62    /// Chain entries and graph files must use `object_hash`.
63    pub fn from_commit_graphs_dir(path: &Path, object_hash: gix_hash::Kind) -> Result<Self> {
64        let commit_graphs_dir = path;
65        let chain_file_path = commit_graphs_dir.join("commit-graph-chain");
66        let chain_file = std::fs::File::open(&chain_file_path).or_raise(|| {
67            message!(
68                "Could not open commit-graph chain file at \"{}\"",
69                chain_file_path.display()
70            )
71        })?;
72
73        let files = read_chain_files(chain_file, &chain_file_path, commit_graphs_dir, object_hash)?;
74        Self::new(files).or_raise(|| {
75            message!(
76                "Could not assemble commit-graph from chain file at \"{}\"",
77                chain_file_path.display()
78            )
79            .corrupted()
80        })
81    }
82
83    /// Instantiate a commit graph from a `.git/objects/info/commit-graph` or
84    /// `.git/objects/info/commit-graphs/graph-*.graph` file.
85    ///
86    /// The graph file must use `object_hash`.
87    pub fn from_file(path: &Path, object_hash: gix_hash::Kind) -> Result<Self> {
88        let file = read_graph_file(path, object_hash)?;
89        Self::new(vec![file])
90            .or_raise(|| message!("Could not assemble commit-graph from file at \"{}\"", path.display()).corrupted())
91    }
92
93    /// Instantiate a commit graph from an `.git/objects/info` directory.
94    ///
95    /// Chain entries and graph files must use `object_hash`.
96    pub fn from_info_dir(info_dir: &Path, object_hash: gix_hash::Kind) -> Result<Self> {
97        Self::from_file(&info_dir.join("commit-graph"), object_hash).or_else(|err| {
98            if err.is_not_found() {
99                Self::from_commit_graphs_dir(&info_dir.join("commit-graphs"), object_hash)
100            } else {
101                Err(err)
102            }
103        })
104    }
105
106    /// Create a new commit graph from a list of `files`.
107    ///
108    /// Invalid file combinations are classified as [`gix_error::Class::Validation`].
109    pub fn new(files: Vec<File>) -> Result<Self> {
110        if files.len() > 256 {
111            bail!(validation("Commit-graph must contain at most 256 files"));
112        }
113        let mut files = nonempty::NonEmpty::from_vec(files)
114            .ok_or_raise(|| validation("Commit-graph must contain at least one file"))?;
115        let num_commits: u64 = files.iter().map(|f| u64::from(f.num_commits())).sum();
116        if num_commits > u64::from(MAX_COMMITS) {
117            bail!(
118                "Commit-graph files contain {num_commits} commits altogether, but only {MAX_COMMITS} commits are allowed".validation()
119            );
120        }
121
122        let mut f1 = files.first();
123        for f2 in files.tail() {
124            if f1.object_hash() != f2.object_hash() {
125                bail!(
126                    "Commit-graph files mismatch: \"{path1}\" uses hash {hash1:?}, but \"{path2}\" uses hash {hash2:?}"
127                        .validation(),
128                    path1 = f1.path().display(),
129                    hash1 = f1.object_hash(),
130                    path2 = f2.path().display(),
131                    hash2 = f2.object_hash(),
132                );
133            }
134            f1 = f2;
135        }
136
137        let mut parent_position_limit = 0;
138        for file in files.iter_mut() {
139            parent_position_limit += file.num_commits();
140            file.parent_position_limit = parent_position_limit;
141        }
142        Ok(Self { files })
143    }
144}
145
146/// Read bounded chain entries of `object_hash` and open their graph files in chain order.
147fn read_chain_files(
148    chain_file: std::fs::File,
149    chain_file_path: &Path,
150    commit_graphs_dir: &Path,
151    object_hash: gix_hash::Kind,
152) -> Result<Vec<File>> {
153    let mut files = Vec::new();
154    let mut reader = BufReader::new(chain_file);
155    let mut line = Vec::with_capacity(object_hash.len_in_hex() + 2 /* CRLF */);
156    loop {
157        line.clear();
158        // Read one extra byte to detect oversized entries without buffering an unbounded line.
159        let bytes = reader
160            .by_ref()
161            .take((object_hash.len_in_hex() + 2 /* CRLF */ + 1/* detect oversized entries */) as u64)
162            .read_until(b'\n', &mut line)
163            .or_raise(|| {
164                message!(
165                    "Could not read (enough data) from commit-graph file at \"{}\"",
166                    chain_file_path.display()
167                )
168            })?;
169        if bytes == 0 {
170            break;
171        }
172        if line.len() > object_hash.len_in_hex() + 2
173        /* CRLF */
174        {
175            bail!(corruption("Commit-graph chain entry exceeds the maximum hash length"));
176        }
177        if line.last() == Some(&b'\n') {
178            line.pop();
179            if line.last() == Some(&b'\r') {
180                line.pop();
181            }
182        }
183        if line.len() != object_hash.len_in_hex() {
184            bail!(
185                "Commit-graph chain entry has {} hex digits, but hash kind {object_hash} requires {}".corrupted(),
186                line.len(),
187                object_hash.len_in_hex()
188            );
189        }
190        gix_hash::ObjectId::from_hex(&line)
191            .or_raise(|| corruption("Commit-graph chain entry is not a valid object ID"))?;
192        let hash = std::str::from_utf8(&line).expect("validated hexadecimal object IDs contain only ASCII");
193        if files.len() == 256 {
194            bail!(corruption("Commit-graph chain contains more than 256 files"));
195        }
196        let graph_file_path = commit_graphs_dir.join(format!("graph-{hash}.graph"));
197        files.push(read_graph_file(&graph_file_path, object_hash)?);
198    }
199    Ok(files)
200}
201
202/// Open a graph file and require its header to declare `object_hash`.
203fn read_graph_file(path: &Path, object_hash: gix_hash::Kind) -> Result<File> {
204    let file = File::at(path)?;
205    if file.object_hash() != object_hash {
206        bail!(
207            "Commit-graph file at \"{}\" uses hash kind {}, but expected {object_hash}".corrupted(),
208            path.display(),
209            file.object_hash()
210        );
211    }
212    Ok(file)
213}