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/// Analysis progress shared between the worker thread and HTTP handlers.
76#[derive(Default)]
77pub(crate) struct Live {
78    /// Completed rows in completion order, tagged with their report index.
79    pub rows: Vec<(usize, ResourceAnalysis)>,
80    pub total: usize,
81    pub error: Option<String>,
82    pub cancelled: bool,
83}
84
85#[derive(Serialize)]
86struct ResultsPage<'a> {
87    phase: &'static str,
88    completed: usize,
89    total: usize,
90    candidates: usize,
91    savings_bytes: u64,
92    next: usize,
93    rows: Vec<Row<'a>>,
94    error: Option<&'a str>,
95}
96#[derive(Serialize)]
97struct Row<'a> {
98    index: usize,
99    row: &'a ResourceAnalysis,
100}
101
102pub(crate) struct App {
103    pub directory: PathBuf,
104    pub project: PathBuf,
105    pub live: Mutex<Live>,
106    pub control: AnalysisControl,
107    /// Set once analysis has finished and the report passed validation.
108    pub review: OnceLock<Review>,
109    batch_status: Mutex<BatchStatus>,
110    batch_cancel: AtomicBool,
111    batch_running: AtomicBool,
112}
113
114impl App {
115    pub fn new(directory: PathBuf, project: PathBuf) -> Self {
116        Self {
117            directory,
118            project,
119            live: Mutex::new(Live::default()),
120            control: AnalysisControl::default(),
121            review: OnceLock::new(),
122            batch_status: Mutex::new(BatchStatus::default()),
123            batch_cancel: AtomicBool::new(false),
124            batch_running: AtomicBool::new(false),
125        }
126    }
127
128    /// Publish a finished report: its rows replace the live rows.
129    pub fn finish(&self, review: Review) {
130        {
131            let mut live = self.live.lock().unwrap_or_else(|e| e.into_inner());
132            live.total = review.report.resources.len();
133            live.cancelled = review.report.cancelled;
134            live.rows = review
135                .report
136                .resources
137                .iter()
138                .cloned()
139                .enumerate()
140                .collect();
141        }
142        let _ = self.review.set(review);
143    }
144
145    fn review(&self) -> Result<&Review> {
146        self.review
147            .get()
148            .context("analysis is still running; changes can be applied once it completes")
149    }
150}
151
152/// Serve an existing analysis on loopback. Port 0 chooses an available port.
153/// The printed URL is the entry point; terminate the process to stop serving.
154pub fn serve(directory: impl AsRef<Path>, port: u16) -> Result<()> {
155    let server = Server::http(("127.0.0.1", port)).map_err(|e| anyhow::anyhow!("{e}"))?;
156    let review = Review::open(directory.as_ref())?;
157    let app = Arc::new(App::new(
158        review.directory.clone(),
159        review.report.root.clone(),
160    ));
161    app.finish(review);
162    let token = session_token()?;
163    println!(
164        "Review server: {}\nProject: {}\nStop with Ctrl-C. Sources change only after an explicit Apply request.",
165        launch_url(&server, &token),
166        app.project.display()
167    );
168    std::io::stdout().flush()?;
169    run(Arc::new(server), app, token)
170}
171
172pub(crate) fn session_token() -> Result<String> {
173    let mut random = [0_u8; 32];
174    getrandom::fill(&mut random).map_err(|e| anyhow::anyhow!("random token: {e}"))?;
175    Ok(random.iter().map(|b| format!("{b:02x}")).collect())
176}
177
178/// The only URL that serves the page: it carries the session key.
179pub(crate) fn launch_url(server: &Server, token: &str) -> String {
180    format!("http://{}/?k={token}", server.server_addr())
181}
182
183/// Handle requests until the process exits.
184pub(crate) fn run(server: Arc<Server>, app: Arc<App>, token: String) -> Result<()> {
185    let address = server.server_addr().to_string();
186    let page = crate::report::render_live_page(&app.project, &token)?;
187    let session = Arc::new(Session {
188        cookie: format!("resopt_{}", address.rsplit(':').next().unwrap_or_default()),
189        origin: format!("http://{address}"),
190        address,
191        token,
192        page,
193    });
194    let workers: Vec<_> = (0..HTTP_WORKERS)
195        .map(|_| {
196            let (server, app, session) = (server.clone(), app.clone(), session.clone());
197            std::thread::spawn(move || {
198                for request in server.incoming_requests() {
199                    handle(request, &app, &session);
200                }
201            })
202        })
203        .collect();
204    for worker in workers {
205        let _ = worker.join();
206    }
207    Ok(())
208}
209
210struct Session {
211    /// Cookie names are not port-scoped, so each server uses its own.
212    cookie: String,
213    address: String,
214    origin: String,
215    token: String,
216    page: String,
217}
218
219fn json(request: Request, code: u16, value: &impl Serialize) {
220    let body = serde_json::to_vec(value)
221        .unwrap_or_else(|_| br#"{"error":"response serialization failed"}"#.to_vec());
222    respond(request, code, "application/json", body);
223}
224
225fn error(request: Request, code: u16, message: &str) {
226    json(request, code, &serde_json::json!({ "error": message }));
227}
228
229fn handle(mut request: Request, app: &Arc<App>, session: &Session) {
230    if header(&request, "Host") != Some(session.address.as_str()) {
231        return error(request, 403, "invalid Host");
232    }
233    let url = request.url().to_string();
234    let (route, query) = url.split_once('?').unwrap_or((&url, ""));
235    let get = request.method() == &Method::Get;
236    let post = request.method() == &Method::Post;
237    if !get && !post {
238        return error(
239            request,
240            405,
241            "method not allowed; this server accepts no uploads",
242        );
243    }
244    let has_cookie = header(&request, "Cookie").is_some_and(|cookies| {
245        cookies
246            .split(';')
247            .filter_map(|pair| pair.trim().split_once('='))
248            .any(|(name, value)| name == session.cookie && value == session.token)
249    });
250    if get && matches!(route, "/" | "/report.html") {
251        let has_key = query
252            .split('&')
253            .any(|pair| pair.strip_prefix("k=") == Some(session.token.as_str()));
254        if !has_key && !has_cookie {
255            return respond(
256                request,
257                403,
258                "text/plain; charset=utf-8",
259                b"Open the full URL that resopt printed in your terminal (it contains the session key).".to_vec(),
260            );
261        }
262        let cookie = format!(
263            "{}={}; HttpOnly; SameSite=Strict; Path=/",
264            session.cookie, session.token
265        );
266        return respond_with(
267            request,
268            200,
269            "text/html; charset=utf-8",
270            session.page.as_bytes().to_vec(),
271            &[("Set-Cookie", cookie.as_str())],
272        );
273    }
274    if get && route == "/favicon.ico" {
275        return respond(request, 204, "image/x-icon", vec![]);
276    }
277    if get && !route.starts_with("/api/") {
278        // Images cannot send custom headers; the session cookie authorizes them.
279        if !has_cookie && header(&request, "X-Resopt-Token") != Some(session.token.as_str()) {
280            return error(
281                request,
282                403,
283                "invalid session; open the URL printed by resopt",
284            );
285        }
286        return serve_artifact(request, app, route);
287    }
288    if !route.starts_with("/api/") {
289        return error(request, 404, "not found");
290    }
291    if header(&request, "X-Resopt-Token") != Some(session.token.as_str()) {
292        return error(
293            request,
294            403,
295            "invalid session; reload the page opened by resopt",
296        );
297    }
298    if post
299        && (header(&request, "Origin") != Some(session.origin.as_str())
300            || header(&request, "Content-Type") != Some("application/json"))
301    {
302        return error(request, 403, "invalid origin or content type");
303    }
304    let result = if get {
305        api_get(app, route, query)
306    } else {
307        read_body(&mut request).and_then(|body| api_post(app, route, &body))
308    };
309    match result {
310        Ok(Some(value)) => json(request, 200, &value),
311        Ok(None) => error(request, 404, "not found"),
312        Err(failure) => {
313            let states = app.review.get().map(Review::states);
314            json(
315                request,
316                409,
317                &serde_json::json!({"error": format!("{failure:#}"), "states": states}),
318            );
319        }
320    }
321}
322
323fn read_body(request: &mut Request) -> Result<Vec<u8>> {
324    ensure!(
325        request.body_length().is_some_and(|n| n <= MAX_BODY_BYTES),
326        "request too large or missing content length"
327    );
328    let mut body = vec![];
329    request
330        .as_reader()
331        .take(MAX_BODY_BYTES as u64 + 1)
332        .read_to_end(&mut body)?;
333    ensure!(body.len() <= MAX_BODY_BYTES, "request too large");
334    Ok(body)
335}
336
337fn api_get(app: &Arc<App>, route: &str, query: &str) -> Result<Option<serde_json::Value>> {
338    Ok(Some(match route {
339        "/api/capabilities" => serde_json::to_value(crate::capabilities())?,
340        "/api/results" => {
341            let after = query
342                .split('&')
343                .find_map(|pair| pair.strip_prefix("after="))
344                .map(|value| value.parse::<usize>())
345                .transpose()
346                .context("invalid after parameter")?
347                .unwrap_or(0);
348            let live = app.live.lock().unwrap_or_else(|e| e.into_inner());
349            let rows: Vec<_> = live
350                .rows
351                .iter()
352                .skip(after)
353                .take(RESULTS_PAGE)
354                .map(|(index, row)| Row { index: *index, row })
355                .collect();
356            serde_json::to_value(ResultsPage {
357                phase: if live.error.is_some() {
358                    "failed"
359                } else if app.review.get().is_some() {
360                    "ready"
361                } else {
362                    "analyzing"
363                },
364                completed: live.rows.len(),
365                total: live.total,
366                candidates: live
367                    .rows
368                    .iter()
369                    .filter(|(_, r)| r.recommended_savings() > 0)
370                    .count(),
371                savings_bytes: live.rows.iter().map(|(_, r)| r.recommended_savings()).sum(),
372                next: after + rows.len(),
373                rows,
374                error: live.error.as_deref(),
375            })?
376        }
377        "/api/report" => crate::report::meta(&app.review()?.report),
378        "/api/state" => match app.review.get() {
379            Some(review) => review.states(),
380            None => serde_json::json!({}),
381        },
382        "/api/batch" => {
383            serde_json::to_value(&*app.batch_status.lock().unwrap_or_else(|e| e.into_inner()))?
384        }
385        _ => return Ok(None),
386    }))
387}
388
389fn api_post(app: &Arc<App>, route: &str, body: &[u8]) -> Result<Option<serde_json::Value>> {
390    match route {
391        "/api/cancel" => {
392            app.control.cancel();
393            return Ok(Some(serde_json::json!({"ok": true})));
394        }
395        "/api/batch/cancel" => {
396            app.batch_cancel.store(true, Ordering::SeqCst);
397            return Ok(Some(serde_json::json!({"ok": true})));
398        }
399        _ => {}
400    }
401    let review = app.review()?;
402    Ok(Some(match route {
403        "/api/preview" => {
404            let action: Action = serde_json::from_slice(body)?;
405            review.preview_with_warnings(
406                action.resource,
407                action.candidate.context("missing candidate")?,
408                &action.warnings(),
409            )?
410        }
411        "/api/apply" | "/api/restore" => {
412            ensure!(
413                !app.batch_running.load(Ordering::SeqCst),
414                "a batch is running; wait for it to finish or cancel it"
415            );
416            let action: Action = serde_json::from_slice(body)?;
417            if route == "/api/apply" {
418                review.apply_with_warnings(
419                    action.resource,
420                    action.candidate.context("missing candidate")?,
421                    &Approvals {
422                        lossy: action.approve_lossy,
423                        warnings: action.warnings(),
424                    },
425                    action.plan_token.as_deref(),
426                    true,
427                )?;
428            } else {
429                review.restore(action.resource)?;
430            }
431            serde_json::json!({"ok": true, "states": review.states()})
432        }
433        "/api/batch/preview" => {
434            let request: BatchRequest = serde_json::from_slice(body)?;
435            serde_json::to_value(batch::plan(review, &request.policy)?)?
436        }
437        "/api/batch/apply" => {
438            let request: BatchRequest = serde_json::from_slice(body)?;
439            let plan = batch::plan(review, &request.policy)?;
440            ensure!(
441                request.token.as_deref() == Some(plan.token.as_str()),
442                "the project or policy changed after the preview; review the batch again"
443            );
444            start_batch(app, Some(plan))?;
445            serde_json::json!({"ok": true})
446        }
447        "/api/restore-all" => {
448            start_batch(app, None)?;
449            serde_json::json!({"ok": true})
450        }
451        _ => return Ok(None),
452    }))
453}
454
455/// Run a batch (or restore-all when `plan` is `None`) on a background thread.
456fn start_batch(app: &Arc<App>, plan: Option<BatchPlan>) -> Result<()> {
457    ensure!(
458        !app.batch_running.swap(true, Ordering::SeqCst),
459        "another batch is already running"
460    );
461    app.batch_cancel.store(false, Ordering::SeqCst);
462    // Reset before returning, so a client that polls right after its request is
463    // accepted never reads the outcome of the previous batch.
464    *app.batch_status.lock().unwrap_or_else(|e| e.into_inner()) = BatchStatus {
465        running: true,
466        total: plan.as_ref().map_or(0, |p| p.items.len()),
467        ..Default::default()
468    };
469    let app = app.clone();
470    std::thread::spawn(move || {
471        if let Some(review) = app.review.get() {
472            match plan {
473                Some(plan) => batch::run(review, &plan, &app.batch_status, &app.batch_cancel),
474                None => {
475                    let status = batch::restore_all(review, &app.batch_cancel);
476                    *app.batch_status.lock().unwrap_or_else(|e| e.into_inner()) = status;
477                }
478            }
479        }
480        app.batch_running.store(false, Ordering::SeqCst);
481    });
482    Ok(())
483}
484
485/// Report artifacts are addressed only as `<folder>/<generated-name>`.
486fn artifact_path(route: &str) -> Option<PathBuf> {
487    if route == "/analysis.json" {
488        return Some(PathBuf::from("analysis.json"));
489    }
490    let (folder, name) = route.strip_prefix('/')?.split_once('/')?;
491    let generated = !name.is_empty()
492        && name.len() <= 96
493        && name.as_bytes()[0].is_ascii_digit()
494        && name
495            .bytes()
496            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.'))
497        && !name.contains("..");
498    (matches!(folder, "previews" | "candidates" | "originals") && generated)
499        .then(|| Path::new(folder).join(name))
500}
501
502fn serve_artifact(request: Request, app: &App, route: &str) {
503    let Some(relative) = artifact_path(route) else {
504        return respond(request, 404, "text/plain", b"Not found".to_vec());
505    };
506    let media = match relative.extension().and_then(|v| v.to_str()) {
507        Some("png") => "image/png",
508        Some("jpeg" | "jpg") => "image/jpeg",
509        Some("heic") => "image/heic",
510        Some("webp") => "image/webp",
511        Some("json") => "application/json",
512        _ => "application/octet-stream",
513    };
514    match contained_file(&app.directory, &relative).and_then(|p| crate::resources::bounded_read(&p))
515    {
516        Ok(bytes) => respond(request, 200, media, bytes),
517        Err(_) => respond(request, 404, "text/plain", b"Artifact unavailable".to_vec()),
518    }
519}
520
521pub(crate) fn header<'a>(request: &'a Request, name: &str) -> Option<&'a str> {
522    request
523        .headers()
524        .iter()
525        .find(|h| h.field.to_string().eq_ignore_ascii_case(name))
526        .map(|h| h.value.as_str())
527}
528
529pub(crate) fn respond(request: Request, code: u16, media: &str, bytes: Vec<u8>) {
530    respond_with(request, code, media, bytes, &[]);
531}
532
533fn respond_with(request: Request, code: u16, media: &str, bytes: Vec<u8>, extra: &[(&str, &str)]) {
534    let mut response = Response::from_data(bytes).with_status_code(StatusCode(code));
535    for (key, value) in [
536        ("Content-Type", media),
537        ("Cache-Control", "no-store"),
538        ("X-Content-Type-Options", "nosniff"),
539        ("Referrer-Policy", "no-referrer"),
540        ("Cross-Origin-Resource-Policy", "same-origin"),
541        (
542            "Content-Security-Policy",
543            "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'",
544        ),
545    ]
546    .into_iter()
547    .chain(extra.iter().copied())
548    {
549        if let Ok(header) = Header::from_bytes(key, value) {
550            response.add_header(header);
551        }
552    }
553    let _ = request.respond(response);
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn only_generated_artifact_names_are_served() {
562        for route in [
563            "/previews/12-webp-85.png",
564            "/candidates/0-png-0.png",
565            "/originals/7.heic",
566            "/analysis.json",
567        ] {
568            assert!(artifact_path(route).is_some(), "{route}");
569        }
570        for route in [
571            "/previews/../analysis.json",
572            "/previews/..%2f..%2fetc",
573            "/operations/0/transaction.json",
574            "/previews/",
575            "/previews/a/b.png",
576            "/candidates/x.png",
577            "/previews/1\\..\\x",
578            "//etc/passwd",
579            "/report.html/../x",
580        ] {
581            assert!(artifact_path(route).is_none(), "{route}");
582        }
583    }
584
585    #[test]
586    fn legacy_alpha_flag_maps_to_both_alpha_warnings() {
587        let action: Action =
588            serde_json::from_str(r#"{"resource":0,"candidate":1,"approve_alpha_loss":true}"#)
589                .unwrap();
590        assert_eq!(
591            action.warnings(),
592            [
593                "alpha_error_exceeds_policy",
594                "transparency_presence_changed"
595            ]
596        );
597        assert!(serde_json::from_str::<Action>(r#"{"resource":0,"path":"/etc/passwd"}"#).is_err());
598    }
599}