fix(core): accept only the exact markup IPOT serves in its table body

Replace the tag scanner with a strict grammar: whole <tr> rows of nine
<td> cells, each holding text or one <span>, attributes as name="value"
without quotes or angle brackets in the value, and no character references
besides &nbsp;. Markup in that form reads the same in any HTML parser, so
a quoted '>', '</tr >', '<td-x' or '&#50;' can no longer make the parser
see other cells than the page shows; such pages are rejected (Codex
re-check, round 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-18 12:24:44 +00:00
commit cecfbab235

View file

@ -131,23 +131,78 @@ fn broker_kind(class: Option<&str>) -> Option<BrokerKind> {
} }
} }
/// Whether `body` (lowercased) holds only `tr`, `td` and `span` tags, each /// Whether `body` (lowercased) is exactly the markup IPOT serves, which
/// closed before the next `<`: no comments, other elements, or markup /// reads the same in any HTML parser:
/// inside attributes that the row checks could miscount. ///
fn plain_markup(body: &str) -> bool { /// ```text
body.match_indices('<').all(|(at, _)| { /// body := ws (row ws)*
let tag = &body[at + 1..]; /// row := "<tr>" ws (cell ws){9} "</tr>"
let Some(end) = tag.find('>') else { /// cell := <td attrs> ws (<span attrs> text "</span>" | text) ws "</td>"
/// attrs := (" " name '="' value '"')* value without " < >
/// text := no "<", and no "&" except "&nbsp;"
/// ```
///
/// Anything else (comments, other tags, unclosed or stray tags, odd
/// spacing or quoting inside tags) rejects the page rather than risk
/// reading cells differently from how the page shows them.
fn canonical_rows(body: &str) -> bool {
let mut rest = body.trim_start();
while !rest.is_empty() {
let Some(after) = canonical_row(rest) else {
return false; return false;
}; };
let inner = &tag[..end]; rest = after.trim_start();
let name: String = inner }
.trim_start_matches('/') true
.chars() }
.take_while(char::is_ascii_alphanumeric)
.collect(); fn canonical_row(row: &str) -> Option<&str> {
!inner.contains('<') && matches!(name.as_str(), "tr" | "td" | "span") let mut rest = row.strip_prefix("<tr>")?;
}) for _ in 0..9 {
rest = canonical_cell(rest.trim_start())?;
}
rest.trim_start().strip_prefix("</tr>")
}
fn canonical_cell(cell: &str) -> Option<&str> {
let rest = open_tag(cell, "td")?.trim_start();
let rest = if rest.starts_with("<span") {
plain_text(open_tag(rest, "span")?)?
.strip_prefix("</span>")?
.trim_start()
} else {
plain_text(rest)?
};
rest.strip_prefix("</td>")
}
/// The rest after `<name attrs>`.
fn open_tag<'a>(markup: &'a str, name: &str) -> Option<&'a str> {
let mut rest = markup.strip_prefix('<')?.strip_prefix(name)?;
loop {
if let Some(after) = rest.strip_prefix('>') {
return Some(after);
}
rest = rest.strip_prefix(' ')?;
let name_len = rest
.bytes()
.take_while(|b| b.is_ascii_lowercase() || *b == b'-')
.count();
if name_len == 0 {
return None;
}
rest = rest[name_len..].strip_prefix("=\"")?;
let end = rest.find(['"', '<', '>'])?;
rest = rest[end..].strip_prefix('"')?;
}
}
/// The rest after text without markup or character references.
fn plain_text(text: &str) -> Option<&str> {
let end = text.find('<').unwrap_or(text.len());
let mut refs = text[..end].match_indices('&');
refs.all(|(at, _)| text[at..].starts_with("&nbsp;"))
.then_some(&text[end..])
} }
/// The `MM/DD/YYYY` value of the input with this id. /// The `MM/DD/YYYY` value of the input with this id.
@ -180,7 +235,7 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
}); });
let single = lower.matches("<tbody").count() == 1 && lower.matches("</tbody").count() == 1; let single = lower.matches("<tbody").count() == 1 && lower.matches("</tbody").count() == 1;
let Some((body_start, body_end)) = let Some((body_start, body_end)) =
body.filter(|(start, end)| single && plain_markup(&lower[*start..*end])) body.filter(|(start, end)| single && canonical_rows(&lower[*start..*end]))
else { else {
return Err(IdxError::ParseError( return Err(IdxError::ParseError(
"ipot broker summary: no single plain table in the page".to_string(), "ipot broker summary: no single plain table in the page".to_string(),
@ -198,9 +253,8 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
value value
}) })
}; };
// A skipped or merged row would shift brokers between ranks, so the body // The body is whole rows (see `canonical_rows`); ranks must run in
// must be whole rows only, each one `<tr>` of nine closed cells, ranked // order, and a broker after a side's blank rows rejects the page.
// in order, and a broker after a side's blank rows rejects the page.
let (mut buyers_ended, mut sellers_ended) = (false, false); let (mut buyers_ended, mut sellers_ended) = (false, false);
let mut rank = 0; let mut rank = 0;
let mut at = body_start; let mut at = body_start;
@ -212,17 +266,11 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
let end = rest let end = rest
.find("</tr>") .find("</tr>")
.map_or(rest.len(), |end| end + "</tr>".len()); .map_or(rest.len(), |end| end + "</tr>".len());
let piece = &rest[..end];
let original = &html[at..at + end]; let original = &html[at..at + end];
at += end; at += end;
rank += 1; rank += 1;
let whole_row = piece.trim_start().starts_with("<tr")
&& piece.ends_with("</tr>")
&& piece.matches("<tr").count() == 1
&& piece.matches("<td").count() == 9
&& piece.matches("</td>").count() == 9;
let row = cells(original); let row = cells(original);
if !whole_row || row.len() != 9 || row[4].text.parse::<u32>().ok() != Some(rank) { if row.len() != 9 || row[4].text.parse::<u32>().ok() != Some(rank) {
return Err(IdxError::ParseError(format!( return Err(IdxError::ParseError(format!(
"ipot broker summary: unexpected row {rank} ({} cells, rank {:?})", "ipot broker summary: unexpected row {rank} ({} cells, rank {:?})",
row.len(), row.len(),
@ -421,6 +469,18 @@ mod tests {
}; };
let commented = hidden(&|cells| format!("<tr><!--{cells}--></tr>")); let commented = hidden(&|cells| format!("<tr><!--{cells}--></tr>"));
let in_attribute = hidden(&|cells| format!("<tr data-x='{cells}'></tr>")); let in_attribute = hidden(&|cells| format!("<tr data-x='{cells}'></tr>"));
let after_quoted_end = hidden(&|cells| format!("<tr data-x='>{cells}'></tr>"));
// Closing tags with a space, a cell under another tag name, and a
// character reference a browser would decode.
let lots = "<span class=\"text-up\">828,163 </span>";
let spaced_row_end = DAY.replacen(lots, &format!("</tr >{lots}"), 1);
let spaced_cell_end = DAY.replacen(lots, &format!("</td >{lots}"), 1);
let other_tag = DAY.replacen(
"<td align=\"center\">5</td>",
"<td-x align=\"center\">5</td>",
1,
);
let reference = DAY.replacen(">828,163 <", ">8&#50;8,163 <", 1);
for page in [ for page in [
short_row, short_row,
skipped_rank, skipped_rank,
@ -431,6 +491,11 @@ mod tests {
early_end, early_end,
commented, commented,
in_attribute, in_attribute,
after_quoted_end,
spaced_row_end,
spaced_cell_end,
other_tag,
reference,
"</tbody><tbody>".to_string(), "</tbody><tbody>".to_string(),
] { ] {
assert!( assert!(