Skip to main content

solid_pod_rs_forge/
api.rs

1//! The JSON API read surface and repo metadata edit.
2//!
3//! Dispatches the `/api/repos`, `/api/repos/<owner>`, `/api/repos/<owner>/<repo>`
4//! and `/api/repos/<owner>/<repo>/{tree,blob,commits,commit}` routes. Shapes
5//! follow the JSS `forge` JSON API (`apiRepoList`, `apiRepoMeta`, `apiTree`,
6//! `apiBlob`, `apiCommits`, `apiCommit`, `apiRepoPatch`), cited by name only.
7//! Strings are raw (JSON is the escape); the one exception is `readme.html`,
8//! which is the already-escaped output of [`crate::markdown`].
9//!
10//! Every ref and path is validated with [`crate::guard`] before it reaches a
11//! git argv, and git is only ever run through [`crate::gitrun`].
12
13use std::path::Path;
14use std::sync::Arc;
15
16use serde_json::{json, Map, Value};
17use tokio::sync::Semaphore;
18use tokio::task::JoinSet;
19
20use crate::error::ForgeError;
21use crate::guard::{is_sha, ok_owner, ok_path, ok_ref, ok_repo};
22use crate::ownership::ForgeAgent;
23use crate::request::{ForgeRequest, ForgeResponse};
24use crate::{gitrun, markdown, ForgeService};
25
26/// Blobs and READMEs above this are "view raw" (JSS `RENDER_CAP`).
27pub const RENDER_CAP: u64 = 512 * 1024;
28/// Commits per page (JSS `PER_PAGE`).
29pub const PER_PAGE: usize = 30;
30/// Repo description cap (JSS `DESCRIPTION_CAP`).
31pub const DESCRIPTION_CAP: usize = 512;
32/// Repos returned by one list call.
33const LIST_CAP: usize = 200;
34/// Tree entries that get a `lastCommit`.
35const LAST_COMMIT_CAP: usize = 100;
36/// Body cap for JSON writes (JSS `ISSUE_BODY_CAP + 8192`).
37pub const JSON_BODY_CAP: usize = 64 * 1024 + 8192;
38
39/// A JSON response with the JSS `sendJson` headers.
40#[must_use]
41pub fn json_resp(status: u16, v: &Value) -> ForgeResponse {
42    ForgeResponse::with_type(
43        status,
44        "application/json; charset=utf-8",
45        bytes::Bytes::from(serde_json::to_vec(v).unwrap_or_else(|_| b"{}".to_vec())),
46    )
47    .with_header("x-content-type-options", "nosniff")
48}
49
50/// A JSON `{ "error": msg }` response (JSS `apiErr`).
51#[must_use]
52pub fn api_err(status: u16, msg: &str) -> ForgeResponse {
53    json_resp(status, &json!({ "error": msg }))
54}
55
56/// `401` with the JSS `WWW-Authenticate: Bearer` challenge.
57#[must_use]
58pub fn unauthorised() -> ForgeResponse {
59    api_err(401, "authentication required")
60        .with_header("www-authenticate", "Bearer realm=\"jss-forge\"")
61}
62
63/// Parse the request body as a JSON **object** (JSS `readJsonBody`): `None`
64/// when empty, oversized, unparseable or not an object.
65#[must_use]
66pub fn read_json_body(req: &ForgeRequest) -> Option<Map<String, Value>> {
67    if req.raw_body.is_empty() || req.raw_body.len() > JSON_BODY_CAP {
68        return None;
69    }
70    match serde_json::from_slice::<Value>(&req.raw_body) {
71        Ok(Value::Object(m)) => Some(m),
72        _ => None,
73    }
74}
75
76/// Is the method a read (`GET` / `HEAD`)?
77#[must_use]
78pub fn is_read(req: &ForgeRequest) -> bool {
79    req.method.eq_ignore_ascii_case("GET") || req.method.eq_ignore_ascii_case("HEAD")
80}
81
82/// Does `s` contain a NUL in its first 8000 bytes (git's binary heuristic)?
83#[must_use]
84pub fn looks_binary(buf: &[u8]) -> bool {
85    buf.iter().take(8000).any(|&b| b == 0)
86}
87
88/// Repository summary used by the list endpoints and the HTML index.
89#[derive(Debug, Clone)]
90pub struct RepoSummary {
91    /// Owner namespace.
92    pub owner: String,
93    /// Repository name.
94    pub name: String,
95    /// Description (file or README first line), possibly empty.
96    pub description: String,
97    /// Newest ref commit time, unix seconds.
98    pub last_push: Option<u64>,
99}
100
101/// A parsed git ref listing row.
102#[derive(Debug, Clone)]
103pub struct RefRow {
104    /// Short ref name.
105    pub name: String,
106    /// Abbreviated object name.
107    pub sha: String,
108    /// Creator date, unix seconds.
109    pub when: i64,
110    /// Subject line.
111    pub subject: String,
112}
113
114/// One `ls-tree -l` row.
115#[derive(Debug, Clone)]
116pub struct TreeRow {
117    /// File mode.
118    pub mode: String,
119    /// `blob`, `tree` or `commit`.
120    pub kind: String,
121    /// Object id.
122    pub sha: String,
123    /// Size, `None` for trees.
124    pub size: Option<u64>,
125    /// Entry name.
126    pub name: String,
127}
128
129/// `for-each-ref` over `refs/heads` or `refs/tags`, newest first.
130pub async fn list_refs(dir: &Path, kind: &str) -> Result<Vec<RefRow>, ForgeError> {
131    let out = gitrun::text(
132        dir,
133        &[
134            "for-each-ref",
135            "--sort=-committerdate",
136            "--format=%(refname:short)%00%(objectname:short)%00%(creatordate:unix)%00%(subject)",
137            &format!("refs/{kind}"),
138        ],
139    )
140    .await?;
141    Ok(out
142        .lines()
143        .filter(|l| !l.is_empty())
144        .map(|l| {
145            let mut f = l.split('\0');
146            RefRow {
147                name: f.next().unwrap_or("").to_string(),
148                sha: f.next().unwrap_or("").to_string(),
149                when: f.next().and_then(|n| n.parse().ok()).unwrap_or(0),
150                subject: f.next().unwrap_or("").to_string(),
151            }
152        })
153        .collect())
154}
155
156fn refs_json(rows: &[RefRow]) -> Value {
157    Value::Array(
158        rows.iter()
159            .map(|r| json!({"name": r.name, "sha": r.sha, "when": r.when, "subject": r.subject}))
160            .collect(),
161    )
162}
163
164/// Longest ref-name match wins (JSS `splitRefPath`): `["feature","x","src"]`
165/// becomes `("feature/x", ["src"])` when `feature/x` is a branch or tag.
166pub async fn split_ref_path(dir: &Path, segs: &[String]) -> (String, Vec<String>) {
167    let mut names = std::collections::HashSet::new();
168    for kind in ["heads", "tags"] {
169        if let Ok(rows) = list_refs(dir, kind).await {
170            names.extend(rows.into_iter().map(|r| r.name));
171        }
172    }
173    for i in (1..=segs.len().min(20)).rev() {
174        let cand = segs[..i].join("/");
175        if names.contains(&cand) {
176            return (cand, segs[i..].to_vec());
177        }
178    }
179    (
180        segs.first().cloned().unwrap_or_default(),
181        segs.get(1..).map(<[String]>::to_vec).unwrap_or_default(),
182    )
183}
184
185/// `ls-tree -z -l <ref>[:path]`, directories first.
186pub async fn ls_tree(dir: &Path, rev: &str, path: &str) -> Result<Vec<TreeRow>, ForgeError> {
187    let spec = if path.is_empty() {
188        rev.to_string()
189    } else {
190        format!("{rev}:{path}")
191    };
192    let out = gitrun::text(dir, &["ls-tree", "-z", "-l", &spec]).await?;
193    let mut rows: Vec<TreeRow> = out
194        .split('\0')
195        .filter(|e| !e.is_empty())
196        .filter_map(|e| {
197            let (meta, name) = e.split_once('\t')?;
198            let mut m = meta.split_whitespace();
199            let mode = m.next()?.to_string();
200            let kind = m.next()?.to_string();
201            let sha = m.next()?.to_string();
202            let size = m.next().and_then(|s| s.parse().ok());
203            Some(TreeRow {
204                mode,
205                kind,
206                sha,
207                size,
208                name: name.to_string(),
209            })
210        })
211        .collect();
212    rows.sort_by(|a, b| {
213        let (at, bt) = (a.kind == "tree", b.kind == "tree");
214        bt.cmp(&at).then_with(|| {
215            a.name
216                .to_lowercase()
217                .cmp(&b.name.to_lowercase())
218                .then_with(|| a.name.cmp(&b.name))
219        })
220    });
221    Ok(rows)
222}
223
224/// Last commit touching `path` (or the ref tip when empty).
225pub async fn last_commit(dir: &Path, rev: &str, path: &str) -> Option<Value> {
226    let fmt = "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%s";
227    let mut args = vec!["log", "-1", fmt, rev, "--"];
228    if !path.is_empty() {
229        args.push(path);
230    }
231    let out = gitrun::text(dir, &args).await.ok()?;
232    let out = out.trim();
233    if out.is_empty() {
234        return None;
235    }
236    let f: Vec<&str> = out.split('\0').collect();
237    if f.len() < 6 {
238        return None;
239    }
240    Some(json!({
241        "sha": f[0], "short": f[1], "author": f[2], "email": f[3],
242        "at": f[4].parse::<i64>().unwrap_or(0), "subject": f[5],
243    }))
244}
245
246/// Description: git's `description` file when customised, else the README
247/// first line.
248pub async fn repo_description(dir: &Path) -> String {
249    if let Ok(d) = tokio::fs::read_to_string(dir.join("description")).await {
250        let d = d.trim();
251        if !d.is_empty() && !d.starts_with("Unnamed repository") {
252            return d.to_string();
253        }
254    }
255    String::new()
256}
257
258async fn readme_first_line(dir: &Path, branch: &str) -> String {
259    let Ok(entries) = ls_tree(dir, branch, "").await else {
260        return String::new();
261    };
262    let Some(e) = entries
263        .iter()
264        .find(|e| e.kind == "blob" && is_readme_name(&e.name))
265    else {
266        return String::new();
267    };
268    if e.size.is_some_and(|s| s >= RENDER_CAP) {
269        return String::new();
270    }
271    let Ok(buf) = gitrun::bytes(
272        dir,
273        &["cat-file", "blob", &format!("{branch}:{}", e.name)],
274        RENDER_CAP as usize,
275    )
276    .await
277    else {
278        return String::new();
279    };
280    if looks_binary(&buf) {
281        return String::new();
282    }
283    String::from_utf8_lossy(&buf)
284        .lines()
285        .find(|l| !l.trim().is_empty())
286        .map(|l| {
287            l.trim_start_matches('#')
288                .trim()
289                .chars()
290                .take(160)
291                .collect::<String>()
292        })
293        .unwrap_or_default()
294}
295
296/// `^readme(\.(md|markdown|txt))?$`, case-insensitive.
297#[must_use]
298pub fn is_readme_name(name: &str) -> bool {
299    let n = name.to_ascii_lowercase();
300    n == "readme" || n == "readme.md" || n == "readme.markdown" || n == "readme.txt"
301}
302
303impl ForgeService {
304    /// Public origin for absolute URLs: configured `base_url`, else the
305    /// request's `host_url` (JSS `publicOrigin`).
306    pub(crate) fn origin_of(&self, req: &ForgeRequest) -> String {
307        self.cfg.public_origin(req.host_url.as_deref())
308    }
309
310    /// Absolute clone URL of `<owner>/<repo>`.
311    pub(crate) fn clone_url(&self, req: &ForgeRequest, owner: &str, repo: &str) -> String {
312        format!(
313            "{}{}/{owner}/{repo}.git",
314            self.origin_of(req),
315            self.prefix()
316        )
317    }
318
319    /// Summarise one repository (description + newest ref time).
320    pub(crate) async fn repo_summary(&self, owner: &str, repo: &str) -> Option<RepoSummary> {
321        let dir = crate::repo::repo_git_dir(&self.repo_root, owner, repo).ok()?;
322        let mut description = repo_description(&dir).await;
323        let last_push = gitrun::text(
324            &dir,
325            &[
326                "for-each-ref",
327                "--sort=-committerdate",
328                "--count=1",
329                "--format=%(committerdate:unix)",
330            ],
331        )
332        .await
333        .ok()
334        .and_then(|s| s.trim().parse::<u64>().ok());
335        if description.is_empty() && last_push.is_some() {
336            let branch = crate::repo::browse::default_branch(&dir).await;
337            description = readme_first_line(&dir, &branch).await;
338        }
339        Some(RepoSummary {
340            owner: owner.to_string(),
341            name: repo.to_string(),
342            description,
343            last_push,
344        })
345    }
346
347    /// Summaries of every repo of `owners` (all owners when `None`), capped
348    /// at 200, newest push first.
349    pub(crate) async fn summaries(&self, owners: Option<&str>) -> Vec<RepoSummary> {
350        let all = crate::repo::list_all(&self.repo_root).await;
351        let mut out = Vec::new();
352        for (o, r) in all {
353            if owners.is_some_and(|f| f != o) {
354                continue;
355            }
356            if out.len() >= LIST_CAP {
357                break;
358            }
359            if let Some(s) = self.repo_summary(&o, &r).await {
360                out.push(s);
361            }
362        }
363        out.sort_by_key(|s| std::cmp::Reverse(s.last_push.unwrap_or(0)));
364        out
365    }
366
367    /// `GET /api/repos` and `GET /api/repos/<owner>`.
368    pub(crate) async fn h_api_repo_list(
369        &self,
370        req: &ForgeRequest,
371        agent: &ForgeAgent,
372        owner: Option<&str>,
373    ) -> Result<ForgeResponse, ForgeError> {
374        if !is_read(req) {
375            return Ok(api_err(405, "method not allowed"));
376        }
377        if let Some(o) = owner {
378            if !ok_owner(o) {
379                return Ok(api_err(404, "not found"));
380            }
381        }
382        // privateRepos: the listing is the caller's own namespace only.
383        let filter = if self.cfg.private_repos {
384            agent.owner().map(str::to_string)
385        } else {
386            owner.map(str::to_string)
387        };
388        let rows = self.summaries(filter.as_deref()).await;
389        let repos: Vec<Value> = rows
390            .iter()
391            .map(|s| {
392                json!({
393                    "owner": s.owner, "name": s.name, "description": s.description,
394                    "lastPush": s.last_push, "parent": Value::Null,
395                    "cloneUrl": self.clone_url(req, &s.owner, &s.name),
396                    "url": format!("{}/{}/{}", self.prefix(), s.owner, s.name),
397                })
398            })
399            .collect();
400        Ok(json_resp(200, &json!({ "repos": repos })))
401    }
402
403    /// `GET|PATCH /api/repos/<owner>/<repo>`.
404    pub(crate) async fn h_api_repo(
405        &self,
406        req: &ForgeRequest,
407        agent: &ForgeAgent,
408        owner: &str,
409        repo: &str,
410    ) -> Result<ForgeResponse, ForgeError> {
411        if !ok_owner(owner) || !ok_repo(repo) {
412            return Ok(api_err(404, "not found"));
413        }
414        let dir = match self.resolve_repo(owner, repo).await {
415            Ok(d) => d,
416            Err(_) => return Ok(api_err(404, "not found")),
417        };
418        if req.method.eq_ignore_ascii_case("PATCH") {
419            return self.api_repo_patch(req, agent, owner, repo, &dir).await;
420        }
421        if !is_read(req) {
422            return Ok(api_err(405, "method not allowed"));
423        }
424        self.api_repo_meta(req, owner, repo, &dir).await
425    }
426
427    async fn api_repo_meta(
428        &self,
429        req: &ForgeRequest,
430        owner: &str,
431        repo: &str,
432        dir: &Path,
433    ) -> Result<ForgeResponse, ForgeError> {
434        let summary = self.repo_summary(owner, repo).await;
435        let branch = crate::repo::browse::default_branch(dir).await;
436        let branches = list_refs(dir, "heads").await.unwrap_or_default();
437        let tags = list_refs(dir, "tags").await.unwrap_or_default();
438        let mut readme = Value::Null;
439        if !branches.is_empty() {
440            if let Ok(entries) = ls_tree(dir, &branch, "").await {
441                if let Some(e) = entries
442                    .iter()
443                    .find(|e| e.kind == "blob" && is_readme_name(&e.name))
444                {
445                    if !e.size.is_some_and(|s| s > RENDER_CAP) {
446                        if let Ok(buf) = gitrun::bytes(
447                            dir,
448                            &["cat-file", "blob", &format!("{branch}:{}", e.name)],
449                            RENDER_CAP as usize,
450                        )
451                        .await
452                        {
453                            if !looks_binary(&buf) {
454                                let text = String::from_utf8_lossy(&buf).into_owned();
455                                let lower = e.name.to_ascii_lowercase();
456                                let is_md = lower.ends_with(".md") || lower.ends_with(".markdown");
457                                let base = format!("{}/{owner}/{repo}", self.prefix());
458                                let html = if is_md {
459                                    markdown::render_with(
460                                        &text,
461                                        &markdown::MdCtx {
462                                            raw_base: format!("{base}/raw/{branch}"),
463                                            blob_base: format!("{base}/blob/{branch}"),
464                                        },
465                                    )
466                                } else {
467                                    format!("<pre>{}</pre>", crate::request::esc(&text))
468                                };
469                                readme = json!({"name": e.name, "html": html});
470                            }
471                        }
472                    }
473                }
474            }
475        }
476        Ok(json_resp(
477            200,
478            &json!({
479                "owner": owner,
480                "name": repo,
481                "description": summary.as_ref().map_or("", |s| s.description.as_str()),
482                "lastPush": summary.as_ref().and_then(|s| s.last_push),
483                "parent": Value::Null,
484                "forks": 0,
485                "empty": branches.is_empty(),
486                "defaultBranch": branch,
487                "branches": refs_json(&branches),
488                "tags": refs_json(&tags),
489                "cloneUrl": self.clone_url(req, owner, repo),
490                "readme": readme,
491            }),
492        ))
493    }
494
495    /// JSS `apiRepoPatch`: `{description}`, owner only.
496    async fn api_repo_patch(
497        &self,
498        req: &ForgeRequest,
499        agent: &ForgeAgent,
500        owner: &str,
501        repo: &str,
502        dir: &Path,
503    ) -> Result<ForgeResponse, ForgeError> {
504        let body = read_json_body(req);
505        if matches!(agent, ForgeAgent::Anonymous) {
506            return Ok(unauthorised());
507        }
508        if agent.owner() != Some(owner) {
509            return Ok(api_err(403, "only the repo owner may edit the repo"));
510        }
511        let Some(p) = body else {
512            return Ok(api_err(400, "invalid JSON body"));
513        };
514        let desc = p.get("description").and_then(Value::as_str).map(str::trim);
515        let Some(desc) = desc.filter(|d| d.chars().count() <= DESCRIPTION_CAP) else {
516            return Ok(api_err(
517                422,
518                &format!("description must be a string (max {DESCRIPTION_CAP} chars)"),
519            ));
520        };
521        tokio::fs::write(dir.join("description"), format!("{desc}\n")).await?;
522        Ok(json_resp(
523            200,
524            &json!({"owner": owner, "name": repo, "description": desc}),
525        ))
526    }
527
528    /// Dispatch `/api/repos/<owner>/<repo>/<action>/…` for the actions in
529    /// [`crate::router::API_ACTIONS`] (JSS `apiHandler` action switch).
530    pub(crate) async fn h_api_repo_action(
531        &self,
532        req: &ForgeRequest,
533        agent: &ForgeAgent,
534        owner: &str,
535        repo: &str,
536        action: &str,
537        tail: &[String],
538    ) -> Result<ForgeResponse, ForgeError> {
539        if !ok_owner(owner) || !ok_repo(repo) {
540            return Ok(api_err(404, "not found"));
541        }
542        let dir = match self.resolve_repo(owner, repo).await {
543            Ok(d) => d,
544            Err(_) => return Ok(api_err(404, "not found")),
545        };
546        if !matches!(action, "issues" | "labels") && !is_read(req) {
547            return Ok(api_err(405, "method not allowed"));
548        }
549        let res = match action {
550            "issues" => {
551                return self.h_api_issues(req, agent, owner, repo, tail).await;
552            }
553            "labels" => {
554                return self.h_api_labels(req, agent, owner, repo, tail).await;
555            }
556            "search" if tail.is_empty() => return self.api_search(req, &dir).await,
557            "releases" if tail.is_empty() => {
558                return self.api_releases(req, owner, repo, &dir).await
559            }
560            "tree" if !tail.is_empty() => self.api_tree(&dir, tail).await,
561            "blob" if tail.len() >= 2 => self.api_blob(&dir, tail).await,
562            "commits" if !tail.is_empty() => self.api_commits(req, &dir, tail).await,
563            "commit" if tail.len() == 1 => self.api_commit(&dir, &tail[0]).await,
564            _ => Ok(api_err(404, "not found")),
565        };
566        // JSS: any failure inside a read handler is a clean 404.
567        Ok(res.unwrap_or_else(|_| api_err(404, "not found")))
568    }
569
570    async fn api_tree(&self, dir: &Path, segs: &[String]) -> Result<ForgeResponse, ForgeError> {
571        let (rev, rest) = split_ref_path(dir, segs).await;
572        if !ok_ref(&rev) || !ok_path(&rest) {
573            return Ok(api_err(404, "not found"));
574        }
575        let dir_path = rest.join("/");
576        let entries = ls_tree(dir, &rev, &dir_path).await?;
577        let sem = Arc::new(Semaphore::new(8));
578        let mut set: JoinSet<(usize, Option<Value>)> = JoinSet::new();
579        for (i, e) in entries.iter().enumerate().take(LAST_COMMIT_CAP) {
580            let (sem, d, r) = (sem.clone(), dir.to_path_buf(), rev.clone());
581            let p = if dir_path.is_empty() {
582                e.name.clone()
583            } else {
584                format!("{dir_path}/{}", e.name)
585            };
586            set.spawn(async move {
587                let _permit = sem.acquire_owned().await.ok();
588                (i, last_commit(&d, &r, &p).await)
589            });
590        }
591        let mut last: Vec<Option<Value>> = vec![None; entries.len()];
592        while let Some(Ok((i, v))) = set.join_next().await {
593            last[i] = v;
594        }
595        let out: Vec<Value> = entries
596            .iter()
597            .zip(last)
598            .map(|(e, lc)| {
599                json!({
600                    "mode": e.mode, "type": e.kind, "sha": e.sha, "size": e.size,
601                    "name": e.name, "lastCommit": lc,
602                })
603            })
604            .collect();
605        Ok(json_resp(
606            200,
607            &json!({"ref": rev, "path": dir_path, "entries": out}),
608        ))
609    }
610
611    async fn api_blob(&self, dir: &Path, segs: &[String]) -> Result<ForgeResponse, ForgeError> {
612        let (rev, rest) = split_ref_path(dir, segs).await;
613        if !ok_ref(&rev) || rest.is_empty() || !ok_path(&rest) {
614            return Ok(api_err(404, "not found"));
615        }
616        let p = rest.join("/");
617        let spec = format!("{rev}:{p}");
618        let ty = gitrun::text(dir, &["cat-file", "-t", &spec]).await?;
619        if ty.trim() != "blob" {
620            return Ok(api_err(404, "not found"));
621        }
622        let size: u64 = gitrun::text(dir, &["cat-file", "-s", &spec])
623            .await?
624            .trim()
625            .parse()
626            .unwrap_or(0);
627        let mut out = json!({
628            "ref": rev, "path": p, "size": size,
629            "binary": false, "tooLarge": false, "content": Value::Null,
630        });
631        if size > RENDER_CAP {
632            out["tooLarge"] = json!(true);
633        } else {
634            let buf =
635                gitrun::bytes(dir, &["cat-file", "blob", &spec], RENDER_CAP as usize + 1).await?;
636            if looks_binary(&buf) {
637                out["binary"] = json!(true);
638            } else {
639                out["content"] = json!(String::from_utf8_lossy(&buf));
640            }
641        }
642        Ok(json_resp(200, &out))
643    }
644
645    async fn api_commits(
646        &self,
647        req: &ForgeRequest,
648        dir: &Path,
649        segs: &[String],
650    ) -> Result<ForgeResponse, ForgeError> {
651        let rev = segs.join("/");
652        if !ok_ref(&rev) && !is_sha(&rev) {
653            return Ok(api_err(404, "not found"));
654        }
655        let page = req
656            .query_param("page")
657            .and_then(|p| p.trim().parse::<i64>().ok())
658            .unwrap_or(1)
659            .clamp(1, 10000) as usize;
660        let skip = ((page - 1) * PER_PAGE).to_string();
661        let n = format!("-n{}", PER_PAGE + 1);
662        let out = gitrun::text(
663            dir,
664            &[
665                "log",
666                "-z",
667                &format!("--skip={skip}"),
668                &n,
669                "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%s",
670                &rev,
671                "--",
672            ],
673        )
674        .await?;
675        let f: Vec<&str> = out.split('\0').collect();
676        let mut commits = Vec::new();
677        let mut i = 0;
678        while i + 5 < f.len() {
679            commits.push(json!({
680                "sha": f[i].trim_start_matches('\n'), "short": f[i + 1], "author": f[i + 2],
681                "email": f[i + 3], "at": f[i + 4].parse::<i64>().unwrap_or(0), "subject": f[i + 5],
682            }));
683            i += 6;
684        }
685        let has_more = commits.len() > PER_PAGE;
686        commits.truncate(PER_PAGE);
687        Ok(json_resp(
688            200,
689            &json!({"ref": rev, "page": page, "perPage": PER_PAGE, "hasMore": has_more, "commits": commits}),
690        ))
691    }
692
693    async fn api_commit(&self, dir: &Path, sha: &str) -> Result<ForgeResponse, ForgeError> {
694        if !is_sha(sha) {
695            return Ok(api_err(404, "not found"));
696        }
697        let out = gitrun::text(
698            dir,
699            &[
700                "show",
701                "--no-patch",
702                "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%P%x00%B",
703                sha,
704                "--",
705            ],
706        )
707        .await;
708        let Ok(out) = out else {
709            return Ok(api_err(404, "not found"));
710        };
711        let mut f = out.splitn(7, '\0');
712        let (full, short, author, email, at, parents, msg) = (
713            f.next().unwrap_or(""),
714            f.next().unwrap_or(""),
715            f.next().unwrap_or(""),
716            f.next().unwrap_or(""),
717            f.next().unwrap_or("0"),
718            f.next().unwrap_or(""),
719            f.next().unwrap_or(""),
720        );
721        let patch = gitrun::text(
722            dir,
723            &["show", "--format=", "--patch", "--no-color", sha, "--"],
724        )
725        .await
726        .unwrap_or_default();
727        let files = crate::diff::parse_patch(&patch);
728        Ok(json_resp(
729            200,
730            &json!({
731                "sha": full.trim(), "short": short, "author": author, "email": email,
732                "at": at.parse::<i64>().unwrap_or(0),
733                "parents": parents.split(' ').filter(|p| !p.is_empty()).collect::<Vec<_>>(),
734                "message": msg.trim(),
735                "files": files,
736            }),
737        ))
738    }
739}
740
741#[cfg(test)]
742pub(crate) mod tests {
743    use super::*;
744    use crate::gitrun::testutil::demo_repo;
745    use crate::ForgeConfig;
746    use bytes::Bytes;
747    use tempfile::TempDir;
748
749    pub(crate) fn rq(method: &str, path: &str, query: &str, body: &str) -> ForgeRequest {
750        ForgeRequest {
751            method: method.into(),
752            path: path.into(),
753            query: query.into(),
754            headers: vec![],
755            raw_body: Bytes::from(body.to_string()),
756            host_url: Some("https://pod.example".into()),
757        }
758    }
759
760    pub(crate) fn casey() -> ForgeAgent {
761        ForgeAgent::Pod {
762            webid: "https://pod.example/casey/profile/card#me".into(),
763            username: "casey".into(),
764        }
765    }
766
767    pub(crate) fn dana() -> ForgeAgent {
768        ForgeAgent::Pod {
769            webid: "https://pod.example/dana/profile/card#me".into(),
770            username: "dana".into(),
771        }
772    }
773
774    /// A service over a temp dir holding the JSS-style `casey/demo` repo.
775    pub(crate) fn fixture() -> (TempDir, ForgeService, String) {
776        let td = TempDir::new().unwrap();
777        let (_bare, sha2) = demo_repo(td.path());
778        let svc = ForgeService::new(ForgeConfig::default(), td.path()).unwrap();
779        (td, svc, sha2)
780    }
781
782    pub(crate) async fn get_json(
783        svc: &ForgeService,
784        path: &str,
785        query: &str,
786        agent: ForgeAgent,
787    ) -> (u16, Value) {
788        let r = svc.handle(rq("GET", path, query, ""), agent).await.unwrap();
789        let v = serde_json::from_slice(&r.body).unwrap_or(Value::Null);
790        (r.status, v)
791    }
792
793    #[tokio::test]
794    async fn repo_list_has_stable_shape() {
795        let (_td, svc, _) = fixture();
796        let (st, v) = get_json(&svc, "/forge/api/repos", "", ForgeAgent::Anonymous).await;
797        assert_eq!(st, 200);
798        let demo = v["repos"]
799            .as_array()
800            .unwrap()
801            .iter()
802            .find(|r| r["owner"] == "casey" && r["name"] == "demo")
803            .expect("casey/demo listed");
804        assert_eq!(demo["cloneUrl"], "https://pod.example/forge/casey/demo.git");
805        assert!(demo["lastPush"].is_u64());
806        let (_, own) = get_json(&svc, "/forge/api/repos/casey", "", ForgeAgent::Anonymous).await;
807        assert_eq!(own["repos"].as_array().unwrap().len(), 1);
808    }
809
810    #[tokio::test]
811    async fn repo_meta_carries_branches_tags_readme_html() {
812        let (_td, svc, _) = fixture();
813        let (st, m) = get_json(
814            &svc,
815            "/forge/api/repos/casey/demo",
816            "",
817            ForgeAgent::Anonymous,
818        )
819        .await;
820        assert_eq!(st, 200);
821        assert_eq!(m["defaultBranch"], "main");
822        assert_eq!(m["empty"], false);
823        assert!(m["branches"]
824            .as_array()
825            .unwrap()
826            .iter()
827            .any(|b| b["name"] == "main"));
828        assert!(m["tags"]
829            .as_array()
830            .unwrap()
831            .iter()
832            .any(|t| t["name"] == "v1.0"));
833        assert_eq!(m["cloneUrl"], "https://pod.example/forge/casey/demo.git");
834        let html = m["readme"]["html"].as_str().unwrap();
835        assert!(html.contains("<h1>Demo Project</h1>"));
836        assert!(!html.contains("<script>alert"), "README html is escaped");
837    }
838
839    #[tokio::test]
840    async fn tree_entries_are_typed_sized_sorted_with_last_commits() {
841        let (_td, svc, _) = fixture();
842        let (st, v) = get_json(
843            &svc,
844            "/forge/api/repos/casey/demo/tree/main",
845            "",
846            ForgeAgent::Anonymous,
847        )
848        .await;
849        assert_eq!(st, 200);
850        let entries = v["entries"].as_array().unwrap();
851        assert_eq!(entries[0]["type"], "tree", "folders sort first");
852        let readme = entries.iter().find(|e| e["name"] == "README.md").unwrap();
853        assert_eq!(readme["type"], "blob");
854        assert!(readme["size"].as_u64().unwrap() > 0);
855        assert!(readme["lastCommit"]["subject"].is_string());
856        let (st, sub) = get_json(
857            &svc,
858            "/forge/api/repos/casey/demo/tree/main/src",
859            "",
860            ForgeAgent::Anonymous,
861        )
862        .await;
863        assert_eq!(st, 200);
864        assert_eq!(sub["path"], "src");
865        assert_eq!(sub["entries"][0]["name"], "main.js");
866    }
867
868    #[tokio::test]
869    async fn blob_returns_content_for_text_and_flags_binary() {
870        let (_td, svc, _) = fixture();
871        let (_, t) = get_json(
872            &svc,
873            "/forge/api/repos/casey/demo/blob/main/src/main.js",
874            "",
875            ForgeAgent::Anonymous,
876        )
877        .await;
878        assert_eq!(t["binary"], false);
879        assert_eq!(t["tooLarge"], false);
880        assert!(t["content"].as_str().unwrap().contains("return 2;"));
881        assert!(t["size"].is_u64());
882        let (_, b) = get_json(
883            &svc,
884            "/forge/api/repos/casey/demo/blob/main/blob.bin",
885            "",
886            ForgeAgent::Anonymous,
887        )
888        .await;
889        assert_eq!(b["binary"], true);
890        assert!(b["content"].is_null());
891    }
892
893    #[tokio::test]
894    async fn commits_paginate_with_has_more() {
895        let (_td, svc, _) = fixture();
896        let (_, p1) = get_json(
897            &svc,
898            "/forge/api/repos/casey/demo/commits/main",
899            "",
900            ForgeAgent::Anonymous,
901        )
902        .await;
903        assert_eq!(p1["page"], 1);
904        assert_eq!(p1["perPage"], 30);
905        assert_eq!(p1["commits"].as_array().unwrap().len(), 2);
906        assert_eq!(p1["hasMore"], false);
907        assert_eq!(p1["commits"][0]["subject"], "tweak main");
908        let (_, p2) = get_json(
909            &svc,
910            "/forge/api/repos/casey/demo/commits/main",
911            "page=2",
912            ForgeAgent::Anonymous,
913        )
914        .await;
915        assert_eq!(p2["commits"].as_array().unwrap().len(), 0);
916        assert_eq!(p2["hasMore"], false);
917    }
918
919    #[tokio::test]
920    async fn commit_returns_structured_diff() {
921        let (_td, svc, sha2) = fixture();
922        let (st, c) = get_json(
923            &svc,
924            &format!("/forge/api/repos/casey/demo/commit/{sha2}"),
925            "",
926            ForgeAgent::Anonymous,
927        )
928        .await;
929        assert_eq!(st, 200);
930        assert_eq!(c["sha"], sha2);
931        assert_eq!(c["message"], "tweak main");
932        let files = c["files"].as_array().unwrap();
933        assert_eq!(files.len(), 1);
934        assert_eq!(files[0]["name"], "src/main.js");
935        assert_eq!(files[0]["adds"], 1);
936        assert_eq!(files[0]["dels"], 1);
937        let lines = files[0]["hunks"][0]["lines"].as_array().unwrap();
938        assert!(lines
939            .iter()
940            .any(|l| l["type"] == "add" && l["text"].as_str().unwrap().contains("return 2;")));
941        assert!(lines
942            .iter()
943            .any(|l| l["type"] == "del" && l["text"].as_str().unwrap().contains("return 1;")));
944        assert!(lines
945            .iter()
946            .all(|l| l["type"] != "add" || l["newLine"].is_u64()));
947        // a branch name is not a sha
948        let (st, _) = get_json(
949            &svc,
950            "/forge/api/repos/casey/demo/commit/main",
951            "",
952            ForgeAgent::Anonymous,
953        )
954        .await;
955        assert_eq!(st, 404);
956    }
957
958    #[tokio::test]
959    async fn unknown_repo_and_traversal_are_clean_json_4xx() {
960        let (_td, svc, _) = fixture();
961        let (st, v) = get_json(
962            &svc,
963            "/forge/api/repos/casey/nope",
964            "",
965            ForgeAgent::Anonymous,
966        )
967        .await;
968        assert_eq!(st, 404);
969        assert!(v["error"].is_string());
970        for p in [
971            "/forge/api/repos/casey/demo/blob/main/../../etc/passwd",
972            "/forge/api/repos/casey/demo/blob/main/%2e%2e/x",
973            "/forge/api/repos/casey/demo/blob/main/a%5Cb",
974            "/forge/api/repos/casey/demo/blob/--upload-pack=x/f",
975        ] {
976            let (st, _) = get_json(&svc, p, "", ForgeAgent::Anonymous).await;
977            assert!((400..500).contains(&st), "{p} got {st}");
978        }
979    }
980
981    #[tokio::test]
982    async fn repo_patch_description_is_owner_only() {
983        let (td, svc, _) = fixture();
984        let patch = |agent: ForgeAgent, body: &str| {
985            let svc = svc.clone();
986            let body = body.to_string();
987            async move {
988                svc.handle(rq("PATCH", "/forge/api/repos/casey/demo", "", &body), agent)
989                    .await
990                    .unwrap()
991            }
992        };
993        assert_eq!(
994            patch(ForgeAgent::Anonymous, r#"{"description":"x"}"#)
995                .await
996                .status,
997            401
998        );
999        assert_eq!(patch(dana(), r#"{"description":"nope"}"#).await.status, 403);
1000        assert_eq!(patch(casey(), "not json").await.status, 400);
1001        assert_eq!(patch(casey(), r#"{"description":5}"#).await.status, 422);
1002        let long = format!(r#"{{"description":"{}"}}"#, "x".repeat(513));
1003        assert_eq!(patch(casey(), &long).await.status, 422);
1004        let ok = patch(
1005            casey(),
1006            r#"{"description":"A demo repo with <angle> brackets"}"#,
1007        )
1008        .await;
1009        assert_eq!(ok.status, 200);
1010        let (_, meta) = get_json(
1011            &svc,
1012            "/forge/api/repos/casey/demo",
1013            "",
1014            ForgeAgent::Anonymous,
1015        )
1016        .await;
1017        assert_eq!(meta["description"], "A demo repo with <angle> brackets");
1018        let file =
1019            std::fs::read_to_string(td.path().join("repos/casey/demo.git/description")).unwrap();
1020        assert_eq!(file, "A demo repo with <angle> brackets\n");
1021        // other methods on the meta route
1022        let r = svc
1023            .handle(rq("DELETE", "/forge/api/repos/casey/demo", "", ""), casey())
1024            .await
1025            .unwrap();
1026        assert_eq!(r.status, 405);
1027    }
1028
1029    #[tokio::test]
1030    async fn options_allows_patch() {
1031        let (_td, svc, _) = fixture();
1032        let r = svc
1033            .handle(
1034                rq("OPTIONS", "/forge/api/repos/casey/demo", "", ""),
1035                ForgeAgent::Anonymous,
1036            )
1037            .await
1038            .unwrap();
1039        let m = r
1040            .headers
1041            .iter()
1042            .find(|(k, _)| k == "access-control-allow-methods")
1043            .unwrap();
1044        assert!(m.1.contains("PATCH"));
1045    }
1046}