.forge/prs/2ca837d0-d2ca-432d-b161-bb509da620e9/patch.diff 12.8 KiB 337 lines Raw
1 From e04d65ca2f1b82bdd5ec037e6340df05833b3613 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 07:09:44 +01004 Subject: [PATCH] m6/diff-colors: tokyo night diff classes dark+light (1535  tests)6 7 ---8  src/commits.rs |  78 +++++++++++++++++++++++++++++++--9  src/forge.rs   |  49 ++++++++++++++++++++-10  src/layout.rs  | 117 ++++++++++++++++++++++++++++++++++++++++++++++++-11  3 files changed, 237 insertions(+), 7 deletions(-)12 13 diff --git a/src/commits.rs b/src/commits.rs14 index 3467d9f..9c3b288 10064415 --- a/src/commits.rs16 +++ b/src/commits.rs17 @@ -1,14 +1,15 @@18  //! `commits.html` + `commit/<sha>.html` (m2, f0rge issue #5).19  //!20 -//! Commit subjects, messages, and diffs are untrusted: everything goes21 -//! through [`crate::layout::esc`] into an escaped `<pre>`. Diffs come from22 +//! Commit subjects and messages go through [`crate::layout::esc`]; diffs go23 +//! through [`crate::layout::diff_html`] (escaped line-level color spans in an24 +//! escaped `<pre>`, m6 diff-colors f0rge #22). Diffs come from25  //! [`crate::gitx::diff`] (binary content noted, output truncated past26  //! 2000 lines with a marker). Nothing here goes through markdown.27  28  use std::path::Path;29  30  use crate::gitx;31 -use crate::layout::{esc, page_site};32 +use crate::layout::{diff_html, esc, page_site};33  34  /// Short display form of a full sha (first 7 hex chars, git-style).35  #[must_use]36 @@ -72,7 +73,7 @@ pub fn render_loaded(37              esc(&c.author),38              esc(&c.date),39              esc(&message),40 -            esc(&diff),41 +            diff_html(&diff),42          );43          let html = page_site(44              &format!("commit {} — {site_name}", short_sha(&c.sha)),45 @@ -237,6 +238,75 @@ mod tests {46          );47      }48  49 +    #[test]50 +    fn diff_lines_carry_color_spans() {51 +        // m6 diff-colors (f0rge #22): hunk cyan, +++/--- bold, + green, - red.52 +        let repo = fixture_repo();53 +        let out = mktemp("out");54 +        render(&repo, &out, "main", 100, "test", "<nav>", None).expect("render");55 +        let sha = shas(&repo)[1].clone(); // text hunk + binary commit56 +        let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))57 +            .expect("detail");58 +        assert!(59 +            detail.contains("<span class=\"diff-hunk\">@@"),60 +            "hunk header cyan: {detail}"61 +        );62 +        assert!(63 +            detail.contains("<span class=\"diff-file\">---"),64 +            "--- file line bold: {detail}"65 +        );66 +        assert!(67 +            detail.contains("<span class=\"diff-file\">+++"),68 +            "+++ file line bold: {detail}"69 +        );70 +        assert!(71 +            detail.contains("<span class=\"diff-add\">+line two added</span>"),72 +            "added line green: {detail}"73 +        );74 +        // Binary note + truncation marker behavior intact (no span, still shown).75 +        assert!(76 +            detail.contains("Binary files") || detail.contains(gitx::BINARY_NOTE),77 +            "binary note intact"78 +        );79 +        let huge_sha = shas(&repo)[0].clone();80 +        let huge = std::fs::read_to_string(out.join("commit").join(format!("{huge_sha}.html")))81 +            .expect("huge detail");82 +        assert!(83 +            huge.contains(gitx::DIFF_TRUNCATED_MARKER),84 +            "truncation marker intact with spans"85 +        );86 +    }87 +88 +    #[test]89 +    fn diff_escaping_intact_inside_spans() {90 +        // Evil `<script>` added line: escaped, still inside the green span.91 +        let dir = mktemp("evil-diff");92 +        git(&dir, &["init", "-b", "main"]);93 +        git(&dir, &["config", "user.name", "hu13-test"]);94 +        git(&dir, &["config", "user.email", "[email protected]"]);95 +        git(&dir, &["config", "commit.gpgsign", "false"]);96 +        std::fs::write(dir.join("x.txt"), "ok\n").unwrap();97 +        git(&dir, &["add", "-A"]);98 +        git(&dir, &["commit", "-m", "base"]);99 +        std::fs::write(dir.join("x.txt"), "ok\nevil <script>alert(1)</script>\n").unwrap();100 +        git(&dir, &["add", "-A"]);101 +        git(&dir, &["commit", "-m", "evil diff"]);102 +        let out = mktemp("out");103 +        render(&dir, &out, "main", 100, "test", "<nav>", None).expect("render");104 +        let sha = shas(&dir)[0].clone();105 +        let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))106 +            .expect("detail");107 +        assert!(!detail.contains("<script>alert(1)</script>"), "no raw HTML");108 +        assert!(109 +            detail.contains("&lt;script&gt;"),110 +            "escaped form present: {detail}"111 +        );112 +        assert!(113 +            detail.contains("<span class=\"diff-add\">+evil &lt;script&gt;"),114 +            "escaped inside green span: {detail}"115 +        );116 +    }117 +118      #[test]119      fn max_log_limits_list_and_details() {120          let repo = fixture_repo();121 diff --git a/src/forge.rs b/src/forge.rs122 index 951136b..1f8af3e 100644123 --- a/src/forge.rs124 +++ b/src/forge.rs125 @@ -23,7 +23,7 @@ use std::path::Path;126  use serde::Deserialize;127  128  use crate::build::markdown_to_html;129 -use crate::layout::{esc, page_site};130 +use crate::layout::{diff_html, esc, page_site};131  132  /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).133  pub const FORGE_MAX_BYTES: u64 = 256 * 1024;134 @@ -898,7 +898,7 @@ fn render_pr_page(pr: &Pr, site_name: &str, nav: &str, description: Option<&str>135          Some(patch) if !patch.is_empty() => {136              body.push_str(&format!(137                  "<div class=\"pre-scroll\"><pre class=\"diff\">{}</pre></div>\n",138 -                esc(patch)139 +                diff_html(patch)140              ));141          }142          _ => body.push_str("<p class=\"muted\">No patch attached.</p>\n"),143 @@ -1326,6 +1326,51 @@ mod tests {144          assert!(html.contains("lgtm"), "review body shown");145      }146  147 +    #[test]148 +    fn pr_patch_lines_carry_diff_spans() {149 +        // m6 diff-colors (f0rge #22): PR patch.diff shares the commit renderer.150 +        let repo = mktemp("pr-diff");151 +        write_forge(152 +            &repo,153 +            "prs/cccccccc-cccc-cccc-cccc-cccccccccccc/pr.md",154 +            "---\nid: cccccccc-cccc-cccc-cccc-cccccccccccc\ntitle: Fix it\nstate: open\nbase: main\nhead: fix\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n",155 +        );156 +        let dest = repo157 +            .join(".forge")158 +            .join("prs/cccccccc-cccc-cccc-cccc-cccccccccccc/patch.diff");159 +        std::fs::write(160 +            &dest,161 +            "diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+evil <script>\n",162 +        )163 +        .unwrap();164 +        let out = mktemp("pr-diff-out");165 +        render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");166 +        let html =167 +            std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))168 +                .expect("pr page");169 +        assert!(170 +            html.contains("<span class=\"diff-hunk\">@@ -1 +1 @@</span>"),171 +            "hunk cyan: {html}"172 +        );173 +        assert!(174 +            html.contains("<span class=\"diff-file\">--- a/x</span>"),175 +            "--- file bold"176 +        );177 +        assert!(178 +            html.contains("<span class=\"diff-file\">+++ b/x</span>"),179 +            "+++ file bold"180 +        );181 +        assert!(182 +            html.contains("<span class=\"diff-del\">-old</span>"),183 +            "removed red"184 +        );185 +        assert!(!html.contains("<script>"), "patch escaped");186 +        assert!(187 +            html.contains("<span class=\"diff-add\">+evil &lt;script&gt;</span>"),188 +            "added green, escaped in span"189 +        );190 +    }191 +192      #[test]193      fn tasks_group_and_ci_fail_badge() {194          let repo = mktemp("taskci");195 diff --git a/src/layout.rs b/src/layout.rs196 index fbecd44..af31aa2 100644197 --- a/src/layout.rs198 +++ b/src/layout.rs199 @@ -24,6 +24,60 @@ pub fn esc(input: &str) -> String {200      out201  }202  203 +/// Render unified diff / patch text as escaped HTML with line-level color204 +/// spans (m6 diff-colors, f0rge issue #22; Tokyo Night vars in205 +/// `static/style.css`, dark + light).206 +///207 +/// Line kinds (line-level only, no word-diff): `@@` hunk headers go in208 +/// `<span class="diff-hunk">`, `+++`/`---` file lines in209 +/// `<span class="diff-file">` (bold fg), `+` additions in210 +/// `<span class="diff-add">`, `-` removals in `<span class="diff-del">`.211 +/// Everything else (context, `diff --git` / `index` headers, commit212 +/// metadata, `Binary files ... differ` notes, `[hu13: ...]` truncation /213 +/// binary markers) is escaped as-is with no span so truncation and binary214 +/// behavior stay intact. Every line is escaped with [`esc`] before wrapping,215 +/// so hostile `<script>` inside an added line renders as text. Output joins216 +/// lines with `\n` (preserving a trailing newline) for use inside an217 +/// already-escaped `<pre class="diff">`.218 +#[must_use]219 +pub fn diff_html(raw: &str) -> String {220 +    if raw.is_empty() {221 +        return String::new();222 +    }223 +    let mut out = String::with_capacity(raw.len() + raw.len() / 4);224 +    let mut first = true;225 +    for line in raw.lines() {226 +        if !first {227 +            out.push('\n');228 +        }229 +        first = false;230 +        let e = esc(line);231 +        if line.starts_with("@@") {232 +            out.push_str("<span class=\"diff-hunk\">");233 +            out.push_str(&e);234 +            out.push_str("</span>");235 +        } else if line.starts_with("+++") || line.starts_with("---") {236 +            out.push_str("<span class=\"diff-file\">");237 +            out.push_str(&e);238 +            out.push_str("</span>");239 +        } else if line.starts_with('+') {240 +            out.push_str("<span class=\"diff-add\">");241 +            out.push_str(&e);242 +            out.push_str("</span>");243 +        } else if line.starts_with('-') {244 +            out.push_str("<span class=\"diff-del\">");245 +            out.push_str(&e);246 +            out.push_str("</span>");247 +        } else {248 +            out.push_str(&e);249 +        }250 +    }251 +    if raw.ends_with('\n') {252 +        out.push('\n');253 +    }254 +    out255 +}256 +257  /// Render a full HTML page. `title` is escaped; `nav` and `body` are258  /// inlined as-is (caller must escape untrusted text with [`esc`]).259  #[allow(dead_code)]260 @@ -189,7 +243,7 @@ pub fn healthcheck_page() -> String {261  262  #[cfg(test)]263  mod tests {264 -    use super::{esc, healthcheck_page, page};265 +    use super::{diff_html, esc, healthcheck_page, page};266  267      #[test]268      fn esc_escapes_angle_amp_quote() {269 @@ -349,4 +403,65 @@ mod tests {270          let pre = "<a href=\"/\" aria-current=\"page\">code</a>";271          assert_eq!(apply_active(pre, "/"), pre);272      }273 +274 +    #[test]275 +    fn diff_html_colors_each_line_kind() {276 +        // m6 diff-colors (f0rge #22): line-level only, spans + classes.277 +        let raw = "diff --git a/notes.txt b/notes.txt\n\278 +--- a/notes.txt\n+++ b/notes.txt\n\279 +@@ -1 +1,2 @@\n line one\n-line gone\n+line two added\n";280 +        let html = diff_html(raw);281 +        assert!(282 +            html.contains("<span class=\"diff-hunk\">@@ -1 +1,2 @@</span>"),283 +            "hunk cyan: {html}"284 +        );285 +        assert!(286 +            html.contains("<span class=\"diff-file\">--- a/notes.txt</span>"),287 +            "--- file bold: {html}"288 +        );289 +        assert!(290 +            html.contains("<span class=\"diff-file\">+++ b/notes.txt</span>"),291 +            "+++ file bold: {html}"292 +        );293 +        assert!(294 +            html.contains("<span class=\"diff-add\">+line two added</span>"),295 +            "added green: {html}"296 +        );297 +        assert!(298 +            html.contains("<span class=\"diff-del\">-line gone</span>"),299 +            "removed red: {html}"300 +        );301 +        // Context + `diff --git` header stay plain (no span).302 +        assert!(html.contains(" line one"), "context kept");303 +        assert!(html.contains("diff --git"), "diff header kept");304 +        assert!(!html.contains("<span> line one"), "context has no span");305 +    }306 +307 +    #[test]308 +    fn diff_html_escapes_before_wrapping() {309 +        let html = diff_html("+evil <script>alert(1)</script>\n-old & \"quoted\"\n");310 +        assert!(!html.contains("<script>"), "no raw HTML: {html}");311 +        assert!(312 +            html.contains(313 +                "<span class=\"diff-add\">+evil &lt;script&gt;alert(1)&lt;/script&gt;</span>"314 +            ),315 +            "added line escaped in span: {html}"316 +        );317 +        assert!(318 +            html.contains("<span class=\"diff-del\">-old &amp; &quot;quoted&quot;</span>"),319 +            "removed line escaped in span: {html}"320 +        );321 +    }322 +323 +    #[test]324 +    fn diff_html_keeps_markers_as_plain_text() {325 +        // Truncation + binary notes pass through escaped, span-free.326 +        let marker = "[hu13: diff truncated at 2000 lines]";327 +        let binary = "Binary files a/bin.dat and b/bin.dat differ";328 +        let html = diff_html(&format!("+x\n{marker}\n{binary}\n"));329 +        assert!(html.contains("<span class=\"diff-add\">+x</span>"));330 +        assert!(html.contains(marker), "truncation marker intact");331 +        assert!(html.contains("Binary files"), "binary note intact");332 +        assert!(!html.contains("<span>\n[hu13"), "markers have no span");333 +    }334  }335 -- 336 2.55.0337