#22 m6/diff-colors

mergedpassopened
details
branches
master ← m6/diff-colors
updated
id
2ca837d0-d2ca-432d-b161-bb509da620e9

Patch

src/commits.rs+74 −4
diff --git a/src/commits.rs b/src/commits.rsindex 3467d9f..9c3b288 100644--- a/src/commits.rs+++ b/src/commits.rs@@ -1,14 +1,15 @@11 //! `commits.html` + `commit/<sha>.html` (m2, f0rge issue #5).22 //!3-//! Commit subjects, messages, and diffs are untrusted: everything goes4-//! through [`crate::layout::esc`] into an escaped `<pre>`. Diffs come from3+//! Commit subjects and messages go through [`crate::layout::esc`]; diffs go4+//! through [`crate::layout::diff_html`] (escaped line-level color spans in an5+//! escaped `<pre>`, m6 diff-colors f0rge #22). Diffs come from56 //! [`crate::gitx::diff`] (binary content noted, output truncated past67 //! 2000 lines with a marker). Nothing here goes through markdown.78 89 use std::path::Path;910 1011 use crate::gitx;11-use crate::layout::{esc, page_site};12+use crate::layout::{diff_html, esc, page_site};1213 1314 /// Short display form of a full sha (first 7 hex chars, git-style).1415 #[must_use]@@ -72,7 +73,7 @@ pub fn render_loaded(7273             esc(&c.author),7374             esc(&c.date),7475             esc(&message),75-            esc(&diff),76+            diff_html(&diff),7677         );7778         let html = page_site(7879             &format!("commit {} — {site_name}", short_sha(&c.sha)),@@ -237,6 +238,75 @@ mod tests {237238         );238239     }239240 241+    #[test]242+    fn diff_lines_carry_color_spans() {243+        // m6 diff-colors (f0rge #22): hunk cyan, +++/--- bold, + green, - red.244+        let repo = fixture_repo();245+        let out = mktemp("out");246+        render(&repo, &out, "main", 100, "test", "<nav>", None).expect("render");247+        let sha = shas(&repo)[1].clone(); // text hunk + binary commit248+        let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))249+            .expect("detail");250+        assert!(251+            detail.contains("<span class=\"diff-hunk\">@@"),252+            "hunk header cyan: {detail}"253+        );254+        assert!(255+            detail.contains("<span class=\"diff-file\">---"),256+            "--- file line bold: {detail}"257+        );258+        assert!(259+            detail.contains("<span class=\"diff-file\">+++"),260+            "+++ file line bold: {detail}"261+        );262+        assert!(263+            detail.contains("<span class=\"diff-add\">+line two added</span>"),264+            "added line green: {detail}"265+        );266+        // Binary note + truncation marker behavior intact (no span, still shown).267+        assert!(268+            detail.contains("Binary files") || detail.contains(gitx::BINARY_NOTE),269+            "binary note intact"270+        );271+        let huge_sha = shas(&repo)[0].clone();272+        let huge = std::fs::read_to_string(out.join("commit").join(format!("{huge_sha}.html")))273+            .expect("huge detail");274+        assert!(275+            huge.contains(gitx::DIFF_TRUNCATED_MARKER),276+            "truncation marker intact with spans"277+        );278+    }279+280+    #[test]281+    fn diff_escaping_intact_inside_spans() {282+        // Evil `<script>` added line: escaped, still inside the green span.283+        let dir = mktemp("evil-diff");284+        git(&dir, &["init", "-b", "main"]);285+        git(&dir, &["config", "user.name", "hu13-test"]);286+        git(&dir, &["config", "user.email", "[email protected]"]);287+        git(&dir, &["config", "commit.gpgsign", "false"]);288+        std::fs::write(dir.join("x.txt"), "ok\n").unwrap();289+        git(&dir, &["add", "-A"]);290+        git(&dir, &["commit", "-m", "base"]);291+        std::fs::write(dir.join("x.txt"), "ok\nevil <script>alert(1)</script>\n").unwrap();292+        git(&dir, &["add", "-A"]);293+        git(&dir, &["commit", "-m", "evil diff"]);294+        let out = mktemp("out");295+        render(&dir, &out, "main", 100, "test", "<nav>", None).expect("render");296+        let sha = shas(&dir)[0].clone();297+        let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))298+            .expect("detail");299+        assert!(!detail.contains("<script>alert(1)</script>"), "no raw HTML");300+        assert!(301+            detail.contains("&lt;script&gt;"),302+            "escaped form present: {detail}"303+        );304+        assert!(305+            detail.contains("<span class=\"diff-add\">+evil &lt;script&gt;"),306+            "escaped inside green span: {detail}"307+        );308+    }309+240310     #[test]241311     fn max_log_limits_list_and_details() {242312         let repo = fixture_repo();
src/forge.rs+47 −2
diff --git a/src/forge.rs b/src/forge.rsindex 951136b..1f8af3e 100644--- a/src/forge.rs+++ b/src/forge.rs@@ -23,7 +23,7 @@ use std::path::Path;2323 use serde::Deserialize;2424 2525 use crate::build::markdown_to_html;26-use crate::layout::{esc, page_site};26+use crate::layout::{diff_html, esc, page_site};2727 2828 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).2929 pub const FORGE_MAX_BYTES: u64 = 256 * 1024;@@ -898,7 +898,7 @@ fn render_pr_page(pr: &Pr, site_name: &str, nav: &str, description: Option<&str>898898         Some(patch) if !patch.is_empty() => {899899             body.push_str(&format!(900900                 "<div class=\"pre-scroll\"><pre class=\"diff\">{}</pre></div>\n",901-                esc(patch)901+                diff_html(patch)902902             ));903903         }904904         _ => body.push_str("<p class=\"muted\">No patch attached.</p>\n"),@@ -1326,6 +1326,51 @@ mod tests {13261326         assert!(html.contains("lgtm"), "review body shown");13271327     }13281328 1329+    #[test]1330+    fn pr_patch_lines_carry_diff_spans() {1331+        // m6 diff-colors (f0rge #22): PR patch.diff shares the commit renderer.1332+        let repo = mktemp("pr-diff");1333+        write_forge(1334+            &repo,1335+            "prs/cccccccc-cccc-cccc-cccc-cccccccccccc/pr.md",1336+            "---\nid: cccccccc-cccc-cccc-cccc-cccccccccccc\ntitle: Fix it\nstate: open\nbase: main\nhead: fix\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n",1337+        );1338+        let dest = repo1339+            .join(".forge")1340+            .join("prs/cccccccc-cccc-cccc-cccc-cccccccccccc/patch.diff");1341+        std::fs::write(1342+            &dest,1343+            "diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+evil <script>\n",1344+        )1345+        .unwrap();1346+        let out = mktemp("pr-diff-out");1347+        render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1348+        let html =1349+            std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))1350+                .expect("pr page");1351+        assert!(1352+            html.contains("<span class=\"diff-hunk\">@@ -1 +1 @@</span>"),1353+            "hunk cyan: {html}"1354+        );1355+        assert!(1356+            html.contains("<span class=\"diff-file\">--- a/x</span>"),1357+            "--- file bold"1358+        );1359+        assert!(1360+            html.contains("<span class=\"diff-file\">+++ b/x</span>"),1361+            "+++ file bold"1362+        );1363+        assert!(1364+            html.contains("<span class=\"diff-del\">-old</span>"),1365+            "removed red"1366+        );1367+        assert!(!html.contains("<script>"), "patch escaped");1368+        assert!(1369+            html.contains("<span class=\"diff-add\">+evil &lt;script&gt;</span>"),1370+            "added green, escaped in span"1371+        );1372+    }1373+13291374     #[test]13301375     fn tasks_group_and_ci_fail_badge() {13311376         let repo = mktemp("taskci");
src/layout.rs+116 −2
diff --git a/src/layout.rs b/src/layout.rsindex fbecd44..af31aa2 100644--- a/src/layout.rs+++ b/src/layout.rs@@ -24,6 +24,60 @@ pub fn esc(input: &str) -> String {2424     out2525 }2626 27+/// Render unified diff / patch text as escaped HTML with line-level color28+/// spans (m6 diff-colors, f0rge issue #22; Tokyo Night vars in29+/// `static/style.css`, dark + light).30+///31+/// Line kinds (line-level only, no word-diff): `@@` hunk headers go in32+/// `<span class="diff-hunk">`, `+++`/`---` file lines in33+/// `<span class="diff-file">` (bold fg), `+` additions in34+/// `<span class="diff-add">`, `-` removals in `<span class="diff-del">`.35+/// Everything else (context, `diff --git` / `index` headers, commit36+/// metadata, `Binary files ... differ` notes, `[hu13: ...]` truncation /37+/// binary markers) is escaped as-is with no span so truncation and binary38+/// behavior stay intact. Every line is escaped with [`esc`] before wrapping,39+/// so hostile `<script>` inside an added line renders as text. Output joins40+/// lines with `\n` (preserving a trailing newline) for use inside an41+/// already-escaped `<pre class="diff">`.42+#[must_use]43+pub fn diff_html(raw: &str) -> String {44+    if raw.is_empty() {45+        return String::new();46+    }47+    let mut out = String::with_capacity(raw.len() + raw.len() / 4);48+    let mut first = true;49+    for line in raw.lines() {50+        if !first {51+            out.push('\n');52+        }53+        first = false;54+        let e = esc(line);55+        if line.starts_with("@@") {56+            out.push_str("<span class=\"diff-hunk\">");57+            out.push_str(&e);58+            out.push_str("</span>");59+        } else if line.starts_with("+++") || line.starts_with("---") {60+            out.push_str("<span class=\"diff-file\">");61+            out.push_str(&e);62+            out.push_str("</span>");63+        } else if line.starts_with('+') {64+            out.push_str("<span class=\"diff-add\">");65+            out.push_str(&e);66+            out.push_str("</span>");67+        } else if line.starts_with('-') {68+            out.push_str("<span class=\"diff-del\">");69+            out.push_str(&e);70+            out.push_str("</span>");71+        } else {72+            out.push_str(&e);73+        }74+    }75+    if raw.ends_with('\n') {76+        out.push('\n');77+    }78+    out79+}80+2781 /// Render a full HTML page. `title` is escaped; `nav` and `body` are2882 /// inlined as-is (caller must escape untrusted text with [`esc`]).2983 #[allow(dead_code)]@@ -189,7 +243,7 @@ pub fn healthcheck_page() -> String {189243 190244 #[cfg(test)]191245 mod tests {192-    use super::{esc, healthcheck_page, page};246+    use super::{diff_html, esc, healthcheck_page, page};193247 194248     #[test]195249     fn esc_escapes_angle_amp_quote() {@@ -349,4 +403,65 @@ mod tests {349403         let pre = "<a href=\"/\" aria-current=\"page\">code</a>";350404         assert_eq!(apply_active(pre, "/"), pre);351405     }406+407+    #[test]408+    fn diff_html_colors_each_line_kind() {409+        // m6 diff-colors (f0rge #22): line-level only, spans + classes.410+        let raw = "diff --git a/notes.txt b/notes.txt\n\411+--- a/notes.txt\n+++ b/notes.txt\n\412+@@ -1 +1,2 @@\n line one\n-line gone\n+line two added\n";413+        let html = diff_html(raw);414+        assert!(415+            html.contains("<span class=\"diff-hunk\">@@ -1 +1,2 @@</span>"),416+            "hunk cyan: {html}"417+        );418+        assert!(419+            html.contains("<span class=\"diff-file\">--- a/notes.txt</span>"),420+            "--- file bold: {html}"421+        );422+        assert!(423+            html.contains("<span class=\"diff-file\">+++ b/notes.txt</span>"),424+            "+++ file bold: {html}"425+        );426+        assert!(427+            html.contains("<span class=\"diff-add\">+line two added</span>"),428+            "added green: {html}"429+        );430+        assert!(431+            html.contains("<span class=\"diff-del\">-line gone</span>"),432+            "removed red: {html}"433+        );434+        // Context + `diff --git` header stay plain (no span).435+        assert!(html.contains(" line one"), "context kept");436+        assert!(html.contains("diff --git"), "diff header kept");437+        assert!(!html.contains("<span> line one"), "context has no span");438+    }439+440+    #[test]441+    fn diff_html_escapes_before_wrapping() {442+        let html = diff_html("+evil <script>alert(1)</script>\n-old & \"quoted\"\n");443+        assert!(!html.contains("<script>"), "no raw HTML: {html}");444+        assert!(445+            html.contains(446+                "<span class=\"diff-add\">+evil &lt;script&gt;alert(1)&lt;/script&gt;</span>"447+            ),448+            "added line escaped in span: {html}"449+        );450+        assert!(451+            html.contains("<span class=\"diff-del\">-old &amp; &quot;quoted&quot;</span>"),452+            "removed line escaped in span: {html}"453+        );454+    }455+456+    #[test]457+    fn diff_html_keeps_markers_as_plain_text() {458+        // Truncation + binary notes pass through escaped, span-free.459+        let marker = "[hu13: diff truncated at 2000 lines]";460+        let binary = "Binary files a/bin.dat and b/bin.dat differ";461+        let html = diff_html(&format!("+x\n{marker}\n{binary}\n"));462+        assert!(html.contains("<span class=\"diff-add\">+x</span>"));463+        assert!(html.contains(marker), "truncation marker intact");464+        assert!(html.contains("Binary files"), "binary note intact");465+        assert!(!html.contains("<span>\n[hu13"), "markers have no span");466+    }352467 }353-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls