OXYGENHUB
# 0cd118748131 oxygenhub / crates / hub / src / reports.rs
20.5 KiB · Rust · last changed Reports retention: files beyond the newest 50 runs off the default bookmark expire 24 days ago source blame history raw
1
//! Reports (oxygenhub#127, docs/reports.md): a directory a Flow job writes
2
//! with `report.toml` at its top. The runner uploads each file as a blob of
3
//! the run's repository, then a manifest naming them as the job's artifact
4
//! under the report's name. The hub checks the manifest, reads the
5
//! declaration and indexes the report, its metrics and its files, so the
6
//! Reports tab, its history and checks read only the database, and garbage
7
//! collection keeps every file a kept report names.
8
9
use crate::db::{now_ms, Pool};
10
use crate::Shared;
11
use serde::Deserialize;
12
13
/// Files in one report, and their total size; the runner keeps to these.
14
pub const MAX_FILES: usize = 2000;
15
pub const MAX_BYTES: u64 = 512 << 20;
16
/// Largest `report.toml` read.
17
const MAX_DECLARATION: usize = 64 << 10;
18
/// Metrics kept from one report.
19
const MAX_METRICS: usize = 200;
20
/// The kinds a report may declare.
21
pub const KINDS: [&str; 4] = ["coverage", "tests", "performance", "custom"];
22
23
/// A report file's path: `/`-separated names of letters, digits, `.`,
24
/// `_`, `-` and `+`, never `.` or `..`, so it travels in a header and a URL
25
/// as it is.
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
/// A report's manifest, as the runner uploads it.
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
    /// The blob of the file at `path`.
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
/// Read and check a manifest: version 1, clean unique paths, blob names,
60
/// within the limits, with `report.toml`.
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
/// What `report.toml` declares.
91
#[derive(Clone, Debug, PartialEq)]
92
pub struct Declaration {
93
    pub kind: String,
94
    pub title: String,
95
    pub summary: String,
96
    /// The data file of a built-in kind (`lcov.info`, `junit.xml`).
97
    pub file: Option<String>,
98
    pub metrics: Vec<Metric>,
99
    /// The free-form `[context]` table, as JSON.
100
    pub context: serde_json::Value,
101
}
102
103
/// A number the hub tracks across runs.
104
#[derive(Clone, Debug, PartialEq, sqlx::FromRow)]
105
pub struct Metric {
106
    pub key: String,
107
    pub value: f64,
108
    pub unit: String,
109
    /// `higher`, `lower`, or empty when neither is better.
110
    pub better: String,
111
    pub fail_above: Option<f64>,
112
    pub fail_below: Option<f64>,
113
}
114
115
impl Declaration {
116
    /// The declaration of a report whose `report.toml` could not be read:
117
    /// kept as a custom report saying why, so its files stay and the reader
118
    /// sees what to fix.
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
/// Read `report.toml` for report `name`; the error says what to fix.
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
/// Where a report came from.
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
/// Index report `name` of a job, replacing any the job kept before.
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
    mark(app, o.repo_id);
304
    Ok(id)
305
}
306
307
static HAS: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
308
309
/// Per hub (tests run several in one process) and repository.
310
fn key(app: &Shared, repo_id: i64) -> String {
311
    format!("{}#{repo_id}", app.repos.root().display())
312
}
313
314
/// Whether a repository has reports, for the Reports tab.
315
pub fn has_reports(app: &Shared, repo_id: i64) -> bool {
316
    HAS.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|s| s.contains(&key(app, repo_id)))
317
}
318
319
fn mark(app: &Shared, repo_id: i64) {
320
    HAS.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id));
321
}
322
323
/// At start: which repositories have reports.
324
pub async fn load(app: &Shared) -> sqlx::Result<()> {
325
    let ids: Vec<i64> = sqlx::query_scalar("SELECT DISTINCT repo_id FROM reports").fetch_all(&app.db).await?;
326
    for id in ids {
327
        mark(app, id);
328
    }
329
    Ok(())
330
}
331
332
/// A kept report (one job's, or one upload's).
333
#[derive(Clone, Debug, sqlx::FromRow)]
334
pub struct Report {
335
    pub id: i64,
336
    pub repo_id: i64,
337
    pub name: String,
338
    pub kind: String,
339
    pub title: String,
340
    pub summary: String,
341
    /// The `[context]` table as JSON.
342
    pub context: String,
343
    pub run_id: Option<i64>,
344
    pub job_id: Option<i64>,
345
    pub run_number: Option<i64>,
346
    pub job: String,
347
    pub bookmark: String,
348
    pub state: String,
349
    pub manifest: Option<String>,
350
    pub files: i64,
351
    pub bytes: i64,
352
    pub created_ms: i64,
353
    /// When its files expired under retention (§3.3); its metrics stay.
354
    pub files_expired_ms: Option<i64>,
355
}
356
357
impl Report {
358
    /// The `[context]` table's entries, as text, in order.
359
    pub fn context_rows(&self) -> Vec<(String, String)> {
360
        let v: serde_json::Value = serde_json::from_str(&self.context).unwrap_or_default();
361
        v.as_object()
362
            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_str().map(String::from).unwrap_or_else(|| v.to_string()))).collect())
