Skip to main content

solid_pod_rs_forge/repo/
browse.rs

1//! Repository browse porcelain — tree/blob/raw, tags, commit log, and a
2//! single-commit view.
3//!
4//! Extends the [`solid_pod_rs_git::api`] porcelain with the read-only
5//! operations the forge's views need. Every git invocation uses an argv
6//! array (never a shell) with `GIT_CONFIG_NOSYSTEM=1` and no inherited
7//! `HOME`, and every ref/path is validated before it reaches git — so a
8//! hostile ref cannot inject a flag or escape the repo. All operations
9//! run against a **bare** repo dir (`…/<name>.git`), which git treats as
10//! its git-dir directly.
11
12use std::path::Path;
13use std::process::Stdio;
14
15use serde::Serialize;
16use tokio::process::Command;
17
18use crate::error::ForgeError;
19
20/// Kind of a tree entry.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
22#[serde(rename_all = "lowercase")]
23pub enum EntryKind {
24    /// A subdirectory (git `tree`).
25    Dir,
26    /// A regular file (git `blob`).
27    File,
28    /// A symbolic link (mode 120000).
29    Symlink,
30    /// A submodule (git `commit` gitlink, mode 160000).
31    Submodule,
32}
33
34/// One row of a directory listing.
35#[derive(Debug, Clone, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub struct TreeEntry {
38    /// Basename within the listed directory.
39    pub name: String,
40    /// Entry kind.
41    pub kind: EntryKind,
42    /// Object sha.
43    pub sha: String,
44    /// Blob size in bytes (None for trees/submodules).
45    pub size: Option<u64>,
46}
47
48/// Run `git` with a fixed, hardened environment and return raw stdout
49/// bytes. `BackendFailed`-style errors carry stderr for diagnostics.
50async fn git_bytes(git_dir: &Path, args: &[&str]) -> Result<Vec<u8>, ForgeError> {
51    let output = Command::new("git")
52        .args(args)
53        .current_dir(git_dir)
54        // Defence-in-depth: no system/global config, no HOME-based include,
55        // no interactive prompt (a private submodule must never block).
56        .env("GIT_CONFIG_NOSYSTEM", "1")
57        .env("GIT_TERMINAL_PROMPT", "0")
58        .env_remove("HOME")
59        .env_remove("XDG_CONFIG_HOME")
60        .stdin(Stdio::null())
61        .stdout(Stdio::piped())
62        .stderr(Stdio::piped())
63        .output()
64        .await
65        .map_err(|e| {
66            if e.kind() == std::io::ErrorKind::NotFound {
67                ForgeError::Unsupported("git binary not found in PATH".into())
68            } else {
69                ForgeError::Io(e)
70            }
71        })?;
72    if output.status.success() {
73        Ok(output.stdout)
74    } else {
75        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
76        // Map "unknown revision"/"not a tree" family to NotFound.
77        if stderr.contains("unknown revision")
78            || stderr.contains("bad revision")
79            || stderr.contains("Not a valid object")
80            || stderr.contains("does not exist")
81            || stderr.contains("exists on disk, but not in")
82            || stderr.contains("bad object")
83        {
84            Err(ForgeError::NotFound(stderr.trim().to_string()))
85        } else {
86            Err(ForgeError::Backend(format!(
87                "git {}: {}",
88                args.first().copied().unwrap_or(""),
89                stderr.trim()
90            )))
91        }
92    }
93}
94
95/// Run `git` and return stdout as UTF-8 (lossy).
96async fn git_text(git_dir: &Path, args: &[&str]) -> Result<String, ForgeError> {
97    let bytes = git_bytes(git_dir, args).await?;
98    Ok(String::from_utf8_lossy(&bytes).into_owned())
99}
100
101/// Validate a git ref / commit-ish. Rejects flag-injection (leading `-`),
102/// range/parent traversal (`..`), whitespace, and shell/path metachars.
103/// Branch names with `/` are allowed. Empty is rejected.
104pub fn valid_rev(rev: &str) -> bool {
105    if rev.is_empty() || rev.len() > 200 || rev.starts_with('-') {
106        return false;
107    }
108    if rev.contains("..") || rev.contains("//") {
109        return false;
110    }
111    rev.chars()
112        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
113}
114
115/// Validate a repo-relative path used inside a `<rev>:<path>` object spec.
116/// Empty is allowed (the repo root). No leading slash, no `..` segment,
117/// no NUL/control chars.
118pub fn valid_repo_path(path: &str) -> bool {
119    if path.is_empty() {
120        return true;
121    }
122    if path.starts_with('/') || path.len() > 4096 {
123        return false;
124    }
125    for seg in path.split('/') {
126        if seg == ".." || seg.chars().any(|c| c.is_control()) {
127            return false;
128        }
129    }
130    true
131}
132
133/// Build a `<rev>:<path>` tree-ish, or bare `<rev>` when `path` is empty.
134fn treeish(rev: &str, path: &str) -> String {
135    if path.is_empty() {
136        rev.to_string()
137    } else {
138        format!("{rev}:{path}")
139    }
140}
141
142/// Resolve the repo's default branch (the branch HEAD points at). Falls
143/// back to `"main"` for an empty repo.
144pub async fn default_branch(git_dir: &Path) -> String {
145    match git_text(git_dir, &["symbolic-ref", "--short", "HEAD"]).await {
146        Ok(s) if !s.trim().is_empty() => s.trim().to_string(),
147        _ => "main".to_string(),
148    }
149}
150
151/// `true` when the repo has at least one commit reachable from any ref.
152pub async fn has_commits(git_dir: &Path) -> bool {
153    git_text(git_dir, &["rev-list", "-n", "1", "--all"])
154        .await
155        .map(|s| !s.trim().is_empty())
156        .unwrap_or(false)
157}
158
159/// List a directory tree at `<rev>:<path>`. Returns entries sorted
160/// directories-first then by name. A missing path yields `NotFound`.
161pub async fn list_tree(
162    git_dir: &Path,
163    rev: &str,
164    path: &str,
165) -> Result<Vec<TreeEntry>, ForgeError> {
166    if !valid_rev(rev) {
167        return Err(ForgeError::BadRequest(format!("invalid ref: {rev}")));
168    }
169    if !valid_repo_path(path) {
170        return Err(ForgeError::PathTraversal(format!("invalid path: {path}")));
171    }
172    let ti = treeish(rev, path);
173    let raw = git_text(git_dir, &["ls-tree", "--long", &ti]).await?;
174    let mut entries = parse_ls_tree(&raw);
175    entries.sort_by(|a, b| {
176        let ad = a.kind == EntryKind::Dir;
177        let bd = b.kind == EntryKind::Dir;
178        bd.cmp(&ad).then_with(|| a.name.cmp(&b.name))
179    });
180    Ok(entries)
181}
182
183/// Parse `git ls-tree --long` output:
184/// `<mode> <type> <sha> <size-or-dash>\t<name>`
185fn parse_ls_tree(raw: &str) -> Vec<TreeEntry> {
186    let mut out = Vec::new();
187    for line in raw.lines() {
188        let Some((meta, name)) = line.split_once('\t') else {
189            continue;
190        };
191        let cols: Vec<&str> = meta.split_whitespace().collect();
192        if cols.len() < 3 {
193            continue;
194        }
195        let mode = cols[0];
196        let typ = cols[1];
197        let sha = cols[2].to_string();
198        let size = cols.get(3).and_then(|s| s.parse::<u64>().ok());
199        let kind = match (typ, mode) {
200            ("tree", _) => EntryKind::Dir,
201            ("commit", _) => EntryKind::Submodule,
202            ("blob", "120000") => EntryKind::Symlink,
203            _ => EntryKind::File,
204        };
205        out.push(TreeEntry {
206            name: name.to_string(),
207            kind,
208            sha,
209            size: if kind == EntryKind::File || kind == EntryKind::Symlink {
210                size
211            } else {
212                None
213            },
214        });
215    }
216    out
217}
218
219/// Read a blob's raw bytes at `<rev>:<path>`. Enforces `max_bytes` by
220/// checking the object size first (cheap) so an oversized blob never
221/// buffers into memory.
222pub async fn read_blob(
223    git_dir: &Path,
224    rev: &str,
225    path: &str,
226    max_bytes: u64,
227) -> Result<Vec<u8>, ForgeError> {
228    if !valid_rev(rev) {
229        return Err(ForgeError::BadRequest(format!("invalid ref: {rev}")));
230    }
231    if path.is_empty() || !valid_repo_path(path) {
232        return Err(ForgeError::PathTraversal(format!("invalid path: {path}")));
233    }
234    let spec = format!("{rev}:{path}");
235    // Confirm it is a blob and get its size before reading.
236    let typ = git_text(git_dir, &["cat-file", "-t", &spec])
237        .await?
238        .trim()
239        .to_string();
240    if typ != "blob" {
241        return Err(ForgeError::NotFound(format!("{path} is not a file")));
242    }
243    let size: u64 = git_text(git_dir, &["cat-file", "-s", &spec])
244        .await?
245        .trim()
246        .parse()
247        .unwrap_or(0);
248    if size > max_bytes {
249        return Err(ForgeError::BadRequest(format!(
250            "blob too large: {size} > {max_bytes} bytes"
251        )));
252    }
253    git_bytes(git_dir, &["cat-file", "blob", &spec]).await
254}
255
256/// Paginated commit log for `rev`. `page` is 1-based; `per_page` is
257/// capped at 100. Returns `(entries, has_next)`.
258pub async fn commit_log(
259    git_dir: &Path,
260    rev: &str,
261    page: u32,
262    per_page: u32,
263) -> Result<(Vec<solid_pod_rs_git::api::CommitEntry>, bool), ForgeError> {
264    if !valid_rev(rev) {
265        return Err(ForgeError::BadRequest(format!("invalid ref: {rev}")));
266    }
267    let per = per_page.clamp(1, 100);
268    let page = page.max(1);
269    let skip = (page - 1) * per;
270    // Fetch one extra to detect a next page.
271    let n = per + 1;
272    let fmt = "%H\x1f%h\x1f%s\x1f%an\x1f%aI\x1f%ar";
273    let raw = match git_text(
274        git_dir,
275        &[
276            "log",
277            &format!("--format={fmt}"),
278            "--skip",
279            &skip.to_string(),
280            "-n",
281            &n.to_string(),
282            rev,
283        ],
284    )
285    .await
286    {
287        Ok(v) => v,
288        Err(ForgeError::NotFound(_)) => return Ok((Vec::new(), false)),
289        Err(e) => return Err(e),
290    };
291    let mut entries = Vec::new();
292    for line in raw.lines() {
293        let parts: Vec<&str> = line.splitn(6, '\x1f').collect();
294        if parts.len() < 6 {
295            continue;
296        }
297        entries.push(solid_pod_rs_git::api::CommitEntry {
298            hash: parts[0].to_string(),
299            short_hash: parts[1].to_string(),
300            message: parts[2].to_string(),
301            author: parts[3].to_string(),
302            date: parts[4].to_string(),
303            date_relative: parts[5].to_string(),
304        });
305    }
306    let has_next = entries.len() as u32 > per;
307    entries.truncate(per as usize);
308    Ok((entries, has_next))
309}
310
311/// The unified-diff patch text for a single commit (`git show`). Rendered
312/// inside an `esc()`'d `<pre>` — never as markup.
313pub async fn commit_patch(git_dir: &Path, sha: &str) -> Result<String, ForgeError> {
314    if !valid_rev(sha) {
315        return Err(ForgeError::BadRequest(format!("invalid sha: {sha}")));
316    }
317    git_text(
318        git_dir,
319        &["show", "--no-color", "--format=", "-p", "--stat", sha],
320    )
321    .await
322}
323
324/// List tag names (sorted, newest-committerdate first).
325pub async fn list_tags(git_dir: &Path) -> Result<Vec<String>, ForgeError> {
326    let raw = git_text(
327        git_dir,
328        &[
329            "for-each-ref",
330            "--sort=-creatordate",
331            "--format=%(refname:short)",
332            "refs/tags",
333        ],
334    )
335    .await?;
336    Ok(raw
337        .lines()
338        .map(str::to_string)
339        .filter(|s| !s.is_empty())
340        .collect())
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn valid_rev_rejects_injection_and_traversal() {
349        assert!(valid_rev("main"));
350        assert!(valid_rev("refs/heads/feature/x"));
351        assert!(valid_rev("v1.2.3"));
352        assert!(valid_rev("deadbeef"));
353        assert!(!valid_rev(""));
354        assert!(!valid_rev("--upload-pack=evil"));
355        assert!(!valid_rev("a..b"));
356        assert!(!valid_rev("a b"));
357        assert!(!valid_rev("a;rm -rf"));
358        assert!(!valid_rev("a$(x)"));
359    }
360
361    #[test]
362    fn valid_repo_path_rejects_dotdot_and_absolute() {
363        assert!(valid_repo_path(""));
364        assert!(valid_repo_path("src/lib.rs"));
365        assert!(!valid_repo_path("/etc/passwd"));
366        assert!(!valid_repo_path("a/../b"));
367        assert!(!valid_repo_path(".."));
368    }
369
370    #[test]
371    fn parse_ls_tree_classifies_entries() {
372        let raw = "100644 blob abc123 42\tREADME.md\n\
373040000 tree def456 -\tsrc\n\
374120000 blob 111 12\tlink\n\
375160000 commit 222 -\tsubmod\n";
376        let entries = parse_ls_tree(raw);
377        assert_eq!(entries.len(), 4);
378        assert_eq!(entries[0].name, "README.md");
379        assert_eq!(entries[0].kind, EntryKind::File);
380        assert_eq!(entries[0].size, Some(42));
381        assert_eq!(entries[1].kind, EntryKind::Dir);
382        assert_eq!(entries[1].size, None);
383        assert_eq!(entries[2].kind, EntryKind::Symlink);
384        assert_eq!(entries[3].kind, EntryKind::Submodule);
385    }
386}