1use std::path::{Path, PathBuf};
52
53use serde_json::{json, Map, Value};
54
55use crate::api::{api_err, json_resp, unauthorised};
56use crate::error::ForgeError;
57use crate::guard::ok_ref;
58use crate::ownership::ForgeAgent;
59use crate::prgit;
60use crate::repo::browse;
61use crate::request::{ForgeRequest, ForgeResponse};
62use crate::ForgeService;
63
64pub const EDIT_CONTENT_CAP: usize = 1024 * 1024;
66pub const EDIT_MSG_CAP: usize = 1000;
68pub const EDIT_PATH_CAP: usize = 1024;
70pub const EDIT_BODY_CAP: usize = EDIT_CONTENT_CAP + 128 * 1024;
73
74fn ok_edit_segment(seg: &str) -> bool {
78 if seg.is_empty()
79 || !seg
80 .bytes()
81 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
82 {
83 return false;
84 }
85 if seg.bytes().all(|b| b == b'.') {
87 return false;
88 }
89 let lower = seg.to_ascii_lowercase();
90 if lower.trim_end_matches('.') == ".git" {
92 return false;
93 }
94 if let Some(n) = lower.strip_prefix("git~") {
96 if !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) {
97 return false;
98 }
99 }
100 true
101}
102
103#[must_use]
124pub fn clean_edit_path(p: &str) -> Option<String> {
125 if p.is_empty() || p.chars().count() > EDIT_PATH_CAP {
126 return None;
127 }
128 if p.starts_with('/') || p.contains('\\') || p.chars().any(|c| (c as u32) < 0x20) {
129 return None;
130 }
131 let segs: Vec<&str> = p.split('/').collect();
132 if !segs.iter().all(|s| ok_edit_segment(s)) {
133 return None;
134 }
135 if segs[0].starts_with('.') {
136 return None;
137 }
138 Some(segs.join("/"))
139}
140
141#[must_use]
156pub fn clean_message(raw: Option<&str>, path: &str) -> String {
157 let mut out = String::new();
158 let mut in_ctl = false;
159 for c in raw.unwrap_or("").chars() {
160 if (c as u32) < 0x20 {
161 if !in_ctl {
162 out.push(' ');
163 }
164 in_ctl = true;
165 } else {
166 in_ctl = false;
167 out.push(c);
168 }
169 }
170 let t: String = out.trim().chars().take(EDIT_MSG_CAP).collect();
171 if t.is_empty() {
172 format!("web edit: {path}")
173 } else {
174 t
175 }
176}
177
178pub(crate) fn cors(resp: ForgeResponse) -> ForgeResponse {
181 resp.with_header("access-control-allow-origin", "*")
182 .with_header("access-control-allow-methods", "POST, OPTIONS")
183 .with_header(
184 "access-control-allow-headers",
185 "content-type, authorization",
186 )
187 .with_header("access-control-max-age", "600")
188}
189
190pub(crate) fn cors_json(status: u16, v: &Value) -> ForgeResponse {
192 cors(json_resp(status, v))
193}
194
195pub(crate) fn cors_err(status: u16, msg: &str) -> ForgeResponse {
197 cors(api_err(status, msg))
198}
199
200#[derive(Debug)]
202pub(crate) struct EditRequest {
203 pub path: String,
205 pub content: String,
207 pub message: String,
209 pub branch: Option<Value>,
211 pub authed_owner: Option<String>,
213}
214
215pub(crate) fn validate(
219 open_edit: bool,
220 req: &ForgeRequest,
221 agent: &ForgeAgent,
222 owner: &str,
223 verb: &str,
224) -> Result<EditRequest, ForgeResponse> {
225 let authed_owner = agent.owner().map(str::to_string);
226 if !open_edit {
227 if matches!(agent, ForgeAgent::Anonymous) {
228 return Err(cors(unauthorised()));
229 }
230 if authed_owner.as_deref() != Some(owner) {
231 return Err(cors_err(
232 403,
233 &format!("only the repo owner may {verb} this repository"),
234 ));
235 }
236 }
237 let body: Option<Map<String, Value>> =
238 if req.raw_body.is_empty() || req.raw_body.len() > EDIT_BODY_CAP {
239 None
240 } else {
241 match serde_json::from_slice::<Value>(&req.raw_body) {
242 Ok(Value::Object(m)) => Some(m),
243 _ => None,
244 }
245 };
246 let Some(p) = body else {
247 return Err(cors_err(400, "invalid JSON body"));
248 };
249 let Some(path) = p
250 .get("path")
251 .and_then(Value::as_str)
252 .and_then(clean_edit_path)
253 else {
254 return Err(cors_err(400, "invalid path"));
255 };
256 let Some(content) = p.get("content").and_then(Value::as_str) else {
257 return Err(cors_err(400, "content must be a string"));
258 };
259 if content.len() > EDIT_CONTENT_CAP {
260 return Err(cors_err(
261 413,
262 &format!("content exceeds the {EDIT_CONTENT_CAP}-byte cap"),
263 ));
264 }
265 let message = clean_message(p.get("message").and_then(Value::as_str), &path);
266 Ok(EditRequest {
267 path,
268 content: content.to_string(),
269 message,
270 branch: p.get("branch").cloned(),
271 authed_owner,
272 })
273}
274
275pub(crate) async fn resolve_branch(
279 dir: &Path,
280 raw: Option<&Value>,
281) -> Result<String, ForgeResponse> {
282 let bad = || cors_err(400, "invalid branch");
283 match raw {
284 Some(Value::String(b)) => {
285 if !ok_ref(b) || b == "HEAD" {
286 return Err(bad());
287 }
288 let full = format!("refs/heads/{b}");
289 match prgit::run(dir, &["check-ref-format", &full]).await {
290 Ok(o) if o.ok() => Ok(b.clone()),
291 _ => Err(bad()),
292 }
293 }
294 Some(_) => Err(bad()),
295 None => Ok(browse::default_branch(dir).await),
296 }
297}
298
299struct Scratch(Vec<PathBuf>);
301
302impl Drop for Scratch {
303 fn drop(&mut self) {
304 for p in &self.0 {
305 let _ = std::fs::remove_file(p);
306 }
307 }
308}
309
310#[derive(Debug, PartialEq, Eq)]
312pub(crate) struct Committed {
313 pub sha: Option<String>,
315 pub changed: bool,
317}
318
319fn valid_oid(s: &str) -> bool {
320 matches!(s.len(), 40 | 64) && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
321}
322
323async fn rev(dir: &Path, spec: &str) -> Option<String> {
326 let o = prgit::run(dir, &["rev-parse", "--verify", "--quiet", spec])
327 .await
328 .ok()?;
329 let s = o.text().trim().to_string();
330 (o.ok() && valid_oid(&s)).then_some(s)
331}
332
333fn ident(s: &str) -> String {
335 let t: String = s
336 .chars()
337 .filter(|c| !c.is_control() && *c != '<' && *c != '>')
338 .collect();
339 let t = t.trim();
340 if t.is_empty() {
341 "anonymous".to_string()
342 } else {
343 t.to_string()
344 }
345}
346
347#[allow(clippy::too_many_arguments)]
359pub(crate) async fn commit_file(
360 scratch_dir: &Path,
361 dir: &Path,
362 branch: &str,
363 parent: Option<&str>,
364 old: Option<&str>,
365 rel_path: &str,
366 content: &[u8],
367 message: &str,
368 who: &str,
369) -> Result<Committed, ForgeError> {
370 use tokio::io::AsyncWriteExt;
371
372 if clean_edit_path(rel_path).as_deref() != Some(rel_path) || !ok_ref(branch) {
374 return Err(ForgeError::BadRequest("invalid path or branch".into()));
375 }
376 if parent.is_some_and(|p| !valid_oid(p)) || old.is_some_and(|o| !valid_oid(o)) {
377 return Err(ForgeError::BadRequest("invalid commit id".into()));
378 }
379 tokio::fs::create_dir_all(scratch_dir).await?;
380 let scratch_dir = tokio::fs::canonicalize(scratch_dir).await?;
381 let token = uuid::Uuid::new_v4().simple().to_string();
382 let blob_file = scratch_dir.join(format!("{token}.blob"));
383 let index_file = scratch_dir.join(format!("{token}.index"));
384 let _cleanup = Scratch(vec![blob_file.clone(), index_file.clone()]);
385
386 let mut opts = tokio::fs::OpenOptions::new();
387 opts.write(true).create_new(true);
388 #[cfg(unix)]
389 opts.mode(0o600);
390 let mut f = opts.open(&blob_file).await?;
391 f.write_all(content).await?;
392 f.flush().await?;
393 drop(f);
394
395 let blob_arg = blob_file
396 .to_str()
397 .ok_or_else(|| ForgeError::Backend("non-UTF-8 scratch path".into()))?;
398 let index_arg = index_file
399 .to_str()
400 .ok_or_else(|| ForgeError::Backend("non-UTF-8 scratch path".into()))?;
401
402 let hashed = prgit::run(
403 dir,
404 &[
405 "hash-object",
406 "-w",
407 "--no-filters",
408 "-t",
409 "blob",
410 "--",
411 blob_arg,
412 ],
413 )
414 .await?;
415 let blob = hashed.text().trim().to_string();
416 if !hashed.ok() || !valid_oid(&blob) {
417 return Err(ForgeError::Backend(format!(
418 "git hash-object: {}",
419 hashed.stderr.trim()
420 )));
421 }
422
423 let idx_env = [("GIT_INDEX_FILE", index_arg)];
424 let mut mode = "100644".to_string();
425 if let Some(parent) = parent {
426 let segs: Vec<&str> = rel_path.split('/').collect();
431 for i in 1..=segs.len() {
432 let spec = segs[..i].join("/");
433 let lt = prgit::run_at(
434 dir,
435 Some(dir),
436 &["ls-tree", "-z", "--full-tree", parent, "--", &spec],
437 &[("GIT_LITERAL_PATHSPECS", "1")],
438 )
439 .await?;
440 if !lt.ok() {
441 return Err(ForgeError::Backend(format!(
442 "git ls-tree: {}",
443 lt.stderr.trim()
444 )));
445 }
446 let listing = lt.text();
447 let entry = listing
448 .split('\0')
449 .filter_map(|r| r.split_once('\t'))
450 .find(|(_, name)| *name == spec);
451 let Some((meta, _)) = entry else {
452 break;
454 };
455 let m = meta.split(' ').next().unwrap_or("");
456 if i < segs.len() {
457 if m != "040000" {
458 return Err(ForgeError::Conflict(format!(
459 "{spec} is not a directory in {branch}"
460 )));
461 }
462 } else if m == "100644" || m == "100755" {
463 mode = m.to_string();
464 } else {
465 return Err(ForgeError::Conflict(
466 "the target is a directory, symlink or submodule".into(),
467 ));
468 }
469 }
470 let rt = prgit::run_at(dir, Some(dir), &["read-tree", parent], &idx_env).await?;
471 if !rt.ok() {
472 return Err(ForgeError::Backend(format!(
473 "git read-tree: {}",
474 rt.stderr.trim()
475 )));
476 }
477 }
478
479 let cacheinfo = format!("{mode},{blob},{rel_path}");
480 let ui = prgit::run_at(
481 dir,
482 Some(dir),
483 &["update-index", "--add", "--cacheinfo", &cacheinfo],
484 &idx_env,
485 )
486 .await?;
487 if !ui.ok() {
488 tracing::warn!(
490 "forge: web edit update-index refused {rel_path}: {}",
491 ui.stderr.trim()
492 );
493 return Err(ForgeError::Conflict(format!(
494 "cannot place {rel_path} in the tree"
495 )));
496 }
497 let wt = prgit::run_at(dir, Some(dir), &["write-tree"], &idx_env).await?;
498 let tree = wt.text().trim().to_string();
499 if !wt.ok() || !valid_oid(&tree) {
500 return Err(ForgeError::Backend(format!(
501 "git write-tree: {}",
502 wt.stderr.trim()
503 )));
504 }
505
506 if let Some(parent) = parent {
507 let parent_tree = rev(dir, &format!("{parent}^{{tree}}")).await;
508 if parent_tree.as_deref() == Some(tree.as_str()) {
509 return Ok(Committed {
510 sha: Some(parent.to_string()),
511 changed: false,
512 });
513 }
514 }
515
516 let author = ident(who);
517 let email = format!("{}@forge.invalid", author.replace(' ', "-"));
518 let mut args: Vec<&str> = vec!["commit-tree", &tree];
519 if let Some(p) = parent {
520 args.extend(["-p", p]);
521 }
522 args.extend(["-m", message]);
523 let ct = prgit::run_at(
524 dir,
525 Some(dir),
526 &args,
527 &[
528 ("GIT_AUTHOR_NAME", &author),
529 ("GIT_AUTHOR_EMAIL", &email),
530 ("GIT_COMMITTER_NAME", "forge web edit"),
531 ("GIT_COMMITTER_EMAIL", "forge@forge.invalid"),
532 ],
533 )
534 .await?;
535 let commit = ct.text().trim().to_string();
536 if !ct.ok() || !valid_oid(&commit) {
537 return Err(ForgeError::Backend(format!(
538 "git commit-tree: {}",
539 ct.stderr.trim()
540 )));
541 }
542
543 let refname = format!("refs/heads/{branch}");
544 let zeros = "0".repeat(commit.len());
545 let expect = old.unwrap_or(&zeros);
546 let upd = prgit::run(dir, &["update-ref", &refname, &commit, expect]).await?;
547 if !upd.ok() {
548 let now = rev(dir, &refname).await;
551 if now.as_deref() != old {
552 return Err(ForgeError::Conflict(
553 "the branch moved during the edit, retry".into(),
554 ));
555 }
556 return Err(ForgeError::BadRequest(
557 "the branch cannot be updated".into(),
558 ));
559 }
560 Ok(Committed {
561 sha: Some(commit),
562 changed: true,
563 })
564}
565
566impl ForgeService {
567 fn edit_failure(e: &ForgeError, what: &str) -> ForgeResponse {
570 match e {
571 ForgeError::NotFound(_) | ForgeError::PathTraversal(_) => cors_err(404, "not found"),
572 ForgeError::Conflict(m) => cors_err(409, m),
573 ForgeError::BadRequest(m) => cors_err(400, m),
574 other => {
575 tracing::warn!("forge: {what} failed: {other}");
576 cors_err(500, "edit failed")
577 }
578 }
579 }
580
581 pub(crate) async fn h_api_edit(
584 &self,
585 req: &ForgeRequest,
586 agent: &ForgeAgent,
587 owner: &str,
588 name: &str,
589 ) -> ForgeResponse {
590 if !req.method.eq_ignore_ascii_case("POST") {
591 return api_err(405, "method not allowed");
592 }
593 let ed = match validate(self.cfg.open_edit, req, agent, owner, "edit") {
594 Ok(e) => e,
595 Err(resp) => return resp,
596 };
597 let dir = match self.resolve_repo(owner, name).await {
598 Ok(d) => d,
599 Err(e) => return Self::edit_failure(&e, "web edit"),
600 };
601 let branch = match resolve_branch(&dir, ed.branch.as_ref()).await {
602 Ok(b) => b,
603 Err(resp) => return resp,
604 };
605 let who = ed.authed_owner.as_deref().unwrap_or("anonymous");
606 let outcome = {
607 let _spine = self.locks.pulls.lock(owner, name).await;
613 let _merge = prgit::pull_locks().lock(&format!("{owner}/{name}")).await;
614 self.edit_locked(&dir, &branch, &ed, who).await
615 };
616 let done = match outcome {
617 Ok(d) => d,
618 Err(e) => {
619 tracing::warn!("forge: web edit {owner}/{name}@{branch} {}: {e}", ed.path);
620 return Self::edit_failure(&e, "web edit");
621 }
622 };
623 if !done.changed {
624 return cors_json(
625 200,
626 &json!({"ok": true, "commit": done.sha, "changed": false}),
627 );
628 }
629 let commit = done.sha.unwrap_or_default();
630 tracing::info!(
631 "forge: web edit {owner}/{name}@{branch} {} -> {} by {}",
632 ed.path,
633 &commit[..7.min(commit.len())],
634 agent.author_id()
635 );
636 self.pr.tip.record_tip(owner, name).await;
640 #[cfg(feature = "anchoring")]
641 if let Err(e) = self.record_tip(owner, name, &branch, &commit).await {
642 tracing::warn!("forge: recording the anchor tip after a web edit failed: {e}");
643 }
644 #[cfg(feature = "announce")]
645 {
646 let origin = self.cfg.public_origin(req.host_url.as_deref());
647 self.announce_repo_in_background(owner, name, &origin);
648 }
649 cors_json(
650 200,
651 &json!({
652 "ok": true,
653 "commit": commit,
654 "changed": true,
655 "url": format!("{}/{owner}/{name}", self.prefix()),
656 }),
657 )
658 }
659
660 async fn edit_locked(
662 &self,
663 dir: &Path,
664 branch: &str,
665 ed: &EditRequest,
666 who: &str,
667 ) -> Result<Committed, ForgeError> {
668 let own = rev(dir, &format!("refs/heads/{branch}^{{commit}}")).await;
669 let (parent, old) = match &own {
670 Some(tip) => (Some(tip.clone()), Some(tip.clone())),
671 None => {
672 let def = browse::default_branch(dir).await;
674 (
675 rev(dir, &format!("refs/heads/{def}^{{commit}}")).await,
676 None,
677 )
678 }
679 };
680 commit_file(
681 &self.plugin_dir.join("web-edit-tmp"),
682 dir,
683 branch,
684 parent.as_deref(),
685 old.as_deref(),
686 &ed.path,
687 ed.content.as_bytes(),
688 &ed.message,
689 who,
690 )
691 .await
692 }
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698 use crate::gitrun::testutil::git;
699
700 #[test]
701 fn path_rules() {
702 for ok in [
703 "index.html",
704 "docs/guide.md",
705 "a/b/c.txt",
706 "docs/.hidden",
707 "-n",
708 "a/-n",
709 ] {
710 assert_eq!(clean_edit_path(ok).as_deref(), Some(ok), "{ok}");
711 }
712 let long = format!("{}/x", "a".repeat(1100));
713 for bad in [
714 "",
715 "/etc/x",
716 "../x",
717 "a/../../b",
718 "a/..",
719 "a/./b",
720 ".",
721 "...",
722 ".git/x",
723 "a/.git",
724 "a/.GIT/x",
725 "a/.Git/x",
726 "a/.git./x",
727 "a/GIT~1/x",
728 "a\\b",
729 "a\0b",
730 "a\nb",
731 "a b",
732 "a//b",
733 "a/",
734 ".acl",
735 ".htaccess",
736 "caf\u{e9}",
737 "a;b",
738 "a%2eb",
739 long.as_str(),
740 ] {
741 assert!(clean_edit_path(bad).is_none(), "{bad:?} must be refused");
742 }
743 }
744
745 #[test]
746 fn message_rules() {
747 assert_eq!(clean_message(Some("x\r\ny"), "p"), "x y");
748 assert_eq!(clean_message(Some("\0"), "p"), "web edit: p");
749 assert_eq!(clean_message(None, "d/e.md"), "web edit: d/e.md");
750 let s = "é".repeat(2000);
751 assert_eq!(clean_message(Some(&s), "p").chars().count(), EDIT_MSG_CAP);
752 }
753
754 fn bare_with_main() -> (tempfile::TempDir, PathBuf, String) {
755 let td = tempfile::tempdir().unwrap();
756 let work = td.path().join("w");
757 std::fs::create_dir_all(&work).unwrap();
758 git(&work, &["init", "-q", "-b", "main"]);
759 std::fs::write(work.join("a.txt"), "one\n").unwrap();
760 git(&work, &["add", "-A"]);
761 git(&work, &["commit", "-q", "-m", "seed"]);
762 let tip = git(&work, &["rev-parse", "HEAD"]);
763 let bare = td.path().join("r.git");
764 git(
765 td.path(),
766 &[
767 "clone",
768 "-q",
769 "--bare",
770 work.to_str().unwrap(),
771 bare.to_str().unwrap(),
772 ],
773 );
774 (td, bare, tip)
775 }
776
777 #[tokio::test]
778 async fn stale_tip_is_a_conflict_and_leaves_the_ref_alone() {
779 let (td, bare, tip) = bare_with_main();
780 let scratch = td.path().join("s");
781 let first = commit_file(
783 &scratch,
784 &bare,
785 "main",
786 Some(&tip),
787 Some(&tip),
788 "b.txt",
789 b"b\n",
790 "first",
791 "u",
792 )
793 .await
794 .unwrap();
795 assert!(first.changed);
796 let moved = first.sha.unwrap();
797 let err = commit_file(
799 &scratch,
800 &bare,
801 "main",
802 Some(&tip),
803 Some(&tip),
804 "c.txt",
805 b"c\n",
806 "second",
807 "u",
808 )
809 .await
810 .unwrap_err();
811 assert!(matches!(err, ForgeError::Conflict(_)), "{err:?}");
812 assert_eq!(git(&bare, &["rev-parse", "refs/heads/main"]), moved);
813 assert!(git(&bare, &["ls-tree", "-r", "--name-only", "main"])
814 .lines()
815 .all(|l| l != "c.txt"));
816 assert_eq!(std::fs::read_dir(&scratch).unwrap().count(), 0);
818 }
819
820 #[tokio::test]
821 async fn new_branch_must_not_exist_when_old_is_none() {
822 let (td, bare, tip) = bare_with_main();
823 let scratch = td.path().join("s");
824 let ok = commit_file(
825 &scratch,
826 &bare,
827 "feat",
828 Some(&tip),
829 None,
830 "n.txt",
831 b"n\n",
832 "m",
833 "u",
834 )
835 .await
836 .unwrap();
837 assert!(ok.changed);
838 let err = commit_file(
840 &scratch,
841 &bare,
842 "feat",
843 Some(&tip),
844 None,
845 "o.txt",
846 b"o\n",
847 "m",
848 "u",
849 )
850 .await
851 .unwrap_err();
852 assert!(matches!(err, ForgeError::Conflict(_)), "{err:?}");
853 }
854}