Skip to content

Commit 8eafbb2

Browse files
author
Jonathan D.A. Jewell
committed
Enforce ABI/FFI layering for SafeDOM integration
1 parent e4e9aeb commit 8eafbb2

3 files changed

Lines changed: 104 additions & 119 deletions

File tree

src/core/SafeDOM.res

Lines changed: 11 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,12 @@
11
/* SPDX-License-Identifier: AGPL-3.0-or-later WITH Palimpsest */
2-
/* Adapted from /mnt/eclipse/repos/rescript-ecosystem/packages/web/dom-mounter/src/SafeDOM.res */
2+
/* High-level API over explicit ABI + FFI layers. */
33

4-
type mountResult =
5-
| Mounted(Dom.element)
6-
| MountPointNotFound(string)
7-
| InvalidSelector(string)
8-
| InvalidHTML(string)
4+
type mountResult = SafeDOMABI.mountResult =
5+
| MountedAt(Dom.element)
6+
| NotFound(string)
7+
| Failed(string)
98

10-
module ProvenSelector = {
11-
type validated = ValidSelector(string)
12-
13-
let hasInvalidSelectorChars: string => bool = %raw(`(selector) => /[^\w\-#.\[\]():>~+= ]/.test(selector)`)
14-
15-
let validate = (selector: string): result<validated, string> => {
16-
let len = selector->Js.String2.length
17-
if len == 0 {
18-
Error("Selector cannot be empty")
19-
} else if len > 255 {
20-
Error("Selector exceeds maximum length (255 characters)")
21-
} else if hasInvalidSelectorChars(selector) {
22-
Error("Selector contains invalid CSS characters")
23-
} else {
24-
Ok(ValidSelector(selector))
25-
}
26-
}
27-
28-
let toString = (ValidSelector(selector)) => selector
29-
}
30-
31-
module ProvenHTML = {
32-
type validated = ValidHTML(string)
33-
34-
let countOpenTags: string => int = %raw(`(html) => (html.match(/<[^\/][^>]*>/g) || []).length`)
35-
let countCloseTags: string => int = %raw(`(html) => (html.match(/<\/[^>]+>/g) || []).length`)
36-
let countSelfClosing: string => int = %raw(`(html) => (html.match(/<[^>]+\/>/g) || []).length`)
37-
38-
let validate = (html: string): result<validated, string> => {
39-
let len = html->Js.String2.length
40-
if len == 0 {
41-
Ok(ValidHTML(""))
42-
} else if len > 1048576 {
43-
Error("HTML content exceeds maximum size (1MB)")
44-
} else {
45-
let openTags = countOpenTags(html)
46-
let closeTags = countCloseTags(html)
47-
let selfClosing = countSelfClosing(html)
48-
if openTags - selfClosing != closeTags {
49-
Error(
50-
"Unbalanced HTML tags: "
51-
++ Belt.Int.toString(openTags - selfClosing)
52-
++ " open, "
53-
++ Belt.Int.toString(closeTags)
54-
++ " close",
55-
)
56-
} else {
57-
Ok(ValidHTML(html))
58-
}
59-
}
60-
}
61-
62-
let toString = (ValidHTML(html)) => html
63-
}
64-
65-
let findMountPoint = (selector: ProvenSelector.validated): option<Dom.element> =>
66-
Webapi.Dom.document->Webapi.Dom.Document.querySelector(selector->ProvenSelector.toString)
67-
68-
let mount = (selector: ProvenSelector.validated, html: ProvenHTML.validated): mountResult => {
69-
switch findMountPoint(selector) {
70-
| None => MountPointNotFound(selector->ProvenSelector.toString)
71-
| Some(element) =>
72-
element->Webapi.Dom.Element.setInnerHTML(html->ProvenHTML.toString)
73-
Mounted(element)
74-
}
75-
}
76-
77-
let mountString = (selector: string, html: string): mountResult => {
78-
switch ProvenSelector.validate(selector) {
79-
| Error(error) => InvalidSelector(error)
80-
| Ok(validSelector) =>
81-
switch ProvenHTML.validate(html) {
82-
| Error(error) => InvalidHTML(error)
83-
| Ok(validHtml) => mount(validSelector, validHtml)
84-
}
85-
}
86-
}
9+
let mountString = (selector: string, html: string): mountResult => SafeDOMFFI.safeMountHTML(selector, html)
8710

