From 9c1c8f6b759353ae03252eb8ecc9a9070c6e5409 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 01:03:45 +0000 Subject: [PATCH] content: asSx wire string-escaping (String>>sxEscaped) + 5 tests (243/243) Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/content/render.sx | 33 +++++++++++++++++++++++---------- lib/content/scoreboard.json | 6 +++--- lib/content/scoreboard.md | 4 ++-- lib/content/tests/render.sx | 26 +++++++++++++++++++++++++- plans/content-on-sx.md | 9 +++++++-- 5 files changed, 60 insertions(+), 18 deletions(-) diff --git a/lib/content/render.sx b/lib/content/render.sx index 124aeffb..17b65ac1 100644 --- a/lib/content/render.sx +++ b/lib/content/render.sx @@ -6,9 +6,9 @@ ;; children's renderings, so (asHTML doc) / (asSx doc) are pure polymorphic ;; sends with no type dispatch in the SX layer. ;; -;; HTML escaping happens HERE, at the boundary: text and attribute values are -;; passed through String>>htmlEscaped (& < > "), so untrusted content cannot -;; break out of its element. asSx wire output is not yet string-escaped (next). +;; Escaping happens HERE, at the boundary. asHTML routes text/attrs through +;; String>>htmlEscaped (& < > "); asSx routes them through String>>sxEscaped +;; (\ and ") so values cannot break out of an element or an SX string literal. (define content-bootstrap-render! @@ -19,6 +19,10 @@ "String" "htmlEscaped" "htmlEscaped | out i n c | out := ''. n := self size. i := 1. [i <= n] whileTrue: [c := self at: i. (c = $&) ifTrue: [out := out , '&'] ifFalse: [(c = $<) ifTrue: [out := out , '<'] ifFalse: [(c = $>) ifTrue: [out := out , '>'] ifFalse: [(c = $\") ifTrue: [out := out , '"'] ifFalse: [out := out , c asString]]]]. i := i + 1]. ^ out") + (ct-def-method! + "String" + "sxEscaped" + "sxEscaped | out i n c | out := ''. n := self size. i := 1. [i <= n] whileTrue: [c := self at: i. (c = $\\) ifTrue: [out := out , '\\\\'] ifFalse: [(c = $\") ifTrue: [out := out , '\\\"'] ifFalse: [out := out , c asString]]. i := i + 1]. ^ out") (ct-def-method! "CtHeading" "asHTML" @@ -55,20 +59,29 @@ (ct-def-method! "CtHeading" "asSx" - "asSx | t | t := level printString. ^ '(h' , t , ' \"' , text , '\")'") - (ct-def-method! "CtText" "asSx" "asSx ^ '(p \"' , text , '\")'") - (ct-def-method! "CtCode" "asSx" "asSx ^ '(pre (code \"' , text , '\"))'") - (ct-def-method! "CtQuote" "asSx" "asSx ^ '(blockquote \"' , text , '\")'") + "asSx | t | t := level printString. ^ '(h' , t , ' \"' , text sxEscaped , '\")'") + (ct-def-method! "CtText" "asSx" "asSx ^ '(p \"' , text sxEscaped , '\")'") + (ct-def-method! + "CtCode" + "asSx" + "asSx ^ '(pre (code \"' , text sxEscaped , '\"))'") + (ct-def-method! + "CtQuote" + "asSx" + "asSx ^ '(blockquote \"' , text sxEscaped , '\")'") (ct-def-method! "CtImage" "asSx" - "asSx ^ '(img :src \"' , src , '\" :alt \"' , alt , '\")'") - (ct-def-method! "CtEmbed" "asSx" "asSx ^ '(iframe :src \"' , url , '\")'") + "asSx ^ '(img :src \"' , src sxEscaped , '\" :alt \"' , alt sxEscaped , '\")'") + (ct-def-method! + "CtEmbed" + "asSx" + "asSx ^ '(iframe :src \"' , url sxEscaped , '\")'") (ct-def-method! "CtDivider" "asSx" "asSx ^ '(hr)'") (ct-def-method! "CtList" "asSx" - "asSx | tag | tag := ordered ifTrue: ['ol'] ifFalse: ['ul']. ^ '(' , tag , ' ' , (items inject: '' into: [:a :x | a , '(li \"' , x , '\")']) , ')'") + "asSx | tag | tag := ordered ifTrue: ['ol'] ifFalse: ['ul']. ^ '(' , tag , ' ' , (items inject: '' into: [:a :x | a , '(li \"' , x sxEscaped , '\")']) , ')'") (ct-def-method! "CtDoc" "asSx" diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index 5f5b647a..a1204ca7 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -2,14 +2,14 @@ "suites": { "block": {"pass": 38, "fail": 0}, "doc": {"pass": 40, "fail": 0}, - "render": {"pass": 37, "fail": 0}, + "render": {"pass": 42, "fail": 0}, "api": {"pass": 26, "fail": 0}, "store": {"pass": 29, "fail": 0}, "crdt": {"pass": 34, "fail": 0}, "sync": {"pass": 14, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 238, + "total_pass": 243, "total_fail": 0, - "total": 238 + "total": 243 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 8b66364d..dbc3e56d 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -6,10 +6,10 @@ _Generated by `lib/content/conformance.sh`_ |-------|-----:|-----:|------:| | block | 38 | 0 | 38 | | doc | 40 | 0 | 40 | -| render | 37 | 0 | 37 | +| render | 42 | 0 | 42 | | api | 26 | 0 | 26 | | store | 29 | 0 | 29 | | crdt | 34 | 0 | 34 | | sync | 14 | 0 | 14 | | fed | 20 | 0 | 20 | -| **Total** | **238** | **0** | **238** | +| **Total** | **243** | **0** | **243** | diff --git a/lib/content/tests/render.sx b/lib/content/tests/render.sx index b81d0a72..d4ee7b94 100644 --- a/lib/content/tests/render.sx +++ b/lib/content/tests/render.sx @@ -1,5 +1,5 @@ ;; Phase 1 — render boundary. asHTML / asSx are polymorphic message sends on -;; blocks and the document. HTML escaping happens at the boundary. +;; blocks and the document. Escaping happens at the boundary. (st-bootstrap-classes!) (content-bootstrap-blocks!) @@ -109,3 +109,27 @@ "escape code body" (asHTML (mk-code "xc" "html" "
&
")) "
<div> & </div>
") + +;; ── asSx string-escaping (build expected via q/bs to avoid miscounts) ── +(define q1 (str "\"")) +(define bs (str "\\")) +(content-test + "asSx escapes quote" + (asSx (mk-text "qt" (str "say " q1 "hi" q1))) + (str "(p " q1 "say " bs q1 "hi" bs q1 q1 ")")) +(content-test + "asSx escapes backslash" + (asSx (mk-text "qb" (str "a" bs "b"))) + (str "(p " q1 "a" bs bs "b" q1 ")")) +(content-test + "asSx plain unchanged" + (asSx (mk-text "pp" "plain")) + "(p \"plain\")") +(content-test + "asSx escapes image attr" + (asSx (mk-image "im" (str "/a" q1) "x")) + (str "(img :src " q1 "/a" bs q1 q1 " :alt " q1 "x" q1 ")")) +(content-test + "asSx escapes list item" + (asSx (mk-list "lq" false (list (str "i" q1) "j"))) + (str "(ul (li " q1 "i" bs q1 q1 ")(li " q1 "j" q1 "))")) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 9413fb09..60fedc5d 100644 --- a/plans/content-on-sx.md +++ b/plans/content-on-sx.md @@ -19,7 +19,7 @@ injected adapter, not core. ## Status (rolling) -`bash lib/content/conformance.sh` → **238/238** (Phases 1–4 COMPLETE + extensions: HTML escaping) +`bash lib/content/conformance.sh` → **243/243** (Phases 1–4 COMPLETE + extensions: HTML + SX escaping) ## Ground rules @@ -77,10 +77,15 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Extensions (post-roadmap) - [x] HTML escaping at the render boundary (`String>>htmlEscaped`: & < > ") -- [ ] asSx wire string-escaping (" and \ in SX string literals) +- [x] asSx wire string-escaping (`String>>sxEscaped`: \ and " in SX literals) ## Progress log +- 2026-06-07 — Extension: asSx wire string-escaping. Added `String>>sxEscaped` + (escapes `\`→`\\` then `"`→`\"`) and routed every `asSx` text/attr/list-item + through it, so the SX wire format stays valid when content contains quotes or + backslashes. +5 render tests (expected strings built from `q`/`bs` helpers to + avoid escaping miscounts). Suite 243/243. - 2026-06-07 — Extension: HTML escaping at the render boundary. Added `String>>htmlEscaped` (recursive char walk escaping & < > ", order-safe so & isn't double-escaped) and routed every `asHTML` text/attr through it — heading,