use std::path::Path;
fn widget_sources() -> Vec<(String, String)> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/output/spa/js");
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("js") {
continue;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_owned();
let src = std::fs::read_to_string(&path).expect("read widget source");
out.push((name, src));
}
}
out.sort();
assert!(
!out.is_empty(),
"scanned zero widget sources — source-path resolution is broken"
);
out
}
const RAW_STRING_ACCESSORS: &[&str] = &[
".path",
".entity",
".author",
".canonical_author",
".source",
".target",
".module",
".tag",
".name",
".function",
"order[",
"_author",
"_path",
"_name",
];
const HTML_TAGS: &[&str] = &[
"div", "span", "br", "strong", "p", "td", "tr", "th", "dt", "dd", "dl", "li", "ul", "ol", "h1",
"h2", "h3", "h4", "h5", "code", "b", "em", "i", "a", "img", "option", "small", "label",
"button", "svg", "table", "tbody", "thead",
];
const HTML_MARKERS: &[&str] = &[
"innerHTML",
"&rarr",
"&harr",
"·",
" ",
"title=\"",
];
fn builds_markup(stmt: &str) -> bool {
HTML_MARKERS.iter().any(|m| stmt.contains(m))
|| literal_tags(stmt).any(|(_, tag)| HTML_TAGS.contains(&tag))
}
fn literal_tags(s: &str) -> impl Iterator<Item = (usize, &str)> {
s.match_indices('<').filter_map(move |(i, _)| {
if !s[..i].trim_end_matches(' ').ends_with(['\'', '"']) {
return None;
}
let rest = &s[i + 1..];
let rest = rest.strip_prefix('/').unwrap_or(rest);
let len = rest
.find(|c: char| !c.is_ascii_alphanumeric())
.unwrap_or(rest.len());
(len > 0).then(|| (i, &rest[..len]))
})
}
fn statements(src: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut start = 0;
for (i, _) in src.match_indices(';') {
let head = src[..i].trim_end_matches(|c: char| c.is_ascii_alphanumeric());
let closes_entity = head.len() < i && head.ends_with('&');
if !closes_entity {
out.push((start, &src[start..i]));
start = i + 1;
}
}
out.push((start, &src[start..]));
out
}
fn preceding(stmt: &str, pos: usize) -> &str {
stmt[..pos]
.trim_end_matches(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '$' || c == '.')
.trim_end()
}
fn is_escaped(stmt: &str, pos: usize) -> bool {
let mut depth = 0usize;
for (i, c) in stmt[..pos].char_indices().rev() {
match c {
')' => depth += 1,
'(' if depth == 0 => {
let head = stmt[..i].trim_end();
let name = head
.trim_end_matches(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '$')
.len();
return &head[name..] == "escapeHtml";
}
'(' => depth -= 1,
_ => {}
}
}
false
}
fn is_lookup_key(stmt: &str, pos: usize) -> bool {
preceding(stmt, pos).ends_with('[')
}
fn unescaped_sinks(src: &str) -> Vec<String> {
let mut out = Vec::new();
for (offset, stmt) in statements(src) {
if !builds_markup(stmt) {
continue;
}
for accessor in RAW_STRING_ACCESSORS {
for (at, _) in stmt.match_indices(accessor) {
if !is_escaped(stmt, at) && !is_lookup_key(stmt, at) {
let line = src[..offset + at].matches('\n').count() + 1;
out.push(format!("{line}: {accessor}"));
}
}
}
}
out
}
#[test]
fn no_widget_concatenates_repository_strings_into_markup_unescaped() {
let mut violations = Vec::new();
for (name, src) in widget_sources() {
for hit in unescaped_sinks(&src) {
violations.push(format!(" {name}:{hit}"));
}
}
assert!(
violations.is_empty(),
"{} SPA sink(s) interpolate a repository-derived string into markup \
without `escapeHtml`:\n{}\n\n\
Repository paths may contain `<` and `>`; they reach the browser \
verbatim because the emitter escapes only `</` in the JSON payload. \
An unescaped concatenation into `innerHTML` — including the return \
value of an ECharts function formatter, which is inserted as markup \
rather than filtered like a `{{b}}` template — executes whatever the \
analysed repository put in that path.\n\n\
Wrap the value in `escapeHtml(...)`, the helper every other widget \
already uses.",
violations.len(),
violations.join("\n"),
);
}
const NON_TAG_PLACEHOLDERS: &[&str] = &["anonymous", "root"];
const LOCAL_CARRIED_SINKS: &[(&str, &str)] = &[
("12_drawer.js", "partnerAuthor"),
("20_hotspots.js", "rowAuthor"),
];
#[test]
fn repository_strings_parked_in_locals_are_escaped_where_they_render() {
let sources = widget_sources();
for (file, local) in LOCAL_CARRIED_SINKS {
let Some((_, src)) = sources.iter().find(|(name, _)| name == file) else {
panic!("{file} is no longer a widget source");
};
let rendered: Vec<_> = src
.lines()
.enumerate()
.filter(|(_, line)| line.contains(local) && builds_markup(line))
.collect();
assert!(
!rendered.is_empty(),
"{file} no longer renders `{local}` into markup. Either the sink \
moved — re-point this entry at it — or it is gone and the entry \
should be. An instance list that describes code which has moved \
is worse than no list, because it reads as coverage."
);
for (idx, line) in rendered {
assert!(
line.contains(&format!("escapeHtml({local}")),
"{file}:{}: `{local}` reaches markup unescaped.\n {}\n\n\
This sink is invisible to the statement scan — the value is \
a local, so there is no accessor to match — which is why it \
is pinned by name here.",
idx + 1,
line.trim(),
);
}
}
}
#[test]
fn every_tag_the_widgets_open_is_covered() {
let mut missing = Vec::new();
for (name, src) in widget_sources() {
for (at, tag) in literal_tags(&src) {
if HTML_TAGS.contains(&tag) || NON_TAG_PLACEHOLDERS.contains(&tag) {
continue;
}
let line = src[..at].matches('\n').count() + 1;
missing.push(format!(" {name}:{line}: <{tag}"));
}
}
missing.sort();
missing.dedup();
assert!(
missing.is_empty(),
"{} tag(s) opened in widget markup are absent from `HTML_TAGS`:\n{}\n\n\
`builds_markup` examines a statement only when it recognises the \
markup, so a tag missing from that list silently takes every \
accessor in the statement out of coverage — no failure, no output, \
just a blind spot. That is the defect this guard was widened to \
close, and the list is the one place it can reopen.\n\n\
Add the tag, or if the token is not markup, add it to \
`NON_TAG_PLACEHOLDERS` with the reason.",
missing.len(),
missing.join("\n"),
);
}
#[test]
fn the_guard_catches_the_shape_it_exists_for() {
let vulnerable =
"return 'Imports: ' + p.data.source + ' → ' + p.data.target + ' (' + n + ')';";
assert_eq!(
unescaped_sinks(vulnerable).len(),
2,
"must flag both unescaped edge endpoints"
);
let vulnerable_leading = "return p.name + '<br/>role: ' + role;";
assert_eq!(
unescaped_sinks(vulnerable_leading).len(),
1,
"must flag an accessor that opens the expression, with no `+` before it"
);
let vulnerable_index = "return order[r] + ' → ' + order[c] + '<br/>' + v;";
assert_eq!(
unescaped_sinks(vulnerable_index).len(),
2,
"must flag indexed axis labels"
);
let fixed =
"return 'Imports: ' + escapeHtml(p.data.source) + ' → ' + escapeHtml(p.data.target);";
assert_eq!(
unescaped_sinks(fixed).len(),
0,
"must accept the escaped form"
);
let not_markup = "const rm = modulePath(rr.path, chosenDepth);";
assert_eq!(
unescaped_sinks(not_markup).len(),
0,
"a statement that builds no markup is not a sink"
);
let lookup = "return escapeHtml(p.name) + '<br/>role: ' + (moduleRole[p.name] || 'periphery');";
assert_eq!(
unescaped_sinks(lookup).len(),
0,
"an accessor inside a subscript is a lookup key, not a sink"
);
}
#[test]
fn the_guard_reaches_table_markup_and_qualifier_first_fields() {
let compound = "html += '<td>' + (r.main_author || '') + '</td>';";
assert_eq!(
unescaped_sinks(compound).len(),
1,
"a qualifier-first compound field is a sink; `.author` does not occur in `main_author`"
);
assert_eq!(
unescaped_sinks("html += '<td>' + escapeHtml(r.main_author || '') + '</td>';").len(),
0,
"must accept the escaped compound field"
);
let parsed_identifier = "html += '<li><code>' + (f.function || '') + '</code></li>';";
assert_eq!(
unescaped_sinks(parsed_identifier).len(),
1,
"a function name parsed from the analysed source is repository-derived"
);
let suffix = "return '<div>' + p.entity_a + '</div>';";
assert_eq!(
unescaped_sinks(suffix).len(),
1,
"a suffix compound is covered by its base accessor, with no entry of its own"
);
let disjunction = "var html = '<b>' + escapeHtml(p.axisValueLabel || p.name) + '</b>';";
assert_eq!(
unescaped_sinks(disjunction).len(),
0,
"one escapeHtml call covers every operand inside its parentheses"
);
let comparison = "if (a.name < b.name) { rank = 1; }";
assert_eq!(
unescaped_sinks(comparison).len(),
0,
"a less-than outside a string literal does not make a statement markup"
);
let placeholder = "node.children.push({ label: r.function || '<anonymous>', value: r.path });";
assert_eq!(
unescaped_sinks(placeholder).len(),
0,
"`<anonymous>` is a placeholder in the X-ray widget, not an opening tag"
);
}