Skip to main content

solid_pod_rs_forge/
web_edit.rs

1//! `POST <prefix>/api/repos/<owner>/<repo>/edit`: commit **one file** over
2//! HTTP, the GitHub web-editor equivalent.
3//!
4//! Follows the JSS `forge` tier 3.7 (`apiRepoEdit`, `cleanEditPath`,
5//! `withCors`, cited by name only; all code original). The request body is
6//! `{"path", "content", "message"?, "branch"?}`.
7//!
8//! # Authorisation
9//!
10//! Owner-signed by default: no credential is `401` (with a `Bearer`
11//! challenge), a credential for another namespace is `403`. With
12//! [`ForgeConfig::open_edit`](crate::ForgeConfig::open_edit) (JSS
13//! `openEdit`) **no authorisation check runs at all**: an anonymous caller
14//! may edit **any** repository. JSS has no "demo namespace" restriction and
15//! neither does this port; the flag is for throwaway demo forges only and
16//! [`ForgeService::new`] logs a warning when it is on.
17//!
18//! # How the commit is made
19//!
20//! JSS clones the bare repository into a temporary working tree. This port
21//! never checks anything out: it hashes the content with `git hash-object`,
22//! stages it into a **private index file** (`GIT_INDEX_FILE`, seeded with
23//! `read-tree`), writes the tree, creates the commit with `commit-tree` and
24//! advances the branch with `update-ref <ref> <new> <old>`. The old value is
25//! the tip read under the per-repository lock, so a racing push makes the
26//! write fail with `409` instead of being overwritten (compare-and-swap).
27//! Every git invocation goes through the hardened argv-only runner; no value
28//! supplied by the caller ever reaches git as an option.
29//!
30//! # Path rules
31//!
32//! See [`clean_edit_path`]. Compared with JSS this port is deliberately
33//! stricter in three places: the `.git` check is case-insensitive (so
34//! `.GIT` and `.Git` are refused), segments made only of dots (`.`, `...`)
35//! and NTFS spellings of `.git` (`.git.`, `git~1`) are refused, and an
36//! existing symlink, submodule or directory at the target (or a symlink or
37//! file on the way to it) is a `409`, never written through.
38//!
39//! # Examples
40//!
41//! ```
42//! use solid_pod_rs_forge::web_edit::{clean_edit_path, clean_message};
43//!
44//! assert_eq!(clean_edit_path("docs/guide.md").as_deref(), Some("docs/guide.md"));
45//! assert!(clean_edit_path("../x").is_none());
46//! assert!(clean_edit_path("a/.GIT/config").is_none());
47//! assert_eq!(clean_message(Some("  fix\n\ttypo "), "a.txt"), "fix typo");
48//! assert_eq!(clean_message(None, "a.txt"), "web edit: a.txt");
49//! ```
50
51use 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
64/// Largest file content accepted, in bytes (JSS `EDIT_CONTENT_CAP`, 1 MiB).
65pub const EDIT_CONTENT_CAP: usize = 1024 * 1024;
66/// Longest commit message kept, in characters (JSS `EDIT_MSG_CAP`).
67pub const EDIT_MSG_CAP: usize = 1000;
68/// Longest accepted path, in characters (JSS `EDIT_PATH_CAP`).
69pub const EDIT_PATH_CAP: usize = 1024;
70/// Largest request body read: content cap plus 128 KiB of JSON framing
71/// (JSS `readJsonBody(request, EDIT_CONTENT_CAP + 128 * 1024)`).
72pub const EDIT_BODY_CAP: usize = EDIT_CONTENT_CAP + 128 * 1024;
73
74/// Is `seg` an acceptable path segment? JSS `EDIT_SEG`
75/// (`^[A-Za-z0-9._-]+$`) plus the three tightenings described in the
76/// module documentation.
77fn 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    // `.`, `..`, `...`: never a real file name (and `..` is traversal).
86    if seg.bytes().all(|b| b == b'.') {
87        return false;
88    }
89    let lower = seg.to_ascii_lowercase();
90    // `.git`, `.GIT`, and the NTFS alias `.git.` (trailing dots are dropped).
91    if lower.trim_end_matches('.') == ".git" {
92        return false;
93    }
94    // NTFS 8.3 short name of `.git`: `git~1`, `GIT~2`, ...
95    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/// Validate a repo-relative edit path (JSS `cleanEditPath`). Returns the
104/// clean path, or `None`.
105///
106/// Refused: empty or longer than [`EDIT_PATH_CAP`], a leading `/`, a
107/// backslash, a control character (NUL included), an empty segment, `..`,
108/// any `.git` segment in any letter case, a segment outside
109/// `[A-Za-z0-9._-]`, and a dotfile at the repository **root** (`.acl`,
110/// `.htaccess`).
111///
112/// # Examples
113///
114/// ```
115/// use solid_pod_rs_forge::web_edit::clean_edit_path;
116///
117/// assert!(clean_edit_path("index.html").is_some());
118/// assert!(clean_edit_path("docs/.hidden").is_some());
119/// for bad in ["", "/etc/x", "a/../b", "a\\b", ".git/x", "a/.Git/x", ".acl", "a//b", "a\0b"] {
120///     assert!(clean_edit_path(bad).is_none(), "{bad:?}");
121/// }
122/// ```
123#[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/// The commit message for an edit: control-character runs become one space,
142/// the result is trimmed and cut to [`EDIT_MSG_CAP`] **characters** (never
143/// inside a UTF-8 sequence), and an empty result becomes
144/// `web edit: <path>`.
145///
146/// # Examples
147///
148/// ```
149/// use solid_pod_rs_forge::web_edit::clean_message;
150///
151/// assert_eq!(clean_message(Some("a\0\0b"), "p"), "a b");
152/// assert_eq!(clean_message(Some("   "), "p"), "web edit: p");
153/// assert_eq!(clean_message(Some(&"x".repeat(5000)), "p").chars().count(), 1000);
154/// ```
155#[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
178/// Attach the JSS `CORS_HEADERS` (`withCors`) to a reply so a cross-origin
179/// editor page can read it, error replies included.
180pub(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
190/// A CORS-readable JSON reply.
191pub(crate) fn cors_json(status: u16, v: &Value) -> ForgeResponse {
192    cors(json_resp(status, v))
193}
194
195/// A CORS-readable `{ "error": msg }` reply (JSS `corsErr`).
196pub(crate) fn cors_err(status: u16, msg: &str) -> ForgeResponse {
197    cors(api_err(status, msg))
198}
199
200/// A validated edit or preview request body.
201#[derive(Debug)]
202pub(crate) struct EditRequest {
203    /// The clean repo-relative path.
204    pub path: String,
205    /// The file content.
206    pub content: String,
207    /// The commit message (already cleaned).
208    pub message: String,
209    /// The `branch` member exactly as sent (`None` when the key is absent).
210    pub branch: Option<Value>,
211    /// The authenticated namespace, if any.
212    pub authed_owner: Option<String>,
213}
214
215/// The gates shared by `/edit` and `/preview`, in JSS order: authorisation,
216/// body shape, path, content type, content size, message. Failures are
217/// complete CORS-readable replies.
218pub(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
275/// The git command to run once the branch is known to be valid: refuses
276/// anything `check-ref-format` would, so a name like `x.lock` or `a/.b` is a
277/// clean `400` rather than a late `update-ref` failure.
278pub(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
299/// Scratch files for one edit, removed on every exit path.
300struct 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/// The result of [`commit_file`].
311#[derive(Debug, PartialEq, Eq)]
312pub(crate) struct Committed {
313    /// The new commit, or the unchanged parent when `changed` is false.
314    pub sha: Option<String>,
315    /// Whether a commit was created and the ref moved.
316    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
323/// `git rev-parse --verify --quiet <spec>` where `spec` is built by this
324/// module from a fixed `refs/heads/` prefix.
325async 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
333/// A git identity part without control characters or angle brackets.
334fn 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/// Create the commit and advance `refs/heads/<branch>` from `old` to it.
348///
349/// * `parent` is the commit the new tree is built on: the branch tip, or for
350///   a branch that does not exist yet the default branch's tip (JSS
351///   `checkout -b`), or `None` for a root commit in an empty repository.
352/// * `old` is the value the ref must still have (`None`: the branch must not
353///   exist). The write is a compare-and-swap: when the ref moved the result
354///   is [`ForgeError::Conflict`] and nothing is changed.
355///
356/// An edit that leaves the tree identical creates nothing
357/// (`changed: false`, the ref is not touched).
358#[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    // Defence in depth: the callers validated these already.
373    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        // What is at the target and on the way to it, one prefix at a time
427        // (a single `ls-tree` over several nested paths lists directory
428        // contents instead of the directory entry). `GIT_LITERAL_PATHSPECS`
429        // makes every operand a plain path, never a pathspec magic.
430        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                // Nothing here, so nothing deeper either.
453                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        // git's own message can name the scratch index path: log it, never send it.
489        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        // Moved under us (a push raced the lock)? Then it is a conflict the
549        // caller can retry; otherwise the ref name itself was unusable.
550        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    /// Map an internal failure to a CORS-readable reply. Details go to the
568    /// log only; the temp paths and raw git stderr never reach the caller.
569    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    /// `POST api/repos/<o>/<n>/edit` (JSS `apiRepoEdit`). Never returns an
582    /// error: every outcome is a CORS-readable JSON reply.
583    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            // One writer per repository at a time: the spine pull lock, plus
608            // the lock `merge` holds while it moves a branch, so an edit and
609            // a merge never interleave (the CAS still guards pushes, which
610            // take neither). Order: spine lock first; `merge` takes only the
611            // second, so the two cannot deadlock.
612            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        // The tip moved: advance any anchor exactly as a push would, THEN
637        // announce, so the 30618 state carries the new anchor (JSS
638        // `recordTipSafe` before `announceRepoSafe`).
639        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    /// Read the tip and commit onto it; the caller holds the repo lock.
661    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                // A new branch starts from the default branch (JSS `checkout -b`).
673                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        // A racing writer advances main after the edit read `tip`.
782        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        // The stale writer still believes the tip is `tip`.
798        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        // The scratch directory is empty afterwards.
817        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        // Creating it again with old = None loses the race.
839        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}