363
            .unwrap_or_default()
364
    }
365
}
366
367
/// The newest report of each name in a repository, on `bookmark` when
368
/// given (else on any), newest first.
369
pub async fn latest(pool: &Pool, repo_id: i64, bookmark: Option<&str>) -> sqlx::Result<Vec<Report>> {
370
    sqlx::query_as(
371
        "SELECT * FROM reports r WHERE repo_id = ? AND (? IS NULL OR bookmark = ?) \
372
         AND id = (SELECT id FROM reports s WHERE s.repo_id = r.repo_id AND s.name = r.name AND (? IS NULL OR s.bookmark = ?) \
373
                   ORDER BY created_ms DESC, id DESC LIMIT 1) \
374
         ORDER BY created_ms DESC",
375
    )
376
    .bind(repo_id)
377
    .bind(bookmark)
378
    .bind(bookmark)
379
    .bind(bookmark)
380
    .bind(bookmark)
381
    .fetch_all(pool)
382
    .await
383
}
384
385
/// Report `name`'s history on `bookmark`, newest first.
386
pub async fn history(pool: &Pool, repo_id: i64, name: &str, bookmark: &str, limit: i64) -> sqlx::Result<Vec<Report>> {
387
    sqlx::query_as("SELECT * FROM reports WHERE repo_id = ? AND name = ? AND bookmark = ? ORDER BY created_ms DESC, id DESC LIMIT ?")
388
        .bind(repo_id)
389
        .bind(name)
390
        .bind(bookmark)
391
        .bind(limit)
392
        .fetch_all(pool)
393
        .await
394
}
395
396
/// The bookmarks report `name` has runs on, most recent first.
397
pub async fn bookmarks_of(pool: &Pool, repo_id: i64, name: &str) -> sqlx::Result<Vec<String>> {
398
    sqlx::query_scalar("SELECT bookmark FROM reports WHERE repo_id = ? AND name = ? GROUP BY bookmark ORDER BY MAX(created_ms) DESC")
399
        .bind(repo_id)
400
        .bind(name)
401
        .fetch_all(pool)
402
        .await
403
}
404
405
/// One report of a repository.
406
pub async fn get(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Report>> {
407
    sqlx::query_as("SELECT * FROM reports WHERE repo_id = ? AND id = ?").bind(repo_id).bind(id).fetch_optional(pool).await
408
}
409
410
/// A file of report `id`: its blob and size.
411
pub async fn file(pool: &Pool, id: i64, path: &str) -> sqlx::Result<Option<(String, i64)>> {
412
    sqlx::query_as("SELECT blob, size FROM report_files WHERE report_id = ? AND path = ?").bind(id).bind(path).fetch_optional(pool).await
413
}
414
415
/// The capability a report's files are served under: they are read from a
416
/// sandboxed frame, which carries no session, so the link itself is what
417
/// lets it in, and only readers of the repository are given it.
418
pub fn file_mac(app: &Shared, id: i64) -> String {
419
    app.cookie_key.mac(format!("report-files:{id}").as_bytes())[..32].to_string()
420
}
421
422
/// Runs of a report on a bookmark other than the default whose files are
423
/// kept (docs/reports.md §3.3); older runs keep their metrics only.
424
pub const KEEP_OTHER_RUNS: i64 = 50;
425
426
/// Expire report files under retention (docs/reports.md §3.3): every run on
427
/// a repository's default bookmark keeps them; on any other bookmark (an
428
/// upload for a change has none) the newest [`KEEP_OTHER_RUNS`] runs of each
429
/// report do. An expired report keeps its metrics and summary and loses
430
/// its `report_files` rows, so garbage collection may prune the blobs.
431
/// Answers how many reports expired.
432
pub async fn expire(pool: &Pool) -> sqlx::Result<u64> {
433
    let mut tx = crate::db::begin_write(pool).await?;
434
    let ids: Vec<i64> = sqlx::query_scalar(
435
        "SELECT r.id FROM reports r JOIN repositories p ON p.id = r.repo_id \
436
         WHERE r.files_expired_ms IS NULL AND r.bookmark != p.default_bookmark \
437
         AND (SELECT COUNT(*) FROM reports s WHERE s.repo_id = r.repo_id AND s.name = r.name AND s.bookmark = r.bookmark \
438
              AND (s.created_ms > r.created_ms OR (s.created_ms = r.created_ms AND s.id > r.id))) >= ?",
439
    )
440
    .bind(KEEP_OTHER_RUNS)
441
    .fetch_all(&mut *tx)
442
    .await?;
443
    let now = now_ms();
444
    for id in &ids {
445
        sqlx::query("DELETE FROM report_files WHERE report_id = ?").bind(id).execute(&mut *tx).await?;
446
        sqlx::query("UPDATE reports SET files_expired_ms = ? WHERE id = ?").bind(now).bind(id).execute(&mut *tx).await?;
447
    }
448
    tx.commit().await?;
449
    Ok(ids.len() as u64)
450
}
451
452
/// Report `id`'s metrics, by key.
453
pub async fn metrics(pool: &Pool, id: i64) -> sqlx::Result<Vec<Metric>> {
454
    sqlx::query_as("SELECT key, value, unit, better, fail_above, fail_below FROM report_metrics WHERE report_id = ? ORDER BY key")
455
        .bind(id)
456
        .fetch_all(pool)
457
        .await
458
}
459
460
/// Every file blob a repository's kept reports name, for garbage collection
461
/// to keep too: the database is the only thing that names them.
462
pub async fn file_blobs(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> {
463
    sqlx::query_scalar("SELECT DISTINCT f.blob FROM report_files f JOIN reports r ON r.id = f.report_id WHERE r.repo_id = ?")
464
        .bind(repo_id)
465
        .fetch_all(pool)
466
        .await
467
}
468
469
#[cfg(test)]
470
mod tests {
471
    use super::*;
472
473
    #[test]
474
    fn declarations_are_read_with_their_metrics_and_context() {
475
        let d = parse_declaration(
476
            "frame-timing",
477
            br#"
478
kind    = "performance"
479
title   = "Frame timing"
480
summary = "Median frame 16.4 ms"
481
482
[metrics]
483
median_frame_ms = { value = 16.4, unit = "ms", better = "lower" }
484
p99_frame_ms    = { value = 18.1, unit = "ms", better = "lower", fail_above = 20 }
485
scenes          = 12
486
487
[context]
488
target_fps    = 30
489
budget_cycles = 268800
490
"#,
491
        )
492
        .unwrap();
493
        assert_eq!((d.kind.as_str(), d.title.as_str()), ("performance", "Frame timing"));
494
        let keys: Vec<&str> = d.metrics.iter().map(|m| m.key.as_str()).collect();
495
        assert_eq!(keys, ["median_frame_ms", "p99_frame_ms", "scenes"]);
496
        assert_eq!(d.metrics[1].fail_above, Some(20.0));
497
        assert_eq!((d.metrics[2].value, d.metrics[2].unit.as_str()), (12.0, ""));
498
        assert_eq!(d.context["budget_cycles"], 268800);
499
500
        let minimal = parse_declaration("cov", b"kind = \"coverage\"\nfile = \"lcov.info\"\n").unwrap();
501
        assert_eq!((minimal.title.as_str(), minimal.file.as_deref()), ("cov", Some("lcov.info")));
502
503
        let bad = |t: &str| parse_declaration("r", t.as_bytes()).unwrap_err();
504
        assert!(bad("title = \"x\"").contains("`kind` is required"));
505
        assert!(bad("kind = \"graphs\"").contains("not known"));
506
        assert!(bad("kind = \"custom\"\ntitel = \"x\"").contains("`titel` is not known"));
507
        assert!(bad("kind = \"custom\"\n[metrics]\nfps = \"fast\"").contains("metric fps"));
508
        assert!(bad("kind = \"custom\"\n[metrics]\nfps = { value = 1, better = \"more\" }").contains("better"));
509
        assert!(bad("kind = \"custom\"\n[metrics]\nfps = { unit = \"Hz\" }").contains("`value` is required"));
510
        assert!(bad("kind = \"custom\"\ntitle = ").starts_with("line 2"), "{}", bad("kind = \"custom\"\ntitle = "));
511
    }
512
513
    #[test]
514
    fn manifests_are_checked() {
515
        let blob = "a".repeat(64);
516
        let m = |files: &str| parse_manifest(format!("{{\"version\":1,\"files\":[{files}]}}").as_bytes());
517
        let file = |p: &str| format!("{{\"path\":\"{p}\",\"blob\":\"{blob}\",\"size\":3}}");
518
        let ok = m(&[file("report.toml"), file("data/a.csv")].join(",")).unwrap();
519
        assert_eq!((ok.files.len(), ok.bytes()), (2, 6));
520
        assert!(m(&file("index.html")).unwrap_err().contains("report.toml"));
521
        assert!(m(&[file("report.toml"), file("../x")].join(",")).is_err());
522
        assert!(m(&[file("report.toml"), file("a b")].join(",")).is_err());
523
        assert!(m(&[file("report.toml"), file("report.toml")].join(",")).unwrap_err().contains("twice"));
524
        assert!(m("{\"path\":\"report.toml\",\"blob\":\"zz\",\"size\":1}").unwrap_err().contains("blob"));
525
        assert!(parse_manifest(b"{\"version\":2,\"files\":[]}").unwrap_err().contains("version 2"));
526
        assert!(path_ok("assets/js/chart.min.js") && !path_ok("/abs") && !path_ok("a//b") && !path_ok(""));
527
    }
528
}