use std::io::{self, Read};
use std::path::Path;
use super::*;
use crate::report::Report;
#[derive(Debug, PartialEq)]
pub(crate) enum Side {
Mat,
Json,
}
pub(crate) fn classify_side(path: &str) -> io::Result<Side> {
let p = Path::new(path);
if p.is_dir() {
return Ok(Side::Mat);
}
let lower = path.to_ascii_lowercase();
if lower.ends_with(".json") {
return Ok(Side::Json);
}
if lower.ends_with(".zip") || lower.ends_with(".html") || lower.ends_with(".htm") {
return Ok(Side::Mat);
}
let mut f = std::fs::File::open(path)?;
let mut head = [0u8; 8];
let n = f.read(&mut head)?;
let head = &head[..n];
if head.starts_with(b"PK") {
return Ok(Side::Mat); }
let trimmed: &[u8] = head
.iter()
.position(|&b| !b.is_ascii_whitespace())
.map(|i| &head[i..])
.unwrap_or(head);
if trimmed.first() == Some(&b'{') {
return Ok(Side::Json);
}
Ok(Side::Mat)
}
pub(crate) fn load_json(path: &str) -> io::Result<Report> {
let s = std::fs::read_to_string(path)?;
serde_json::from_str(&s).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid report JSON: {e}"),
)
})
}
struct HtmlDoc {
name: String,
html: String,
}
fn load_mat_html(path: &str) -> io::Result<Vec<HtmlDoc>> {
let p = Path::new(path);
let lower = path.to_ascii_lowercase();
if p.is_dir() {
return collect_dir_html(p);
}
if lower.ends_with(".html") || lower.ends_with(".htm") {
let html = std::fs::read_to_string(path)?;
return Ok(vec![HtmlDoc {
name: p
.file_name()
.map(|s| s.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default(),
html,
}]);
}
read_zip_html(path)
}
fn collect_dir_html(dir: &Path) -> io::Result<Vec<HtmlDoc>> {
let mut out = Vec::new();
fn walk(dir: &Path, out: &mut Vec<HtmlDoc>) -> io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
walk(&path, out)?;
} else if let Some(ext) = path.extension() {
if ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm") {
if let Ok(html) = std::fs::read_to_string(&path) {
out.push(HtmlDoc {
name: path
.file_name()
.map(|s| s.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default(),
html,
});
}
}
}
}
Ok(())
}
walk(dir, &mut out)?;
Ok(out)
}
fn read_zip_html(path: &str) -> io::Result<Vec<HtmlDoc>> {
let bytes = std::fs::read(path)?;
let mut out = Vec::new();
let eocd = find_eocd(&bytes)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "not a zip: no EOCD record"))?;
let cd_count = u16::from_le_bytes([bytes[eocd + 10], bytes[eocd + 11]]) as usize;
let cd_off = u32::from_le_bytes([
bytes[eocd + 16],
bytes[eocd + 17],
bytes[eocd + 18],
bytes[eocd + 19],
]) as usize;
let mut p = cd_off;
for _ in 0..cd_count {
if p + 46 > bytes.len() || &bytes[p..p + 4] != b"PK\x01\x02" {
break;
}
let method = u16::from_le_bytes([bytes[p + 10], bytes[p + 11]]);
let comp_size =
u32::from_le_bytes([bytes[p + 20], bytes[p + 21], bytes[p + 22], bytes[p + 23]])
as usize;
let name_len = u16::from_le_bytes([bytes[p + 28], bytes[p + 29]]) as usize;
let extra_len = u16::from_le_bytes([bytes[p + 30], bytes[p + 31]]) as usize;
let comment_len = u16::from_le_bytes([bytes[p + 32], bytes[p + 33]]) as usize;
let lho = u32::from_le_bytes([bytes[p + 42], bytes[p + 43], bytes[p + 44], bytes[p + 45]])
as usize;
if p + 46 + name_len + extra_len + comment_len > bytes.len() {
break;
}
let name = String::from_utf8_lossy(&bytes[p + 46..p + 46 + name_len]).to_string();
p += 46 + name_len + extra_len + comment_len;
let low = name.to_ascii_lowercase();
if !(low.ends_with(".html") || low.ends_with(".htm")) {
continue;
}
if lho + 30 > bytes.len() || &bytes[lho..lho + 4] != b"PK\x03\x04" {
continue;
}
let l_name = u16::from_le_bytes([bytes[lho + 26], bytes[lho + 27]]) as usize;
let l_extra = u16::from_le_bytes([bytes[lho + 28], bytes[lho + 29]]) as usize;
let data_off = lho + 30 + l_name + l_extra;
if data_off + comp_size > bytes.len() {
continue;
}
let data = &bytes[data_off..data_off + comp_size];
let html = match method {
0 => String::from_utf8_lossy(data).to_string(), 8 => {
use flate2::read::DeflateDecoder;
let mut dec = DeflateDecoder::new(data);
let mut s = String::new();
dec.read_to_string(&mut s)?;
s
}
_ => continue,
};
let base = low.rsplit('/').next().unwrap_or(&low).to_string();
out.push(HtmlDoc { name: base, html });
}
Ok(out)
}
fn find_eocd(bytes: &[u8]) -> Option<usize> {
if bytes.len() < 22 {
return None;
}
let start = bytes.len().saturating_sub(22 + 65_536);
(start..=bytes.len() - 22).rev().find(|&i| {
if &bytes[i..i + 4] != b"PK\x05\x06" {
return false;
}
let comment_len = u16::from_le_bytes([bytes[i + 20], bytes[i + 21]]) as usize;
if i + 22 + comment_len != bytes.len() {
return false;
}
let cd_size =
u32::from_le_bytes([bytes[i + 12], bytes[i + 13], bytes[i + 14], bytes[i + 15]])
as usize;
let cd_off =
u32::from_le_bytes([bytes[i + 16], bytes[i + 17], bytes[i + 18], bytes[i + 19]])
as usize;
cd_off + cd_size <= i
})
}
fn parse_int(s: &str) -> Option<u64> {
let cleaned: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
if cleaned.is_empty() {
None
} else {
cleaned.parse().ok()
}
}
pub fn parse_system_overview(html: &str, out: &mut MatReport) {
use scraper::{Html, Selector};
let doc = Html::parse_document(html);
let table_sel = Selector::parse("table.result").unwrap();
let row_sel = Selector::parse("tr").unwrap();
let td_sel = Selector::parse("td").unwrap();
for table in doc.select(&table_sel) {
for row in table.select(&row_sel) {
if row
.value()
.attr("class")
.map(|c| c.contains("totals"))
.unwrap_or(false)
{
continue;
}
let tds: Vec<String> = row
.select(&td_sel)
.map(|td| td.text().collect::<String>().trim().to_string())
.collect();
if tds.len() != 2 {
continue;
}
let (label, value) = (tds[0].as_str(), tds[1].as_str());
match label {
"Used heap dump" => out.used_heap_dump = Some(value.to_string()),
"Number of objects" => out.number_of_objects = parse_int(value),
"Number of classes" => out.number_of_classes = parse_int(value),
"Number of class loaders" => out.number_of_class_loaders = parse_int(value),
"Number of GC roots" => out.number_of_gc_roots = parse_int(value),
"Format" => out.format = Some(value.to_string()),
"File length" => out.file_length = parse_int(value),
_ => {}
}
}
}
}
pub fn parse_class_histogram(html: &str, out: &mut MatReport) {
use scraper::{Html, Selector};
let doc = Html::parse_document(html);
let table_sel = Selector::parse("table.result").unwrap();
let row_sel = Selector::parse("tr").unwrap();
let td_sel = Selector::parse("td").unwrap();
let a_sel = Selector::parse("a[href^=\"mat://object/\"]").unwrap();
let Some(table) = doc.select(&table_sel).next() else {
return;
};
for row in table.select(&row_sel) {
let is_totals = row
.value()
.attr("class")
.map(|c| c.contains("totals"))
.unwrap_or(false);
let tds: Vec<_> = row.select(&td_sel).collect();
if is_totals {
if tds.len() >= 3 {
out.histogram_total_objects = parse_int(&tds[1].text().collect::<String>());
out.histogram_total_shallow = parse_int(&tds[2].text().collect::<String>());
}
continue;
}
if tds.len() < 3 {
continue; }
let Some(a) = tds[0].select(&a_sel).next() else {
continue;
};
let class_name = a.text().collect::<String>().trim().to_string();
let objects = parse_int(&tds[1].text().collect::<String>());
let shallow = parse_int(&tds[2].text().collect::<String>());
let retained = tds
.get(3)
.and_then(|td| parse_int(&td.text().collect::<String>()));
if let (Some(objects), Some(shallow)) = (objects, shallow) {
out.histogram.push(MatHistRow {
class_name,
objects,
shallow,
retained,
});
}
}
}
pub fn parse_leak_suspects(html: &str, out: &mut MatReport) {
use scraper::{Html, Selector};
let doc = Html::parse_document(html);
let imp_sel = Selector::parse("div.important").unwrap();
let q_sel = Selector::parse("q").unwrap();
let strong_sel = Selector::parse("strong").unwrap();
for imp in doc.select(&imp_sel) {
let full_text = imp.text().collect::<String>();
let trimmed = full_text.trim_start();
let is_thread_variant = trimmed.starts_with("The thread ");
let class_name = if is_thread_variant {
let Some(st) = imp.select(&strong_sel).next() else {
continue;
};
normalize_mat_object_label(&st.text().collect::<String>())
} else {
let Some(q) = imp.select(&q_sel).next() else {
continue;
};
q.text().collect::<String>().trim().to_string()
};
let instance_count = if is_thread_variant {
Some(1)
} else {
full_text.split_whitespace().next().and_then(parse_int)
};
let mut retained = None;
let mut pct = None;
for st in imp.select(&strong_sel) {
let t = st.text().collect::<String>();
if let Some((bytes, p)) = parse_bytes_pct(&t) {
retained = Some(bytes);
pct = Some(p);
break;
}
}
if let (Some(retained), Some(pct)) = (retained, pct) {
out.suspects.push(MatSuspect {
class_name,
instance_count,
retained,
pct,
});
}
}
}
fn parse_bytes_pct(t: &str) -> Option<(u64, f64)> {
let open = t.find('(')?;
let close = t.find('%')?;
if close < open {
return None;
}
let bytes = parse_int(&t[..open])?;
let pct: f64 = t[open + 1..close].trim().parse().ok()?;
Some((bytes, pct))
}
pub fn parse_top_components(html: &str, out: &mut MatReport) {
use scraper::{Html, Selector};
let doc = Html::parse_document(html);
let h2_sel = Selector::parse("h2").unwrap();
let a_sel = Selector::parse("a[href^=\"pages/\"]").unwrap();
for h2 in doc.select(&h2_sel) {
let Some(a) = h2.select(&a_sel).next() else {
continue;
};
let txt = a.text().collect::<String>();
let txt = txt.trim();
if let Some(open) = txt.rfind('(') {
if let Some(pctpos) = txt[open..].find('%') {
let name = txt[..open].trim().to_string();
let pct = parse_int(&txt[open + 1..open + pctpos]);
if let (false, Some(pct)) = (name.is_empty(), pct) {
out.components.push(MatComponent {
name,
pct: pct as u32,
});
}
}
}
}
}
fn normalize_mat_object_label(label: &str) -> String {
let s = label.trim();
let s = s.strip_prefix("class ").unwrap_or(s);
let s = match s.find(" @ 0x") {
Some(i) => &s[..i],
None => s,
};
normalize_array_len(s.trim())
}
pub(crate) fn normalize_array_len(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_bracket = false;
for c in s.chars() {
match c {
'[' => {
in_bracket = true;
out.push('[');
}
']' => {
in_bracket = false;
out.push(']');
}
d if in_bracket && d.is_ascii_digit() => {} _ => out.push(c),
}
}
out
}
pub fn parse_top_consumers(html: &str, out: &mut MatReport) {
use scraper::{Html, Selector};
let doc = Html::parse_document(html);
let table_sel = Selector::parse("table.result").unwrap();
let row_sel = Selector::parse("tr").unwrap();
let td_sel = Selector::parse("td").unwrap();
let th_sel = Selector::parse("th").unwrap();
let obj_a_sel = Selector::parse("a[href^=\"mat://object/\"]").unwrap();
let li_sel = Selector::parse("li").unwrap();
for table in doc.select(&table_sel) {
let headers: Vec<String> = table
.select(&th_sel)
.map(|th| th.text().collect::<String>().trim().to_string())
.collect();
let is_objects = headers == ["Class Name", "Shallow Heap", "Retained Heap"];
let is_classes = headers.first().map(|h| h == "Label").unwrap_or(false)
&& headers.iter().any(|h| h == "Number of Objects")
&& headers.iter().any(|h| h == "Retained Heap Size");
let is_packages = headers.first().map(|h| h == "Package").unwrap_or(false);
if is_objects {
for row in table.select(&row_sel) {
if row_is_totals(&row) {
continue;
}
let tds: Vec<_> = row.select(&td_sel).collect();
if tds.len() < 3 {
continue;
}
let Some(a) = tds[0].select(&obj_a_sel).next() else {
continue;
};
let label = a.text().collect::<String>();
let class_name = normalize_mat_object_label(&label);
let shallow = parse_int(&tds[1].text().collect::<String>());
let retained = parse_int(&tds[2].text().collect::<String>());
if let (Some(shallow), Some(retained)) = (shallow, retained) {
out.biggest_objects.push(MatBiggestObject {
class_name,
shallow,
retained,
});
}
}
} else if is_classes {
for row in table.select(&row_sel) {
if row_is_totals(&row) {
continue;
}
let tds: Vec<_> = row.select(&td_sel).collect();
if tds.len() < 4 {
continue;
}
let Some(a) = tds[0].select(&obj_a_sel).next() else {
continue;
};
let class_name = a.text().collect::<String>().trim().to_string();
if class_name == "<system class loader>" || class_name.contains(" @ 0x") {
continue;
}
let objects = parse_int(&tds[1].text().collect::<String>());
let retained = parse_int(&tds[3].text().collect::<String>());
if let (Some(objects), Some(retained)) = (objects, retained) {
out.biggest_classes.push(MatBiggestClass {
class_name,
objects,
retained,
});
}
}
} else if is_packages {
let mut path_stack: Vec<String> = Vec::new();
for row in table.select(&row_sel) {
if row_is_totals(&row) {
continue; }
let tds: Vec<_> = row.select(&td_sel).collect();
if tds.len() < 4 {
continue;
}
let first_html = tds[0].inner_html();
let prefix_len = first_html
.find("<img")
.map(|i| first_html[..i].chars().count())
.unwrap_or(0);
let Some(li) = tds[0].select(&li_sel).next() else {
continue;
};
let seg_raw = li
.text()
.next()
.map(|t| t.trim().to_string())
.unwrap_or_default();
let segment = if seg_raw == "<all>" {
String::new()
} else {
seg_raw.clone()
};
let retained = parse_int(&tds[1].text().collect::<String>());
let top_dominators = parse_int(&tds[3].text().collect::<String>());
let (Some(retained), Some(top_dominators)) = (retained, top_dominators) else {
continue;
};
if prefix_len > 0 {
path_stack.truncate(prefix_len - 1);
path_stack.push(segment.clone());
} else {
path_stack.clear();
}
let dotted_path = path_stack.join(".");
out.packages.push(MatPackageRow {
depth: prefix_len,
segment,
dotted_path,
retained,
top_dominators,
});
}
}
}
}
fn row_is_totals(row: &scraper::ElementRef) -> bool {
row.value()
.attr("class")
.map(|c| c.contains("totals"))
.unwrap_or(false)
}
fn parse_mat_docs(docs: &[HtmlDoc]) -> MatReport {
let mut rep = MatReport::default();
let top_consumer_docs: Vec<&HtmlDoc> = docs
.iter()
.filter(|d| d.name.contains("top_consumers"))
.collect();
let parse_whole_heap_top = top_consumer_docs.len() == 1;
for doc in docs {
let n = &doc.name;
if n.contains("class_histogram") {
parse_class_histogram(&doc.html, &mut rep);
} else if n.contains("top_consumers") {
if parse_whole_heap_top {
parse_top_consumers(&doc.html, &mut rep);
}
} else if n == "index.html" || n == "index.htm" {
if doc.html.contains("Suspect #")
|| doc.html.contains("Problem Suspect")
|| doc.html.contains("class=\"important\"")
{
parse_leak_suspects(&doc.html, &mut rep);
}
if doc.html.contains("Top Components") {
parse_top_components(&doc.html, &mut rep);
}
if doc.html.contains("Used heap dump") || doc.html.contains("class=\"result\"") {
parse_system_overview(&doc.html, &mut rep);
}
}
}
rep
}
pub fn load_mat_report(path: &str) -> io::Result<MatReport> {
let docs = load_mat_html(path)?;
Ok(parse_mat_docs(&docs))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_zip_html_survives_oversized_name_len() {
let mut cd = Vec::new();
cd.extend_from_slice(b"PK\x01\x02"); cd.extend_from_slice(&[0u8; 6]); cd.extend_from_slice(&[0u8; 2]); cd.extend_from_slice(&[0u8; 8]); cd.extend_from_slice(&0u32.to_le_bytes()); cd.extend_from_slice(&0u32.to_le_bytes()); cd.extend_from_slice(&1000u16.to_le_bytes()); cd.extend_from_slice(&0u16.to_le_bytes()); cd.extend_from_slice(&0u16.to_le_bytes()); cd.extend_from_slice(&[0u8; 8]); cd.extend_from_slice(&0u32.to_le_bytes()); assert_eq!(cd.len(), 46);
let cd_off = 0u32;
let cd_size = cd.len() as u32;
let mut eocd = Vec::new();
eocd.extend_from_slice(b"PK\x05\x06"); eocd.extend_from_slice(&0u16.to_le_bytes()); eocd.extend_from_slice(&0u16.to_le_bytes()); eocd.extend_from_slice(&1u16.to_le_bytes()); eocd.extend_from_slice(&1u16.to_le_bytes()); eocd.extend_from_slice(&cd_size.to_le_bytes()); eocd.extend_from_slice(&cd_off.to_le_bytes()); eocd.extend_from_slice(&0u16.to_le_bytes());
let mut blob = cd;
blob.extend_from_slice(&eocd);
let dir = std::env::temp_dir();
let path = dir.join(format!("hprof_zip_fuzz_{}.zip", std::process::id()));
std::fs::write(&path, &blob).unwrap();
let result = read_zip_html(path.to_str().unwrap());
let _ = std::fs::remove_file(&path);
assert!(
result.is_ok(),
"read_zip_html should bail cleanly, not error, on an overrun name_len"
);
assert!(
result.unwrap().is_empty(),
"no HTML docs should be extracted from the malformed record"
);
}
}