Skip to main content

capsula_server/
lib.rs

1use askama::Template;
2use askama_web::WebTemplate;
3
4#[expect(
5    clippy::unnecessary_wraps,
6    reason = "Askama filter_fn macro generates code that triggers this lint, but Result return type is required by the Askama filter API"
7)]
8#[expect(
9    clippy::inline_always,
10    clippy::unused_self,
11    reason = "Askama's #[filter_fn] macro generates builder pattern code with #[inline(always)] that triggers these lints"
12)]
13mod filters {
14    use askama::filter_fn;
15
16    #[filter_fn]
17    pub fn format_command(s: &str, _env: &dyn askama::Values) -> ::askama::Result<String> {
18        // Try to parse as JSON array
19        Ok(serde_json::from_str::<Vec<String>>(s).map_or_else(
20            |_| s.to_string(),
21            |cmd_array| {
22                shlex::try_join(cmd_array.iter().map(String::as_str))
23                    .unwrap_or_else(|_| cmd_array.join(" "))
24            },
25        ))
26    }
27
28    #[filter_fn]
29    pub fn pretty_json<T: std::fmt::Display>(
30        s: T,
31        _env: &dyn askama::Values,
32    ) -> ::askama::Result<String> {
33        let s_str = s.to_string();
34        // Try to parse as JSON and pretty-print with 2-space indentation
35        Ok(serde_json::from_str::<serde_json::Value>(&s_str)
36            .ok()
37            .and_then(|value| serde_json::to_string_pretty(&value).ok())
38            .unwrap_or(s_str))
39    }
40}
41
42use axum::{
43    Router,
44    body::Body,
45    extract::{DefaultBodyLimit, Multipart, Path, Query, State},
46    http::{StatusCode, header},
47    response::{IntoResponse, Json, Response},
48    routing::{get, post},
49};
50use capsula_api_types::VaultInfo;
51use capsula_core::util::hex_encode;
52use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
53use serde_json::json;
54use sha2::{Digest, Sha256};
55use sqlx::PgPool;
56use std::collections::VecDeque;
57use std::path::{Component, Path as StdPath, PathBuf};
58use tracing::{error, info, warn};
59
60// RFC 5987 `attr-char` set: ALPHA / DIGIT plus `!#$&+-.^_`|~`. Everything else
61// must be percent-encoded when used in a `filename*=UTF-8''...` parameter.
62const RFC5987_ATTR_CHAR: &AsciiSet = &NON_ALPHANUMERIC
63    .remove(b'!')
64    .remove(b'#')
65    .remove(b'$')
66    .remove(b'&')
67    .remove(b'+')
68    .remove(b'-')
69    .remove(b'.')
70    .remove(b'^')
71    .remove(b'_')
72    .remove(b'`')
73    .remove(b'|')
74    .remove(b'~');
75
76#[derive(Clone)]
77pub struct AppState {
78    pub pool: PgPool,
79    pub storage_path: PathBuf,
80}
81
82mod models;
83mod query;
84
85#[derive(Template, WebTemplate)]
86#[template(path = "index.html")]
87struct IndexTemplate;
88
89#[derive(Template, WebTemplate)]
90#[template(path = "vaults.html")]
91struct VaultsTemplate {
92    vaults: Vec<VaultInfo>,
93}
94
95#[derive(Template, WebTemplate)]
96#[template(path = "runs.html")]
97struct RunsTemplate {
98    runs: Vec<models::Run>,
99    vault: Option<String>,
100    page: i64,
101    total_pages: i64,
102}
103
104#[derive(Template, WebTemplate)]
105#[template(path = "run_detail.html")]
106struct RunDetailTemplate {
107    run: models::Run,
108    pre_run_hooks: Vec<models::HookOutput>,
109    post_run_hooks: Vec<models::HookOutput>,
110    files: Vec<models::CapturedFile>,
111}
112
113#[derive(Template, WebTemplate)]
114#[template(path = "error.html")]
115struct ErrorTemplate {
116    status_code: u16,
117    title: String,
118    message: String,
119}
120
121pub async fn create_pool(database_url: &str, max_connections: u32) -> Result<PgPool, sqlx::Error> {
122    sqlx::postgres::PgPoolOptions::new()
123        .max_connections(max_connections)
124        .acquire_timeout(std::time::Duration::from_secs(3))
125        .connect(database_url)
126        .await
127}
128
129pub fn build_app(pool: PgPool, storage_path: PathBuf, max_body_size: usize) -> Router {
130    let state = AppState { pool, storage_path };
131
132    Router::new()
133        .route("/", get(index))
134        .route("/vaults", get(vaults_page))
135        .route("/runs", get(runs_page))
136        .route("/runs/{id}", get(run_detail_page))
137        .route("/health", get(health_check))
138        .route("/api/v1/vaults", get(list_vaults))
139        .route("/api/v1/vaults/{name}", get(get_vault_info))
140        .route("/api/v1/runs", post(create_run).get(list_runs))
141        .route("/api/v1/runs/search", post(search_runs))
142        .route("/api/v1/runs/{id}", get(get_run))
143        .route("/api/v1/runs/{id}/files/{*path}", get(download_file))
144        .route(
145            "/api/v1/upload",
146            post(upload_files).layer(DefaultBodyLimit::max(max_body_size)),
147        )
148        .route("/static/style.css", get(serve_style_css))
149        .fallback(not_found)
150        .with_state(state)
151}
152
153async fn serve_style_css() -> impl IntoResponse {
154    (
155        [(header::CONTENT_TYPE, mime_guess::mime::TEXT_CSS.as_ref())],
156        include_str!("../static/style.css"),
157    )
158}
159
160/// Serialize a handler's response body into a `Json<Value>`, falling back to an
161/// error envelope if `serde_json::to_value` fails instead of panicking.
162///
163/// In practice `to_value` does not fail for any of our `#[derive(Serialize)]`
164/// API response structs, but axum handlers should not abort the task on a
165/// should-be-impossible error — they should return a 500 body the client can
166/// parse.
167fn json_response<T: serde::Serialize>(value: T) -> Json<serde_json::Value> {
168    Json(serde_json::to_value(value).unwrap_or_else(|e| {
169        error!("Failed to serialize response body: {e}");
170        json!({
171            "status": "error",
172            "error": "response serialization failed"
173        })
174    }))
175}
176
177/// Fetch the list of vaults with run counts, sorted by name.
178///
179/// Shared by the `/vaults` HTML page and the `/api/v1/vaults` JSON endpoint.
180async fn fetch_vault_list(pool: &PgPool) -> Result<Vec<VaultInfo>, sqlx::Error> {
181    let rows = sqlx::query!(
182        r#"
183        SELECT vault as name, COUNT(*) as "run_count!"
184        FROM runs
185        GROUP BY vault
186        ORDER BY vault
187        "#
188    )
189    .fetch_all(pool)
190    .await?;
191
192    Ok(rows
193        .into_iter()
194        .map(|row| VaultInfo {
195            name: row.name,
196            run_count: row.run_count,
197        })
198        .collect())
199}
200
201/// Fetch a page of runs, optionally filtered by vault, newest first.
202///
203/// Shared by the `/runs` HTML page and the `/api/v1/runs` JSON endpoint.
204async fn fetch_runs(
205    pool: &PgPool,
206    vault: Option<&str>,
207    limit: i64,
208    offset: i64,
209) -> Result<Vec<models::Run>, sqlx::Error> {
210    if let Some(vault) = vault {
211        sqlx::query_as!(
212            models::Run,
213            r#"
214            SELECT id, name, timestamp, command, vault, project_root,
215                   exit_code, duration_ms, stdout, stderr,
216                   created_at, updated_at
217            FROM runs
218            WHERE vault = $1
219            ORDER BY timestamp DESC
220            LIMIT $2 OFFSET $3
221            "#,
222            vault,
223            limit,
224            offset
225        )
226        .fetch_all(pool)
227        .await
228    } else {
229        sqlx::query_as!(
230            models::Run,
231            r#"
232            SELECT id, name, timestamp, command, vault, project_root,
233                   exit_code, duration_ms, stdout, stderr,
234                   created_at, updated_at
235            FROM runs
236            ORDER BY timestamp DESC
237            LIMIT $1 OFFSET $2
238            "#,
239            limit,
240            offset
241        )
242        .fetch_all(pool)
243        .await
244    }
245}
246
247/// Count the total number of runs, optionally filtered by vault.
248async fn count_runs(pool: &PgPool, vault: Option<&str>) -> Result<i64, sqlx::Error> {
249    if let Some(vault) = vault {
250        sqlx::query_scalar!(
251            r#"
252            SELECT COUNT(*)::bigint
253            FROM runs
254            WHERE vault = $1
255            "#,
256            vault
257        )
258        .fetch_one(pool)
259        .await
260        .map(|v| v.unwrap_or(0))
261    } else {
262        sqlx::query_scalar!(
263            r#"
264            SELECT COUNT(*)::bigint
265            FROM runs
266            "#
267        )
268        .fetch_one(pool)
269        .await
270        .map(|v| v.unwrap_or(0))
271    }
272}
273
274/// Fetch a single run by its id.
275///
276/// Shared by the run-detail HTML page and the `/api/v1/runs/{id}` JSON endpoint.
277async fn fetch_run_by_id(pool: &PgPool, id: &str) -> Result<Option<models::Run>, sqlx::Error> {
278    sqlx::query_as!(
279        models::Run,
280        r#"
281        SELECT id, name, timestamp, command, vault, project_root,
282               exit_code, duration_ms, stdout, stderr,
283               created_at, updated_at
284        FROM runs
285        WHERE id = $1
286        "#,
287        id
288    )
289    .fetch_optional(pool)
290    .await
291}
292
293async fn index() -> impl IntoResponse {
294    IndexTemplate
295}
296
297async fn not_found() -> impl IntoResponse {
298    (
299        StatusCode::NOT_FOUND,
300        ErrorTemplate {
301            status_code: 404,
302            title: "Page Not Found".to_string(),
303            message: "The page you are looking for does not exist.".to_string(),
304        },
305    )
306}
307
308async fn vaults_page(State(state): State<AppState>) -> impl IntoResponse {
309    info!("Rendering vaults page");
310
311    let vaults = fetch_vault_list(&state.pool).await.unwrap_or_else(|e| {
312        error!("Failed to fetch vaults: {}", e);
313        Vec::new()
314    });
315    VaultsTemplate { vaults }
316}
317
318async fn runs_page(
319    State(state): State<AppState>,
320    Query(params): Query<models::ListRunsQuery>,
321) -> impl IntoResponse {
322    let page = params.offset.unwrap_or(0) / params.limit.unwrap_or(50) + 1;
323    let limit = params.limit.unwrap_or(50);
324    let offset = (page - 1) * limit;
325
326    info!(
327        "Rendering runs page: page={}, vault={:?}",
328        page, params.vault
329    );
330
331    let total_count = count_runs(&state.pool, params.vault.as_deref())
332        .await
333        .unwrap_or(0);
334
335    let total_pages = (total_count + limit - 1) / limit;
336
337    let runs_result = fetch_runs(&state.pool, params.vault.as_deref(), limit, offset).await;
338
339    match runs_result {
340        Ok(runs) => RunsTemplate {
341            runs,
342            vault: params.vault,
343            page,
344            total_pages,
345        },
346        Err(e) => {
347            error!("Failed to fetch runs: {}", e);
348            RunsTemplate {
349                runs: Vec::new(),
350                vault: params.vault,
351                page: 1,
352                total_pages: 1,
353            }
354        }
355    }
356}
357
358async fn run_detail_page(
359    State(state): State<AppState>,
360    Path(id): Path<String>,
361) -> Result<RunDetailTemplate, StatusCode> {
362    info!("Rendering run detail page for: {}", id);
363
364    let run = fetch_run_by_id(&state.pool, &id)
365        .await
366        .map_err(|e| {
367            error!("Database error while fetching run: {}", e);
368            StatusCode::INTERNAL_SERVER_ERROR
369        })?
370        .ok_or_else(|| {
371            info!("Run not found: {}", id);
372            StatusCode::NOT_FOUND
373        })?;
374
375    // Fetch hook outputs
376    let hook_outputs_result = sqlx::query_as!(
377        models::RunOutputRow,
378        r#"
379        SELECT phase, hook_id, config, output, success, error
380        FROM run_outputs
381        WHERE run_id = $1
382        ORDER BY id
383        "#,
384        id
385    )
386    .fetch_all(&state.pool)
387    .await;
388
389    let (pre_run_hooks, post_run_hooks) = match hook_outputs_result {
390        Ok(rows) => {
391            let mut pre_hooks = Vec::new();
392            let mut post_hooks = Vec::new();
393
394            for row in rows {
395                let hook_output = models::HookOutput {
396                    meta: models::HookMeta {
397                        id: row.hook_id,
398                        config: row.config,
399                        success: row.success,
400                        error: row.error,
401                    },
402                    output: row.output,
403                };
404
405                if row.phase == "pre" {
406                    pre_hooks.push(hook_output);
407                } else if row.phase == "post" {
408                    post_hooks.push(hook_output);
409                } else {
410                    warn!("Unknown hook phase: {}", row.phase);
411                }
412            }
413
414            (pre_hooks, post_hooks)
415        }
416        Err(e) => {
417            error!("Failed to fetch hook outputs: {}", e);
418            (Vec::new(), Vec::new())
419        }
420    };
421
422    // Fetch captured files
423    let files_result = sqlx::query_as!(
424        models::CapturedFile,
425        r#"
426        SELECT path, size, hash, storage_path, content_type
427        FROM captured_files
428        WHERE run_id = $1
429        ORDER BY path
430        "#,
431        id
432    )
433    .fetch_all(&state.pool)
434    .await;
435
436    let files = match files_result {
437        Ok(files) => files,
438        Err(e) => {
439            error!("Failed to fetch captured files: {}", e);
440            Vec::new()
441        }
442    };
443
444    Ok(RunDetailTemplate {
445        run,
446        pre_run_hooks,
447        post_run_hooks,
448        files,
449    })
450}
451
452async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
453    match sqlx::query("SELECT 1").fetch_one(&state.pool).await {
454        Ok(_) => Json(json!({
455            "status": "ok",
456            "database": "connected"
457        })),
458        Err(e) => Json(json!({
459            "status": "error",
460            "database": "disconnected",
461            "error": e.to_string()
462        })),
463    }
464}
465
466async fn list_vaults(State(state): State<AppState>) -> impl IntoResponse {
467    info!("Listing all vaults");
468
469    match fetch_vault_list(&state.pool).await {
470        Ok(vaults) => {
471            info!("Found {} vaults", vaults.len());
472            json_response(capsula_api_types::VaultsResponse {
473                status: "ok".to_string(),
474                vaults,
475            })
476        }
477        Err(e) => {
478            error!("Failed to list vaults: {}", e);
479            json_response(capsula_api_types::ErrorResponse {
480                status: "error".to_string(),
481                error: e.to_string(),
482            })
483        }
484    }
485}
486
487async fn get_vault_info(
488    State(state): State<AppState>,
489    Path(name): Path<String>,
490) -> impl IntoResponse {
491    info!("Getting vault info: {}", name);
492
493    let result = sqlx::query!(
494        r#"
495        SELECT vault as name, COUNT(*) as "run_count!"
496        FROM runs
497        WHERE vault = $1
498        GROUP BY vault
499        "#,
500        name
501    )
502    .fetch_optional(&state.pool)
503    .await;
504
505    match result {
506        Ok(Some(row)) => {
507            let vault = VaultInfo {
508                name: row.name,
509                run_count: row.run_count,
510            };
511            info!("Found vault: {} with {} runs", vault.name, vault.run_count);
512            let response = capsula_api_types::VaultExistsResponse {
513                status: "ok".to_string(),
514                exists: true,
515                vault: Some(vault),
516            };
517            json_response(response)
518        }
519        Ok(None) => {
520            info!("Vault not found: {}", name);
521            let response = capsula_api_types::VaultExistsResponse {
522                status: "ok".to_string(),
523                exists: false,
524                vault: None,
525            };
526            json_response(response)
527        }
528        Err(e) => {
529            error!("Failed to get vault info: {}", e);
530            let response = capsula_api_types::ErrorResponse {
531                status: "error".to_string(),
532                error: e.to_string(),
533            };
534            json_response(response)
535        }
536    }
537}
538
539async fn list_runs(
540    State(state): State<AppState>,
541    Query(params): Query<models::ListRunsQuery>,
542) -> impl IntoResponse {
543    let limit = params.limit.unwrap_or(100);
544    let offset = params.offset.unwrap_or(0);
545
546    if let Some(ref vault) = params.vault {
547        info!(
548            "Listing runs for vault: {} (limit={}, offset={})",
549            vault, limit, offset
550        );
551    } else {
552        info!("Listing all runs (limit={}, offset={})", limit, offset);
553    }
554
555    let result = fetch_runs(&state.pool, params.vault.as_deref(), limit, offset).await;
556
557    match result {
558        Ok(runs) => {
559            info!("Found {} runs", runs.len());
560            Json(json!({
561                "status": "ok",
562                "runs": runs,
563                "limit": limit,
564                "offset": offset
565            }))
566        }
567        Err(e) => {
568            error!("Failed to list runs: {}", e);
569            Json(json!({
570                "status": "error",
571                "error": e.to_string()
572            }))
573        }
574    }
575}
576
577/// Search runs with `JSONPath` filters on hook outputs
578#[expect(clippy::too_many_lines, reason = "TODO: Refactor later")]
579async fn search_runs(
580    State(state): State<AppState>,
581    Json(request): Json<models::SearchRunsRequest>,
582) -> impl IntoResponse {
583    info!(
584        "Searching runs: vault={:?}, hook_filters={}",
585        request.vault,
586        request.hook_filters.len()
587    );
588
589    // Build the query
590    let builder = match query::RunQueryBuilder::from_request(&request) {
591        Ok(b) => b,
592        Err(e) => {
593            error!("Failed to build query: {}", e);
594            return Json(json!({
595                "status": "error",
596                "error": e.to_string()
597            }));
598        }
599    };
600
601    let query_sql = builder.build_query();
602    let count_sql = builder.build_count_query();
603    let bind_values = builder.bind_values();
604
605    info!("Executing search query: {}", query_sql);
606
607    // Execute count query first
608    let total: i64 = {
609        let mut query = sqlx::query_scalar::<_, i64>(&count_sql);
610        for value in bind_values {
611            query = match value {
612                query::BindValue::String(s) => query.bind(s.clone()),
613                query::BindValue::I32(i) => query.bind(*i),
614                query::BindValue::I64(i) => query.bind(*i),
615                query::BindValue::DateTime(dt) => query.bind(*dt),
616                query::BindValue::Bool(b) => query.bind(*b),
617            };
618        }
619        match query.fetch_one(&state.pool).await {
620            Ok(count) => count,
621            Err(e) => {
622                error!("Failed to execute count query: {}", e);
623                return Json(json!({
624                    "status": "error",
625                    "error": "Query execution failed"
626                }));
627            }
628        }
629    };
630
631    // Execute main query
632    let runs: Vec<models::Run> = {
633        let mut query = sqlx::query_as::<_, models::Run>(&query_sql);
634        for value in bind_values {
635            query = match value {
636                query::BindValue::String(s) => query.bind(s.clone()),
637                query::BindValue::I32(i) => query.bind(*i),
638                query::BindValue::I64(i) => query.bind(*i),
639                query::BindValue::DateTime(dt) => query.bind(*dt),
640                query::BindValue::Bool(b) => query.bind(*b),
641            };
642        }
643        match query.fetch_all(&state.pool).await {
644            Ok(runs) => runs,
645            Err(e) => {
646                error!("Failed to execute search query: {}", e);
647                return Json(json!({
648                    "status": "error",
649                    "error": "Query execution failed"
650                }));
651            }
652        }
653    };
654
655    info!("Found {} runs (total: {})", runs.len(), total);
656
657    // Determine what to include in response
658    let include_files = request.include.contains(&models::IncludeField::Files);
659    let include_stdout = request.include.contains(&models::IncludeField::Stdout);
660    let include_stderr = request.include.contains(&models::IncludeField::Stderr);
661    let include_hooks = request.include.contains(&models::IncludeField::Hooks);
662
663    // Build response
664    let mut results = Vec::with_capacity(runs.len());
665    for run in runs {
666        let files = if include_files {
667            match sqlx::query_as::<_, models::CapturedFile>(
668                "SELECT path, size, hash, storage_path, content_type \
669                 FROM captured_files WHERE run_id = $1 ORDER BY path",
670            )
671            .bind(&run.id)
672            .fetch_all(&state.pool)
673            .await
674            {
675                Ok(files) => Some(
676                    files
677                        .into_iter()
678                        .map(|f| models::FileInfo {
679                            path: f.path.clone(),
680                            size: f.size,
681                            hash: f.hash,
682                            url: format!("/api/v1/runs/{}/files/{}", run.id, f.path),
683                        })
684                        .collect(),
685                ),
686                Err(e) => {
687                    warn!("Failed to fetch files for run {}: {}", run.id, e);
688                    None
689                }
690            }
691        } else {
692            None
693        };
694
695        let (pre_run_hooks, post_run_hooks) = if include_hooks {
696            match sqlx::query_as::<_, models::RunOutputRow>(
697                "SELECT phase, hook_id, config, output, success, error \
698                 FROM run_outputs WHERE run_id = $1 ORDER BY id",
699            )
700            .bind(&run.id)
701            .fetch_all(&state.pool)
702            .await
703            {
704                Ok(rows) => {
705                    let mut pre_hooks = Vec::new();
706                    let mut post_hooks = Vec::new();
707                    for row in rows {
708                        let hook_output = models::HookOutput {
709                            meta: models::HookMeta {
710                                id: row.hook_id,
711                                config: row.config,
712                                success: row.success,
713                                error: row.error,
714                            },
715                            output: row.output,
716                        };
717                        if row.phase == "pre" {
718                            pre_hooks.push(hook_output);
719                        } else if row.phase == "post" {
720                            post_hooks.push(hook_output);
721                        } else {
722                            // Unknown phase, skip
723                        }
724                    }
725                    (Some(pre_hooks), Some(post_hooks))
726                }
727                Err(e) => {
728                    warn!("Failed to fetch hooks for run {}: {}", run.id, e);
729                    (None, None)
730                }
731            }
732        } else {
733            (None, None)
734        };
735
736        results.push(models::SearchRunResult {
737            id: run.id,
738            name: run.name,
739            timestamp: run.timestamp,
740            vault: run.vault,
741            command: run.command,
742            project_root: run.project_root,
743            exit_code: run.exit_code,
744            duration_ms: run.duration_ms,
745            stdout: if include_stdout { run.stdout } else { None },
746            stderr: if include_stderr { run.stderr } else { None },
747            files,
748            pre_run_hooks,
749            post_run_hooks,
750        });
751    }
752
753    let response = models::SearchRunsResponse {
754        status: "ok".to_string(),
755        total,
756        runs: results,
757    };
758
759    json_response(response)
760}
761
762async fn create_run(
763    State(state): State<AppState>,
764    Json(request): Json<models::CreateRunRequest>,
765) -> impl IntoResponse {
766    info!("Creating run: {}", request.id);
767
768    let result = sqlx::query!(
769        r#"
770        INSERT INTO runs (
771            id, name, timestamp, command, vault, project_root,
772            exit_code, duration_ms, stdout, stderr
773        ) VALUES (
774            $1, $2, $3, $4, $5, $6,
775            $7, $8, $9, $10
776        )
777        "#,
778        request.id,
779        request.name,
780        request.timestamp,
781        request.command,
782        request.vault,
783        request.project_root,
784        request.exit_code,
785        request.duration_ms,
786        request.stdout,
787        request.stderr
788    )
789    .execute(&state.pool)
790    .await;
791
792    match result {
793        Ok(_) => {
794            info!("Run created successfully");
795            (
796                StatusCode::CREATED,
797                Json(json!({
798                    "status": "created",
799                    "run": request
800                })),
801            )
802        }
803        Err(e) => {
804            error!("Failed to insert run: {}", e);
805            let status = if e.to_string().contains("duplicate key") {
806                StatusCode::CONFLICT
807            } else {
808                StatusCode::INTERNAL_SERVER_ERROR
809            };
810            (
811                status,
812                Json(json!({
813                    "status": "error",
814                    "error": e.to_string()
815                })),
816            )
817        }
818    }
819}
820
821async fn get_run(State(state): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
822    info!("Getting run: {}", id);
823
824    let result = fetch_run_by_id(&state.pool, &id).await;
825
826    match result {
827        Ok(Some(run)) => {
828            info!("Found run: {}", run.id);
829
830            // Fetch hook outputs
831            let hook_outputs_result = sqlx::query_as!(
832                models::RunOutputRow,
833                r#"
834                SELECT phase, hook_id, config, output, success, error
835                FROM run_outputs
836                WHERE run_id = $1
837                ORDER BY id
838                "#,
839                id
840            )
841            .fetch_all(&state.pool)
842            .await;
843
844            let (pre_run_hooks, post_run_hooks) = match hook_outputs_result {
845                Ok(rows) => {
846                    let mut pre_hooks = Vec::new();
847                    let mut post_hooks = Vec::new();
848
849                    for row in rows {
850                        let hook_output = models::HookOutput {
851                            meta: models::HookMeta {
852                                id: row.hook_id,
853                                config: row.config,
854                                success: row.success,
855                                error: row.error,
856                            },
857                            output: row.output,
858                        };
859
860                        if row.phase == "pre" {
861                            pre_hooks.push(hook_output);
862                        } else if row.phase == "post" {
863                            post_hooks.push(hook_output);
864                        } else {
865                            warn!("Unknown hook phase: {}", row.phase);
866                        }
867                    }
868
869                    info!(
870                        "Found {} pre-run hooks and {} post-run hooks",
871                        pre_hooks.len(),
872                        post_hooks.len()
873                    );
874                    (pre_hooks, post_hooks)
875                }
876                Err(e) => {
877                    error!("Failed to fetch hook outputs: {}", e);
878                    (Vec::new(), Vec::new())
879                }
880            };
881
882            Json(json!({
883                "status": "ok",
884                "run": run,
885                "pre_run_hooks": pre_run_hooks,
886                "post_run_hooks": post_run_hooks
887            }))
888        }
889        Ok(None) => {
890            info!("Run not found: {}", id);
891            Json(json!({
892                "status": "not_found",
893                "error": format!("Run with id {} not found", id)
894            }))
895        }
896        Err(e) => {
897            error!("Failed to retrieve run: {}", e);
898            Json(json!({
899                "status": "error",
900                "error": e.to_string()
901            }))
902        }
903    }
904}
905
906#[expect(clippy::too_many_lines, reason = "TODO: Refactor later")]
907#[expect(
908    clippy::else_if_without_else,
909    reason = "There is `continue` or `return` in each branch, so `else` is redundant"
910)]
911async fn upload_files(
912    State(state): State<AppState>,
913    mut multipart: Multipart,
914) -> impl IntoResponse {
915    let storage_path = &state.storage_path;
916    info!("Received file upload request");
917
918    if let Err(e) = tokio::fs::create_dir_all(&storage_path).await {
919        error!(
920            "Failed to create storage directory at {}: {}",
921            storage_path.display(),
922            e
923        );
924        return Json(json!({
925            "status": "error",
926            "error": format!("Failed to create storage directory: {}", e)
927        }));
928    }
929
930    let mut files_processed = 0;
931    let mut total_bytes = 0u64;
932    let mut run_id: Option<String> = None;
933    let mut pending_paths: VecDeque<String> = VecDeque::new();
934    let mut pre_run_hooks: Option<Vec<models::HookOutput>> = None;
935    let mut post_run_hooks: Option<Vec<models::HookOutput>> = None;
936
937    while let Ok(Some(field)) = multipart.next_field().await {
938        let field_name = field.name().unwrap_or("unknown").to_string();
939
940        if field_name == "run_id" {
941            match field.text().await {
942                Ok(text) => {
943                    run_id = Some(text);
944                    continue;
945                }
946                Err(e) => {
947                    error!("Failed to read run_id field: {}", e);
948                    return Json(json!({
949                        "status": "error",
950                        "error": format!("Failed to read run_id: {}", e)
951                    }));
952                }
953            }
954        } else if field_name == "path" {
955            match field.text().await {
956                Ok(text) => {
957                    pending_paths.push_back(text);
958                    continue;
959                }
960                Err(e) => {
961                    error!("Failed to read path field: {}", e);
962                    return Json(json!({
963                        "status": "error",
964                        "error": format!("Failed to read path: {}", e)
965                    }));
966                }
967            }
968        } else if field_name == "pre_run" {
969            match field.text().await {
970                Ok(text) => match serde_json::from_str::<Vec<models::HookOutput>>(&text) {
971                    Ok(hooks) => {
972                        info!("Parsed {} pre-run hooks", hooks.len());
973                        pre_run_hooks = Some(hooks);
974                        continue;
975                    }
976                    Err(e) => {
977                        error!("Failed to parse pre_run JSON: {}", e);
978                        return Json(json!({
979                            "status": "error",
980                            "error": format!("Failed to parse pre_run JSON: {}", e)
981                        }));
982                    }
983                },
984                Err(e) => {
985                    error!("Failed to read pre_run field: {}", e);
986                    return Json(json!({
987                        "status": "error",
988                        "error": format!("Failed to read pre_run: {}", e)
989                    }));
990                }
991            }
992        } else if field_name == "post_run" {
993            match field.text().await {
994                Ok(text) => match serde_json::from_str::<Vec<models::HookOutput>>(&text) {
995                    Ok(hooks) => {
996                        info!("Parsed {} post-run hooks", hooks.len());
997                        post_run_hooks = Some(hooks);
998                        continue;
999                    }
1000                    Err(e) => {
1001                        error!("Failed to parse post_run JSON: {}", e);
1002                        return Json(json!({
1003                            "status": "error",
1004                            "error": format!("Failed to parse post_run JSON: {}", e)
1005                        }));
1006                    }
1007                },
1008                Err(e) => {
1009                    error!("Failed to read post_run field: {}", e);
1010                    return Json(json!({
1011                        "status": "error",
1012                        "error": format!("Failed to read post_run: {}", e)
1013                    }));
1014                }
1015            }
1016        }
1017
1018        let file_name = field.file_name().unwrap_or("unknown").to_string();
1019        let content_type = field
1020            .content_type()
1021            .unwrap_or("application/octet-stream")
1022            .to_string();
1023
1024        info!(
1025            "Processing file: field_name={}, file_name={}, content_type={}",
1026            field_name, file_name, content_type
1027        );
1028
1029        match field.bytes().await {
1030            Ok(data) => {
1031                let size = data.len();
1032                total_bytes += size as u64;
1033                let Ok(size_i64) = i64::try_from(size) else {
1034                    error!("File too large to store size in database: {} bytes", size);
1035                    return Json(json!({
1036                        "status": "error",
1037                        "error": "File too large to store size in database"
1038                    }));
1039                };
1040
1041                let mut hasher = Sha256::new();
1042                hasher.update(&data);
1043                let hash = hex_encode(hasher.finalize().as_ref());
1044
1045                info!("File hash: {}, size: {} bytes", hash, size);
1046
1047                let hash_dir = &hash[0..2];
1048                let file_storage_dir = storage_path.join(hash_dir);
1049                if let Err(e) = tokio::fs::create_dir_all(&file_storage_dir).await {
1050                    error!(
1051                        "Failed to create hash directory at {}: {}",
1052                        file_storage_dir.display(),
1053                        e
1054                    );
1055                    return Json(json!({
1056                        "status": "error",
1057                        "error": format!("Failed to create storage directory: {}", e)
1058                    }));
1059                }
1060
1061                let file_storage_path = file_storage_dir.join(&hash);
1062                let storage_path_str = file_storage_path.to_string_lossy().to_string();
1063
1064                if file_storage_path.exists() {
1065                    info!("File already exists (deduplicated): {}", storage_path_str);
1066                } else {
1067                    if let Err(e) = tokio::fs::write(&file_storage_path, &data).await {
1068                        error!("Failed to write file: {}", e);
1069                        return Json(json!({
1070                            "status": "error",
1071                            "error": format!("Failed to write file: {}", e)
1072                        }));
1073                    }
1074                    info!("Saved new file: {}", storage_path_str);
1075                }
1076
1077                if let Some(ref rid) = run_id {
1078                    let candidate_path = pending_paths
1079                        .pop_front()
1080                        .or_else(|| {
1081                            if file_name == "unknown" {
1082                                None
1083                            } else {
1084                                Some(file_name.clone())
1085                            }
1086                        })
1087                        .unwrap_or_else(|| {
1088                            if field_name != "unknown" && field_name != "file" {
1089                                field_name.clone()
1090                            } else {
1091                                format!("file-{}", files_processed + 1)
1092                            }
1093                        });
1094
1095                    let relative_path = match sanitize_relative_path(&candidate_path) {
1096                        Ok(p) => p,
1097                        Err(reason) => {
1098                            warn!(
1099                                "Rejected captured-file path '{}': {}",
1100                                candidate_path, reason
1101                            );
1102                            return Json(json!({
1103                                "status": "error",
1104                                "error": format!("Invalid file path '{}': {}", candidate_path, reason)
1105                            }));
1106                        }
1107                    };
1108
1109                    let result = sqlx::query!(
1110                        r#"
1111                        INSERT INTO captured_files (run_id, path, size, hash, storage_path, content_type)
1112                        VALUES ($1, $2, $3, $4, $5, $6)
1113                        ON CONFLICT (run_id, path) DO UPDATE
1114                        SET size = EXCLUDED.size,
1115                            hash = EXCLUDED.hash,
1116                            storage_path = EXCLUDED.storage_path,
1117                            content_type = EXCLUDED.content_type
1118                        "#,
1119                        rid,
1120                        relative_path,
1121                        size_i64,
1122                        hash,
1123                        storage_path_str,
1124                        content_type
1125                    )
1126                    .execute(&state.pool)
1127                    .await;
1128
1129                    match result {
1130                        Ok(_) => {
1131                            info!("Stored file metadata in database");
1132                        }
1133                        Err(e) => {
1134                            error!("Failed to store file metadata: {}", e);
1135                            return Json(json!({
1136                                "status": "error",
1137                                "error": format!("Failed to store file metadata: {}", e)
1138                            }));
1139                        }
1140                    }
1141                }
1142
1143                files_processed += 1;
1144                info!("Successfully processed file: {} bytes", size);
1145            }
1146            Err(e) => {
1147                error!("Failed to read file field: {}", e);
1148                return Json(json!({
1149                    "status": "error",
1150                    "error": format!("Failed to read file: {}", e)
1151                }));
1152            }
1153        }
1154    }
1155
1156    info!(
1157        "Upload complete: {} files, {} bytes total",
1158        files_processed, total_bytes
1159    );
1160
1161    // Store hook outputs if provided
1162    let mut pre_run_count = 0;
1163    let mut post_run_count = 0;
1164
1165    if let Some(ref rid) = run_id {
1166        if let Some(hooks) = pre_run_hooks {
1167            for hook in hooks {
1168                let result = sqlx::query!(
1169                    r#"
1170                    INSERT INTO run_outputs (run_id, phase, hook_id, config, output, success, error)
1171                    VALUES ($1, 'pre', $2, $3, $4, $5, $6)
1172                    ON CONFLICT (run_id, phase, hook_id) DO UPDATE
1173                    SET config = EXCLUDED.config,
1174                        output = EXCLUDED.output,
1175                        success = EXCLUDED.success,
1176                        error = EXCLUDED.error
1177                    "#,
1178                    rid,
1179                    hook.meta.id,
1180                    hook.meta.config,
1181                    hook.output,
1182                    hook.meta.success,
1183                    hook.meta.error
1184                )
1185                .execute(&state.pool)
1186                .await;
1187
1188                match result {
1189                    Ok(_) => {
1190                        pre_run_count += 1;
1191                        info!("Stored pre-run hook: {}", hook.meta.id);
1192                    }
1193                    Err(e) => {
1194                        error!("Failed to store pre-run hook {}: {}", hook.meta.id, e);
1195                        return Json(json!({
1196                            "status": "error",
1197                            "error": format!("Failed to store pre-run hook {}: {}", hook.meta.id, e)
1198                        }));
1199                    }
1200                }
1201            }
1202        }
1203
1204        if let Some(hooks) = post_run_hooks {
1205            for hook in hooks {
1206                let result = sqlx::query!(
1207                    r#"
1208                    INSERT INTO run_outputs (run_id, phase, hook_id, config, output, success, error)
1209                    VALUES ($1, 'post', $2, $3, $4, $5, $6)
1210                    ON CONFLICT (run_id, phase, hook_id) DO UPDATE
1211                    SET config = EXCLUDED.config,
1212                        output = EXCLUDED.output,
1213                        success = EXCLUDED.success,
1214                        error = EXCLUDED.error
1215                    "#,
1216                    rid,
1217                    hook.meta.id,
1218                    hook.meta.config,
1219                    hook.output,
1220                    hook.meta.success,
1221                    hook.meta.error
1222                )
1223                .execute(&state.pool)
1224                .await;
1225
1226                match result {
1227                    Ok(_) => {
1228                        post_run_count += 1;
1229                        info!("Stored post-run hook: {}", hook.meta.id);
1230                    }
1231                    Err(e) => {
1232                        error!("Failed to store post-run hook {}: {}", hook.meta.id, e);
1233                        return Json(json!({
1234                            "status": "error",
1235                            "error": format!("Failed to store post-run hook {}: {}", hook.meta.id, e)
1236                        }));
1237                    }
1238                }
1239            }
1240        }
1241    }
1242
1243    info!(
1244        "Hook outputs stored: {} pre-run, {} post-run",
1245        pre_run_count, post_run_count
1246    );
1247
1248    let response = capsula_api_types::UploadResponse {
1249        status: "ok".to_string(),
1250        files_processed,
1251        total_bytes,
1252        pre_run_hooks: pre_run_count,
1253        post_run_hooks: post_run_count,
1254    };
1255    json_response(response)
1256}
1257
1258async fn download_file(
1259    State(state): State<AppState>,
1260    Path((run_id, file_path)): Path<(String, String)>,
1261) -> Result<Response, StatusCode> {
1262    info!("Downloading file: run_id={}, path={}", run_id, file_path);
1263
1264    // Query the database for file metadata
1265    let file_record = sqlx::query!(
1266        r#"
1267        SELECT storage_path, content_type, path as file_path
1268        FROM captured_files
1269        WHERE run_id = $1 AND path = $2
1270        "#,
1271        run_id,
1272        file_path
1273    )
1274    .fetch_optional(&state.pool)
1275    .await
1276    .map_err(|e| {
1277        error!("Database error while fetching file metadata: {}", e);
1278        StatusCode::INTERNAL_SERVER_ERROR
1279    })?;
1280
1281    let Some(record) = file_record else {
1282        info!("File not found: run_id={}, path={}", run_id, file_path);
1283        return Err(StatusCode::NOT_FOUND);
1284    };
1285
1286    // Read the file from storage
1287    let file_data = tokio::fs::read(&record.storage_path).await.map_err(|e| {
1288        error!(
1289            "Failed to read file from storage {}: {}",
1290            record.storage_path, e
1291        );
1292        StatusCode::INTERNAL_SERVER_ERROR
1293    })?;
1294
1295    info!(
1296        "Successfully read file: {} bytes from {}",
1297        file_data.len(),
1298        record.storage_path
1299    );
1300
1301    // Determine content type (use stored value or guess from filename)
1302    let content_type = record.content_type.unwrap_or_else(|| {
1303        mime_guess::from_path(&record.file_path)
1304            .first_or_octet_stream()
1305            .to_string()
1306    });
1307
1308    // Extract filename from path for Content-Disposition
1309    let filename = std::path::Path::new(&record.file_path)
1310        .file_name()
1311        .and_then(|n| n.to_str())
1312        .unwrap_or("download");
1313
1314    // Build response with appropriate headers
1315    Response::builder()
1316        .status(StatusCode::OK)
1317        .header(header::CONTENT_TYPE, content_type)
1318        .header(
1319            header::CONTENT_DISPOSITION,
1320            content_disposition_inline(filename),
1321        )
1322        .body(Body::from(file_data))
1323        .map_err(|e| {
1324            error!("Failed to build response: {}", e);
1325            StatusCode::INTERNAL_SERVER_ERROR
1326        })
1327}
1328
1329// Build an RFC 6266 / RFC 5987 Content-Disposition header value for the given
1330// filename. Emits both a safe ASCII `filename=` fallback and a percent-encoded
1331// `filename*=UTF-8''...` parameter so arbitrary bytes (including quotes, CR/LF,
1332// and control characters) in the filename cannot inject additional headers.
1333fn content_disposition_inline(filename: &str) -> String {
1334    let ascii_fallback: String = filename
1335        .chars()
1336        .map(|c| {
1337            if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
1338                c
1339            } else {
1340                '_'
1341            }
1342        })
1343        .collect();
1344    let ascii_fallback = if ascii_fallback.is_empty() {
1345        "download".to_owned()
1346    } else {
1347        ascii_fallback
1348    };
1349    let encoded = utf8_percent_encode(filename, RFC5987_ATTR_CHAR);
1350    format!("inline; filename=\"{ascii_fallback}\"; filename*=UTF-8''{encoded}")
1351}
1352
1353// Validate a user-supplied relative path for the `captured_files.path` column.
1354// Rejects absolute paths, path-traversal segments, Windows drive prefixes, NUL
1355// bytes, and empty values so downstream callers (download URLs, local
1356// reconstruction) cannot be tricked into escaping the run's virtual root.
1357fn sanitize_relative_path(candidate: &str) -> Result<String, &'static str> {
1358    if candidate.is_empty() {
1359        return Err("path is empty");
1360    }
1361    if candidate.contains('\0') {
1362        return Err("path contains NUL byte");
1363    }
1364
1365    // Normalize backslashes to forward slashes so Windows-style separators are
1366    // parsed consistently on all platforms. `Component::Prefix` is only emitted
1367    // when `std::path` parses on Windows, so drive letters are detected
1368    // explicitly below rather than relying on the path parser alone.
1369    let normalized = candidate.replace('\\', "/");
1370    let first_segment = normalized.split('/').next().unwrap_or("");
1371    let mut first_chars = first_segment.chars();
1372    if first_chars.next().is_some_and(|c| c.is_ascii_alphabetic())
1373        && first_chars.next() == Some(':')
1374    {
1375        return Err("path must be relative");
1376    }
1377
1378    let mut out = String::with_capacity(normalized.len());
1379    for component in StdPath::new(&normalized).components() {
1380        match component {
1381            Component::Normal(seg) => {
1382                let seg = seg.to_str().ok_or("path contains invalid UTF-8")?;
1383                if !out.is_empty() {
1384                    out.push('/');
1385                }
1386                out.push_str(seg);
1387            }
1388            Component::CurDir => {}
1389            Component::ParentDir => return Err("path contains '..' segment"),
1390            Component::RootDir | Component::Prefix(_) => return Err("path must be relative"),
1391        }
1392    }
1393
1394    if out.is_empty() {
1395        return Err("path is empty");
1396    }
1397    Ok(out)
1398}
1399
1400#[cfg(test)]
1401#[expect(clippy::unwrap_used, reason = "tests may use unwrap for brevity")]
1402mod tests {
1403    use super::{content_disposition_inline, sanitize_relative_path};
1404
1405    #[test]
1406    fn sanitize_rejects_absolute_paths() {
1407        assert!(sanitize_relative_path("/etc/passwd").is_err());
1408        assert!(sanitize_relative_path("\\windows\\system32").is_err());
1409    }
1410
1411    #[test]
1412    fn sanitize_rejects_parent_traversal() {
1413        assert!(sanitize_relative_path("../etc/passwd").is_err());
1414        assert!(sanitize_relative_path("a/../b").is_err());
1415        assert!(sanitize_relative_path("a\\..\\b").is_err());
1416    }
1417
1418    #[test]
1419    fn sanitize_rejects_drive_prefix_and_nul() {
1420        assert!(sanitize_relative_path("C:\\Users").is_err());
1421        assert!(sanitize_relative_path("d:/foo").is_err());
1422        assert!(sanitize_relative_path("foo\0bar").is_err());
1423        assert!(sanitize_relative_path("").is_err());
1424    }
1425
1426    #[test]
1427    fn sanitize_accepts_normal_paths() {
1428        assert_eq!(sanitize_relative_path("foo.txt").unwrap(), "foo.txt");
1429        assert_eq!(
1430            sanitize_relative_path("dir/sub/file.log").unwrap(),
1431            "dir/sub/file.log"
1432        );
1433        // Backslashes are normalized to forward slashes.
1434        assert_eq!(
1435            sanitize_relative_path("dir\\sub\\file.log").unwrap(),
1436            "dir/sub/file.log"
1437        );
1438    }
1439
1440    #[test]
1441    fn sanitize_normalizes_redundant_segments() {
1442        // `Path::components()` collapses empty segments and drops `.` segments.
1443        assert_eq!(sanitize_relative_path("a//b").unwrap(), "a/b");
1444        assert_eq!(sanitize_relative_path("./a/b").unwrap(), "a/b");
1445        assert_eq!(sanitize_relative_path("a/./b").unwrap(), "a/b");
1446        assert_eq!(sanitize_relative_path("a/b/").unwrap(), "a/b");
1447    }
1448
1449    #[test]
1450    fn content_disposition_strips_dangerous_chars_in_fallback() {
1451        let header = content_disposition_inline("a\"b\r\nc.txt");
1452        assert!(header.starts_with("inline; filename=\"a_b__c.txt\";"));
1453        // Must not contain a bare CR or LF in the header.
1454        assert!(!header.contains('\r'));
1455        assert!(!header.contains('\n'));
1456    }
1457
1458    #[test]
1459    fn content_disposition_encodes_unicode_in_filename_star() {
1460        let header = content_disposition_inline("日本語.txt");
1461        assert!(header.contains("filename*=UTF-8''"));
1462        // UTF-8 for '日' starts with 0xE6 0x97 0xA5.
1463        assert!(header.contains("%E6%97%A5"));
1464    }
1465
1466    #[test]
1467    fn content_disposition_percent_encodes_attr_char_boundary() {
1468        // Unreserved ASCII round-trips; spaces and quotes are percent-encoded.
1469        let header = content_disposition_inline("abc.txt");
1470        assert!(header.contains("filename*=UTF-8''abc.txt"));
1471        let header = content_disposition_inline("a b\"c");
1472        assert!(header.contains("filename*=UTF-8''a%20b%22c"));
1473    }
1474}