8811
let mountSafe = (
8912
selector: string,
@@ -92,49 +15,18 @@ let mountSafe = (
9215
~onError: string => unit,
9316
): unit => {
9417
switch mountString(selector, html) {
95-
| Mounted(element) => onSuccess(element)
96-
| MountPointNotFound(value) => onError("Mount point not found: " ++ value)
97-
| InvalidSelector(value) => onError("Invalid selector: " ++ value)
98-
| InvalidHTML(value) => onError("Invalid HTML: " ++ value)
18+
| MountedAt(element) => onSuccess(element)
19+
| NotFound(value) => onError("Mount point not found: " ++ value)
20+
| Failed(value) => onError(value)
9921
}
10022
}
10123

102-
type mountSpec = {
103-
selector: string,
104-
html: string,
105-
}
106-
107-
let mountBatch = (specs: array<mountSpec>): result<array<Dom.element>, string> => {
108-
let mounted = []
109-
let rec loop = (index: int): result<array<Dom.element>, string> => {
110-
if index >= Belt.Array.length(specs) {
111-
Ok(mounted)
112-
} else {
113-
switch specs[index] {
114-
| spec =>
115-
switch mountString(spec.selector, spec.html) {
116-
| Mounted(element) =>
117-
mounted->Belt.Array.push(element)
118-
loop(index + 1)
119-
| MountPointNotFound(value) => Error("Mount point not found: " ++ value)
120-
| InvalidSelector(value) => Error("Invalid selector: " ++ value)
121-
| InvalidHTML(value) => Error("Invalid HTML: " ++ value)
122-
}
123-
}
124-
}
125-
}
126-
loop(0)
127-
}
128-
129-
let domReadyState: unit => string = %raw(`() => document.readyState`)
130-
let onDOMContentLoaded: (unit => unit) => unit = %raw(`(callback) => document.addEventListener("DOMContentLoaded", callback)`)
131-
13224
let onDOMReady = (callback: unit => unit): unit => {
133-
let state = domReadyState()
25+
let state = SafeDOMFFI.domReadyState()
13426
if state == "complete" || state == "interactive" {
13527
callback()
13628
} else {
137-
onDOMContentLoaded(callback)
29+
SafeDOMFFI.onDOMContentLoaded(callback)
13830
}
13931
}
14032

src/core/SafeDOMABI.res

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/* SPDX-License-Identifier: AGPL-3.0-or-later WITH Palimpsest */
2+
/* Idris-aligned ABI surface, modeled after:
3+
/mnt/eclipse/repos/rescript-ecosystem/packages/web/dom-mounter/src/ABI/Types.idr */
4+
5+
type domResult =
6+
| Mounted
7+
| MountPointNotFound
8+
| InvalidSelector
9+
| InvalidHTML
10+
11+
type mountResult =
12+
| MountedAt(Dom.element)
13+
| NotFound(string)
14+
| Failed(string)
15+
16+
let selectorMinLength = 1
17+
let selectorMaxLength = 255
18+
let htmlMaxLength = 1048576

src/core/SafeDOMFFI.res

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/* SPDX-License-Identifier: AGPL-3.0-or-later WITH Palimpsest */
2+
/* FFI boundary for DOM mounting operations (JS host calls only). */
3+
4+
let validateSelectorCode: (string, int) => int = %raw(`(selector, len) => {
5+
if (len === 0) return 1;
6+
if (len > 255) return 2;
7+
return /[^\w\-#.\[\]():>~+= ]/.test(selector) ? 3 : 0;
8+
}`)
9+
10+
let validateHtmlCode: (string, int) => int = %raw(`(html, len) => {
11+
if (len > 1048576) return 1;
12+
if (len === 0) return 0;
13+
const openTags = (html.match(/<[^\/][^>]*>/g) || []).length;
14+
const closeTags = (html.match(/<\/[^>]+>/g) || []).length;
15+
const selfClosing = (html.match(/<[^>]+\/>/g) || []).length;
16+
return (openTags - selfClosing) === closeTags ? 0 : 2;
17+
}`)
18+
19+
let findElement: string => option<Dom.element> = %raw(`(selector) => {
20+
const el = document.querySelector(selector);
21+
return el == null ? undefined : el;
22+
}`)
23+
24+
let mountInnerHtml: (Dom.element, string) => int = %raw(`(element, html) => {
25+
if (element == null) return 1;
26+
try {
27+
element.innerHTML = html;
28+
return 0;
29+
} catch {
30+
return 2;
31+
}
32+
}`)
33+
34+
let domReadyState: unit => string = %raw(`() => document.readyState`)
35+
let onDOMContentLoaded: (unit => unit) => unit = %raw(`(callback) => document.addEventListener("DOMContentLoaded", callback)`)
36+
37+
let safeMountHTML = (selector: string, html: string): SafeDOMABI.mountResult => {
38+
let selectorLen = selector->Js.String2.length
39+
let selectorValidation =
40+
switch validateSelectorCode(selector, selectorLen) {
41+
| 0 => None
42+
| 1 => Some("Invalid selector: Selector cannot be empty")
43+
| 2 => Some("Invalid selector: Selector exceeds maximum length (255 characters)")
44+
| 3 => Some("Invalid selector: Selector contains invalid CSS characters")
45+
| _ => Some("Invalid selector: Unknown validation error")
46+
}
47+
48+
switch selectorValidation {
49+
| Some(error) => SafeDOMABI.Failed(error)
50+
| None =>
51+
let htmlLen = html->Js.String2.length
52+
let htmlValidation =
53+
switch validateHtmlCode(html, htmlLen) {
54+
| 0 => None
55+
| 1 => Some("Invalid HTML: HTML content exceeds maximum size (1MB)")
56+
| 2 => Some("Invalid HTML: HTML tags are unbalanced")
57+
| _ => Some("Invalid HTML: Unknown validation error")
58+
}
59+
60+
switch htmlValidation {
61+
| Some(error) => SafeDOMABI.Failed(error)
62+
| None =>
63+
switch findElement(selector) {
64+
| None => SafeDOMABI.NotFound(selector)
65+
| Some(element) =>
66+
switch mountInnerHtml(element, html) {
67+
| 0 => SafeDOMABI.MountedAt(element)
68+
| 1 => SafeDOMABI.Failed("Null element (impossible under validated lookup)")
69+
| 2 => SafeDOMABI.Failed("Mount operation failed")
70+
| _ => SafeDOMABI.Failed("Unknown mount error")
71+
}
72+
}
73+
}
74+
}
75+
}

0 commit comments

Comments
 (0)