Skip to main content

resopt/
server.rs

1//! Loopback-only application server for `resopt web` and `resopt serve`.
2//!
3//! The server binds 127.0.0.1, never accepts filesystem paths or file uploads
4//! from HTTP clients, and checks the Host header on every request (DNS
5//! rebinding), a per-process session token on every API route, and the Origin
6//! header on every state-changing request (cross-site requests).
7//!
8//! The page itself is served only to the launch URL, which carries a one-time
9//! key; it then sets an HttpOnly, SameSite=Strict cookie that report artifacts
10//! require. Another local process that merely knows the port can therefore
11//! neither obtain the session token nor read project data.
12use crate::{
13    AnalysisControl, ResourceAnalysis,
14    batch::{self, BatchPlan, BatchPolicy, BatchStatus},
15    filesystem::contained_file,
16    review::{Approvals, Review},
17};
18use anyhow::{Context, Result, ensure};
19use serde::{Deserialize, Serialize};
20use std::{
21    io::{Read, Write},
22    path::{Path, PathBuf},
23    sync::{
24        Arc, Mutex, OnceLock,
25        atomic::{AtomicBool, Ordering},
26    },
27};
28use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
29
30const MAX_BODY_BYTES: usize = 256 * 1024;
31const RESULTS_PAGE: usize = 500;
32const HTTP_WORKERS: usize = 4;
33
34#[derive(Deserialize)]
35#[serde(deny_unknown_fields)]
36struct Action {
37    resource: usize,
38    candidate: Option<usize>,
39    #[serde(default)]
40    approve_lossy: bool,
41    /// Legacy flag: approves both Alpha warning kinds.
42    #[serde(default)]
43    approve_alpha_loss: bool,
44    #[serde(default)]
45    approve_warnings: Vec<String>,
46    #[serde(default)]
47    plan_token: Option<String>,
48}
49
50impl Action {
51    fn warnings(&self) -> Vec<String> {
52        let mut warnings = self.approve_warnings.clone();
53        if self.approve_alpha_loss {
54            for kind in [
55                "alpha_error_exceeds_policy",
56                "transparency_presence_changed",
57            ] {
58                if !warnings.iter().any(|w| w == kind) {
59                    warnings.push(kind.to_string());
60                }
61            }
62        }
63        warnings
64    }
65}
66
67#[derive(Deserialize)]
68#[serde(deny_unknown_fields)]
69struct BatchRequest {
70    policy: BatchPolicy,
71    #[serde(default)]
72    token: Option<String>,
73}
74
75#[derive(Deserialize)]
76#[serde(deny_unknown_fields)]
77struct RestoreRequest {
78    resources: Vec<usize>,
79}
80
81enum BatchJob {
82    Apply(BatchPlan),
83    Restore(Option<Vec<usize>>),
84}
85
86/// Analysis progress shared between the worker thread and HTTP handlers.
87#[derive(Default)]
88pub(crate) struct Live {
89    /// Completed rows in completion order, tagged with their report index.
90    pub rows: Vec<(usize, ResourceAnalysis)>,
91    pub total: usize,
92    pub error: Option<String>,
93    pub cancelled: bool,
94}
95
96#[derive(Serialize)]
97struct ResultsPage<'a> {
98    phase: &'static str,
99    completed: usize,
100    total: usize,
101    candidates: usize,
102    savings_bytes: u64,
103    next: usize,
104    rows: Vec<Row<'a>>,
105    error: Option<&'a str>,
106}
107#[derive(Serialize)]
108struct Row<'a> {
109    index: usize,
110    row: &'a ResourceAnalysis,
111}
112
113pub(crate) struct App {
114    pub directory: PathBuf,
115    pub project: PathBuf,
116    pub live: Mutex<Live>,
117    pub control: AnalysisControl,
118    /// Set once analysis has finished and the report passed validation.
119    pub review: OnceLock<Review>,
120    /// The most recently played animation, parsed once and reused per frame.
121    animation: Mutex<Option<(usize, Arc<crate::svga_render::Renderer>)>>,
122    batch_status: Mutex<BatchStatus>,
123    batch_cancel: AtomicBool,
124    batch_running: AtomicBool,
125}
126
127impl App {
128    pub fn new(directory: PathBuf, project: PathBuf) -> Self {
129        Self {
130            directory,
131            project,
132            live: Mutex::new(Live::default()),
133            control: AnalysisControl::default(),
134            review: OnceLock::new(),
135            animation: Mutex::new(None),
136            batch_status: Mutex::new(BatchStatus::default()),
137            batch_cancel: AtomicBool::new(false),
138            batch_running: AtomicBool::new(false),
139        }
140    }
141
142    /// Publish a finished report: its rows replace the live rows.
143    pub fn finish(&self, review: Review) {
144        {
145            let mut live = self.live.lock().unwrap_or_else(|e| e.into_inner());
146            live.total = review.report.resources.len();
147            live.cancelled = review.report.cancelled;
148            live.rows = review
149                .report
150                .resources
151                .iter()
152                .cloned()
153                .enumerate()
154                .collect();
155        }
156        let _ = self.review.set(review);
157    }
158
159    fn review(&self) -> Result<&Review> {
160        self.review
161            .get()
162            .context("analysis is still running; changes can be applied once it completes")
163    }
164}
165
166/// Serve an existing analysis on loopback. Port 0 chooses an available port.
167/// The printed URL is the entry point; terminate the process to stop serving.
168pub fn serve(directory: impl AsRef<Path>, port: u16) -> Result<()> {
169    let server = Server::http(("127.0.0.1", port)).map_err(|e| anyhow::anyhow!("{e}"))?;
170    let review = Review::open(directory.as_ref())?;
171    let app = Arc::new(App::new(
172        review.directory.clone(),
173        review.report.root.clone(),
174    ));
175    app.finish(review);
176    let token = session_token()?;
177    println!(
178        "Review server: {}\nProject: {}\nStop with Ctrl-C. Sources change only after an explicit Apply request.",
179        launch_url(&server, &token),
180        app.project.display()
181    );
182    std::io::stdout().flush()?;
183    run(Arc::new(server), app, token)
184}
185
186pub(crate) fn session_token() -> Result<String> {
187    let mut random = [0_u8; 32];
188    getrandom::fill(&mut random).map_err(|e| anyhow::anyhow!("random token: {e}"))?;
189    Ok(random.iter().map(|b| format!("{b:02x}")).collect())
190}
191
192/// The only URL that serves the page: it carries the session key.
193pub(crate) fn launch_url(server: &Server, token: &str) -> String {
194    format!("http://{}/?k={token}", server.server_addr())
195}
196
197/// Handle requests until the process exits.
198pub(crate) fn run(server: Arc<Server>, app: Arc<App>, token: String) -> Result<()> {
199    let address = server.server_addr().to_string();
200    let page = crate::report::render_live_page(&app.project, &token)?;
201    let session = Arc::new(Session {
202        cookie: format!("resopt_{}", address.rsplit(':').next().unwrap_or_default()),
203        origin: format!("http://{address}"),
204        address,
205        token,
206        page,
207    });
208    let workers: Vec<_> = (0..HTTP_WORKERS)
209        .map(|_| {
210            let (server, app, session) = (server.clone(), app.clone(), session.clone());
211            std::thread::spawn(move || {
212                for request in server.incoming_requests() {
213                    handle(request, &app, &session);
214                }
215            })
216        })
217        .collect();
218    for worker in workers {
219        let _ = worker.join();
220    }
221    Ok(())
222}
223
224struct Session {
225    /// Cookie names are not port-scoped, so each server uses its own.
226    cookie: String,
227    address: String,
228    origin: String,
229    token: String,
230    page: String,
231}
232
233fn json(request: Request, code: u16, value: &impl Serialize) {
234    let body = serde_json::to_vec(value)
235        .unwrap_or_else(|_| br#"{"error":"response serialization failed"}"#.to_vec());
236    respond(request, code, "application/json", body);
237}
238
239fn error(request: Request, code: u16, message: &str) {
240    json(request, code, &serde_json::json!({ "error": message }));
241}
242
243fn handle(mut request: Request, app: &Arc<App>, session: &Session) {
244    if header(&request, "Host") != Some(session.address.as_str()) {
245        return error(request, 403, "invalid Host");
246    }
247    let url = request.url().to_string();
248    let (route, query) = url.split_once('?').unwrap_or((&url, ""));
249    let get = request.method() == &Method::Get;
250    let post = request.method() == &Method::Post;
251    if !get && !post {
252        return error(
253            request,
254            405,
255            "method not allowed; this server accepts no uploads",
256        );
257    }
258    let has_cookie = header(&request, "Cookie").is_some_and(|cookies| {
259        cookies
260            .split(';')
261            .filter_map(|pair| pair.trim().split_once('='))
262            .any(|(name, value)| name == session.cookie && value == session.token)
263    });
264    if get && matches!(route, "/" | "/report.html") {
265        let has_key = query
266            .split('&')
267            .any(|pair| pair.strip_prefix("k=") == Some(session.token.as_str()));
268        if !has_key && !has_cookie {
269            return respond(
270                request,
271                403,
272                "text/plain; charset=utf-8",
273                b"Open the full URL that resopt printed in your terminal (it contains the session key).".to_vec(),
274            );
275        }
276        let cookie = format!(
277            "{}={}; HttpOnly; SameSite=Strict; Path=/",
278            session.cookie, session.token
279        );
280        return respond_with(
281            request,
282            200,
283            "text/html; charset=utf-8",
284            session.page.as_bytes().to_vec(),
285            &[("Set-Cookie", cookie.as_str())],
286        );
287    }
288    if get && route == "/favicon.ico" {
289        return respond(request, 204, "image/x-icon", vec![]);
290    }
291    if get && !route.starts_with("/api/") {
292        // Images cannot send custom headers; the session cookie authorizes them.
293        if !has_cookie && header(&request, "X-Resopt-Token") != Some(session.token.as_str()) {
294            return error(
295                request,
296                403,
297                "invalid session; open the URL printed by resopt",
298            );
299        }
300        if let Some(index) = route.strip_prefix("/source/") {
301            return serve_source(request, app, index);
302        }
303        if let Some(target) = route.strip_prefix("/animation/") {
304            return serve_animation_frame(request, app, target, query);
305        }
306        return serve_artifact(request, app, route);
307    }
308    if !route.starts_with("/api/") {
309        return error(request, 404, "not found");
310    }
311    if header(&request, "X-Resopt-Token") != Some(session.token.as_str()) {
312        return error(
313            request,
314            403,
315            "invalid session; reload the page opened by resopt",
316        );
317    }
318    if post
319        && (header(&request, "Origin") != Some(session.origin.as_str())
320            || header(&request, "Content-Type") != Some("application/json"))
321    {
322        return error(request, 403, "invalid origin or content type");
323    }
324    let result = if get {
325        api_get(app, route, query)
326    } else {
327        read_body(&mut request).and_then(|body| api_post(app, route, &body))
328    };
329    match result {
330        Ok(Some(value)) => json(request, 200, &value),
331        Ok(None) => error(request, 404, "not found"),
332        Err(failure) => {
333            let states = app.review.get().map(Review::states);
334            json(
335                request,
336                409,
337                &serde_json::json!({"error": format!("{failure:#}"), "states": states}),
338            );
339        }
340    }
341}
342
343fn read_body(request: &mut Request) -> Result<Vec<u8>> {
344    ensure!(
345        request.body_length().is_some_and(|n| n <= MAX_BODY_BYTES),
346        "request too large or missing content length"
347    );
348    let mut body = vec![];
349    request
350        .as_reader()
351        .take(MAX_BODY_BYTES as u64 + 1)
352        .read_to_end(&mut body)?;
353    ensure!(body.len() <= MAX_BODY_BYTES, "request too large");
354    Ok(body)
355}
356
357fn api_get(app: &Arc<App>, route: &str, query: &str) -> Result<Option<serde_json::Value>> {
358    Ok(Some(match route {
359        "/api/capabilities" => serde_json::to_value(crate::capabilities())?,
360        "/api/results" => {
361            let after = query
362                .split('&')
363                .find_map(|pair| pair.strip_prefix("after="))
364                .map(|value| value.parse::<usize>())
365                .transpose()
366                .context("invalid after parameter")?
367                .unwrap_or(0);
368            let live = app.live.lock().unwrap_or_else(|e| e.into_inner());
369            let rows: Vec<_> = live
370                .rows
371                .iter()
372                .skip(after)
373                .take(RESULTS_PAGE)
374                .map(|(index, row)| Row { index: *index, row })
375                .collect();
376            serde_json::to_value(ResultsPage {
377                phase: if live.error.is_some() {
378                    "failed"
379                } else if app.review.get().is_some() {
380                    "ready"
381                } else {
382                    "analyzing"
383                },
384                completed: live.rows.len(),
385                total: live.total,
386                candidates: live
387                    .rows
388                    .iter()
389                    .filter(|(_, r)| r.recommended_savings() > 0)
390                    .count(),
391                savings_bytes: live.rows.iter().map(|(_, r)| r.recommended_savings()).sum(),
392                next: after + rows.len(),
393                rows,
394                error: live.error.as_deref(),
395            })?
396        }
397        "/api/report" => crate::report::meta(&app.review()?.report),
398        "/api/state" => match app.review.get() {
399            Some(review) => review.states(),
400            None => serde_json::json!({}),
401        },
402        "/api/batch" => {
403            serde_json::to_value(&*app.batch_status.lock().unwrap_or_else(|e| e.into_inner()))?
404        }
405        _ => return Ok(None),
406    }))
407}
408
409fn api_post(app: &Arc<App>, route: &str, body: &[u8]) -> Result<Option<serde_json::Value>> {
410    match route {
411        "/api/cancel" => {
412            app.control.cancel();
413            return Ok(Some(serde_json::json!({"ok": true})));
414        }
415        "/api/batch/cancel" => {
416            app.batch_cancel.store(true, Ordering::SeqCst);
417            return Ok(Some(serde_json::json!({"ok": true})));
418        }
419        _ => {}
420    }
421    let review = app.review()?;
422    Ok(Some(match route {
423        "/api/preview" => {
424            let action: Action = serde_json::from_slice(body)?;
425            review.preview_with_warnings(
426                action.resource,
427                action.candidate.context("missing candidate")?,
428                &action.warnings(),
429            )?
430        }
431        "/api/apply" | "/api/restore" => {
432            ensure!(
433                !app.batch_running.load(Ordering::SeqCst),
434                "a batch is running; wait for it to finish or cancel it"
435            );
436            let action: Action = serde_json::from_slice(body)?;
437            if route == "/api/apply" {
438                review.apply_with_warnings(
439                    action.resource,
440                    action.candidate.context("missing candidate")?,
441                    &Approvals {
442                        lossy: action.approve_lossy,
443                        warnings: action.warnings(),
444                    },
445                    action.plan_token.as_deref(),
446                    true,
447                )?;
448            } else {
449                review.restore(action.resource)?;
450            }
451            serde_json::json!({"ok": true, "states": review.states()})
452        }
453        "/api/batch/preview" => {
454            let request: BatchRequest = serde_json::from_slice(body)?;
455            serde_json::to_value(batch::plan(review, &request.policy)?)?
456        }
457        "/api/batch/apply" => {
458            let request: BatchRequest = serde_json::from_slice(body)?;
459            let plan = batch::plan(review, &request.policy)?;
460            ensure!(
461                request.token.as_deref() == Some(plan.token.as_str()),
462                "the project or policy changed after the preview; review the batch again"
463            );
464            start_batch(app, BatchJob::Apply(plan))?;
465            serde_json::json!({"ok": true})
466        }
467        "/api/batch/restore" => {
468            let mut request: RestoreRequest = serde_json::from_slice(body)?;
469            ensure!(
470                !request.resources.is_empty(),
471                "select at least one resource to restore"
472            );
473            request.resources.sort_unstable();
474            request.resources.dedup();
475            ensure!(
476                request
477                    .resources
478                    .iter()
479                    .all(|&index| index < review.report.resources.len()),
480                "invalid resource index"
481            );
482            start_batch(app, BatchJob::Restore(Some(request.resources)))?;
483            serde_json::json!({"ok": true})
484        }
485        "/api/restore-all" => {
486            start_batch(app, BatchJob::Restore(None))?;
487            serde_json::json!({"ok": true})
488        }
489        _ => return Ok(None),
490    }))
491}
492
493/// Run an apply or restore batch on a background thread.
494fn start_batch(app: &Arc<App>, job: BatchJob) -> Result<()> {
495    ensure!(
496        !app.batch_running.swap(true, Ordering::SeqCst),
497        "another batch is already running"
498    );
499    app.batch_cancel.store(false, Ordering::SeqCst);
500    // Reset before returning, so a client that polls right after its request is
501    // accepted never reads the outcome of the previous batch.
502    *app.batch_status.lock().unwrap_or_else(|e| e.into_inner()) = BatchStatus {
503        running: true,
504        total: match &job {
505            BatchJob::Apply(plan) => plan.items.len(),
506            BatchJob::Restore(Some(resources)) => resources.len(),
507            BatchJob::Restore(None) => 0,
508        },
509        ..Default::default()
510    };
511    let app = app.clone();
512    std::thread::spawn(move || {
513        if let Some(review) = app.review.get() {
514            match job {
515                BatchJob::Apply(plan) => {
516                    batch::run(review, &plan, &app.batch_status, &app.batch_cancel)
517                }
518                BatchJob::Restore(resources) => {
519                    let status =
520                        batch::restore_many(review, resources.as_deref(), &app.batch_cancel);
521                    *app.batch_status.lock().unwrap_or_else(|e| e.into_inner()) = status;
522                }
523            }
524        }
525        app.batch_running.store(false, Ordering::SeqCst);
526    });
527    Ok(())
528}
529
530/// Report artifacts are addressed only as `<folder>/<generated-name>`.
531fn artifact_path(route: &str) -> Option<PathBuf> {
532    if route == "/analysis.json" {
533        return Some(PathBuf::from("analysis.json"));
534    }
535    let (folder, name) = route.strip_prefix('/')?.split_once('/')?;
536    let generated = !name.is_empty()
537        && name.len() <= 96
538        && name.as_bytes()[0].is_ascii_digit()
539        && name
540            .bytes()
541            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.'))
542        && !name.contains("..");
543    (matches!(folder, "previews" | "candidates" | "originals") && generated)
544        .then(|| Path::new(folder).join(name))
545}
546
547/// The project's own copy of an inventoried image, addressed by report index
548/// only, so images without candidates can still be opened at full size.
549fn serve_source(request: Request, app: &App, index: &str) {
550    let found = index.parse::<usize>().ok().and_then(|index| {
551        let live = app.live.lock().unwrap_or_else(|e| e.into_inner());
552        live.rows
553            .iter()
554            .find(|(row, _)| *row == index)
555            .filter(|(_, analysis)| analysis.resource.kind == "image")
556            .map(|(_, analysis)| {
557                (
558                    analysis.resource.path.clone(),
559                    analysis.resource.format.clone(),
560                )
561            })
562    });
563    let Some((path, format)) = found else {
564        return respond(request, 404, "text/plain", b"Not found".to_vec());
565    };
566    let media = match format.as_str() {
567        "png" => "image/png",
568        "jpeg" => "image/jpeg",
569        "webp" => "image/webp",
570        "gif" => "image/gif",
571        "heic" | "heif" => "image/heic",
572        _ => "application/octet-stream",
573    };
574    match contained_file(&app.project, &path).and_then(|p| crate::resources::bounded_read(&p)) {
575        Ok(bytes) => respond(request, 200, media, bytes),
576        Err(_) => respond(request, 404, "text/plain", b"Source unavailable".to_vec()),
577    }
578}
579
580/// Largest frame side the player may ask for.
581const MAX_FRAME_SIDE: u32 = 1024;
582
583/// `/animation/<index>/<frame>?side=N`: one rendered frame of an inventoried
584/// SVGA file, addressed by report index. Frames are rendered on demand from
585/// the project's own file, so nothing is written for playback.
586fn serve_animation_frame(request: Request, app: &App, target: &str, query: &str) {
587    let parsed = target.split_once('/').and_then(|(index, frame)| {
588        Some((index.parse::<usize>().ok()?, frame.parse::<usize>().ok()?))
589    });
590    let Some((index, frame)) = parsed else {
591        return respond(request, 404, "text/plain", b"Not found".to_vec());
592    };
593    let side = query
594        .split('&')
595        .find_map(|pair| pair.strip_prefix("side="))
596        .and_then(|value| value.parse::<u32>().ok())
597        .unwrap_or(512)
598        .clamp(16, MAX_FRAME_SIDE);
599    let rendered = animation_renderer(app, index)
600        .and_then(|renderer| crate::svga_render::encode_png(&renderer.render(frame, side)?));
601    match rendered {
602        // Frames are deterministic for a session, and the player shows each one
603        // right after preloading it, so let the browser keep them briefly.
604        Ok(png) => respond_with(
605            request,
606            200,
607            "image/png",
608            png,
609            &[("Cache-Control", "private, max-age=600")],
610        ),
611        Err(_) => respond(request, 404, "text/plain", b"Frame unavailable".to_vec()),
612    }
613}
614
615fn animation_renderer(app: &App, index: usize) -> Result<Arc<crate::svga_render::Renderer>> {
616    if let Some((cached, renderer)) = &*app.animation.lock().unwrap_or_else(|e| e.into_inner())
617        && *cached == index
618    {
619        return Ok(renderer.clone());
620    }
621    let path = {
622        let live = app.live.lock().unwrap_or_else(|e| e.into_inner());
623        live.rows
624            .iter()
625            .find(|(row, analysis)| *row == index && analysis.resource.format == "svga")
626            .map(|(_, analysis)| analysis.resource.path.clone())
627            .context("not an animation")?
628    };
629    let bytes = crate::resources::bounded_read(&contained_file(&app.project, &path)?)?;
630    let renderer = Arc::new(crate::svga_render::Renderer::new(&bytes)?);
631    *app.animation.lock().unwrap_or_else(|e| e.into_inner()) = Some((index, renderer.clone()));
632    Ok(renderer)
633}
634
635fn serve_artifact(request: Request, app: &App, route: &str) {
636    let Some(relative) = artifact_path(route) else {
637        return respond(request, 404, "text/plain", b"Not found".to_vec());
638    };
639    let media = match relative.extension().and_then(|v| v.to_str()) {
640        Some("png") => "image/png",
641        Some("jpeg" | "jpg") => "image/jpeg",
642        Some("heic") => "image/heic",
643        Some("webp") => "image/webp",
644        Some("json") => "application/json",
645        _ => "application/octet-stream",
646    };
647    match contained_file(&app.directory, &relative).and_then(|p| crate::resources::bounded_read(&p))
648    {
649        Ok(bytes) => respond(request, 200, media, bytes),
650        Err(_) => respond(request, 404, "text/plain", b"Artifact unavailable".to_vec()),
651    }
652}
653
654pub(crate) fn header<'a>(request: &'a Request, name: &str) -> Option<&'a str> {
655    request
656        .headers()
657        .iter()
658        .find(|h| h.field.to_string().eq_ignore_ascii_case(name))
659        .map(|h| h.value.as_str())
660}
661
662pub(crate) fn respond(request: Request, code: u16, media: &str, bytes: Vec<u8>) {
663    respond_with(request, code, media, bytes, &[]);
664}
665
666fn respond_with(request: Request, code: u16, media: &str, bytes: Vec<u8>, extra: &[(&str, &str)]) {
667    let mut response = Response::from_data(bytes).with_status_code(StatusCode(code));
668    for (key, value) in [
669        ("Content-Type", media),
670        ("Cache-Control", "no-store"),
671        ("X-Content-Type-Options", "nosniff"),
672        ("Referrer-Policy", "no-referrer"),
673        ("Cross-Origin-Resource-Policy", "same-origin"),
674        (
675            "Content-Security-Policy",
676            "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
677        ),
678    ]
679    .into_iter()
680    // A caller-supplied header replaces the default of the same name.
681    .filter(|(key, _)| !extra.iter().any(|(name, _)| name.eq_ignore_ascii_case(key)))
682    .chain(extra.iter().copied())
683    {
684        if let Ok(header) = Header::from_bytes(key, value) {
685            response.add_header(header);
686        }
687    }
688    let _ = request.respond(response);
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    #[test]
696    fn only_generated_artifact_names_are_served() {
697        for route in [
698            "/previews/12-webp-85.png",
699            "/candidates/0-png-0.png",
700            "/originals/7.heic",
701            "/analysis.json",
702        ] {
703            assert!(artifact_path(route).is_some(), "{route}");
704        }
705        for route in [
706            "/previews/../analysis.json",
707            "/previews/..%2f..%2fetc",
708            "/operations/0/transaction.json",
709            "/previews/",
710            "/previews/a/b.png",
711            "/candidates/x.png",
712            "/previews/1\\..\\x",
713            "//etc/passwd",
714            "/report.html/../x",
715        ] {
716            assert!(artifact_path(route).is_none(), "{route}");
717        }
718    }
719
720    #[test]
721    fn legacy_alpha_flag_maps_to_both_alpha_warnings() {
722        let action: Action =
723            serde_json::from_str(r#"{"resource":0,"candidate":1,"approve_alpha_loss":true}"#)
724                .unwrap();
725        assert_eq!(
726            action.warnings(),
727            [
728                "alpha_error_exceeds_policy",
729                "transparency_presence_changed"
730            ]
731        );
732        assert!(serde_json::from_str::<Action>(r#"{"resource":0,"path":"/etc/passwd"}"#).is_err());
733    }
734}