Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/relune-app/src/usecases/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ fn render_with_schema(
let svg = render_svg_with_overlay(&positioned, svg_options, request.overlay.as_ref())?;

match request.output_format {
OutputFormat::Svg => Ok(svg),
OutputFormat::Svg => Ok(svg.into_string()),
OutputFormat::Html => {
let html_theme = match request.options.theme {
RenderTheme::Light => HtmlTheme::Light,
Expand Down
2 changes: 1 addition & 1 deletion crates/relune-app/src/usecases/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ fn render_svg_output(
compact: false,
show_tooltips: true,
};
Ok(render_svg_with_overlay(positioned, options, overlay)?)
Ok(render_svg_with_overlay(positioned, options, overlay)?.into_string())
}

/// Render to HTML format.
Expand Down
1 change: 1 addition & 0 deletions crates/relune-render-html/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ rust-version.workspace = true
base64.workspace = true
relune-core = { path = "../relune-core" }
relune-layout = { path = "../relune-layout" }
relune-render-svg = { path = "../relune-render-svg" }
relune-render-theme = { path = "../relune-render-theme" }
serde.workspace = true
serde_json.workspace = true
Expand Down
98 changes: 92 additions & 6 deletions crates/relune-render-html/src/html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,53 @@ static FAVICON_DATA_URI: LazyLock<String> = LazyLock::new(|| {
});

/// Escape JSON content for safe embedding in a script tag.
///
/// The HTML parser treats script end tags case-insensitively, so any
/// `</script` sequence must be neutralized regardless of casing (e.g.
/// `</SCRIPT>`, `</Script foo>`) to prevent premature script termination
/// and XSS via metadata. `<!--` and `-->` are also neutralized to avoid
/// HTML comment issues in legacy parsers.
pub fn escape_json_for_script(json: &str) -> String {
// For JSON inside a script tag, we need to escape </script> to prevent
// premature script termination. We also escape <!-- and --> to prevent
// HTML comment issues in older browsers.
json.replace("</script>", "<\\/script>")
.replace("<!--", "<\\!--")
.replace("-->", "-\\->")
let bytes = json.as_bytes();
let mut out = String::with_capacity(json.len());
let mut copy_from = 0;
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'<' && matches_ascii_ci(bytes, i + 1, b"/script") {
// Preserve the original casing of the matched substring and only
// insert the inert backslash before the slash.
out.push_str(&json[copy_from..i]);
out.push_str("<\\");
out.push_str(&json[i + 1..i + "</script".len()]);
i += "</script".len();
copy_from = i;
} else if b == b'<' && bytes.get(i + 1..i + 4) == Some(b"!--") {
// Emit a JSON `\uXXXX` escape for `<` so the HTML parser never sees
// `<!--`, while `JSON.parse` still recovers the original character.
out.push_str(&json[copy_from..i]);
out.push_str("\\u003C!--");
i += "<!--".len();
copy_from = i;
} else if b == b'-' && bytes.get(i + 1..i + 3) == Some(b"->") {
// Same approach for `-->`: escape `>` so the runtime string is unchanged.
out.push_str(&json[copy_from..i]);
out.push_str("--\\u003E");
i += "-->".len();
copy_from = i;
} else {
i += 1;
}
}
out.push_str(&json[copy_from..]);
out
}

/// Returns true when `bytes[start..]` starts with `needle` ignoring ASCII case.
fn matches_ascii_ci(bytes: &[u8], start: usize, needle: &[u8]) -> bool {
bytes
.get(start..start + needle.len())
.is_some_and(|slice| slice.eq_ignore_ascii_case(needle))
}

/// Build the complete HTML document.
Expand Down Expand Up @@ -189,13 +229,59 @@ mod tests {
assert!(!escaped.contains("</script>"));
}

#[test]
fn test_escape_json_for_script_neutralizes_uppercase_end_tag() {
// HTML parsers treat </SCRIPT> identically to </script>; the escape
// must catch it to prevent XSS via metadata that contains uppercase
// or mixed-case script end tags.
let input = r#"{"name": "</SCRIPT>"}"#;
let escaped = escape_json_for_script(input);
assert_eq!(escaped, r#"{"name": "<\/SCRIPT>"}"#);
assert!(!escaped.to_ascii_lowercase().contains("</script>"));
}

#[test]
fn test_escape_json_for_script_neutralizes_mixed_case_end_tag() {
let input = r#"{"name": "</Script>"}"#;
let escaped = escape_json_for_script(input);
assert_eq!(escaped, r#"{"name": "<\/Script>"}"#);
}

#[test]
fn test_escape_json_for_script_neutralizes_end_tag_with_attribute() {
// Browsers still terminate the script when </script foo> appears.
let input = r#"{"name": "</script foo>"}"#;
let escaped = escape_json_for_script(input);
assert_eq!(escaped, r#"{"name": "<\/script foo>"}"#);
}

#[test]
fn test_escape_json_preserves_content() {
let input = r#"{"key": "value", "number": 42}"#;
let escaped = escape_json_for_script(input);
assert_eq!(escaped, input);
}

#[test]
fn test_escape_json_preserves_unicode_content() {
let input = r#"{"name": "テスト", "emoji": "🚀"}"#;
let escaped = escape_json_for_script(input);
assert_eq!(escaped, input);
}

#[test]
fn test_escape_json_neutralizes_html_comments() {
let input = r#"{"a": "<!--", "b": "-->"}"#;
let escaped = escape_json_for_script(input);
// The `<` / `>` forms are valid JSON escapes that hide the raw
// `<` / `>` from the HTML parser; `JSON.parse` decodes them back losslessly.
assert_eq!(escaped, r#"{"a": "\u003C!--", "b": "--\u003E"}"#);
// Output must still be parseable by JSON consumers (e.g. the embedded viewer).
let parsed: serde_json::Value = serde_json::from_str(&escaped).expect("valid JSON");
assert_eq!(parsed["a"], "<!--");
assert_eq!(parsed["b"], "-->");
}

#[test]
fn test_escape_xml_text_for_html() {
assert_eq!(escape_xml_text("<script>"), "&lt;script&gt;");
Expand Down
43 changes: 25 additions & 18 deletions crates/relune-render-html/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod options;

pub use error::HtmlRenderError;
pub use options::{HtmlRenderOptions, Theme};
pub use relune_render_svg::SvgArtifact;

use relune_layout::LayoutGraph;

Expand All @@ -20,15 +21,15 @@ use relune_layout::LayoutGraph;
/// # Arguments
///
/// * `graph` - The layout graph containing node/edge/group information
/// * `svg` - Pre-rendered SVG content to embed
/// * `svg` - Pre-rendered SVG produced by [`relune_render_svg`]
/// * `options` - HTML rendering options
///
/// # Returns
///
/// A complete, self-contained HTML document string.
pub fn render_html(
graph: &LayoutGraph,
svg: &str,
svg: &SvgArtifact,
options: &HtmlRenderOptions,
) -> Result<String, HtmlRenderError> {
render_html_with_overlay(graph, svg, options, None)
Expand All @@ -40,15 +41,15 @@ pub fn render_html(
/// so that client-side scripts can display lint warnings, diff status, etc.
pub fn render_html_with_overlay(
graph: &LayoutGraph,
svg: &str,
svg: &SvgArtifact,
options: &HtmlRenderOptions,
overlay: Option<&relune_layout::DiagramOverlay>,
) -> Result<String, HtmlRenderError> {
let metadata = metadata::build_metadata_with_overlay(graph, overlay);
let metadata_json = serde_json::to_string(&metadata)?;
let escaped_metadata = html::escape_json_for_script(&metadata_json);

let html_document = html::build_html_document(svg, &escaped_metadata, options);
let html_document = html::build_html_document(svg.as_str(), &escaped_metadata, options);

Ok(html_document)
}
Expand Down Expand Up @@ -151,12 +152,15 @@ mod tests {
}
}

fn create_test_svg() -> &'static str {
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 400">
fn create_test_svg() -> SvgArtifact {
SvgArtifact::from_trusted(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 400">
<g class="node" data-id="users"><rect x="56" y="56" width="260" height="94"/></g>
<g class="node" data-id="posts"><rect x="56" y="230" width="260" height="112"/></g>
<g class="edge"><line x1="316" y1="286" x2="56" y2="103"/></g>
</svg>"#
.to_string(),
)
}

#[test]
Expand All @@ -165,7 +169,7 @@ mod tests {
let svg = create_test_svg();
let options = HtmlRenderOptions::default();

let result = render_html(&graph, svg, &options).unwrap();
let result = render_html(&graph, &svg, &options).unwrap();

assert!(result.contains("<!DOCTYPE html>"));
assert!(result.contains("<html"));
Expand All @@ -180,7 +184,7 @@ mod tests {
let svg = create_test_svg();
let options = HtmlRenderOptions::default();

let result = render_html(&graph, svg, &options).unwrap();
let result = render_html(&graph, &svg, &options).unwrap();

assert!(result.contains(r#"<svg xmlns="http://www.w3.org/2000/svg""#));
assert!(result.contains(r#"data-id="users""#));
Expand All @@ -193,7 +197,7 @@ mod tests {
let svg = create_test_svg();
let options = HtmlRenderOptions::default();

let result = render_html(&graph, svg, &options).unwrap();
let result = render_html(&graph, &svg, &options).unwrap();

assert!(result.contains(r#"id="relune-metadata""#));
assert!(result.contains(r#""tables""#));
Expand All @@ -211,7 +215,7 @@ mod tests {
..Default::default()
};

let result = render_html(&graph, svg, &options).unwrap();
let result = render_html(&graph, &svg, &options).unwrap();

assert!(result.contains("<title>My Schema ERD</title>"));
assert!(result.contains("<h1>My Schema ERD</h1>"));
Expand All @@ -226,7 +230,7 @@ mod tests {
..Default::default()
};

let result = render_html(&graph, svg, &options).unwrap();
let result = render_html(&graph, &svg, &options).unwrap();

assert!(result.contains("--bg-color: #0c0f1a"));
assert!(result.contains("--text-color: #e2e8f0"));
Expand All @@ -241,7 +245,7 @@ mod tests {
..Default::default()
};

let result = render_html(&graph, svg, &options).unwrap();
let result = render_html(&graph, &svg, &options).unwrap();

assert!(result.contains("--bg-color: #f7f8fc"));
assert!(result.contains("--text-color: #1e293b"));
Expand All @@ -253,7 +257,7 @@ mod tests {
let svg = create_test_svg();
let options = HtmlRenderOptions::default();

let result = render_html(&graph, svg, &options).unwrap();
let result = render_html(&graph, &svg, &options).unwrap();

assert!(result.contains("updateTransform"));
assert!(result.contains("addEventListener"));
Expand All @@ -265,7 +269,7 @@ mod tests {
let svg = create_test_svg();
let options = HtmlRenderOptions::default();

let result = render_html(&graph, svg, &options).unwrap();
let result = render_html(&graph, &svg, &options).unwrap();

// Should not reference external HTTP resources
assert!(result.contains("<link"));
Expand All @@ -279,7 +283,7 @@ mod tests {
let svg = create_test_svg();
let options = HtmlRenderOptions::default();

let result = render_html(&graph, svg, &options).unwrap();
let result = render_html(&graph, &svg, &options).unwrap();

// Check metadata contains expected structure
assert!(result.contains(r#""id":"users""#));
Expand Down Expand Up @@ -350,12 +354,15 @@ mod tests {
node_index: std::collections::BTreeMap::new(),
reverse_index: std::collections::BTreeMap::new(),
};
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 400">
let svg = SvgArtifact::from_trusted(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 400">
<g class="table-node node node-kind-view" data-table-id="active_users" data-id="active_users" data-node-kind="view"></g>
<g class="table-node node node-kind-enum" data-table-id="status" data-id="status" data-node-kind="enum"></g>
</svg>"#;
</svg>"#
.to_string(),
);

let result = render_html(&graph, svg, &HtmlRenderOptions::default()).unwrap();
let result = render_html(&graph, &svg, &HtmlRenderOptions::default()).unwrap();

assert!(result.contains(r#""kind":"view""#));
assert!(result.contains(r#""kind":"enum""#));
Expand Down
Loading
Loading