#22 m6/diff-colors
Patch
src/commits.rs+74 −4--- 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("<script>"),302+ "escaped form present: {detail}"303+ );304+ assert!(305+ detail.contains("<span class=\"diff-add\">+evil <script>"),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--- 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 <script></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--- 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 <script>alert(1)</script></span>"447+ ),448+ "added line escaped in span: {html}"449+ );450+ assert!(451+ html.contains("<span class=\"diff-del\">-old & "quoted"</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--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.