Skip to main content

cortex/frontend/
concerns.rs

1//! Common concerns for frontend routes
2use diesel::PgConnection;
3use rocket::fs::NamedFile;
4use rocket::http::Status;
5use rocket::response::status::{Accepted, NotFound};
6use rocket::serde::json::Json;
7use rocket::tokio::sync::{OwnedSemaphorePermit, Semaphore};
8use rocket::{Route, State, get, post, routes};
9use rocket_dyn_templates::Template;
10use std::collections::HashMap;
11use std::str;
12use std::sync::Arc;
13
14use crate::backend::{
15  DbPool, PooledConn, RerunOptions, live_run_diff, mark_all_blocked, mark_blocked, mark_rerun,
16  progress_report, resume_all_blocked, resume_blocked, save_historical_tasks,
17};
18use crate::frontend::actor::AdminSession;
19use crate::frontend::helpers::*;
20use crate::frontend::params::{ReportParams, RerunRequestParams, TemplateContext};
21use crate::frontend::render::task_report;
22use crate::models::{Corpus, HistoricalRun, Service, Task};
23
24/// Placeholder word for unknown filters/fields
25pub const UNKNOWN: &str = "_unknown_";
26
27/// A live-computed (non-rollup) report taking at least this long held its pooled connection for the
28/// whole query — past this it is logged at `warn` as a pool-saturation risk (KNOWN_ISSUES P-2). The
29/// rollup-backed reports return in ~10–90 ms, so this only fires on the expensive `all=true` /
30/// large-corpus live aggregations, not normal traffic.
31const SLOW_REPORT_WARN_MS: i64 = 2000;
32
33/// How long a cold report slice may aggregate **inline** on the request path before we abandon the
34/// attempt and hand it to a background job (showing a "report computing" page instead). Small and
35/// empty slices finish in well under this; only the largest (full-arXiv `info`) ones exceed it.
36/// This is the hard ceiling on how long a single report view can pin its pooled connection (P-2).
37const INLINE_POPULATE_BUDGET_MS: u32 = 4000;
38
39/// Default cap on concurrent expensive **live** (`?all=true`) report aggregations (KNOWN_ISSUES
40/// P-2). Each such request holds a pooled connection for its whole multi-second run, so without a
41/// bound a burst can exhaust the pool (default 32) and `503` every other request. Four lets a small
42/// flurry through while leaving the bulk of the pool for cheap rollup-backed traffic.
43pub const MAX_CONCURRENT_LIVE_REPORTS: usize = 4;
44
45/// Bounds how many expensive live (`?all=true`) report aggregations run at once so a burst can't
46/// saturate the frontend connection pool and `503` other requests (KNOWN_ISSUES P-2, owner-chosen
47/// mitigation). A permit is acquired **before** a pooled connection is checked out, and the
48/// `(N+1)`th expensive request **waits asynchronously** (no worker thread or DB connection held)
49/// rather than being rejected — it changes no result and rejects nothing, pure blast-radius
50/// isolation (DESIGN_PRINCIPLES #6). Cheap rollup-backed and paged reports never take a permit.
51// ponytail: a const cap; promote to a `config.web` knob if a deployment needs to tune it.
52pub struct LiveReportLimiter(Arc<Semaphore>);
53
54impl LiveReportLimiter {
55  /// Builds a limiter capping concurrent live reports at `max_concurrent` (clamped to ≥1).
56  pub fn new(max_concurrent: usize) -> Self {
57    LiveReportLimiter(Arc::new(Semaphore::new(max_concurrent.max(1))))
58  }
59
60  /// Acquires a permit, awaiting asynchronously if all are currently in use. The returned guard
61  /// releases the permit on drop. Errs only if the semaphore were closed (it never is).
62  pub async fn acquire(&self) -> Result<OwnedSemaphorePermit, Status> {
63    self
64      .0
65      .clone()
66      .acquire_owned()
67      .await
68      .map_err(|_| Status::ServiceUnavailable)
69  }
70}
71
72impl Default for LiveReportLimiter {
73  fn default() -> Self { Self::new(MAX_CONCURRENT_LIVE_REPORTS) }
74}
75
76/// Decide whether a cold rollup slice should be aggregated **in the background** — returning
77/// `true`, so the caller renders the "report computing" shell — instead of inline on the request
78/// path.
79///
80/// Returns `false` (serve normally/inline) for a live report, an already-warm slice, or a slice
81/// small enough to aggregate within [`INLINE_POPULATE_BUDGET_MS`]. Returns `true` only when the
82/// slice is genuinely large: either a background job is already populating it, or a bounded inline
83/// attempt overran the budget (the full-arXiv `info` case), in which case this spawns the
84/// (debounced) background populate job before returning. Empty severities aggregate to zero rows
85/// well inside the budget, so they serve inline and never get stuck "computing".
86fn defer_cold_slice(
87  connection: &mut PgConnection,
88  pool: &crate::backend::DbPool,
89  used_rollup: bool,
90  severity: Option<&str>,
91  corpus: &Corpus,
92  service: &Service,
93) -> bool {
94  if !used_rollup {
95    return false;
96  }
97  let Some(sev) = severity else {
98    return false;
99  };
100  if crate::backend::scope_cached(connection, corpus.id, service.id, sev) {
101    return false; // already warm — read it directly
102  }
103  if crate::jobs::report_populate_active(connection, corpus.id, service.id, sev) {
104    return true; // a background job is already aggregating this slice
105  }
106  // Try inline, bounded: small and empty slices finish well inside the budget and serve as before.
107  if crate::backend::populate_scope_bounded(
108    connection,
109    corpus.id,
110    service.id,
111    sev,
112    INLINE_POPULATE_BUDGET_MS,
113  )
114  .is_ok()
115  {
116    return false;
117  }
118  // Overran the budget — hand the heavy aggregation to a background job and show "computing".
119  let _ =
120    crate::jobs::spawn_report_populate(pool.clone(), corpus.id, service.id, sev, "report-view");
121  true
122}
123
124/// Prepare a configurable report for a <corpus,server> pair, reading over the caller-supplied
125/// (pooled) `connection` — no per-request fresh `Backend::default()`. `404` on unknown
126/// corpus/service.
127#[allow(clippy::too_many_arguments)]
128pub fn serve_report(
129  connection: &mut PgConnection,
130  pool: &crate::backend::DbPool,
131  corpus_name: String,
132  service_name: String,
133  severity: Option<String>,
134  category: Option<String>,
135  what: Option<String>,
136  params: Option<ReportParams>,
137  is_admin: bool,
138) -> Result<Template, Status> {
139  let report_start = chrono::Utc::now();
140  // `is_admin` gates the footer's admin-only actions (Rerun / Save snapshot) — anonymous viewers
141  // see a sign-in card instead.
142  let mut context = TemplateContext {
143    is_admin,
144    ..TemplateContext::default()
145  };
146  let mut global = HashMap::new();
147
148  let corpus_name = corpus_name.to_lowercase();
149  let service_name = service_name.to_lowercase();
150  let corpus_result = Corpus::find_by_name(&corpus_name, connection);
151  if let Ok(corpus) = corpus_result {
152    let service_result = Service::find_by_name(&service_name, connection);
153    if let Ok(service) = service_result {
154      // Metadata in all reports
155      global.insert(
156        "title".to_string(),
157        "Corpus Report for ".to_string() + &corpus.name,
158      );
159      global.insert(
160        "description".to_string(),
161        "An analysis framework for corpora of TeX/LaTeX documents - statistical reports for "
162          .to_string()
163          + &corpus.name,
164      );
165      // Render the STORED names (case-insensitive lookup preserves the display case, e.g. `arXiv`),
166      // not the lowercased URL params.
167      global.insert("corpus_name".to_string(), corpus.name.clone());
168      global.insert("corpus_description".to_string(), corpus.description.clone());
169      global.insert("service_name".to_string(), service.name.clone());
170      global.insert(
171        "service_description".to_string(),
172        service.description.clone(),
173      );
174      global.insert("type".to_string(), "Conversion".to_string());
175      global.insert("inputformat".to_string(), service.inputformat.clone());
176      global.insert("outputformat".to_string(), service.outputformat.clone());
177
178      if let Ok(Some(historical_run)) = HistoricalRun::find_current(&corpus, &service, connection) {
179        global.insert(
180          "run_start_time".to_string(),
181          crate::frontend::helpers::iso_utc(historical_run.start_time),
182        );
183        global.insert("run_owner".to_string(), historical_run.owner);
184        global.insert("run_description".to_string(), historical_run.description);
185      }
186      let all_messages = match params {
187        None => false,
188        Some(ref params) => *params.all.as_ref().unwrap_or(&false),
189      };
190      global.insert("all_messages".to_string(), all_messages.to_string());
191      if all_messages {
192        // Handlebars has a weird limitation on its #if conditional, can only test for field
193        // presence. So...
194        global.insert("all_messages_true".to_string(), all_messages.to_string());
195      }
196      // Whether THIS report's data is matview-backed (the report_summary rollup) or live-computed —
197      // the same oracle that gates the serving path. Captured here, before severity/category/what
198      // are moved into `task_report` below, so the freshness footer stamps the matview time
199      // *iff* the matview was actually used (else the data is current — "just now").
200      let used_rollup = crate::backend::report_uses_rollup(
201        severity.as_deref(),
202        category.as_deref(),
203        what.as_deref(),
204        all_messages,
205      );
206      // Cloned so the freshness footer can look up this drill-down's cache slice *after*
207      // `task_report` populates it below (the owned `severity` is moved into `task_report`).
208      let report_severity = severity.clone();
209      // Cold-miss handoff: if this rollup slice is too large to aggregate inline (the full-arXiv
210      // `info` case is minutes), hand it to a background job and render a "report computing" shell
211      // instead of blocking the request thread (see [`defer_cold_slice`]).
212      let report_computing = defer_cold_slice(
213        connection,
214        pool,
215        used_rollup,
216        report_severity.as_deref(),
217        &corpus,
218        &service,
219      );
220      match service.inputconverter {
221        Some(ref ic_service_name) => {
222          global.insert("inputconverter".to_string(), ic_service_name.clone())
223        },
224        // No declared input converter ⇒ this service's input is the raw imported
225        // source, which `serve_entry` serves under the `import` pseudo-service
226        // (`/entry/import/<taskid>` → `task.entry`). Defaulting to "import" keeps the
227        // report's source link live; the old "missing?" placeholder produced a
228        // guaranteed 404 (`/entry/missing%3F/<id>` — no such result archive).
229        None => global.insert("inputconverter".to_string(), "import".to_string()),
230      };
231
232      let report;
233      let template;
234      if severity.is_none() {
235        // Top-level report
236        report = progress_report(connection, corpus.id, service.id);
237        // Derive the "Rerun Progress" view server-side, from the same counts: the
238        // completed-so-far total and each severity's share of it. report.html.tera
239        // renders this directly while a conversion is in progress, replacing the old
240        // client-side `progress_report.js`, which scraped the formatted Full Corpus
241        // cells and `parseInt`-ed them — truncating any count >= 1000 at the
242        // thousands separator (parseInt("3,312") === 3).
243        let count = |key: &str| *report.get(key).unwrap_or(&0.0);
244        let (np, warn, err, fat) = (
245          count("no_problem"),
246          count("warning"),
247          count("error"),
248          count("fatal"),
249        );
250        let completed = np + warn + err + fat;
251        let pct = |n: f64| {
252          if completed > 0.0 {
253            format!("{:.2}", 100.0 * n / completed)
254          } else {
255            "0.00".to_string()
256          }
257        };
258        global.insert("rerun_completed".to_string(), completed.to_string());
259        global.insert("rerun_no_problem_percent".to_string(), pct(np));
260        global.insert("rerun_warning_percent".to_string(), pct(warn));
261        global.insert("rerun_error_percent".to_string(), pct(err));
262        global.insert("rerun_fatal_percent".to_string(), pct(fat));
263        // Unified report's progress bar: share of the non-invalid corpus processed so far
264        // (`total` is the non-invalid size — progress_report discounts invalids).
265        let total = count("total");
266        global.insert(
267          "processed_percent".to_string(),
268          if total > 0.0 {
269            format!("{:.2}", 100.0 * completed / total)
270          } else {
271            "0.00".to_string()
272          },
273        );
274        // Live run-diff: of the tasks completed so far, how many improved / regressed / stayed the
275        // same vs the previous run's baseline snapshot. Read-through cached (the join is the only
276        // expensive bit — the headline counts above stay live), shown only once there's a baseline
277        // and something completed to compare.
278        let ld = live_run_diff(connection, corpus.id, service.id);
279        if ld.compared() > 0 {
280          global.insert("livediff_improved".to_string(), ld.improved.to_string());
281          global.insert("livediff_regressed".to_string(), ld.regressed.to_string());
282          global.insert("livediff_unchanged".to_string(), ld.unchanged.to_string());
283          global.insert(
284            "livediff_reclassified".to_string(),
285            ld.reclassified.to_string(),
286          );
287        }
288        // Record the report into the globals
289        for (key, val) in report {
290          global.insert(key.clone(), val.to_string());
291        }
292        global.insert(
293          "report_time".to_string(),
294          crate::frontend::helpers::report_timestamp(),
295        );
296        template = "report";
297      } else if category.is_none() {
298        // Severity-level report
299        global.insert("severity".to_string(), severity.clone().unwrap_or_default());
300        global.insert(
301          "highlight".to_string(),
302          severity_highlight(&severity.clone().unwrap_or_default()).to_string(),
303        );
304        let no_problem_kind = match severity {
305          Some(ref s) => s == "no_problem",
306          None => false,
307        };
308        if report_computing {
309          // Slice is being aggregated in the background (see the cold-miss handoff above) — render
310          // the categories shell empty with a "computing" notice rather than running the heavy
311          // `task_report` aggregation inline. `report_computing` is only ever set for a rollup
312          // severity, which excludes `no_problem`, so this is always the categories template.
313          global.insert("report_computing".to_string(), "true".to_string());
314          context.categories = Some(Vec::new());
315          template = "severity-report";
316        } else {
317          let fetched_report = task_report(
318            connection,
319            &mut global,
320            &corpus,
321            &service,
322            severity,
323            None,
324            None,
325            &params,
326          );
327          template = if no_problem_kind {
328            // Record the report into "entries" vector
329            context.entries = Some(fetched_report);
330            // And set the task list template
331            "task-list-report"
332          } else {
333            // Record the report into "categories" vector
334            context.categories = Some(fetched_report);
335            // And set the severity template
336            "severity-report"
337          };
338        }
339      } else if what.is_none() {
340        // Category-level report
341        global.insert("severity".to_string(), severity.clone().unwrap_or_default());
342        global.insert(
343          "highlight".to_string(),
344          severity_highlight(&severity.clone().unwrap_or_default()).to_string(),
345        );
346        global.insert("category".to_string(), category.clone().unwrap_or_default());
347        let no_messages_kind = category.as_deref() == Some("no_messages");
348        if report_computing {
349          // Same cold-miss handoff as the severity branch; `report_computing` excludes the
350          // `no_messages` per-task list, so this is always the `what`-drill-down (category)
351          // template.
352          global.insert("report_computing".to_string(), "true".to_string());
353          context.whats = Some(Vec::new());
354          template = "category-report";
355        } else {
356          let fetched_report = task_report(
357            connection,
358            &mut global,
359            &corpus,
360            &service,
361            severity,
362            category,
363            None,
364            &params,
365          );
366          template = if no_messages_kind {
367            // Record the report into "entries" vector
368            context.entries = Some(fetched_report);
369            // And set the task list template
370            "task-list-report"
371          } else {
372            // Record the report into "whats" vector
373            context.whats = Some(fetched_report);
374            // And set the category template
375            "category-report"
376          };
377        }
378      } else {
379        // What-level report
380        global.insert("severity".to_string(), severity.clone().unwrap_or_default());
381        global.insert(
382          "highlight".to_string(),
383          severity_highlight(&severity.clone().unwrap_or_default()).to_string(),
384        );
385        global.insert("category".to_string(), category.clone().unwrap_or_default());
386        global.insert("what".to_string(), what.clone().unwrap_or_default());
387        let entries = task_report(
388          connection,
389          &mut global,
390          &corpus,
391          &service,
392          severity,
393          category,
394          what,
395          &params,
396        );
397        // Record the report into "entries" vector
398        context.entries = Some(entries);
399        // And set the task list template
400        template = "task-list-report";
401      }
402      // Pass the globals(reports+metadata) onto the stash
403      context.global = global;
404      // And pass the handy lambdas
405      // And render the correct template
406      decorate_uri_encodings(&mut context);
407
408      // Report also the query times
409      let report_end = chrono::Utc::now();
410      let report_duration = (report_end - report_start).num_milliseconds();
411      context
412        .global
413        .insert("report_duration".to_string(), report_duration.to_string());
414      // Observability for the expensive live-aggregation path (KNOWN_ISSUES P-2). A non-rollup
415      // report (the `all=true` toggle, a per-task list, or the overview) computes live and holds
416      // its pooled connection for the whole query, so a burst can exhaust the pool and 503
417      // other requests. Emit the path + duration so an operator can measure how often the
418      // slow `all=true` toggle is actually hit and how slow it runs in production — the
419      // evidence the P-2 cost call needs — and `warn` past the pool-pinning-risk threshold.
420      // Pure observability: no behaviour change. (The metric name/JSON stay stable; this is a
421      // structured log via Rocket's tracing subscriber.)
422      if !used_rollup {
423        let corpus_l = context
424          .global
425          .get("corpus_name")
426          .map(String::as_str)
427          .unwrap_or("?");
428        let service_l = context
429          .global
430          .get("service_name")
431          .map(String::as_str)
432          .unwrap_or("?");
433        let severity_l = context
434          .global
435          .get("severity")
436          .map(String::as_str)
437          .unwrap_or("-");
438        if report_duration >= SLOW_REPORT_WARN_MS {
439          tracing::warn!(
440            corpus = corpus_l,
441            service = service_l,
442            severity = severity_l,
443            all_messages,
444            duration_ms = report_duration,
445            "slow live report held a pooled connection for the whole query (P-2 pool-saturation risk)"
446          );
447        } else {
448          tracing::debug!(
449            corpus = corpus_l,
450            service = service_l,
451            severity = severity_l,
452            all_messages,
453            duration_ms = report_duration,
454            "live-computed (non-rollup) report"
455          );
456        }
457      }
458      // Report freshness = the **data's** age, and it must match where the data actually came from
459      // (KNOWN_ISSUES): a matview-backed report is only as current as its last `report_summary`
460      // refresh, but a live-computed one (all-severities `all=true`, per-task lists, the top-level
461      // overview) is current as of *now*. Stamping a live report with the stale matview time lies
462      // about freshness — so branch on `used_rollup`. The footer renders a colour-coded "data
463      // refreshed N ago" from this epoch (localized to the viewer's zone).
464      if used_rollup {
465        // Cache-backed drill-down: the footer's "generated at" is the slice's `computed_at`.
466        if let Some((epoch_ms, human)) = report_severity.as_deref().and_then(|sev| {
467          crate::backend::report_cache_computed_at(connection, corpus.id, service.id, sev)
468        }) {
469          context.global.insert("report_time".to_string(), human);
470          context
471            .global
472            .insert("report_time_epoch".to_string(), epoch_ms.to_string());
473        }
474      } else {
475        // Live data: current as of this request — "just now".
476        context.global.insert(
477          "report_time".to_string(),
478          crate::frontend::helpers::report_timestamp(),
479        );
480        context.global.insert(
481          "report_time_epoch".to_string(),
482          chrono::Utc::now().timestamp_millis().to_string(),
483        );
484      }
485      Ok(Template::render(template, context))
486    } else {
487      Err(Status::NotFound)
488    }
489  } else {
490    Err(Status::NotFound)
491  }
492}
493
494/// Rerun a filtered subset of tasks for a <corpus,service> pair, over the caller-supplied (pooled)
495/// `connection`, attributed to the already-authenticated `owner` (the signed-in admin — the route
496/// gates on the [`crate::frontend::actor::AdminSession`] cookie, so there is no token here). `404`
497/// on an unknown corpus/service, `500` if the rerun marking fails.
498#[allow(clippy::too_many_arguments)]
499pub fn serve_rerun(
500  connection: &mut PgConnection,
501  _pool: &DbPool,
502  corpus_name: String,
503  service_name: String,
504  severity: Option<String>,
505  category: Option<String>,
506  what: Option<String>,
507  owner: &str,
508  description: &str,
509) -> Result<Accepted<String>, Status> {
510  let corpus_name = corpus_name.to_lowercase();
511  let service_name = service_name.to_lowercase();
512  // Reject an out-of-scope / typo'd rerun severity (R-9) up front, instead of letting `mark_rerun`
513  // silently mis-scope it to `no_problem` — the same guard the agent `rerun_report` applies, so the
514  // human and agent surfaces accept/reject the same set.
515  if let Some(ref severity) = severity
516    && !crate::frontend::reports::is_valid_rerun_severity(severity, category.is_some())
517  {
518    return Err(Status::BadRequest);
519  }
520  // Structured admin-action log (the audit fairing also records actor + outcome to the DB; this is
521  // the operational journal line). Emitted here, before the scope is moved into `RerunOptions`.
522  tracing::info!(
523    actor = owner,
524    corpus = corpus_name,
525    service = service_name,
526    severity = ?severity,
527    category = ?category,
528    what = ?what,
529    "rerun requested"
530  );
531  let corpus = Corpus::find_by_name(&corpus_name, connection).map_err(|_| Status::NotFound)?;
532  let service = Service::find_by_name(&service_name, connection).map_err(|_| Status::NotFound)?;
533  let report_start = chrono::Utc::now();
534  let rerun_result = mark_rerun(
535    connection,
536    RerunOptions {
537      corpus: &corpus,
538      service: &service,
539      severity_opt: severity,
540      category_opt: category,
541      what_opt: what,
542      description_opt: Some(description.to_string()),
543      owner_opt: Some(owner.to_string()),
544    },
545  );
546  let report_duration = (chrono::Utc::now() - report_start).num_milliseconds();
547  match rerun_result {
548    Err(error) => {
549      tracing::warn!(actor = owner, duration_ms = report_duration, %error, "rerun failed");
550      Err(Status::InternalServerError)
551    },
552    Ok(_) => {
553      tracing::info!(
554        actor = owner,
555        duration_ms = report_duration,
556        "rerun committed"
557      );
558      // The reran (corpus, service) scope's report cache was already invalidated inside the rerun
559      // transaction (`mark_rerun`), so its reports repopulate fresh on the next view — no separate,
560      // globally-scoped refresh job needed.
561      Ok(Accepted(String::default()))
562    },
563  }
564}
565
566/// **Pause** (`pause = true`) or **resume** a whole `(corpus, service)` run, over the
567/// caller-supplied (pooled) `connection`, attributed to the authenticated `owner`. Pause blocks
568/// every in-progress task (`status >= 0` → Blocked) so the dispatcher stops leasing them; resume
569/// returns every Blocked task to TODO so the dispatcher picks them up again — the inverse pair.
570/// `404` on an unknown corpus/service, `500` if the status update fails; on success returns the
571/// number of tasks affected. Shared by the human `POST /{pause,resume}/<c>/<s>` (cookie) and the
572/// agent `POST /api/reports/<c>/<s>/{pause,resume}` (token) — one core, identical effect.
573pub fn serve_pause_resume(
574  connection: &mut PgConnection,
575  corpus_name: &str,
576  service_name: &str,
577  owner: &str,
578  pause: bool,
579) -> Result<usize, Status> {
580  let corpus_name = corpus_name.to_lowercase();
581  let service_name = service_name.to_lowercase();
582  let action = if pause { "pause" } else { "resume" };
583  // Operational-journal line (the audit fairing also records actor + outcome to the DB).
584  tracing::info!(
585    actor = owner,
586    corpus = corpus_name,
587    service = service_name,
588    action,
589    "run control requested"
590  );
591  let corpus = Corpus::find_by_name(&corpus_name, connection).map_err(|_| Status::NotFound)?;
592  let service = Service::find_by_name(&service_name, connection).map_err(|_| Status::NotFound)?;
593  let result = if pause {
594    mark_blocked(connection, corpus.id, service.id)
595  } else {
596    resume_blocked(connection, corpus.id, service.id)
597  };
598  match result {
599    Err(error) => {
600      tracing::warn!(actor = owner, action, %error, "run control failed");
601      Err(Status::InternalServerError)
602    },
603    Ok(count) => {
604      // No rollup refresh: pause/resume only move tasks across TODO↔Blocked (neither is in the
605      // completed-task severity matview); the report's live in-progress tally updates on next load.
606      tracing::info!(
607        actor = owner,
608        action,
609        affected = count,
610        "run control committed"
611      );
612      Ok(count)
613    },
614  }
615}
616
617/// Human **pause run** — block every in-progress task of a `(corpus, service)` so the dispatcher
618/// stops. Cookie-gated; a plain form POST that redirects back to the report so the admin sees the
619/// new state (`401` without a session). The agent twin is `POST /api/reports/<c>/<s>/pause`.
620#[post("/pause/<corpus_name>/<service_name>")]
621pub fn pause_run(
622  corpus_name: String,
623  service_name: String,
624  session: Option<AdminSession>,
625  pool: &State<DbPool>,
626) -> Result<rocket::response::Redirect, Status> {
627  let session = session.ok_or(Status::Unauthorized)?;
628  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
629  serve_pause_resume(
630    &mut connection,
631    &corpus_name,
632    &service_name,
633    &session.owner,
634    true,
635  )?;
636  Ok(rocket::response::Redirect::to(format!(
637    "/corpus/{corpus_name}/{service_name}"
638  )))
639}
640
641/// Human **resume run** — return every Blocked task of a `(corpus, service)` to TODO. Cookie-gated;
642/// form POST that redirects back to the report. The agent twin is `POST
643/// /api/reports/<c>/<s>/resume`.
644#[post("/resume/<corpus_name>/<service_name>")]
645pub fn resume_run(
646  corpus_name: String,
647  service_name: String,
648  session: Option<AdminSession>,
649  pool: &State<DbPool>,
650) -> Result<rocket::response::Redirect, Status> {
651  let session = session.ok_or(Status::Unauthorized)?;
652  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
653  serve_pause_resume(
654    &mut connection,
655    &corpus_name,
656    &service_name,
657    &session.owner,
658    false,
659  )?;
660  Ok(rocket::response::Redirect::to(format!(
661    "/corpus/{corpus_name}/{service_name}"
662  )))
663}
664
665/// Pause or resume **all** conversions globally — the dashboard's "Pause/Resume all conversions".
666/// The global twin of [`serve_pause_resume`]: across every `(corpus, service)`, pause blocks every
667/// in-progress task and resume returns every Blocked task to TODO. Returns the number of tasks
668/// moved. Audited by the fairing; fully reversible (the inverse action restores TODO).
669pub fn serve_pause_resume_all(
670  connection: &mut PgConnection,
671  owner: &str,
672  pause: bool,
673) -> Result<usize, Status> {
674  let action = if pause { "pause-all" } else { "resume-all" };
675  tracing::info!(actor = owner, action, "global run control requested");
676  let result = if pause {
677    mark_all_blocked(connection)
678  } else {
679    resume_all_blocked(connection)
680  };
681  match result {
682    Err(error) => {
683      tracing::warn!(actor = owner, action, %error, "global run control failed");
684      Err(Status::InternalServerError)
685    },
686    Ok(count) => {
687      tracing::info!(
688        actor = owner,
689        action,
690        affected = count,
691        "global run control committed"
692      );
693      Ok(count)
694    },
695  }
696}
697
698/// Human **pause all conversions** — block every in-progress task fleet-wide so the dispatcher
699/// stops leasing new work everywhere. Cookie-gated; redirects to `/admin`. Agent twin:
700/// `POST /api/conversions/pause`.
701#[post("/pause-all")]
702pub fn pause_all(
703  session: Option<AdminSession>,
704  pool: &State<DbPool>,
705) -> Result<rocket::response::Redirect, Status> {
706  let session = session.ok_or(Status::Unauthorized)?;
707  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
708  serve_pause_resume_all(&mut connection, &session.owner, true)?;
709  Ok(rocket::response::Redirect::to("/admin"))
710}
711
712/// Human **resume all conversions** — return every Blocked task fleet-wide to TODO. Cookie-gated;
713/// redirects to `/admin`. Agent twin: `POST /api/conversions/resume`.
714#[post("/resume-all")]
715pub fn resume_all(
716  session: Option<AdminSession>,
717  pool: &State<DbPool>,
718) -> Result<rocket::response::Redirect, Status> {
719  let session = session.ok_or(Status::Unauthorized)?;
720  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
721  serve_pause_resume_all(&mut connection, &session.owner, false)?;
722  Ok(rocket::response::Redirect::to("/admin"))
723}
724
725/// Save the historical tasks of a corpus run, for reference, for a <corpus,service> pair. Auth is
726/// the signed-in [`crate::frontend::actor::AdminSession`] cookie (gated at the route). `404` on an
727/// unknown corpus/service.
728pub fn serve_savetasks(
729  connection: &mut PgConnection,
730  corpus_name: String,
731  service_name: String,
732) -> Result<Accepted<String>, Status> {
733  let corpus_name = corpus_name.to_lowercase();
734  let service_name = service_name.to_lowercase();
735  let corpus = Corpus::find_by_name(&corpus_name, connection).map_err(|_| Status::NotFound)?;
736  let service = Service::find_by_name(&service_name, connection).map_err(|_| Status::NotFound)?;
737  // A snapshot taken mid-run is a moving target (the in-progress tasks will resolve to a different
738  // status moments later), so refuse it while any task is still TODO or Queued (status >= 0). The
739  // UI disables the button on the same condition; this is the authoritative guard for both the
740  // human and agent paths.
741  let progress = progress_report(connection, corpus.id, service.id);
742  let in_progress =
743    progress.get("todo").copied().unwrap_or(0.0) + progress.get("queued").copied().unwrap_or(0.0);
744  if in_progress > 0.0 {
745    return Err(Status::Conflict);
746  }
747  match save_historical_tasks(connection, &corpus, &service) {
748    Err(_) => Err(Status::InternalServerError),
749    Ok(count) => Ok(Accepted(format!("Saved {count} tasks"))),
750  }
751}
752
753/// A file download served with a `Content-Disposition` filename derived from the document's **entry
754/// name** (e.g. `0811.0417.zip`) instead of the opaque task id in the URL — so corpus curators get
755/// an informative filename (UX request 2026-06-16). Wraps a [`NamedFile`] and adds the header.
756pub struct EntryDownload {
757  file: NamedFile,
758  /// The download filename, already sanitised to a safe character set.
759  filename: String,
760}
761
762impl<'r> rocket::response::Responder<'r, 'static> for EntryDownload {
763  fn respond_to(self, request: &'r rocket::Request<'_>) -> rocket::response::Result<'static> {
764    let mut response = self.file.respond_to(request)?;
765    response.set_raw_header(
766      "Content-Disposition",
767      format!("attachment; filename=\"{}\"", self.filename),
768    );
769    Ok(response)
770  }
771}
772
773/// Restricts a download filename to a safe set (alphanumerics + `.`/`-`/`_`), replacing any other
774/// character with `_`. Prevents `Content-Disposition` header injection or odd filenames from a
775/// hostile entry path; falls back to `download` if nothing safe remains.
776fn sanitize_download_filename(name: &str) -> String {
777  let safe: String = name
778    .chars()
779    .map(|c| {
780      if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
781        c
782      } else {
783        '_'
784      }
785    })
786    .collect();
787  if safe.is_empty() {
788    "download".to_string()
789  } else {
790    safe
791  }
792}
793
794/// The informative `Content-Disposition` filename for an entry download. The `import` service
795/// serves the **source** archive, which keeps the bare document name (`0811.0417.zip`); every other
796/// service serves a **result** archive, whose name appends the service
797/// (`0811.0417_tex_to_html.zip`) so a curator can tell the source from a result and several
798/// services' results for one document never collide. Sanitised to a safe charset
799/// (header-injection-proof).
800fn entry_download_filename(document_name: &str, service_name: &str, ext: &str) -> String {
801  let stem = if service_name == "import" {
802    document_name.to_string()
803  } else {
804    format!("{document_name}_{service_name}")
805  };
806  sanitize_download_filename(&format!("{stem}.{ext}"))
807}
808
809/// Provide a downloadable file for an entry, looking the task up over the caller-supplied (pooled)
810/// `connection` (the borrow ends before the file open). The download is named from the report's
811/// "Entry" name + the served file's real extension (not the opaque task id): `<document>.<ext>` for
812/// the `import` source archive, `<document>_<service>.<ext>` for a result archive.
813pub async fn serve_entry(
814  connection: &mut PgConnection,
815  service_name: String,
816  entry_id: usize,
817) -> Result<EntryDownload, NotFound<String>> {
818  // Defense-in-depth path-traversal guard: `service_name` is a raw URL segment that gets
819  // interpolated into the result-archive **filesystem path** (`{entry_dir}/{service_name}.zip` via
820  // `result_archive_path`), with no service-registry lookup on this download path. A real service
821  // name is a bare identifier; reject any path separator / `..` / NUL so a crafted segment can
822  // never resolve outside the corpus's data directory (a `.zip`-scoped local-file read on a
823  // public route).
824  if service_name.contains('/')
825    || service_name.contains('\\')
826    || service_name.contains("..")
827    || service_name.contains('\0')
828  {
829    return Err(NotFound("invalid service".to_string()));
830  }
831  match Task::find(entry_id as i64, connection) {
832    Ok(task) => {
833      // The informative download name (the report's "Entry" name), captured before `task.entry`
834      // is consumed below.
835      let document_name = crate::helpers::entry_document_name(&task.entry);
836      let zip_path = if service_name == "import" {
837        Some(std::path::PathBuf::from(&task.entry))
838      } else {
839        // A sandbox's results are name-scoped by its corpus id (F-6), so resolve the task's corpus
840        // to match what the sink wrote. One lookup per file-serve request (not a hot path).
841        let sandbox_id = Corpus::find_by_id(task.corpus_id, connection)
842          .ok()
843          .and_then(|corpus| corpus.sandbox_id());
844        crate::helpers::result_archive_path(&task.entry, &service_name, sandbox_id)
845      };
846      match zip_path {
847        Some(path) => {
848          let file = NamedFile::open(&path)
849            .await
850            .map_err(|_| NotFound("Invalid Zip at path".to_string()))?;
851          // Name the download from the served file's real extension (zip / gz / …). The `import`
852          // service serves the **source** archive, which keeps the bare document name
853          // (`0811.0417.zip`); any **result** archive appends its service so a curator can tell the
854          // source from the `tex_to_html` output (`0811.0417_tex_to_html.zip`) and downloading
855          // several services' results for one document never collides (UX request 2026-06-16).
856          let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("zip");
857          let filename = entry_download_filename(&document_name, &service_name, ext);
858          Ok(EntryDownload { file, filename })
859        },
860        None => Err(NotFound(format!(
861          "Service {service_name:?} does not have a result for entry {entry_id:?}"
862        ))),
863      }
864    },
865    // Don't echo the raw diesel error to the client (info disclosure on a public route); a missing
866    // task is an unremarkable 404 with a generic body.
867    Err(_) => Err(NotFound("Task not found".to_string())),
868  }
869}
870
871/// Serves an entry as a `Template` instance to be preview via a client-side asset renderer, over
872/// the caller-supplied (pooled) `connection`.
873pub fn serve_entry_preview(
874  connection: &mut PgConnection,
875  corpus_name: String,
876  service_name: String,
877  entry_name: String,
878) -> Result<Template, NotFound<String>> {
879  let report_start = chrono::Utc::now();
880  let corpus_name = corpus_name.to_lowercase();
881  let mut context = TemplateContext::default();
882  let mut global = HashMap::new();
883
884  // Resolve corpus → service → document, each a clean *informative* 404. Previously an unknown
885  // corpus/service fell through to render `task-preview` with half-populated globals, which Tera
886  // then errored on → a 500 (e.g. the `tex-to-html` vs `tex_to_html` slug mismatch). Robustness
887  // mandate: no 500 on the request path — a missing anything is a 404 that says what was missing.
888  let corpus = Corpus::find_by_name(&corpus_name, connection)
889    .map_err(|_| NotFound(format!("Unknown corpus: {corpus_name}")))?;
890  let service = Service::find_by_name(&service_name, connection)
891    .map_err(|_| NotFound(format!("Unknown service: {service_name}")))?;
892  // Assemble the Download URL from where we will gather the page contents — first, the taskid.
893  let task = Task::find_by_name(&entry_name, &corpus, &service, connection).map_err(|_| {
894    NotFound(format!(
895      "No '{entry_name}' document found in {corpus_name} / {service_name}"
896    ))
897  })?;
898  let download_url = format!("/entry/{}/{}", service_name, task.id);
899  global.insert("download_url".to_string(), download_url);
900
901  // Metadata for preview page
902  global.insert(
903    "title".to_string(),
904    "Corpus Report for ".to_string() + &corpus_name,
905  );
906  global.insert(
907    "description".to_string(),
908    "An analysis framework for corpora of TeX/LaTeX documents - statistical reports for "
909      .to_string()
910      + &corpus_name,
911  );
912  global.insert("corpus_description".to_string(), corpus.description);
913  global.insert("service_name".to_string(), service_name);
914  global.insert(
915    "service_description".to_string(),
916    service.description.clone(),
917  );
918  global.insert("type".to_string(), "Conversion".to_string());
919  global.insert("inputformat".to_string(), service.inputformat.clone());
920  global.insert("outputformat".to_string(), service.outputformat.clone());
921  match service.inputconverter {
922    Some(ref ic_service_name) => {
923      global.insert("inputconverter".to_string(), ic_service_name.clone())
924    },
925    // A missing input converter means the input is the raw imported source, served under the
926    // `import` pseudo-service. Default to "import" so the source link resolves instead of 404'ing.
927    None => global.insert("inputconverter".to_string(), "import".to_string()),
928  };
929  global.insert(
930    "report_time".to_string(),
931    crate::frontend::helpers::report_timestamp(),
932  );
933  global.insert("corpus_name".to_string(), corpus_name);
934  global.insert("severity".to_string(), entry_name.clone());
935  global.insert("entry_name".to_string(), entry_name);
936
937  // Pass the globals(reports+metadata) onto the stash
938  context.global = global;
939  // And pass the handy lambdas
940  // And render the correct template
941  decorate_uri_encodings(&mut context);
942
943  // Report also the query times
944  let report_end = chrono::Utc::now();
945  let report_duration = (report_end - report_start).num_milliseconds();
946  context
947    .global
948    .insert("report_duration".to_string(), report_duration.to_string());
949  Ok(Template::render("task-preview", context))
950}
951
952/// Checks out a pooled connection for the document-serving routes, mapping pool exhaustion to their
953/// shared `404` error type.
954fn pooled(pool: &State<DbPool>) -> Result<PooledConn, NotFound<String>> {
955  pool
956    .get()
957    .map_err(|_| NotFound("database unavailable".to_string()))
958}
959
960/// The per-article **preview** screen: renders a shell whose converted-document asset is fetched
961/// client-side from the download URL. `404` on an unknown corpus / service / document.
962#[get("/preview/<corpus_name>/<service_name>/<entry_name>")]
963pub fn preview_entry(
964  corpus_name: String,
965  service_name: String,
966  entry_name: String,
967  pool: &State<DbPool>,
968) -> Result<Template, NotFound<String>> {
969  let mut connection = pooled(pool)?;
970  serve_entry_preview(&mut connection, corpus_name, service_name, entry_name)
971}
972
973/// **Downloads** a converted document's result archive (streamed, so a large archive never loads
974/// into memory). `404` when the task is unknown **or** the archive is missing/unreadable on `/data`
975/// — a hostile or unmounted filesystem yields a clean `404`, never a panic or a `500`.
976#[post("/entry/<service_name>/<entry_id>")]
977pub async fn entry_fetch(
978  service_name: String,
979  entry_id: usize,
980  pool: &State<DbPool>,
981) -> Result<EntryDownload, NotFound<String>> {
982  let mut connection = pooled(pool)?;
983  serve_entry(&mut connection, service_name, entry_id).await
984}
985
986/// `GET` twin of [`entry_fetch`] for plain downloadable links (browser-native `<a download>`), used
987/// by the run-diff "Task severity changes" table to offer each entry's source archive without the
988/// jQuery/AJAX `entry-submit` downloader. Reuses [`serve_entry`]; same `404`-not-`500` discipline
989/// on an unknown task or a missing/unreadable archive.
990#[get("/entry/<service_name>/<entry_id>")]
991pub async fn entry_download(
992  service_name: String,
993  entry_id: usize,
994  pool: &State<DbPool>,
995) -> Result<EntryDownload, NotFound<String>> {
996  let mut connection = pooled(pool)?;
997  serve_entry(&mut connection, service_name, entry_id).await
998}
999
1000/// The document-serving route set (preview + archive download), migrated out of `bin/frontend.rs`
1001/// onto the pooled, testable library surface.
1002/// Shared core of the **human** (cookie-authed) rerun routes: require a signed-in admin, then mark
1003/// the `(corpus, service[, severity, category, what])` scope for reconversion — attributed to the
1004/// admin, spawning the debounced rollup refresh off the request path (the agent twin is the
1005/// token-gated `POST /api/reports/<c>/<s>/rerun`). `401` without a session; `404` on an unknown
1006/// corpus/service (in `serve_rerun`).
1007#[allow(clippy::too_many_arguments)]
1008fn human_rerun(
1009  session: Option<AdminSession>,
1010  pool: &State<DbPool>,
1011  corpus_name: String,
1012  service_name: String,
1013  severity: Option<String>,
1014  category: Option<String>,
1015  what: Option<String>,
1016  description: &str,
1017) -> Result<Accepted<String>, Status> {
1018  let session = session.ok_or(Status::Unauthorized)?;
1019  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1020  serve_rerun(
1021    &mut connection,
1022    pool.inner(),
1023    corpus_name,
1024    service_name,
1025    severity,
1026    category,
1027    what,
1028    &session.owner,
1029    description,
1030  )
1031}
1032
1033/// Human rerun — whole `(corpus, service)`. Cookie-gated; the rerun modal XHRs a JSON
1034/// `{description}`.
1035#[post(
1036  "/rerun/<corpus_name>/<service_name>",
1037  format = "application/json",
1038  data = "<rr>"
1039)]
1040pub fn rerun_corpus(
1041  corpus_name: String,
1042  service_name: String,
1043  rr: Json<RerunRequestParams>,
1044  session: Option<AdminSession>,
1045  pool: &State<DbPool>,
1046) -> Result<Accepted<String>, Status> {
1047  human_rerun(
1048    session,
1049    pool,
1050    corpus_name,
1051    service_name,
1052    None,
1053    None,
1054    None,
1055    &rr.description,
1056  )
1057}
1058
1059/// Human rerun — scoped to a `severity`.
1060#[post(
1061  "/rerun/<corpus_name>/<service_name>/<severity>",
1062  format = "application/json",
1063  data = "<rr>"
1064)]
1065pub fn rerun_severity(
1066  corpus_name: String,
1067  service_name: String,
1068  severity: String,
1069  rr: Json<RerunRequestParams>,
1070  session: Option<AdminSession>,
1071  pool: &State<DbPool>,
1072) -> Result<Accepted<String>, Status> {
1073  human_rerun(
1074    session,
1075    pool,
1076    corpus_name,
1077    service_name,
1078    Some(severity),
1079    None,
1080    None,
1081    &rr.description,
1082  )
1083}
1084
1085/// Human rerun — scoped to a `severity`/`category`.
1086#[post(
1087  "/rerun/<corpus_name>/<service_name>/<severity>/<category>",
1088  format = "application/json",
1089  data = "<rr>"
1090)]
1091#[allow(clippy::too_many_arguments)]
1092pub fn rerun_category(
1093  corpus_name: String,
1094  service_name: String,
1095  severity: String,
1096  category: String,
1097  rr: Json<RerunRequestParams>,
1098  session: Option<AdminSession>,
1099  pool: &State<DbPool>,
1100) -> Result<Accepted<String>, Status> {
1101  human_rerun(
1102    session,
1103    pool,
1104    corpus_name,
1105    service_name,
1106    Some(severity),
1107    Some(category),
1108    None,
1109    &rr.description,
1110  )
1111}
1112
1113/// Human rerun — scoped to a `severity`/`category`/`what`.
1114#[post(
1115  "/rerun/<corpus_name>/<service_name>/<severity>/<category>/<what>",
1116  format = "application/json",
1117  data = "<rr>"
1118)]
1119#[allow(clippy::too_many_arguments)]
1120pub fn rerun_what(
1121  corpus_name: String,
1122  service_name: String,
1123  severity: String,
1124  category: String,
1125  what: String,
1126  rr: Json<RerunRequestParams>,
1127  session: Option<AdminSession>,
1128  pool: &State<DbPool>,
1129) -> Result<Accepted<String>, Status> {
1130  human_rerun(
1131    session,
1132    pool,
1133    corpus_name,
1134    service_name,
1135    Some(severity),
1136    Some(category),
1137    Some(what),
1138    &rr.description,
1139  )
1140}
1141
1142/// Human **save-snapshot**: freezes the current per-task statuses into `historical_tasks`.
1143/// Cookie-gated (`401` without a session); `404` on an unknown corpus/service.
1144#[post("/savetasks/<corpus_name>/<service_name>")]
1145pub fn savetasks(
1146  corpus_name: String,
1147  service_name: String,
1148  session: Option<AdminSession>,
1149  pool: &State<DbPool>,
1150) -> Result<Accepted<String>, Status> {
1151  if session.is_none() {
1152    return Err(Status::Unauthorized);
1153  }
1154  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1155  serve_savetasks(&mut connection, corpus_name.to_lowercase(), service_name)
1156}
1157
1158/// A stray **GET** to a rerun URL — which is a POST-only mutation (the rerun modal issues a
1159/// cookie-gated XHR `POST`) — used to dead-end at a confusing `404`: e.g. a copied/bookmarked form
1160/// action, or a stale tab navigated directly. Redirect it to the matching report page, where the
1161/// rerun control lives, instead. `303 See Other`, so the browser switches to a `GET`. The trailing
1162/// `<scope..>` captures the optional `severity[/category[/what]]` segments **and matches zero
1163/// segments too**, so the bare `/rerun/<c>/<s>` (rerun-all) is covered by this single route — a
1164/// separate 2-segment route would *collide* with it and abort Rocket at ignite.
1165#[get("/rerun/<corpus_name>/<service_name>/<scope..>")]
1166pub fn rerun_get_redirect(
1167  corpus_name: &str,
1168  service_name: &str,
1169  scope: std::path::PathBuf,
1170) -> rocket::response::Redirect {
1171  let scope = scope.display().to_string();
1172  let target = if scope.is_empty() {
1173    format!("/corpus/{corpus_name}/{service_name}")
1174  } else {
1175    format!("/corpus/{corpus_name}/{service_name}/{scope}")
1176  };
1177  rocket::response::Redirect::to(target)
1178}
1179
1180/// The document-serving + human rerun/save-snapshot route set, migrated out of `bin/frontend.rs`
1181/// onto the pooled, testable library surface.
1182pub fn routes() -> Vec<Route> {
1183  routes![
1184    preview_entry,
1185    entry_fetch,
1186    entry_download,
1187    rerun_corpus,
1188    rerun_severity,
1189    rerun_category,
1190    rerun_what,
1191    rerun_get_redirect,
1192    pause_run,
1193    resume_run,
1194    pause_all,
1195    resume_all,
1196    savetasks
1197  ]
1198}
1199
1200#[cfg(test)]
1201mod live_report_limiter_tests {
1202  use super::LiveReportLimiter;
1203
1204  #[test]
1205  fn caps_then_releases_permits() {
1206    let limiter = LiveReportLimiter::new(1);
1207    assert_eq!(limiter.0.available_permits(), 1);
1208    {
1209      let _permit = limiter
1210        .0
1211        .clone()
1212        .try_acquire_owned()
1213        .expect("first permit available");
1214      assert!(
1215        limiter.0.clone().try_acquire_owned().is_err(),
1216        "exhausted at the cap of 1"
1217      );
1218    }
1219    // The guard dropped at the block end, so the permit is restored (RAII release).
1220    assert_eq!(limiter.0.available_permits(), 1, "permit released on drop");
1221    assert!(
1222      limiter.0.clone().try_acquire_owned().is_ok(),
1223      "reacquire succeeds after release"
1224    );
1225  }
1226
1227  #[test]
1228  fn zero_is_clamped_to_one() {
1229    let limiter = LiveReportLimiter::new(0);
1230    assert_eq!(limiter.0.available_permits(), 1, "a 0 cap can't deadlock");
1231  }
1232}
1233
1234#[cfg(test)]
1235mod download_filename_tests {
1236  use super::entry_download_filename;
1237
1238  #[test]
1239  fn import_keeps_the_bare_document_name() {
1240    // The `import` service serves the source archive — no service suffix.
1241    assert_eq!(
1242      entry_download_filename("0811.0417", "import", "zip"),
1243      "0811.0417.zip"
1244    );
1245  }
1246
1247  #[test]
1248  fn a_result_archive_appends_its_service() {
1249    // The UX request: /entry/tex_to_html/<id> downloads `<doc>_tex_to_html.zip`.
1250    assert_eq!(
1251      entry_download_filename("2105.13573", "tex_to_html", "zip"),
1252      "2105.13573_tex_to_html.zip"
1253    );
1254    // The real extension is honored (e.g. a gz source-derived result).
1255    assert_eq!(
1256      entry_download_filename("0801.1234", "ngram", "gz"),
1257      "0801.1234_ngram.gz"
1258    );
1259  }
1260
1261  #[test]
1262  fn unsafe_characters_are_sanitised() {
1263    // A hostile document or service name can't inject into the Content-Disposition header.
1264    assert_eq!(
1265      entry_download_filename("0801.1234", "te x/t", "zip"),
1266      "0801.1234_te_x_t.zip"
1267    );
1268  }
1269}