use super::bundle::{LogRecord, SignerRecord, Window, DISCLAIMER};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fmt::Write;
const MAX_ROWS: usize = 2000;
pub struct SummaryInput<'a> {
pub title: &'a str,
pub engine_version: &'a str,
pub created_utc: Option<&'a str>,
pub window: Window,
pub log: &'a LogRecord,
pub signer: &'a SignerRecord,
pub config: &'a Value,
pub epochs: &'a [Value],
}
pub fn esc(s: &str) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => o.push_str("&"),
'<' => o.push_str("<"),
'>' => o.push_str(">"),
'"' => o.push_str("""),
'\'' => o.push_str("'"),
c => o.push(c),
}
}
o
}
fn scalar(v: &Value) -> String {
match v {
Value::Null => "none".into(),
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn names(v: Option<&Value>) -> Vec<String> {
v.and_then(Value::as_array)
.map(|a| a.iter().map(scalar).collect())
.unwrap_or_default()
}
pub fn render(i: &SummaryInput<'_>) -> String {
use crate::palette::{dark, light};
let mut h = String::new();
let _ = writeln!(
h,
"<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\n\
<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'\">\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
<title>{t}</title>\n<style>\n\
body{{font:15px/1.5 system-ui,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem;color:{l_ink};background:{l_bg}}}\n\
h1{{font-size:1.5rem}}h2{{font-size:1.1rem;margin-top:2rem}}\n\
.box{{border:2px solid {l_amber};background:{l_panel};padding:.75rem 1rem;border-radius:6px}}\n\
table{{border-collapse:collapse;width:100%}}td,th{{border-bottom:1px solid {l_rule};padding:.25rem .5rem;text-align:left;vertical-align:top}}\n\
code{{font:13px ui-monospace,monospace;word-break:break-all}}\n\
.s-untrusted{{color:{l_coral};font-weight:600}}.s-degraded{{color:{l_amber};font-weight:600}}\n\
@media (prefers-color-scheme:dark){{body{{background:{d_bg};color:{d_ink}}}.box{{background:{d_panel};border-color:{d_amber}}}td,th{{border-color:{d_rule}}}.s-untrusted{{color:{d_coral}}}.s-degraded{{color:{d_amber}}}}}\n\
</style></head><body>",
t = esc(i.title),
l_ink = light::INK,
l_bg = light::BG,
l_amber = light::AMBER,
l_panel = light::PANEL,
l_rule = light::RULE,
l_coral = light::CORAL,
d_bg = dark::BG,
d_ink = dark::INK,
d_panel = dark::PANEL_2,
d_amber = dark::AMBER,
d_rule = dark::RULE,
d_coral = dark::CORAL
);
let _ = writeln!(h, "<h1>{}</h1>", esc(i.title));
let _ = writeln!(
h,
"<p class=\"box\"><strong>Technical record, not a legal opinion.</strong> {}</p>",
esc(DISCLAIMER)
);
if let Some(a) = i.config.get("advisory").and_then(Value::as_str) {
let _ = writeln!(
h,
"<p class=\"box\"><strong>Advisory.</strong> {}</p>",
esc(a)
);
}
h.push_str("<h2>Identification</h2>\n<table>\n");
let mut row = |k: &str, v: &str| {
let _ = writeln!(
h,
"<tr><th>{}</th><td><code>{}</code></td></tr>",
esc(k),
esc(v)
);
};
row("Engine version", i.engine_version);
row("Created (UTC)", i.created_utc.unwrap_or("not stated"));
row(
"Window",
&format!(
"{} s to {} s after the first epoch of the log",
i.window.from_s, i.window.to_s
),
);
row(
"First epoch of the log",
i.log
.start_label
.as_deref()
.unwrap_or("not stated by the log"),
);
row("Log format", &i.log.format);
row("Log file name", &i.log.file_name);
row("Full log SHA-256", &i.log.full_sha256);
row("Full log size (bytes)", &i.log.full_bytes.to_string());
row(
"Log slice in this pack",
&format!(
"{:?}, bytes {} to {}",
i.log.slice.kind, i.log.slice.start, i.log.slice.end
),
);
row("Log slice SHA-256", &i.log.slice.sha256);
row("Signer (Ed25519) fingerprint", &i.signer.fingerprint);
row("Signer public key", &i.signer.public_key);
h.push_str("</table>\n");
let _ = writeln!(
h,
"<p>A slice of kind <code>WholeLog</code> means the source could not give a byte range \
for the window, so the whole log is bundled and the window limits only which epochs are \
reported. A timestamp token, if one is attached later, is not part of this page; \
its time is shown only by <code>kshana evidence verify</code>, and <strong>timestamp authority \
signature not verified by Kshana</strong>: check it with <code>openssl ts -verify</code>. \
Check this pack with <code>kshana evidence verify</code>; compare the signer \
fingerprint with one you obtained from the signer by another route.</p>"
);
let mut by_state: BTreeMap<String, usize> = BTreeMap::new();
let mut by_monitor: BTreeMap<String, usize> = BTreeMap::new();
let mut first_bad: Option<(f64, String)> = None;
for e in i.epochs {
let state = e
.get("state")
.map(scalar)
.unwrap_or_else(|| "unknown".into());
*by_state.entry(state.clone()).or_insert(0) += 1;
for m in names(e.get("alarms")) {
*by_monitor.entry(m).or_insert(0) += 1;
}
if first_bad.is_none() && (state == "degraded" || state == "untrusted") {
first_bad = Some((
e.get("t_s").and_then(Value::as_f64).unwrap_or(f64::NAN),
state,
));
}
}
let _ = writeln!(
h,
"<h2>Results in the window</h2>\n<p>{} epochs.</p>\n<table>",
i.epochs.len()
);
for (s, n) in &by_state {
let _ = writeln!(h, "<tr><th>{}</th><td>{n} epochs</td></tr>", esc(s));
}
h.push_str("</table>\n");
match &first_bad {
Some((t, s)) => {
let _ = writeln!(
h,
"<p>First epoch not nominal in the window: <code>{}</code> s, state <code>{}</code>.</p>",
esc(&t.to_string()),
esc(s)
);
}
None => h.push_str("<p>No epoch in the window was degraded or untrusted.</p>\n"),
}
if !by_monitor.is_empty() {
h.push_str("<h3>Reasons (monitors that alarmed), epochs each</h3>\n<table>\n");
for (m, n) in &by_monitor {
let _ = writeln!(h, "<tr><th>{}</th><td>{n}</td></tr>", esc(m));
}
h.push_str("</table>\n");
}
let mut by_geometry: BTreeMap<String, usize> = BTreeMap::new();
let mut by_label: BTreeMap<String, usize> = BTreeMap::new();
let mut mismatch: Option<usize> = None;
let mut by_class: BTreeMap<String, usize> = BTreeMap::new();
for e in i.epochs {
if let Some(g) = e.get("geometry") {
let st = g
.get("status")
.map(scalar)
.unwrap_or_else(|| "unknown".into());
*by_geometry.entry(st).or_insert(0) += 1;
if let Some(l) = g.get("label") {
*by_label.entry(scalar(l)).or_insert(0) += 1;
}
if let Some(m) = g.get("reported_derived_mismatch") {
*mismatch.get_or_insert(0) += usize::from(m.as_bool() == Some(true));
}
}
let mut classes: Vec<String> = e
.get("score")
.and_then(|s| s.get("deductions"))
.and_then(Value::as_array)
.map(|ds| {
ds.iter()
.filter_map(|d| d.get("class").map(scalar))
.collect()
})
.unwrap_or_default();
classes.sort();
classes.dedup();
for c in classes {
*by_class.entry(c).or_insert(0) += 1;
}
}
if !by_geometry.is_empty() {
h.push_str("<h3>Satellite geometry, epochs each (<code>unknown</code> where the log gives no satellite geometry)</h3>\n<table>\n");
for (s, n) in &by_geometry {
let _ = writeln!(h, "<tr><th>{}</th><td>{n}</td></tr>", esc(s));
}
h.push_str("</table>\n");
}
if !by_label.is_empty() {
h.push_str("<h3>Geometry label, epochs each (good, fair, poor: always reported; the score deducts only for <code>poor</code>)</h3>\n<table>\n");
for (s, n) in &by_label {
let _ = writeln!(h, "<tr><th>{}</th><td>{n}</td></tr>", esc(s));
}
h.push_str("</table>\n");
}
if let Some(n) = mismatch {
let _ = writeln!(
h,
"<h3>Stated HDOP the stated sky does not explain (<code>reported_derived_mismatch</code>)</h3>\n<table>\n<tr><th>epochs</th><td>{n}</td></tr>\n</table>\n<p>Classed an inconsistency where the geometry is poor; reported, not scored, otherwise.</p>"
);
}
if !by_class.is_empty() {
h.push_str("<h3>Deductions by class, epochs each (geometry: the sky; environment: the signal environment; inconsistency: attack-like evidence)</h3>\n<table>\n");
for (s, n) in &by_class {
let _ = writeln!(h, "<tr><th>{}</th><td>{n}</td></tr>", esc(s));
}
h.push_str("</table>\n");
}
h.push_str("<h2>Configuration and thresholds</h2>\n<p>Complete in <code>config.json</code>; the values below are the run's own.</p>\n");
let mut flat: Vec<(String, String)> = Vec::new();
fn walk(prefix: &str, v: &Value, out: &mut Vec<(String, String)>) {
match v {
Value::Object(o) => {
for (k, x) in o {
let p = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
walk(&p, x, out);
}
}
other => out.push((prefix.to_string(), scalar(other))),
}
}
walk("", i.config, &mut flat);
h.push_str("<table>\n");
for (k, v) in &flat {
let _ = writeln!(
h,
"<tr><th>{}</th><td><code>{}</code></td></tr>",
esc(k),
esc(v)
);
}
h.push_str("</table>\n");
h.push_str("<h2>Per-epoch results</h2>\n<table>\n<tr><th>t (s)</th><th>state</th><th>score</th><th>reasons</th><th>reported position (lat, lon, height m)</th></tr>\n");
for e in i.epochs.iter().take(MAX_ROWS) {
let state = e.get("state").map(scalar).unwrap_or_default();
let score = e
.get("score")
.filter(|v| !v.is_null())
.map(scalar)
.unwrap_or_else(|| "-".into());
let pos = match e.get("position") {
Some(Value::Object(p)) => ["lat_deg", "lon_deg", "height_m"]
.iter()
.map(|k| p.get(*k).map(scalar).unwrap_or_else(|| "-".into()))
.collect::<Vec<_>>()
.join(", "),
_ => "-".into(),
};
let _ = writeln!(
h,
"<tr><td>{}</td><td class=\"s-{}\">{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
esc(&e.get("t_s").map(scalar).unwrap_or_default()),
esc(&state
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect::<String>()),
esc(&state),
esc(&score),
esc(&names(e.get("alarms")).join(", ")),
esc(&pos)
);
}
h.push_str("</table>\n");
if i.epochs.len() > MAX_ROWS {
let _ = writeln!(
h,
"<p>{} further epochs are in <code>epochs.json</code>.</p>",
i.epochs.len() - MAX_ROWS
);
}
h.push_str("<h2>Limits of this record</h2>\n<p>It shows what the engine computed from the stated log bytes under the stated configuration. It does not say what caused any event, who was responsible, or whether any obligation was met. It cannot show that the log reflects what the receiver actually received. Integrity and authorship are checked with <code>kshana evidence verify</code>, not by reading this page.</p>\n</body></html>\n");
h
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escapes_everything_external() {
assert_eq!(
esc("<a href=\"x\">&'"),
"<a href="x">&'"
);
}
}