| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
use crate::db::{now_ms, Pool}; |
| 10 |
use crate::Shared; |
| 11 |
use serde::Deserialize; |
| 12 |
|
| 13 |
|
| 14 |
pub const MAX_FILES: usize = 2000; |
| 15 |
pub const MAX_BYTES: u64 = 512 << 20; |
| 16 |
|
| 17 |
const MAX_DECLARATION: usize = 64 << 10; |
| 18 |
|
| 19 |
const MAX_METRICS: usize = 200; |
| 20 |
|
| 21 |
pub const KINDS: [&str; 4] = ["coverage", "tests", "performance", "custom"]; |
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
|
| 26 |
pub fn path_ok(p: &str) -> bool { |
| 27 |
p.len() <= 1024 |
| 28 |
&& p.split('/').count() <= 32 |
| 29 |
&& p.split('/').all(|n| { |
| 30 |
!n.is_empty() && n != "." && n != ".." && n.len() <= 255 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b)) |
| 31 |
}) |
| 32 |
} |
| 33 |
|
| 34 |
|
| 35 |
#[derive(Clone, Debug, Deserialize)] |
| 36 |
pub struct Manifest { |
| 37 |
pub version: u32, |
| 38 |
pub files: Vec<ManifestFile>, |
| 39 |
} |
| 40 |
|
| 41 |
#[derive(Clone, Debug, Deserialize)] |
| 42 |
pub struct ManifestFile { |
| 43 |
pub path: String, |
| 44 |
pub blob: String, |
| 45 |
pub size: u64, |
| 46 |
} |
| 47 |
|
| 48 |
impl Manifest { |
| 49 |
pub fn bytes(&self) -> u64 { |
| 50 |
self.files.iter().map(|f| f.size).sum() |
| 51 |
} |
| 52 |
|
| 53 |
|
| 54 |
pub fn blob(&self, path: &str) -> Option<&str> { |
| 55 |
self.files.iter().find(|f| f.path == path).map(|f| f.blob.as_str()) |
| 56 |
} |
| 57 |
} |
| 58 |
|
| 59 |
|
| 60 |
|
| 61 |
pub fn parse_manifest(bytes: &[u8]) -> Result<Manifest, String> { |
| 62 |
let m: Manifest = serde_json::from_slice(bytes).map_err(|e| format!("the report manifest is not valid: {e}"))?; |
| 63 |
if m.version != 1 { |
| 64 |
return Err(format!("report manifest version {} is not known; this hub reads version 1", m.version)); |
| 65 |
} |
| 66 |
if m.files.len() > MAX_FILES { |
| 67 |
return Err(format!("the report holds {} files; at most {MAX_FILES}", m.files.len())); |
| 68 |
} |
| 69 |
if m.bytes() > MAX_BYTES { |
| 70 |
return Err(format!("the report holds {} MiB; at most {} MiB", m.bytes() >> 20, MAX_BYTES >> 20)); |
| 71 |
} |
| 72 |
let mut seen = std::collections::HashSet::new(); |
| 73 |
for f in &m.files { |
| 74 |
if !path_ok(&f.path) { |
| 75 |
return Err(format!("report file {:?}: names may hold letters, digits, '.', '_', '-' and '+'", f.path)); |
| 76 |
} |
| 77 |
if !seen.insert(f.path.as_str()) { |
| 78 |
return Err(format!("report file {} is named twice", f.path)); |
| 79 |
} |
| 80 |
if oxy_core::hash::ObjectRef::from_hex(&f.blob).is_none() { |
| 81 |
return Err(format!("report file {}: {:?} is not a blob name", f.path, f.blob)); |
| 82 |
} |
| 83 |
} |
| 84 |
if m.blob("report.toml").is_none() { |
| 85 |
return Err("the report has no report.toml at its top".into()); |
| 86 |
} |
| 87 |
Ok(m) |
| 88 |
} |
| 89 |
|
| 90 |
|
| 91 |
#[derive(Clone, Debug, PartialEq)] |
| 92 |
pub struct Declaration { |
| 93 |
pub kind: String, |
| 94 |
pub title: String, |
| 95 |
pub summary: String, |
| 96 |
|
| 97 |
pub file: Option<String>, |
| 98 |
pub metrics: Vec<Metric>, |
| 99 |
|
| 100 |
pub context: serde_json::Value, |
| 101 |
} |
| 102 |
|
| 103 |
|
| 104 |
#[derive(Clone, Debug, PartialEq, sqlx::FromRow)] |
| 105 |
pub struct Metric { |
| 106 |
pub key: String, |
| 107 |
pub value: f64, |
| 108 |
pub unit: String, |
| 109 |
|
| 110 |
pub better: String, |
| 111 |
pub fail_above: Option<f64>, |
| 112 |
pub fail_below: Option<f64>, |
| 113 |
} |
| 114 |
|
| 115 |
impl Declaration { |
| 116 |
|
| 117 |
|
| 118 |
|
| 119 |
pub fn unreadable(name: &str, why: &str) -> Declaration { |
| 120 |
Declaration { |
| 121 |
kind: "custom".into(), |
| 122 |
title: name.into(), |
| 123 |
summary: format!("report.toml: {why}"), |
| 124 |
file: None, |
| 125 |
metrics: vec![], |
| 126 |
context: serde_json::json!({}), |
| 127 |
} |
| 128 |
} |
| 129 |
} |
| 130 |
|
| 131 |
fn number(v: &toml::Value) -> Option<f64> { |
| 132 |
match v { |
| 133 |
toml::Value::Integer(i) => Some(*i as f64), |
| 134 |
toml::Value::Float(f) if f.is_finite() => Some(*f), |
| 135 |
_ => None, |
| 136 |
} |
| 137 |
} |
| 138 |
|
| 139 |
fn metric_key_ok(k: &str) -> bool { |
| 140 |
!k.is_empty() && k.len() <= 64 && k.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) |
| 141 |
} |
| 142 |
|
| 143 |
|
| 144 |
pub fn parse_declaration(name: &str, bytes: &[u8]) -> Result<Declaration, String> { |
| 145 |
if bytes.len() > MAX_DECLARATION { |
| 146 |
return Err(format!("{} KiB; at most {} KiB", bytes.len() >> 10, MAX_DECLARATION >> 10)); |
| 147 |
} |
| 148 |
let text = std::str::from_utf8(bytes).map_err(|_| "not UTF-8 text".to_string())?; |
| 149 |
let table: toml::Table = toml::from_str(text).map_err(|e| { |
| 150 |
let line = e.span().map(|s| text[..s.start.min(text.len())].lines().count().max(1)); |
| 151 |
let what = if e.message().trim().is_empty() { "not valid TOML" } else { e.message().trim() }; |
| 152 |
match line { |
| 153 |
Some(n) => format!("line {n}: {what}"), |
| 154 |
None => what.to_string(), |
| 155 |
} |
| 156 |
})?; |
| 157 |
let text_of = |key: &str, max: usize| -> Result<Option<String>, String> { |
| 158 |
match table.get(key) { |
| 159 |
None => Ok(None), |
| 160 |
Some(toml::Value::String(s)) if s.len() <= max => Ok(Some(s.clone())), |
| 161 |
Some(toml::Value::String(_)) => Err(format!("`{key}` is longer than {max} bytes")), |
| 162 |
Some(_) => Err(format!("`{key}` is a string")), |
| 163 |
} |
| 164 |
}; |
| 165 |
for key in table.keys() { |
| 166 |
if !matches!(key.as_str(), "kind" | "title" | "summary" | "file" | "metrics" | "context") { |
| 167 |
return Err(format!("`{key}` is not known (kind, title, summary, file, metrics, context)")); |
| 168 |
} |
| 169 |
} |
| 170 |
let kind = text_of("kind", 32)?.ok_or("`kind` is required: one of coverage, tests, performance, custom")?; |
| 171 |
if !KINDS.contains(&kind.as_str()) { |
| 172 |
return Err(format!("kind {kind:?} is not known: one of coverage, tests, performance, custom")); |
| 173 |
} |
| 174 |
let file = text_of("file", 1024)?; |
| 175 |
if let Some(f) = &file { |
| 176 |
if !path_ok(f) { |
| 177 |
return Err(format!("file {f:?} is not a path in the report")); |
| 178 |
} |
| 179 |
} |
| 180 |
let mut metrics = Vec::new(); |
| 181 |
match table.get("metrics") { |
| 182 |
None => {} |
| 183 |
Some(toml::Value::Table(t)) => { |
| 184 |
if t.len() > MAX_METRICS { |
| 185 |
return Err(format!("{} metrics; at most {MAX_METRICS}", t.len())); |
| 186 |
} |
| 187 |
for (key, v) in t { |
| 188 |
if !metric_key_ok(key) { |
| 189 |
return Err(format!("metric {key:?}: names hold letters, digits, '.', '_' and '-'")); |
| 190 |
} |
| 191 |
metrics.push(parse_metric(key, v).map_err(|e| format!("metric {key}: {e}"))?); |
| 192 |
} |
| 193 |
} |
| 194 |
Some(_) => return Err("`metrics` is a table".into()), |
| 195 |
} |
| 196 |
let context = match table.get("context") { |
| 197 |
None => serde_json::json!({}), |
| 198 |
Some(v @ toml::Value::Table(_)) => serde_json::to_value(v).map_err(|e| format!("context: {e}"))?, |
| 199 |
Some(_) => return Err("`context` is a table".into()), |
| 200 |
}; |
| 201 |
Ok(Declaration { |
| 202 |
kind, |
| 203 |
title: text_of("title", 200)?.unwrap_or_else(|| name.to_string()), |
| 204 |
summary: text_of("summary", 1000)?.unwrap_or_default(), |
| 205 |
file, |
| 206 |
metrics, |
| 207 |
context, |
| 208 |
}) |
| 209 |
} |
| 210 |
|
| 211 |
fn parse_metric(key: &str, v: &toml::Value) -> Result<Metric, String> { |
| 212 |
let mut m = Metric { key: key.into(), value: 0.0, unit: String::new(), better: String::new(), fail_above: None, fail_below: None }; |
| 213 |
if let Some(n) = number(v) { |
| 214 |
m.value = n; |
| 215 |
return Ok(m); |
| 216 |
} |
| 217 |
let toml::Value::Table(t) = v else { |
| 218 |
return Err("a number, or a table with `value`".into()); |
| 219 |
}; |
| 220 |
for (k, v) in t { |
| 221 |
match k.as_str() { |
| 222 |
"value" => m.value = number(v).ok_or("`value` is a number")?, |
| 223 |
"unit" => match v { |
| 224 |
toml::Value::String(s) if s.len() <= 32 => m.unit = s.clone(), |
| 225 |
_ => return Err("`unit` is a short string".into()), |
| 226 |
}, |
| 227 |
"better" => match v.as_str() { |
| 228 |
Some(s @ ("higher" | "lower")) => m.better = s.into(), |
| 229 |
_ => return Err("`better` is \"higher\" or \"lower\"".into()), |
| 230 |
}, |
| 231 |
"fail_above" => m.fail_above = Some(number(v).ok_or("`fail_above` is a number")?), |
| 232 |
"fail_below" => m.fail_below = Some(number(v).ok_or("`fail_below` is a number")?), |
| 233 |
_ => return Err(format!("`{k}` is not known (value, unit, better, fail_above, fail_below)")), |
| 234 |
} |
| 235 |
} |
| 236 |
if !t.contains_key("value") { |
| 237 |
return Err("`value` is required".into()); |
| 238 |
} |
| 239 |
Ok(m) |
| 240 |
} |
| 241 |
|
| 242 |
|
| 243 |
pub struct Origin<'a> { |
| 244 |
pub repo_id: i64, |
| 245 |
pub run_id: i64, |
| 246 |
pub run_number: i64, |
| 247 |
pub job_id: i64, |
| 248 |
pub job: &'a str, |
| 249 |
pub bookmark: &'a str, |
| 250 |
pub state: &'a str, |
| 251 |
} |
| 252 |
|
| 253 |
|
| 254 |
pub async fn keep(app: &Shared, o: &Origin<'_>, name: &str, d: &Declaration, m: &Manifest, manifest: &str) -> sqlx::Result<i64> { |
| 255 |
let mut tx = crate::db::begin_write(&app.db).await?; |
| 256 |
sqlx::query("DELETE FROM reports WHERE job_id = ? AND name = ?").bind(o.job_id).bind(name).execute(&mut *tx).await?; |
| 257 |
let id: i64 = sqlx::query_scalar( |
| 258 |
"INSERT INTO reports (repo_id, name, kind, title, summary, context, run_id, job_id, run_number, job, bookmark, state, \ |
| 259 |
manifest, files, bytes, created_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", |
| 260 |
) |
| 261 |
.bind(o.repo_id) |
| 262 |
.bind(name) |
| 263 |
.bind(&d.kind) |
| 264 |
.bind(&d.title) |
| 265 |
.bind(&d.summary) |
| 266 |
.bind(d.context.to_string()) |
| 267 |
.bind(o.run_id) |
| 268 |
.bind(o.job_id) |
| 269 |
.bind(o.run_number) |
| 270 |
.bind(o.job) |
| 271 |
.bind(o.bookmark) |
| 272 |
.bind(o.state) |
| 273 |
.bind(manifest) |
| 274 |
.bind(m.files.len() as i64) |
| 275 |
.bind(m.bytes() as i64) |
| 276 |
.bind(now_ms()) |
| 277 |
.fetch_one(&mut *tx) |
| 278 |
.await?; |
| 279 |
for x in &d.metrics { |
| 280 |
sqlx::query( |
| 281 |
"INSERT INTO report_metrics (report_id, key, value, unit, better, fail_above, fail_below) VALUES (?, ?, ?, ?, ?, ?, ?)", |
| 282 |
) |
| 283 |
.bind(id) |
| 284 |
.bind(&x.key) |
| 285 |
.bind(x.value) |
| 286 |
.bind(&x.unit) |
| 287 |
.bind(&x.better) |
| 288 |
.bind(x.fail_above) |
| 289 |
.bind(x.fail_below) |
| 290 |
.execute(&mut *tx) |
| 291 |
.await?; |
| 292 |
} |
| 293 |
for f in &m.files { |
| 294 |
sqlx::query("INSERT INTO report_files (report_id, path, blob, size) VALUES (?, ?, ?, ?)") |
| 295 |
.bind(id) |
| 296 |
.bind(&f.path) |
| 297 |
.bind(&f.blob) |
| 298 |
.bind(f.size as i64) |
| 299 |
.execute(&mut *tx) |
| 300 |
.await?; |
| 301 |
} |
| 302 |
tx.commit().await?; |
| 303 |
Ok(id) |
| 304 |
} |
| 305 |
|
| 306 |
|
| 307 |
pub async fn metrics(pool: &Pool, id: i64) -> sqlx::Result<Vec<Metric>> { |
| 308 |
sqlx::query_as("SELECT key, value, unit, better, fail_above, fail_below FROM report_metrics WHERE report_id = ? ORDER BY key") |
| 309 |
.bind(id) |
| 310 |
.fetch_all(pool) |
| 311 |
.await |
| 312 |
} |
| 313 |
|
| 314 |
|
| 315 |
|
| 316 |
pub async fn file_blobs(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> { |
| 317 |
sqlx::query_scalar("SELECT DISTINCT f.blob FROM report_files f JOIN reports r ON r.id = f.report_id WHERE r.repo_id = ?") |
| 318 |
.bind(repo_id) |
| 319 |
.fetch_all(pool) |
| 320 |
.await |
| 321 |
} |
| 322 |
|
| 323 |
#[cfg(test)] |
| 324 |
mod tests { |
| 325 |
use super::*; |
| 326 |
|
| 327 |
#[test] |
| 328 |
fn declarations_are_read_with_their_metrics_and_context() { |
| 329 |
let d = parse_declaration( |
| 330 |
"frame-timing", |
| 331 |
br#" |
| 332 |
kind = "performance" |
| 333 |
title = "Frame timing" |
| 334 |
summary = "Median frame 16.4 ms" |
| 335 |
|
| 336 |
[metrics] |
| 337 |
median_frame_ms = { value = 16.4, unit = "ms", better = "lower" } |
| 338 |
p99_frame_ms = { value = 18.1, unit = "ms", better = "lower", fail_above = 20 } |
| 339 |
scenes = 12 |
| 340 |
|
| 341 |
[context] |
| 342 |
target_fps = 30 |
| 343 |
budget_cycles = 268800 |
| 344 |
"#, |
| 345 |
) |
| 346 |
.unwrap(); |
| 347 |
assert_eq!((d.kind.as_str(), d.title.as_str()), ("performance", "Frame timing")); |
| 348 |
let keys: Vec<&str> = d.metrics.iter().map(|m| m.key.as_str()).collect(); |
| 349 |
assert_eq!(keys, ["median_frame_ms", "p99_frame_ms", "scenes"]); |
| 350 |
assert_eq!(d.metrics[1].fail_above, Some(20.0)); |
| 351 |
assert_eq!((d.metrics[2].value, d.metrics[2].unit.as_str()), (12.0, "")); |
| 352 |
assert_eq!(d.context["budget_cycles"], 268800); |
| 353 |
|
| 354 |
let minimal = parse_declaration("cov", b"kind = \"coverage\"\nfile = \"lcov.info\"\n").unwrap(); |
| 355 |
assert_eq!((minimal.title.as_str(), minimal.file.as_deref()), ("cov", Some("lcov.info"))); |
| 356 |
|
| 357 |
let bad = |t: &str| parse_declaration("r", t.as_bytes()).unwrap_err(); |
| 358 |
assert!(bad("title = \"x\"").contains("`kind` is required")); |
| 359 |
assert!(bad("kind = \"graphs\"").contains("not known")); |
| 360 |
assert!(bad("kind = \"custom\"\ntitel = \"x\"").contains("`titel` is not known")); |
| 361 |
assert!(bad("kind = \"custom\"\n[metrics]\nfps = \"fast\"").contains("metric fps")); |
| 362 |
assert!(bad("kind = \"custom\"\n[metrics]\nfps = { value = 1, better = \"more\" }").contains("better")); |
| 363 |
assert!(bad("kind = \"custom\"\n[metrics]\nfps = { unit = \"Hz\" }").contains("`value` is required")); |
| 364 |
assert!(bad("kind = \"custom\"\ntitle = ").starts_with("line 2"), "{}", bad("kind = \"custom\"\ntitle = ")); |
| 365 |
} |
| 366 |
|
| 367 |
#[test] |
| 368 |
fn manifests_are_checked() { |
| 369 |
let blob = "a".repeat(64); |
| 370 |
let m = |files: &str| parse_manifest(format!("{{\"version\":1,\"files\":[{files}]}}").as_bytes()); |
| 371 |
let file = |p: &str| format!("{{\"path\":\"{p}\",\"blob\":\"{blob}\",\"size\":3}}"); |
| 372 |
let ok = m(&[file("report.toml"), file("data/a.csv")].join(",")).unwrap(); |
| 373 |
assert_eq!((ok.files.len(), ok.bytes()), (2, 6)); |
| 374 |
assert!(m(&file("index.html")).unwrap_err().contains("report.toml")); |
| 375 |
assert!(m(&[file("report.toml"), file("../x")].join(",")).is_err()); |
| 376 |
assert!(m(&[file("report.toml"), file("a b")].join(",")).is_err()); |
| 377 |
assert!(m(&[file("report.toml"), file("report.toml")].join(",")).unwrap_err().contains("twice")); |
| 378 |
assert!(m("{\"path\":\"report.toml\",\"blob\":\"zz\",\"size\":1}").unwrap_err().contains("blob")); |
| 379 |
assert!(parse_manifest(b"{\"version\":2,\"files\":[]}").unwrap_err().contains("version 2")); |
| 380 |
assert!(path_ok("assets/js/chart.min.js") && !path_ok("/abs") && !path_ok("a//b") && !path_ok("")); |
| 381 |
} |
| 382 |
} |