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,