| 1 | use std::path::Path; |
| 2 | use std::process::Command; |
| 3 | |
| 4 | #[derive(Debug, Clone)] |
| 5 | pub struct TreeEntry { |
| 6 | pub kind: String, // "blob" or "tree" |
| 7 | pub name: String, |
| 8 | } |
| 9 | |
| 10 | #[derive(Debug, Clone)] |
| 11 | pub struct CommitInfo { |
| 12 | pub short_hash: String, |
| 13 | pub subject: String, |
| 14 | pub author: String, |
| 15 | pub date: String, |
| 16 | } |
| 17 | |
| 18 | /// Reject path/rev components that could be used for command injection. |
| 19 | fn sanitize_param(s: &str) -> Result<(), &'static str> { |
| 20 | if s.is_empty() { |
| 21 | return Err("empty parameter"); |
| 22 | } |
| 23 | if s.contains('\0') || s.contains("..") || s.starts_with('-') { |
| 24 | return Err("invalid parameter"); |
| 25 | } |
| 26 | Ok(()) |
| 27 | } |
| 28 | |
| 29 | fn repo_path(repos_root: &str, owner: &str, repo: &str) -> std::path::PathBuf { |
| 30 | Path::new(repos_root).join(owner).join(format!("{repo}.git")) |
| 31 | } |
| 32 | |
| 33 | /// Get the default branch name (usually "main"). |
| 34 | pub fn get_default_branch(repos_root: &str, owner: &str, repo: &str) -> Option<String> { |
| 35 | let dir = repo_path(repos_root, owner, repo); |
| 36 | let output = Command::new("git") |
| 37 | .arg("-C").arg(&dir) |
| 38 | .arg("symbolic-ref") |
| 39 | .arg("--short") |
| 40 | .arg("HEAD") |
| 41 | .output() |
| 42 | .ok()?; |
| 43 | if output.status.success() { |
| 44 | let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); |
| 45 | if branch.is_empty() { None } else { Some(branch) } |
| 46 | } else { |
| 47 | None |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | /// List tree entries at a given rev and path. Returns entries sorted dirs-first. |
| 52 | pub fn list_tree( |
| 53 | repos_root: &str, |
| 54 | owner: &str, |
| 55 | repo: &str, |
| 56 | rev: &str, |
| 57 | path: &str, |
| 58 | ) -> Result<Vec<TreeEntry>, String> { |
| 59 | sanitize_param(rev).map_err(|e| e.to_string())?; |
| 60 | if !path.is_empty() { |
| 61 | for segment in path.split('/') { |
| 62 | sanitize_param(segment).map_err(|e| e.to_string())?; |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | let dir = repo_path(repos_root, owner, repo); |
| 67 | let tree_spec = if path.is_empty() { |
| 68 | format!("{rev}:") |
| 69 | } else { |
| 70 | format!("{rev}:{path}") |
| 71 | }; |
| 72 | |
| 73 | let output = Command::new("git") |
| 74 | .arg("-C").arg(&dir) |
| 75 | .arg("ls-tree") |
| 76 | .arg("--") |
| 77 | .arg(&tree_spec) |
| 78 | .output() |
| 79 | .map_err(|e| e.to_string())?; |
| 80 | |
| 81 | if !output.status.success() { |
| 82 | return Err("ls-tree failed".to_string()); |
| 83 | } |
| 84 | |
| 85 | let text = String::from_utf8_lossy(&output.stdout); |
| 86 | let mut dirs = Vec::new(); |
| 87 | let mut files = Vec::new(); |
| 88 | |
| 89 | for line in text.lines() { |
| 90 | // Format: "<mode> <type> <hash>\t<name>" |
| 91 | let Some((meta, name)) = line.split_once('\t') else { continue }; |
| 92 | let parts: Vec<&str> = meta.split_whitespace().collect(); |
| 93 | if parts.len() < 3 { continue; } |
| 94 | let kind = parts[1].to_string(); |
| 95 | let entry = TreeEntry { |
| 96 | kind: kind.clone(), |
| 97 | name: name.to_string(), |
| 98 | }; |
| 99 | if kind == "tree" { |
| 100 | dirs.push(entry); |
| 101 | } else { |
| 102 | files.push(entry); |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | dirs.sort_by(|a, b| a.name.cmp(&b.name)); |
| 107 | files.sort_by(|a, b| a.name.cmp(&b.name)); |
| 108 | dirs.extend(files); |
| 109 | Ok(dirs) |
| 110 | } |
| 111 | |
| 112 | /// Get file contents as raw bytes. |
| 113 | pub fn get_blob( |
| 114 | repos_root: &str, |
| 115 | owner: &str, |
| 116 | repo: &str, |
| 117 | rev: &str, |
| 118 | path: &str, |
| 119 | ) -> Result<Vec<u8>, String> { |
| 120 | sanitize_param(rev).map_err(|e| e.to_string())?; |
| 121 | for segment in path.split('/') { |
| 122 | sanitize_param(segment).map_err(|e| e.to_string())?; |
| 123 | } |
| 124 | |
| 125 | let dir = repo_path(repos_root, owner, repo); |
| 126 | let spec = format!("{rev}:{path}"); |
| 127 | |
| 128 | let output = Command::new("git") |
| 129 | .arg("-C").arg(&dir) |
| 130 | .arg("show") |
| 131 | .arg(&spec) |
| 132 | .output() |
| 133 | .map_err(|e| e.to_string())?; |
| 134 | |
| 135 | if !output.status.success() { |
| 136 | return Err("blob not found".to_string()); |
| 137 | } |
| 138 | Ok(output.stdout) |
| 139 | } |
| 140 | |
| 141 | /// List commits with pagination. |
| 142 | pub fn list_commits( |
| 143 | repos_root: &str, |
| 144 | owner: &str, |
| 145 | repo: &str, |
| 146 | rev: &str, |
| 147 | count: usize, |
| 148 | skip: usize, |
| 149 | ) -> Result<Vec<CommitInfo>, String> { |
| 150 | sanitize_param(rev).map_err(|e| e.to_string())?; |
| 151 | |
| 152 | let dir = repo_path(repos_root, owner, repo); |
| 153 | let output = Command::new("git") |
| 154 | .arg("-C").arg(&dir) |
| 155 | .arg("log") |
| 156 | .arg(format!("--max-count={count}")) |
| 157 | .arg(format!("--skip={skip}")) |
| 158 | .arg("--format=%h%x00%s%x00%an%x00%ar") |
| 159 | .arg(rev) |
| 160 | .arg("--") |
| 161 | .output() |
| 162 | .map_err(|e| e.to_string())?; |
| 163 | |
| 164 | // Empty repo / invalid rev - return empty list rather than error |
| 165 | if !output.status.success() { |
| 166 | return Ok(Vec::new()); |
| 167 | } |
| 168 | |
| 169 | let text = String::from_utf8_lossy(&output.stdout); |
| 170 | let mut commits = Vec::new(); |
| 171 | for line in text.lines() { |
| 172 | let parts: Vec<&str> = line.splitn(4, '\0').collect(); |
| 173 | if parts.len() < 4 { continue; } |
| 174 | commits.push(CommitInfo { |
| 175 | short_hash: parts[0].to_string(), |
| 176 | subject: parts[1].to_string(), |
| 177 | author: parts[2].to_string(), |
| 178 | date: parts[3].to_string(), |
| 179 | }); |
| 180 | } |
| 181 | Ok(commits) |
| 182 | } |
| 183 | |
| 184 | /// Find and render README.md from a tree. |
| 185 | pub fn get_readme( |
| 186 | repos_root: &str, |
| 187 | owner: &str, |
| 188 | repo: &str, |
| 189 | rev: &str, |
| 190 | path: &str, |
| 191 | ) -> Option<String> { |
| 192 | let entries = list_tree(repos_root, owner, repo, rev, path).ok()?; |
| 193 | let readme_name = entries.iter().find(|e| { |
| 194 | let lower = e.name.to_lowercase(); |
| 195 | lower == "readme.md" || lower == "readme" |
| 196 | })?; |
| 197 | |
| 198 | let readme_path = if path.is_empty() { |
| 199 | readme_name.name.clone() |
| 200 | } else { |
| 201 | format!("{path}/{}", readme_name.name) |
| 202 | }; |
| 203 | |
| 204 | let bytes = get_blob(repos_root, owner, repo, rev, &readme_path).ok()?; |
| 205 | let text = String::from_utf8(bytes).ok()?; |
| 206 | |
| 207 | if readme_name.name.to_lowercase().ends_with(".md") { |
| 208 | Some(render_markdown(&text)) |
| 209 | } else { |
| 210 | // Plain text README — wrap in <pre> |
| 211 | Some(format!("<pre>{}</pre>", html_escape(&text))) |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | fn render_markdown(md: &str) -> String { |
| 216 | use pulldown_cmark::{html, Options, Parser}; |
| 217 | let opts = Options::ENABLE_TABLES |
| 218 | | Options::ENABLE_STRIKETHROUGH |
| 219 | | Options::ENABLE_TASKLISTS; |
| 220 | let parser = Parser::new_ext(md, opts); |
| 221 | let mut out = String::new(); |
| 222 | html::push_html(&mut out, parser); |
| 223 | out |
| 224 | } |
| 225 | |
| 226 | fn html_escape(s: &str) -> String { |
| 227 | s.replace('&', "&") |
| 228 | .replace('<', "<") |
| 229 | .replace('>', ">") |
| 230 | .replace('"', """) |
| 231 | } |