1use 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
26pub const RENDER_CAP: u64 = 512 * 1024;
28pub const PER_PAGE: usize = 30;
30pub const DESCRIPTION_CAP: usize = 512;
32const LIST_CAP: usize = 200;
34const LAST_COMMIT_CAP: usize = 100;
36pub const JSON_BODY_CAP: usize = 64 * 1024 + 8192;
38
39#[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#[must_use]
52pub fn api_err(status: u16, msg: &str) -> ForgeResponse {
53 json_resp(status, &json!({ "error": msg }))
54}
55
56#[must_use]
58pub fn unauthorised() -> ForgeResponse {
59 api_err(401, "authentication required")
60 .with_header("www-authenticate", "Bearer realm=\"jss-forge\"")
61}
62
63#[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#[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#[must_use]
84pub fn looks_binary(buf: &[u8]) -> bool {
85 buf.iter().take(8000).any(|&b| b == 0)
86}
87
88#[derive(Debug, Clone)]
90pub struct RepoSummary {
91 pub owner: String,
93 pub name: String,
95 pub description: String,
97 pub last_push: Option<u64>,
99}
100
101#[derive(Debug, Clone)]
103pub struct RefRow {
104 pub name: String,
106 pub sha: String,
108 pub when: i64,
110 pub subject: String,
112}
113
114#[derive(Debug, Clone)]
116pub struct TreeRow {
117 pub mode: String,
119 pub kind: String,
121 pub sha: String,
123 pub size: Option<u64>,
125 pub name: String,
127}
128
129pub 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
164pub 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
185pub 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
224pub 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
246pub 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#[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 pub(crate) fn origin_of(&self, req: &ForgeRequest) -> String {
307 self.cfg.public_origin(req.host_url.as_deref())
308 }
309
310 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 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 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 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 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 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 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 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 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 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 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 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}