1use std::path::Path;
13use std::process::Stdio;
14
15use serde::Serialize;
16use tokio::process::Command;
17
18use crate::error::ForgeError;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
22#[serde(rename_all = "lowercase")]
23pub enum EntryKind {
24 Dir,
26 File,
28 Symlink,
30 Submodule,
32}
33
34#[derive(Debug, Clone, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub struct TreeEntry {
38 pub name: String,
40 pub kind: EntryKind,
42 pub sha: String,
44 pub size: Option<u64>,
46}
47
48async 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 .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 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
95async 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
101pub 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
115pub 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
133fn treeish(rev: &str, path: &str) -> String {
135 if path.is_empty() {
136 rev.to_string()
137 } else {
138 format!("{rev}:{path}")
139 }
140}
141
142pub 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
151pub 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
159pub 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
183fn 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
219pub 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 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
256pub 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 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
311pub 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
324pub 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}