forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
14.1 KiB
1//! In-process repo reads via gix.
2//!
3//! Everything here is blocking; callers run it inside `spawn_blocking`. Repositories are opened
4//! isolated: the environment and user/system git configuration are ignored.
5
6use std::path::Path;
7
8use gix::bstr::ByteSlice;
9use gix::objs::tree::EntryKind as GixEntryKind;
10
11use crate::error::{Error, Result};
12
13/// Bytes inspected for a NUL when deciding whether a blob is binary.
14const BINARY_PROBE_BYTES: usize = 8000;
15/// Maximum accepted length of a repo path, in bytes.
16const MAX_PATH_BYTES: usize = 4096;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum RefKind {
20 Branch,
21 Tag,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct RefInfo {
26 pub name: String,
27 pub full_name: String,
28 pub target: String, // peeled commit oid
29 pub kind: RefKind,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum EntryKind {
34 Blob,
35 Executable,
36 Symlink,
37 Tree,
38 Submodule,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct TreeEntry {
43 pub name: String,
44 pub kind: EntryKind,
45 pub oid: String,
46 pub size: Option<u64>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct BlobData {
51 pub oid: String,
52 pub kind: EntryKind,
53 pub size: u64,
54 pub data: Vec<u8>,
55 pub truncated: bool,
56 pub is_binary: bool,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct CommitInfo {
61 pub oid: String,
62 pub parents: Vec<String>,
63 pub author_name: String,
64 pub author_email: String,
65 pub author_time: i64,
66 pub committer_name: String,
67 pub committer_email: String,
68 pub committer_time: i64,
69 pub summary: String,
70 pub message: String,
71}
72
73fn gerr(e: impl std::fmt::Display) -> Error {
74 Error::Failed(e.to_string())
75}
76
77fn open(repo: &Path) -> Result<gix::Repository> {
78 gix::open_opts(repo, gix::open::Options::isolated()).map_err(gerr)
79}
80
81/// Git's ref-name rules (check-ref-format), plus rejection of anything that could be read as
82/// revision syntax. Only names passing this are ever handed to gix.
83fn valid_ref_name(name: &str) -> bool {
84 if name.is_empty() || name.len() > 255 || name == "@" {
85 return false;
86 }
87 if name.starts_with('-') || name.starts_with('/') || name.ends_with('/') || name.ends_with('.')
88 {
89 return false;
90 }
91 if name.contains("..") || name.contains("@{") || name.contains("//") {
92 return false;
93 }
94 if name
95 .chars()
96 .any(|c| c.is_control() || " ~^:?*[\\".contains(c))
97 {
98 return false;
99 }
100 name.split('/')
101 .all(|seg| !seg.is_empty() && !seg.starts_with('.') && !seg.ends_with(".lock"))
102}
103
104fn valid_hex_prefix(s: &str) -> bool {
105 (4..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit())
106}
107
108fn parse_oid(hex: &str) -> Result<gix::ObjectId> {
109 gix::ObjectId::from_hex(hex.as_bytes())
110 .map_err(|_| Error::InvalidInput(format!("not a full object id: {hex}")))
111}
112
113/// Commit id of `refs/heads/<name>` or `refs/tags/<name>` (tags peeled), if it exists and is a commit.
114/// The name must already be validated.
115fn ref_commit(repo: &gix::Repository, name: &str) -> Result<Option<gix::ObjectId>> {
116 for prefix in ["refs/heads/", "refs/tags/"] {
117 let full = format!("{prefix}{name}");
118 let Some(mut reference) = repo.try_find_reference(full.as_str()).map_err(gerr)? else {
119 continue;
120 };
121 let id = reference.peel_to_id().map_err(gerr)?.detach();
122 if is_commit(repo, id)? {
123 return Ok(Some(id));
124 }
125 }
126 Ok(None)
127}
128
129fn is_commit(repo: &gix::Repository, id: gix::ObjectId) -> Result<bool> {
130 Ok(matches!(
131 repo.try_find_header(id).map_err(gerr)?,
132 Some(h) if h.kind() == gix::objs::Kind::Commit
133 ))
134}
135
136/// Every branch and tag, with tags peeled to commits. Refs that do not peel to a commit are skipped.
137pub fn list_refs(repo: &Path) -> Result<Vec<RefInfo>> {
138 let repo = open(repo)?;
139 let refs = repo.references().map_err(gerr)?;
140 let mut out = Vec::new();
141 let groups = [
142 (RefKind::Branch, refs.local_branches().map_err(gerr)?),
143 (RefKind::Tag, refs.tags().map_err(gerr)?),
144 ];
145 for (kind, iter) in groups {
146 for reference in iter {
147 let mut reference = reference.map_err(gerr)?;
148 let full_name = reference.name().as_bstr().to_str_lossy().into_owned();
149 let name = reference.name().shorten().to_str_lossy().into_owned();
150 let id = reference.peel_to_id().map_err(gerr)?.detach();
151 if !is_commit(&repo, id)? {
152 continue;
153 }
154 out.push(RefInfo {
155 name,
156 full_name,
157 target: id.to_hex().to_string(),
158 kind,
159 });
160 }
161 }
162 Ok(out)
163}
164
165/// Commit at the tip of `refs/heads/<branch>`, or None if the branch does not exist.
166pub fn default_branch_commit(repo: &Path, branch: &str) -> Result<Option<String>> {
167 if !valid_ref_name(branch) {
168 return Ok(None);
169 }
170 let repo = open(repo)?;
171 let full = format!("refs/heads/{branch}");
172 let Some(mut reference) = repo.try_find_reference(full.as_str()).map_err(gerr)? else {
173 return Ok(None);
174 };
175 let id = reference.peel_to_id().map_err(gerr)?.detach();
176 Ok(is_commit(&repo, id)?.then(|| id.to_hex().to_string()))
177}
178
179/// Resolve user input to a full commit oid. Accepts a branch or tag name, or a 4-64 char hex prefix
180/// that matches exactly one commit. Anything else resolves to None.
181pub fn resolve_commit(repo: &Path, rev: &str) -> Result<Option<String>> {
182 if valid_ref_name(rev) {
183 let repo = open(repo)?;
184 if let Some(id) = ref_commit(&repo, rev)? {
185 return Ok(Some(id.to_hex().to_string()));
186 }
187 // A ref name that doesn't exist may still be a hex prefix below.
188 if !valid_hex_prefix(rev) {
189 return Ok(None);
190 }
191 return resolve_hex(&repo, rev);
192 }
193 if valid_hex_prefix(rev) {
194 let repo = open(repo)?;
195 return resolve_hex(&repo, rev);
196 }
197 Ok(None)
198}
199
200fn resolve_hex(repo: &gix::Repository, hex: &str) -> Result<Option<String>> {
201 let lower = hex.to_ascii_lowercase();
202 let prefix = gix::hash::Prefix::from_hex(&lower).map_err(gerr)?;
203 let outcome = repo.objects.lookup_prefix(prefix, None).map_err(gerr)?;
204 match outcome {
205 Some(Ok(id)) if is_commit(repo, id)? => Ok(Some(id.to_hex().to_string())),
206 _ => Ok(None), // missing, ambiguous, or not a commit
207 }
208}
209
210/// Split `"feature/x/src/main.rs"` into `("feature/x", commit, "src/main.rs")`. The longest existing
211/// branch or tag prefix wins. Otherwise a leading hex segment is taken as a commit prefix.
212pub fn split_ref_and_path(repo: &Path, rest: &str) -> Result<Option<(String, String, String)>> {
213 let segments: Vec<&str> = rest.split('/').collect();
214 let repo = open(repo)?;
215
216 for count in (1..=segments.len()).rev() {
217 let candidate = segments[..count].join("/");
218 if !valid_ref_name(&candidate) {
219 continue;
220 }
221 if let Some(id) = ref_commit(&repo, &candidate)? {
222 let path = segments[count..].join("/");
223 if validate_repo_path(&path).is_err() {
224 return Ok(None);
225 }
226 return Ok(Some((candidate, id.to_hex().to_string(), path)));
227 }
228 }
229
230 let first = segments[0];
231 if valid_hex_prefix(first)
232 && let Some(oid) = resolve_hex(&repo, first)?
233 {
234 let path = segments[1..].join("/");
235 if validate_repo_path(&path).is_err() {
236 return Ok(None);
237 }
238 return Ok(Some((first.to_string(), oid, path)));
239 }
240 Ok(None)
241}
242
243fn map_kind(kind: GixEntryKind) -> EntryKind {
244 match kind {
245 GixEntryKind::Tree => EntryKind::Tree,
246 GixEntryKind::Blob => EntryKind::Blob,
247 GixEntryKind::BlobExecutable => EntryKind::Executable,
248 GixEntryKind::Link => EntryKind::Symlink,
249 GixEntryKind::Commit => EntryKind::Submodule,
250 }
251}
252
253/// Tree object for `commit` at `path` ("" is the root). Ok(None) if the commit is missing or the
254/// path does not exist. A path that is a blob also yields None.
255fn tree_at<'r>(
256 repo: &'r gix::Repository,
257 commit: &str,
258 path: &str,
259) -> Result<Option<gix::Tree<'r>>> {
260 let id = parse_oid(commit)?;
261 if !is_commit(repo, id)? {
262 return Ok(None);
263 }
264 let root = repo.find_commit(id).map_err(gerr)?.tree().map_err(gerr)?;
265 if path.is_empty() {
266 return Ok(Some(root));
267 }
268 let mut root = root;
269 let Some(entry) = root.peel_to_entry_by_path(path).map_err(gerr)? else {
270 return Ok(None);
271 };
272 if entry.mode().kind() != gix::objs::tree::EntryKind::Tree {
273 return Ok(None);
274 }
275 let tree = entry
276 .object()
277 .map_err(gerr)?
278 .try_into_tree()
279 .map_err(gerr)?;
280 Ok(Some(tree))
281}
282
283/// Directory listing at `path` in `commit`: directories first, then names in bytewise order.
284pub fn read_tree(repo: &Path, commit: &str, path: &str) -> Result<Option<Vec<TreeEntry>>> {
285 validate_repo_path(path)?;
286 let repo = open(repo)?;
287 let Some(tree) = tree_at(&repo, commit, path)? else {
288 return Ok(None);
289 };
290
291 let mut entries = Vec::new();
292 for entry in tree.iter() {
293 let entry = entry.map_err(gerr)?;
294 let kind = map_kind(entry.mode().kind());
295 let oid = entry.object_id();
296 let size = match kind {
297 EntryKind::Tree | EntryKind::Submodule => None,
298 _ => Some(repo.find_header(oid).map_err(gerr)?.size()),
299 };
300 entries.push(TreeEntry {
301 name: entry.filename().to_str_lossy().into_owned(),
302 kind,
303 oid: oid.to_hex().to_string(),
304 size,
305 });
306 }
307 entries.sort_by(|a, b| {
308 (a.kind != EntryKind::Tree, a.name.as_bytes())
309 .cmp(&(b.kind != EntryKind::Tree, b.name.as_bytes()))
310 });
311 Ok(Some(entries))
312}
313
314/// Blob at `path` in `commit`, with at most `max_bytes` of data. Symlinks yield their target text.
315/// Submodules yield an empty entry (their target commit is not in this repo). Trees and missing
316/// paths yield None.
317///
318/// Note: gix offers no public streaming read for packed objects, so the whole blob is loaded and
319/// then truncated to `max_bytes`.
320pub fn read_blob(
321 repo: &Path,
322 commit: &str,
323 path: &str,
324 max_bytes: usize,
325) -> Result<Option<BlobData>> {
326 validate_repo_path(path)?;
327 if path.is_empty() {
328 return Ok(None);
329 }
330 let repo = open(repo)?;
331 let id = parse_oid(commit)?;
332 if !is_commit(&repo, id)? {
333 return Ok(None);
334 }
335 let mut root = repo.find_commit(id).map_err(gerr)?.tree().map_err(gerr)?;
336 let Some(entry) = root.peel_to_entry_by_path(path).map_err(gerr)? else {
337 return Ok(None);
338 };
339 let kind = map_kind(entry.mode().kind());
340 let oid = entry.object_id();
341 match kind {
342 EntryKind::Tree => Ok(None),
343 EntryKind::Submodule => Ok(Some(BlobData {
344 oid: oid.to_hex().to_string(),
345 kind,
346 size: 0,
347 data: Vec::new(),
348 truncated: false,
349 is_binary: false,
350 })),
351 EntryKind::Blob | EntryKind::Executable | EntryKind::Symlink => {
352 let blob = repo.find_blob(oid).map_err(gerr)?;
353 let full = &blob.data;
354 let size = full.len() as u64;
355 let is_binary = full[..full.len().min(BINARY_PROBE_BYTES)].contains(&0);
356 let truncated = full.len() > max_bytes;
357 let data = full[..full.len().min(max_bytes)].to_vec();
358 Ok(Some(BlobData {
359 oid: oid.to_hex().to_string(),
360 kind,
361 size,
362 data,
363 truncated,
364 is_binary,
365 }))
366 }
367 }
368}
369
370/// The text of the regular file at `path` in `commit`. None when the path is missing, is not a
371/// regular file or symlink, is binary, is not valid UTF-8, or is larger than `max_bytes`.
372pub fn read_text_blob(
373 repo: &Path,
374 commit: &str,
375 path: &str,
376 max_bytes: usize,
377) -> Result<Option<String>> {
378 let Some(blob) = read_blob(repo, commit, path, max_bytes)? else {
379 return Ok(None);
380 };
381 if matches!(blob.kind, EntryKind::Tree | EntryKind::Submodule)
382 || blob.truncated
383 || blob.is_binary
384 {
385 return Ok(None);
386 }
387 Ok(String::from_utf8(blob.data).ok())
388}
389
390fn lossy(bytes: &[u8]) -> String {
391 String::from_utf8_lossy(bytes).into_owned()
392}
393
394/// Metadata and message of a commit. Ok(None) if the object is missing or not a commit.
395pub fn read_commit(repo: &Path, oid: &str) -> Result<Option<CommitInfo>> {
396 let repo = open(repo)?;
397 let id = parse_oid(oid)?;
398 if !is_commit(&repo, id)? {
399 return Ok(None);
400 }
401 let commit = repo.find_commit(id).map_err(gerr)?;
402 let decoded = commit.decode().map_err(gerr)?;
403 let author = decoded.author().map_err(gerr)?;
404 let committer = decoded.committer().map_err(gerr)?;
405 let message = lossy(commit.message_raw_sloppy());
406 let summary = message.lines().next().unwrap_or_default().to_string();
407 Ok(Some(CommitInfo {
408 oid: id.to_hex().to_string(),
409 parents: decoded.parents().map(|p| p.to_hex().to_string()).collect(),
410 author_name: lossy(author.name),
411 author_email: lossy(author.email),
412 author_time: author.time().map_err(gerr)?.seconds,
413 committer_name: lossy(committer.name),
414 committer_email: lossy(committer.email),
415 committer_time: committer.time().map_err(gerr)?.seconds,
416 summary,
417 message,
418 }))
419}
420
421/// Rules for `/`-separated repo paths: no empty, `.` or `..` segment, no NUL, at most 4096 bytes.
422/// The empty path (the root) is valid.
423pub fn validate_repo_path(path: &str) -> Result<()> {
424 if path.len() > MAX_PATH_BYTES {
425 return Err(Error::InvalidInput("path too long".into()));
426 }
427 if path.contains('\0') {
428 return Err(Error::InvalidInput("path contains NUL".into()));
429 }
430 if path.is_empty() {
431 return Ok(());
432 }
433 for seg in path.split('/') {
434 if seg.is_empty() || seg == "." || seg == ".." {
435 return Err(Error::InvalidInput(format!(
436 "invalid path segment in {path:?}"
437 )));
438 }
439 }
440 Ok(())
441}