{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[993d359c] Fix Reddit children import wiring and unranked child labels.\n\nListing imports attach posts directly under the subreddit without ensure_path\npulling comment-path segments in, and the ranking panel shows imported titles.\nUpdate integration tests for JS SSE morphs and children fetch.\n\nCo-authored-by: Cursor \n\nSide A — unified diff (full patch):\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex d1defd28242fd2ca3b886adc91a7070bef75e653..e649a7d192feade465e19ce6187a829f6ec74372 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -58,7 +58,7 @@ pub async fn post_ui_html(\n let tree = state.tree.read().await;\n let empty = crate::reducer::NodeState::default();\n let node = tree.get(&parent).unwrap_or(&empty);\n- let panel = ranking_panel(&parent, node);\n+ let panel = ranking_panel(&parent, node, &tree);\n JsBuilder::new()\n .morph_selector(\"#ranking-panel\", panel)\n .into_response()\ndiff --git a/server/src/fetch/mod.rs b/server/src/fetch/mod.rs\nindex 0177bb161cea1b72a100b52efdfc5e710271c9eb..35f968c21c69ca557bab7951413e3cfbbccfebfd 100644\n--- a/server/src/fetch/mod.rs\n+++ b/server/src/fetch/mod.rs\n@@ -107,7 +107,7 @@ pub fn fetch_entity_stream(\n let mut b = JsBuilder::new()\n .morph_selector(\"#entity-section\", html::entity_section(&id, node, false));\n if kind == FetchKind::Children {\n- b = b.morph_selector(\"#ranking-panel\", ranking_panel(&id, node));\n+ b = b.morph_selector(\"#ranking-panel\", ranking_panel(&id, node, &tree));\n }\n yield Ok(js_event(b.build()));\n }\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 27ce9118c73ec5643e04363ab1a36cf5da6101bf..e88cc43ddc9d8100f7994be6f5960ec4d8f22c55 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -15,7 +15,7 @@ use crate::{\n ranking::{\n connected_components_from_voted_pairs, ranked_items_subset, RankedItem, MAX_ITERS, TOL,\n },\n- reducer::NodeState,\n+ reducer::{GlobalTree, NodeState},\n state::AppState,\n ui_action::UI_RPC_FIELD,\n };\n@@ -182,8 +182,15 @@ fn display_label(id: &ItemId) -> String {\n .to_string()\n }\n \n+fn child_label(tree: &GlobalTree, id: &ItemId) -> String {\n+ tree.get(id)\n+ .and_then(|n| n.data.as_ref())\n+ .map(|d| d.title.clone())\n+ .unwrap_or_else(|| display_label(id))\n+}\n+\n /// Plain (unscored) list of children that have no votes yet.\n-fn unranked_list(label: &str, items: &[ItemId]) -> Markup {\n+fn unranked_list(label: &str, items: &[ItemId], tree: &GlobalTree) -> Markup {\n html! {\n @if !items.is_empty() {\n h3 class=\"rank-heading muted small\" { (label) }\n@@ -191,7 +198,7 @@ fn unranked_list(label: &str, items: &[ItemId]) -> Markup {\n @for it in items {\n li {\n a href=(item_href(it)) {\n- strong { (display_label(it)) }\n+ strong { (child_label(tree, it)) }\n }\n }\n }\n@@ -200,7 +207,7 @@ fn unranked_list(label: &str, items: &[ItemId]) -> Markup {\n }\n }\n \n-pub fn ranking_panel(item: &ItemId, node: &NodeState) -> Markup {\n+pub fn ranking_panel(item: &ItemId, node: &NodeState, tree: &GlobalTree) -> Markup {\n let group = &node.local_ranking;\n let n = group.idx_to_item.len();\n let (comps, _isolates) =\n@@ -248,7 +255,7 @@ pub fn ranking_panel(item: &ItemId, node: &NodeState) -> Markup {\n @let label = if multi { format!(\"Ranking group {}\", gi + 1) } else { \"Ranking\".to_string() };\n (rank_list(&label, ranked, 1))\n }\n- (unranked_list(\"Unranked\", &unranked))\n+ (unranked_list(\"Unranked\", &unranked, tree))\n }\n }\n }\n@@ -297,7 +304,7 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {\n (input_panel(\"\", None))\n (breadcrumb_path(&item))\n (entity_section(&item, node, false))\n- (ranking_panel(&item, node))\n+ (ranking_panel(&item, node, &tree))\n };\n layout(\"sorter2\", body, views)\n }\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nindex a0eb688709478ee0185b953b41a5d26cc354764d..69d979bc7e4a1cb078f70114dc539bdc1986b574 100644\n--- a/server/src/reddit.rs\n+++ b/server/src/reddit.rs\n@@ -300,9 +300,16 @@ async fn reddit_worker(\n }\n {\n let mut tree = tree.write().await;\n- apply_entity_import(&mut tree, &child_id, child_payload);\n if kind == FetchKind::Children {\n- tree.link_child(&fetch_id, &child_id);\n+ let view = entity_view_from_payload(&child_id, &child_payload);\n+ tree.apply_entity_under_parent(\n+ &fetch_id,\n+ &child_id,\n+ child_payload,\n+ view,\n+ );\n+ } else {\n+ apply_entity_import(&mut tree, &child_id, child_payload);\n }\n }\n written += 1;\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex a36cd5c9287d61536f9f4a3f6b0df2342388857a..4e42d0369dab50bb2f8ca664aa69b628292f6c07 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -209,14 +209,24 @@ impl GlobalTree {\n }\n }\n \n- /// Directly attach `child` under `parent`, bypassing path-based nesting.\n- /// Used for imported listings (e.g. a subreddit's posts) so they show up\n- /// as children of the subreddit rather than a deep `…/comments/` path.\n- pub fn link_child(&mut self, parent: &ItemId, child: &ItemId) {\n+ /// Import entity data for `id` and attach it as a direct child of `parent`\n+ /// without running [`Self::ensure_path`] on `id` (avoids Reddit `/comments/`\n+ /// parent rules pulling intermediate path segments into the subreddit).\n+ pub fn apply_entity_under_parent(\n+ &mut self,\n+ parent: &ItemId,\n+ id: &ItemId,\n+ payload: Value,\n+ view: Option,\n+ ) {\n self.ensure_path(parent);\n- self.ensure_path(child);\n+ self.ensure_node(id);\n+ if let Some(node) = self.nodes.get_mut(id) {\n+ node.entity_raw = Some(payload);\n+ node.data = view;\n+ }\n if let Some(p) = self.nodes.get_mut(parent) {\n- p.children.insert(child.clone());\n+ p.children.insert(id.clone());\n }\n }\n }\ndiff --git a/test/reddit_import.clj b/test/reddit_import.clj\nindex 84cbdcf7965e50290313cbce2243a16b583d2097..45a2a19f20799d77e84d8aa64735ab5e7e45f97c 100644\n--- a/test/reddit_import.clj\n+++ b/test/reddit_import.clj\n@@ -67,6 +67,36 @@\n (do (Thread/sleep 200) (recur))\n false)))))\n \n+(defn- run-reddit-fetch-assertions [app-base data-dir]\n+ (let [browse-url (str app-base \"/~/https://reddit.com/r/rust\")\n+ log-path (str data-dir \"/events.jsonl\")\n+ before (:out (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" browse-url))]\n+ (is (str/includes? before \"Fetch from Reddit\"))\n+ (is (not (str/includes? before \"The Rust Programming Language\")))\n+ (let [sse (curl-fetch-ui-sse app-base \"reddit.com/r/rust\" \"self\")]\n+ (is (zero? (:exit sse)) \"POST /ui fetch_entity (self) SSE succeeds\")\n+ (is (str/includes? (:out sse) \"Idiomorph.morph\"))\n+ (is (str/includes? (:out sse) \"The Rust Programming Language\"))\n+ (is (wait-event-log log-path 2000) \"event log written\"))\n+ (let [after (:out (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" browse-url))\n+ log (slurp (io/file log-path))]\n+ (is (str/includes? after \"The Rust Programming Language\"))\n+ (is (str/includes? log \"\\\"type\\\":\\\"entity_imported\\\"\"))\n+ (is (str/includes? log \"\\\"subscribers\\\":350000\"))\n+ (is (str/includes? log \"\\\"display_name\\\":\\\"rust\\\"\")))\n+ (let [children-sse (curl-fetch-ui-sse app-base \"reddit.com/r/rust\" \"children\")]\n+ (is (zero? (:exit children-sse)) \"POST /ui fetch_entity (children) SSE succeeds\")\n+ (is (str/includes? (:out children-sse) \"Idiomorph.morph\"))\n+ (is (str/includes? (:out children-sse) \"Announcing Rust 1.99\")))\n+ (let [after-children (:out (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" browse-url))\n+ log2 (slurp (io/file log-path))]\n+ (is (str/includes? after-children \"Announcing Rust 1.99\"))\n+ (is (str/includes? after-children \"Unranked\"))\n+ (is (str/includes? log2 \"announcing_rust_199\")))))\n+\n (deftest reddit-fetch-via-mock-api\n (testing \"Fetch more queues import; event log stores full payload; page shows title\"\n (let [root (repo-root)\n@@ -102,24 +132,7 @@\n bin)]\n (try\n (is (wait-health app-base 20000) \"app healthz\")\n- (let [browse-url (str app-base \"/~/https://reddit.com/r/rust\")\n- before (:out (process/shell {:out :string :err :string}\n- \"curl\" \"-sf\" browse-url))]\n- (is (str/includes? before \"Fetch from Reddit\"))\n- (is (not (str/includes? before \"The Rust Programming Language\")))\n- (let [log-path (str data-dir \"/events.jsonl\")\n- sse (curl-fetch-ui-sse app-base \"reddit.com/r/rust\")]\n- (is (zero? (:exit sse)) \"POST /ui fetch_entity SSE succeeds\")\n- (is (str/includes? (:out sse) \"event: complete\"))\n- (is (str/includes? (:out sse) \"The Rust Programming Language\"))\n- (is (wait-event-log log-path 2000) \"event log written\")\n- (let [after (:out (process/shell {:out :string :err :string}\n- \"curl\" \"-sf\" browse-url))\n- log (slurp (io/file log-path))]\n- (is (str/includes? after \"The Rust Programming Language\"))\n- (is (str/includes? log \"\\\"type\\\":\\\"entity_imported\\\"\"))\n- (is (str/includes? log \"\\\"subscribers\\\":350000\"))\n- (is (str/includes? log \"\\\"display_name\\\":\\\"rust\\\"\")))))\n+ (run-reddit-fetch-assertions app-base data-dir)\n (finally\n (process/destroy proc))))\n (finally\ndiff --git a/test/smoke.clj b/test/smoke.clj\nindex 11887c48282088e140d823a88ba616f6325835b3..ce9f958c84b9a89ae55e215ab519f1df6435e24b 100644\n--- a/test/smoke.clj\n+++ b/test/smoke.clj\n@@ -49,7 +49,7 @@\n (is (wait-health base 15000) \"server responds to /healthz\")\n (let [home (:out (process/shell {:out :string :err :string}\n \"curl\" \"-sf\" (str base \"/\")))]\n- (is (str/includes? home \"vote-panel\"))\n+ (is (str/includes? home \"entity-section\"))\n (is (str/includes? home \"ranking-panel\"))\n (is (str/includes? home \"parser-panel\"))\n (is (str/includes? home \"__rpc__\")))\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[bf118bdf] Sanitize Reddit entity body HTML before rendering.\n\nUse ammonia at render time so untrusted selftext_html cannot execute scripts in our origin.\n\nCo-authored-by: Cursor \n\nSide B — unified diff (full patch):\ndiff --git a/Cargo.lock b/Cargo.lock\nindex 3dec7cb72a182dc654a37dca8ba0b49d77504daa..0dd4fce5fb6400ae153cca4e3dbf5a5158e6d8b4 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -11,6 +11,19 @@ dependencies = [\n \"memchr\",\n ]\n \n+[[package]]\n+name = \"ammonia\"\n+version = \"4.1.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6\"\n+dependencies = [\n+ \"cssparser\",\n+ \"html5ever\",\n+ \"maplit\",\n+ \"tendril\",\n+ \"url\",\n+]\n+\n [[package]]\n name = \"anyhow\"\n version = \"1.0.102\"\n@@ -355,6 +368,29 @@ version = \"0.2.4\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5\"\n \n+[[package]]\n+name = \"cssparser\"\n+version = \"0.35.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa\"\n+dependencies = [\n+ \"cssparser-macros\",\n+ \"dtoa-short\",\n+ \"itoa\",\n+ \"phf\",\n+ \"smallvec\",\n+]\n+\n+[[package]]\n+name = \"cssparser-macros\"\n+version = \"0.6.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331\"\n+dependencies = [\n+ \"quote\",\n+ \"syn\",\n+]\n+\n [[package]]\n name = \"deranged\"\n version = \"0.5.8\"\n@@ -381,6 +417,21 @@ version = \"0.15.7\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b\"\n \n+[[package]]\n+name = \"dtoa\"\n+version = \"1.0.11\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590\"\n+\n+[[package]]\n+name = \"dtoa-short\"\n+version = \"0.3.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87\"\n+dependencies = [\n+ \"dtoa\",\n+]\n+\n [[package]]\n name = \"durable\"\n version = \"0.2.0\"\n@@ -482,6 +533,16 @@ dependencies = [\n \"percent-encoding\",\n ]\n \n+[[package]]\n+name = \"futf\"\n+version = \"0.1.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843\"\n+dependencies = [\n+ \"mac\",\n+ \"new_debug_unreachable\",\n+]\n+\n [[package]]\n name = \"futures-channel\"\n version = \"0.3.32\"\n@@ -614,6 +675,17 @@ version = \"0.5.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea\"\n \n+[[package]]\n+name = \"html5ever\"\n+version = \"0.35.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4\"\n+dependencies = [\n+ \"log\",\n+ \"markup5ever\",\n+ \"match_token\",\n+]\n+\n [[package]]\n name = \"http\"\n version = \"1.4.1\"\n@@ -974,6 +1046,15 @@ version = \"0.8.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0\"\n \n+[[package]]\n+name = \"lock_api\"\n+version = \"0.4.14\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965\"\n+dependencies = [\n+ \"scopeguard\",\n+]\n+\n [[package]]\n name = \"log\"\n version = \"0.4.30\"\n@@ -990,6 +1071,40 @@ dependencies = [\n \"libc\",\n ]\n \n+[[package]]\n+name = \"mac\"\n+version = \"0.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4\"\n+\n+[[package]]\n+name = \"maplit\"\n+version = \"1.0.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d\"\n+\n+[[package]]\n+name = \"markup5ever\"\n+version = \"0.35.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3\"\n+dependencies = [\n+ \"log\",\n+ \"tendril\",\n+ \"web_atoms\",\n+]\n+\n+[[package]]\n+name = \"match_token\"\n+version = \"0.35.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n [[package]]\n name = \"matchers\"\n version = \"0.2.0\"\n@@ -1092,6 +1207,12 @@ dependencies = [\n \"tempfile\",\n ]\n \n+[[package]]\n+name = \"new_debug_unreachable\"\n+version = \"1.0.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086\"\n+\n [[package]]\n name = \"nom\"\n version = \"7.1.3\"\n@@ -1175,6 +1296,29 @@ dependencies = [\n \"vcpkg\",\n ]\n \n+[[package]]\n+name = \"parking_lot\"\n+version = \"0.12.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a\"\n+dependencies = [\n+ \"lock_api\",\n+ \"parking_lot_core\",\n+]\n+\n+[[package]]\n+name = \"parking_lot_core\"\n+version = \"0.9.12\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"libc\",\n+ \"redox_syscall\",\n+ \"smallvec\",\n+ \"windows-link\",\n+]\n+\n [[package]]\n name = \"peeking_take_while\"\n version = \"0.1.2\"\n@@ -1187,6 +1331,58 @@ version = \"2.3.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220\"\n \n+[[package]]\n+name = \"phf\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078\"\n+dependencies = [\n+ \"phf_macros\",\n+ \"phf_shared\",\n+]\n+\n+[[package]]\n+name = \"phf_codegen\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a\"\n+dependencies = [\n+ \"phf_generator\",\n+ \"phf_shared\",\n+]\n+\n+[[package]]\n+name = \"phf_generator\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d\"\n+dependencies = [\n+ \"phf_shared\",\n+ \"rand 0.8.6\",\n+]\n+\n+[[package]]\n+name = \"phf_macros\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216\"\n+dependencies = [\n+ \"phf_generator\",\n+ \"phf_shared\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"phf_shared\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5\"\n+dependencies = [\n+ \"siphasher\",\n+]\n+\n [[package]]\n name = \"pin-project-lite\"\n version = \"0.2.17\"\n@@ -1223,6 +1419,12 @@ dependencies = [\n \"zerocopy\",\n ]\n \n+[[package]]\n+name = \"precomputed-hash\"\n+version = \"0.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c\"\n+\n [[package]]\n name = \"prettyplease\"\n version = \"0.2.37\"\n@@ -1379,6 +1581,15 @@ dependencies = [\n \"rand_core 0.9.5\",\n ]\n \n+[[package]]\n+name = \"redox_syscall\"\n+version = \"0.5.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d\"\n+dependencies = [\n+ \"bitflags 2.11.1\",\n+]\n+\n [[package]]\n name = \"regex\"\n version = \"1.12.3\"\n@@ -1563,6 +1774,12 @@ dependencies = [\n \"windows-sys 0.61.2\",\n ]\n \n+[[package]]\n+name = \"scopeguard\"\n+version = \"1.2.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49\"\n+\n [[package]]\n name = \"security-framework\"\n version = \"3.7.0\"\n@@ -1683,6 +1900,12 @@ dependencies = [\n \"libc\",\n ]\n \n+[[package]]\n+name = \"siphasher\"\n+version = \"1.0.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649\"\n+\n [[package]]\n name = \"slab\"\n version = \"0.4.12\"\n@@ -1709,6 +1932,7 @@ dependencies = [\n name = \"sorter2-server\"\n version = \"0.0.1\"\n dependencies = [\n+ \"ammonia\",\n \"async-stream\",\n \"axum\",\n \"axum-extra\",\n@@ -1742,6 +1966,31 @@ version = \"1.2.1\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596\"\n \n+[[package]]\n+name = \"string_cache\"\n+version = \"0.8.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f\"\n+dependencies = [\n+ \"new_debug_unreachable\",\n+ \"parking_lot\",\n+ \"phf_shared\",\n+ \"precomputed-hash\",\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"string_cache_codegen\"\n+version = \"0.5.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0\"\n+dependencies = [\n+ \"phf_generator\",\n+ \"phf_shared\",\n+ \"proc-macro2\",\n+ \"quote\",\n+]\n+\n [[package]]\n name = \"subtle\"\n version = \"2.6.1\"\n@@ -1813,6 +2062,17 @@ dependencies = [\n \"windows-sys 0.61.2\",\n ]\n \n+[[package]]\n+name = \"tendril\"\n+version = \"0.4.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0\"\n+dependencies = [\n+ \"futf\",\n+ \"mac\",\n+ \"utf-8\",\n+]\n+\n [[package]]\n name = \"thiserror\"\n version = \"1.0.69\"\n@@ -2116,6 +2376,12 @@ version = \"2.1.3\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da\"\n \n+[[package]]\n+name = \"utf-8\"\n+version = \"0.7.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9\"\n+\n [[package]]\n name = \"utf8_iter\"\n version = \"1.0.4\"\n@@ -2281,6 +2547,18 @@ dependencies = [\n \"wasm-bindgen\",\n ]\n \n+[[package]]\n+name = \"web_atoms\"\n+version = \"0.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414\"\n+dependencies = [\n+ \"phf\",\n+ \"phf_codegen\",\n+ \"string_cache\",\n+ \"string_cache_codegen\",\n+]\n+\n [[package]]\n name = \"windows-link\"\n version = \"0.2.1\"\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex 47659ff82fbfb50972eb2b87575e80f66e572ba4..27f552c20b97ef28cdde4cb6b1a4980375135111 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -13,6 +13,7 @@ serde = { version = \"1\", features = [\"derive\"] }\n serde_json = \"1\"\n thiserror = \"1\"\n maud = { version = \"0.26\", features = [\"axum\"] }\n+ammonia = \"4.1\"\n tower = \"0.5\"\n tower-http = { version = \"0.5\", features = [\"trace\"] }\n tracing = \"0.1\"\ndiff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs\nindex dadf050515f0473943dad97df5d318032c8cb385..5b160c6b8bd216dfaf80149854aec0566cd00460 100644\n--- a/server/src/fetch/html.rs\n+++ b/server/src/fetch/html.rs\n@@ -4,6 +4,7 @@ use maud::{html, Markup};\n \n use crate::{\n form_template::template_json_compact,\n+ html::sanitize::entity_body_html,\n path_types::ItemId,\n reddit::{is_children_fetchable, is_fetchable},\n reducer::NodeState,\n@@ -27,7 +28,7 @@ pub fn entity_panel(node: &NodeState) -> Markup {\n p class=\"muted small\" { \"by \" (author) }\n }\n @if let Some(body) = &data.body_html {\n- div class=\"entity-body\" { (maud::PreEscaped(body)) }\n+ div class=\"entity-body\" { (maud::PreEscaped(entity_body_html(body))) }\n }\n }\n }\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex e180a0ca542a33e2300c0a4809e6b9cfee07ecfe..a58cbbee3490a08a625cb06df06848c59a615d65 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -20,6 +20,7 @@ use crate::{\n ui_action::UI_RPC_FIELD,\n };\n \n+pub mod sanitize;\n pub mod vote;\n \n const SORTER_CSS: &str = include_str!(\"../../static/sorter.css\");\ndiff --git a/server/src/html/sanitize.rs b/server/src/html/sanitize.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..6685ed2535281b9fd5d65b042cbc5a11c5d4df37\n--- /dev/null\n+++ b/server/src/html/sanitize.rs\n@@ -0,0 +1,39 @@\n+//! Whitelist sanitization for untrusted HTML fragments (e.g. Reddit `selftext_html`).\n+\n+use std::sync::LazyLock;\n+\n+use ammonia::Builder;\n+\n+static ENTITY_BODY: LazyLock> = LazyLock::new(|| {\n+ let mut b = Builder::default();\n+ b.strip_comments(true);\n+ b.link_rel(Some(\"noopener noreferrer\"));\n+ b\n+});\n+\n+/// Sanitize HTML safe for embedding in our pages via [`maud::PreEscaped`].\n+pub fn entity_body_html(raw: &str) -> String {\n+ ENTITY_BODY.clean(raw).to_string()\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::entity_body_html;\n+\n+ #[test]\n+ fn keeps_benign_markup() {\n+ assert_eq!(\n+ entity_body_html(\"

release notes

\"),\n+ \"

release notes

\"\n+ );\n+ }\n+\n+ #[test]\n+ fn strips_scripts_and_event_handlers() {\n+ let raw = \"

ok

\";\n+ let clean = entity_body_html(raw);\n+ assert!(!clean.contains(\"ok

\"));\n+ }\n+}\ndiff --git a/server/src/render/reddit.rs b/server/src/render/reddit.rs\nindex c4f98fc760c32ce1b41a91bd41e97908bd198937..7f840aa33b734a31d8cf3341a0581c8bcb9bbcf3 100644\n--- a/server/src/render/reddit.rs\n+++ b/server/src/render/reddit.rs\n@@ -3,6 +3,7 @@\n use maud::{html, Markup};\n \n use crate::{\n+ html::sanitize::entity_body_html,\n path_types::ItemId,\n reducer::{EntityData, GlobalTree, NodeState},\n };\n@@ -57,7 +58,7 @@ fn post_entity_card(data: &EntityData) -> Markup {\n }\n }\n @if let Some(body) = &data.body_html {\n- div class=\"entity-body\" { (maud::PreEscaped(body)) }\n+ div class=\"entity-body\" { (maud::PreEscaped(entity_body_html(body))) }\n }\n }\n }\n","role":"user"}],"model":"~anthropic/claude-sonnet-latest"}