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::HookOutputResponse>,
109    post_run_hooks: Vec<models::HookOutputResponse>,
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_index, hook_id, config, output, success, error
380        FROM run_outputs
381        WHERE run_id = $1
382        ORDER BY phase, hook_index
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::HookOutputResponse {
396                    meta: models::HookMetaResponse {
397                        id: row.hook_id,
398                        config: row.config,
399                        success: row.success,
400                        error: row.error,
401                        hook_index: row.hook_index,
402                    },
403                    output: row.output,
404                };
405
406                if row.phase == "pre" {
407                    pre_hooks.push(hook_output);
408                } else if row.phase == "post" {
409                    post_hooks.push(hook_output);
410                } else {
411                    warn!("Unknown hook phase: {}", row.phase);
412                }
413            }
414
415            (pre_hooks, post_hooks)
416        }
417        Err(e) => {
418            error!("Failed to fetch hook outputs: {}", e);
419            (Vec::new(), Vec::new())
420        }
421    };
422
423    // Fetch captured files
424    let files_result = sqlx::query_as!(
425        models::CapturedFile,
426        r#"
427        SELECT path, size, hash, storage_path, content_type
428        FROM captured_files
429        WHERE run_id = $1
430        ORDER BY path
431        "#,
432        id
433    )
434    .fetch_all(&state.pool)
435    .await;
436
437    let files = match files_result {
438        Ok(files) => files,
439        Err(e) => {
440            error!("Failed to fetch captured files: {}", e);
441            Vec::new()
442        }
443    };
444
445    Ok(RunDetailTemplate {
446        run,
447        pre_run_hooks,
448        post_run_hooks,
449        files,
450    })
451}
452
453async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
454    match sqlx::query("SELECT 1").fetch_one(&state.pool).await {
455        Ok(_) => Json(json!({
456            "status": "ok",
457            "database": "connected"
458        })),
459        Err(e) => Json(json!({
460            "status": "error",
461            "database": "disconnected",
462            "error": e.to_string()
463        })),
464    }
465}
466
467async fn list_vaults(State(state): State<AppState>) -> impl IntoResponse {
468    info!("Listing all vaults");
469
470    match fetch_vault_list(&state.pool).await {
471        Ok(vaults) => {
472            info!("Found {} vaults", vaults.len());
473            json_response(capsula_api_types::VaultsResponse {
474                status: "ok".to_string(),
475                vaults,
476            })
477        }
478        Err(e) => {
479            error!("Failed to list vaults: {}", e);
480            json_response(capsula_api_types::ErrorResponse {
481                status: "error".to_string(),
482                error: e.to_string(),
483            })
484        }
485    }
486}
487
488async fn get_vault_info(
489    State(state): State<AppState>,
490    Path(name): Path<String>,
491) -> impl IntoResponse {
492    info!("Getting vault info: {}", name);
493
494    let result = sqlx::query!(
495        r#"
496        SELECT vault as name, COUNT(*) as "run_count!"
497        FROM runs
498        WHERE vault = $1
499        GROUP BY vault
500        "#,
501        name
502    )
503    .fetch_optional(&state.pool)
504    .await;
505
506    match result {
507        Ok(Some(row)) => {
508            let vault = VaultInfo {
509                name: row.name,
510                run_count: row.run_count,
511            };
512            info!("Found vault: {} with {} runs", vault.name, vault.run_count);
513            let response = capsula_api_types::VaultExistsResponse {
514                status: "ok".to_string(),
515                exists: true,
516                vault: Some(vault),
517            };
518            json_response(response)
519        }
520        Ok(None) => {
521            info!("Vault not found: {}", name);
522            let response = capsula_api_types::VaultExistsResponse {
523                status: "ok".to_string(),
524                exists: false,
525                vault: None,
526            };
527            json_response(response)
528        }
529        Err(e) => {
530            error!("Failed to get vault info: {}", e);
531            let response = capsula_api_types::ErrorResponse {
532                status: "error".to_string(),
533                error: e.to_string(),
534            };
535            json_response(response)
536        }
537    }
538}
539
540async fn list_runs(
541    State(state): State<AppState>,
542    Query(params): Query<models::ListRunsQuery>,
543) -> impl IntoResponse {
544    let limit = params.limit.unwrap_or(100);
545    let offset = params.offset.unwrap_or(0);
546
547    if let Some(ref vault) = params.vault {
548        info!(
549            "Listing runs for vault: {} (limit={}, offset={})",
550            vault, limit, offset
551        );
552    } else {
553        info!("Listing all runs (limit={}, offset={})", limit, offset);
554    }
555
556    let result = fetch_runs(&state.pool, params.vault.as_deref(), limit, offset).await;
557
558    match result {
559        Ok(runs) => {
560            info!("Found {} runs", runs.len());
561            Json(json!({
562                "status": "ok",
563                "runs": runs,
564                "limit": limit,
565                "offset": offset
566            }))
567        }
568        Err(e) => {
569            error!("Failed to list runs: {}", e);
570            Json(json!({
571                "status": "error",
572                "error": e.to_string()
573            }))
574        }
575    }
576}
577
578/// Search runs with `JSONPath` filters on hook outputs
579#[expect(clippy::too_many_lines, reason = "TODO: Refactor later")]
580async fn search_runs(
581    State(state): State<AppState>,
582    Json(request): Json<models::SearchRunsRequest>,
583) -> impl IntoResponse {
584    info!(
585        "Searching runs: vault={:?}, hook_filters={}",
586        request.vault,
587        request.hook_filters.len()
588    );
589
590    // Build the query
591    let builder = match query::RunQueryBuilder::from_request(&request) {
592        Ok(b) => b,
593        Err(e) => {
594            error!("Failed to build query: {}", e);
595            return Json(json!({
596                "status": "error",
597                "error": e.to_string()
598            }));
599        }
600    };
601
602    let query_sql = builder.build_query();
603    let count_sql = builder.build_count_query();
604    let bind_values = builder.bind_values();
605    // SAFETY: RunQueryBuilder only interpolates server-selected SQL fragments
606    // (column predicates, parameter placeholders, sort order, and clamped numeric
607    // LIMIT/OFFSET). User-provided values are bound below.
608
609    info!("Executing search query: {}", query_sql);
610
611    // Execute count query first
612    let total: i64 = {
613        let mut query = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(count_sql));
614        for value in bind_values {
615            query = match value {
616                query::BindValue::String(s) => query.bind(s.clone()),
617                query::BindValue::I32(i) => query.bind(*i),
618                query::BindValue::I64(i) => query.bind(*i),
619                query::BindValue::DateTime(dt) => query.bind(*dt),
620                query::BindValue::Bool(b) => query.bind(*b),
621            };
622        }
623        match query.fetch_one(&state.pool).await {
624            Ok(count) => count,
625            Err(e) => {
626                error!("Failed to execute count query: {}", e);
627                return Json(json!({
628                    "status": "error",
629                    "error": "Query execution failed"
630                }));
631            }
632        }
633    };
634
635    // Execute main query
636    let runs: Vec<models::Run> = {
637        let mut query = sqlx::query_as::<_, models::Run>(sqlx::AssertSqlSafe(query_sql));
638        for value in bind_values {
639            query = match value {
640                query::BindValue::String(s) => query.bind(s.clone()),
641                query::BindValue::I32(i) => query.bind(*i),
642                query::BindValue::I64(i) => query.bind(*i),
643                query::BindValue::DateTime(dt) => query.bind(*dt),
644                query::BindValue::Bool(b) => query.bind(*b),
645            };
646        }
647        match query.fetch_all(&state.pool).await {
648            Ok(runs) => runs,
649            Err(e) => {
650                error!("Failed to execute search query: {}", e);
651                return Json(json!({
652                    "status": "error",
653                    "error": "Query execution failed"
654                }));
655            }
656        }
657    };
658
659    info!("Found {} runs (total: {})", runs.len(), total);
660
661    // Determine what to include in response
662    let include_files = request.include.contains(&models::IncludeField::Files);
663    let include_stdout = request.include.contains(&models::IncludeField::Stdout);
664    let include_stderr = request.include.contains(&models::IncludeField::Stderr);
665    let include_hooks = request.include.contains(&models::IncludeField::Hooks);
666
667    // Build response
668    let mut results = Vec::with_capacity(runs.len());
669    for run in runs {
670        let files = if include_files {
671            match sqlx::query_as::<_, models::CapturedFile>(
672                "SELECT path, size, hash, storage_path, content_type \
673                 FROM captured_files WHERE run_id = $1 ORDER BY path",
674            )
675            .bind(&run.id)
676            .fetch_all(&state.pool)
677            .await
678            {
679                Ok(files) => Some(
680                    files
681                        .into_iter()
682                        .map(|f| models::FileInfo {
683                            path: f.path.clone(),
684                            size: f.size,
685                            hash: f.hash,
686                            url: format!("/api/v1/runs/{}/files/{}", run.id, f.path),
687                        })
688                        .collect(),
689                ),
690                Err(e) => {
691                    warn!("Failed to fetch files for run {}: {}", run.id, e);
692                    None
693                }
694            }
695        } else {
696            None
697        };
698
699        let (pre_run_hooks, post_run_hooks) = if include_hooks {
700            match sqlx::query_as::<_, models::RunOutputRow>(
701                "SELECT phase, hook_index, hook_id, config, output, success, error \
702                 FROM run_outputs WHERE run_id = $1 ORDER BY phase, hook_index",
703            )
704            .bind(&run.id)
705            .fetch_all(&state.pool)
706            .await
707            {
708                Ok(rows) => {
709                    let mut pre_hooks = Vec::new();
710                    let mut post_hooks = Vec::new();
711                    for row in rows {
712                        let hook_output = models::HookOutputResponse {
713                            meta: models::HookMetaResponse {
714                                id: row.hook_id,
715                                config: row.config,
716                                success: row.success,
717                                error: row.error,
718                                hook_index: row.hook_index,
719                            },
720                            output: row.output,
721                        };
722                        if row.phase == "pre" {
723                            pre_hooks.push(hook_output);
724                        } else if row.phase == "post" {
725                            post_hooks.push(hook_output);
726                        } else {
727                            // Unknown phase, skip
728                        }
729                    }
730                    (Some(pre_hooks), Some(post_hooks))
731                }
732                Err(e) => {
733                    warn!("Failed to fetch hooks for run {}: {}", run.id, e);
734                    (None, None)
735                }
736            }
737        } else {
738            (None, None)
739        };
740
741        results.push(models::SearchRunResult {
742            id: run.id,
743            name: run.name,
744            timestamp: run.timestamp,
745            vault: run.vault,
746            command: run.command,
747            project_root: run.project_root,
748            exit_code: run.exit_code,
749            duration_ms: run.duration_ms,
750            stdout: if include_stdout { run.stdout } else { None },
751            stderr: if include_stderr { run.stderr } else { None },
752            files,
753            pre_run_hooks,
754            post_run_hooks,
755        });
756    }
757
758    let response = models::SearchRunsResponse {
759        status: "ok".to_string(),
760        total,
761        runs: results,
762    };
763
764    json_response(response)
765}
766
767async fn create_run(
768    State(state): State<AppState>,
769    Json(request): Json<models::CreateRunRequest>,
770) -> impl IntoResponse {
771    info!("Creating run: {}", request.id);
772
773    let result = sqlx::query!(
774        r#"
775        INSERT INTO runs (
776            id, name, timestamp, command, vault, project_root,
777            exit_code, duration_ms, stdout, stderr
778        ) VALUES (
779            $1, $2, $3, $4, $5, $6,
780            $7, $8, $9, $10
781        )
782        "#,
783        request.id,
784        request.name,
785        request.timestamp,
786        request.command,
787        request.vault,
788        request.project_root,
789        request.exit_code,
790        request.duration_ms,
791        request.stdout,
792        request.stderr
793    )
794    .execute(&state.pool)
795    .await;
796
797    match result {
798        Ok(_) => {
799            info!("Run created successfully");
800            (
801                StatusCode::CREATED,
802                Json(json!({
803                    "status": "created",
804                    "run": request
805                })),
806            )
807        }
808        Err(e) => {
809            error!("Failed to insert run: {}", e);
810            let status = if e.to_string().contains("duplicate key") {
811                StatusCode::CONFLICT
812            } else {
813                StatusCode::INTERNAL_SERVER_ERROR
814            };
815            (
816                status,
817                Json(json!({
818                    "status": "error",
819                    "error": e.to_string()
820                })),
821            )
822        }
823    }
824}
825
826async fn get_run(State(state): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
827    info!("Getting run: {}", id);
828
829    let result = fetch_run_by_id(&state.pool, &id).await;
830
831    match result {
832        Ok(Some(run)) => {
833            info!("Found run: {}", run.id);
834
835            // Fetch hook outputs
836            let hook_outputs_result = sqlx::query_as!(
837                models::RunOutputRow,
838                r#"
839                SELECT phase, hook_index, hook_id, config, output, success, error
840                FROM run_outputs
841                WHERE run_id = $1
842                ORDER BY phase, hook_index
843                "#,
844                id
845            )
846            .fetch_all(&state.pool)
847            .await;
848
849            let (pre_run_hooks, post_run_hooks) = match hook_outputs_result {
850                Ok(rows) => {
851                    let mut pre_hooks = Vec::new();
852                    let mut post_hooks = Vec::new();
853
854                    for row in rows {
855                        let hook_output = models::HookOutputResponse {
856                            meta: models::HookMetaResponse {
857                                id: row.hook_id,
858                                config: row.config,
859                                success: row.success,
860                                error: row.error,
861                                hook_index: row.hook_index,
862                            },
863                            output: row.output,
864                        };
865
866                        if row.phase == "pre" {
867                            pre_hooks.push(hook_output);
868                        } else if row.phase == "post" {
869                            post_hooks.push(hook_output);
870                        } else {
871                            warn!("Unknown hook phase: {}", row.phase);
872                        }
873                    }
874
875                    info!(
876                        "Found {} pre-run hooks and {} post-run hooks",
877                        pre_hooks.len(),
878                        post_hooks.len()
879                    );
880                    (pre_hooks, post_hooks)
881                }
882                Err(e) => {
883                    error!("Failed to fetch hook outputs: {}", e);
884                    (Vec::new(), Vec::new())
885                }
886            };
887
888            Json(json!({
889                "status": "ok",
890                "run": run,
891                "pre_run_hooks": pre_run_hooks,
892                "post_run_hooks": post_run_hooks
893            }))
894        }
895        Ok(None) => {
896            info!("Run not found: {}", id);
897            Json(json!({
898                "status": "not_found",
899                "error": format!("Run with id {} not found", id)
900            }))
901        }
902        Err(e) => {
903            error!("Failed to retrieve run: {}", e);
904            Json(json!({
905                "status": "error",
906                "error": e.to_string()
907            }))
908        }
909    }
910}
911
912#[expect(clippy::too_many_lines, reason = "TODO: Refactor later")]
913#[expect(
914    clippy::else_if_without_else,
915    reason = "There is `continue` or `return` in each branch, so `else` is redundant"
916)]
917async fn upload_files(
918    State(state): State<AppState>,
919    mut multipart: Multipart,
920) -> impl IntoResponse {
921    let storage_path = &state.storage_path;
922    info!("Received file upload request");
923
924    if let Err(e) = tokio::fs::create_dir_all(&storage_path).await {
925        error!(
926            "Failed to create storage directory at {}: {}",
927            storage_path.display(),
928            e
929        );
930        return Json(json!({
931            "status": "error",
932            "error": format!("Failed to create storage directory: {}", e)
933        }));
934    }
935
936    let mut files_processed = 0;
937    let mut total_bytes = 0u64;
938    let mut run_id: Option<String> = None;
939    let mut pending_paths: VecDeque<String> = VecDeque::new();
940    let mut pre_run_hooks: Option<Vec<models::HookOutputUpload>> = None;
941    let mut post_run_hooks: Option<Vec<models::HookOutputUpload>> = None;
942
943    while let Ok(Some(field)) = multipart.next_field().await {
944        let field_name = field.name().unwrap_or("unknown").to_string();
945
946        if field_name == "run_id" {
947            match field.text().await {
948                Ok(text) => {
949                    run_id = Some(text);
950                    continue;
951                }
952                Err(e) => {
953                    error!("Failed to read run_id field: {}", e);
954                    return Json(json!({
955                        "status": "error",
956                        "error": format!("Failed to read run_id: {}", e)
957                    }));
958                }
959            }
960        } else if field_name == "path" {
961            match field.text().await {
962                Ok(text) => {
963                    pending_paths.push_back(text);
964                    continue;
965                }
966                Err(e) => {
967                    error!("Failed to read path field: {}", e);
968                    return Json(json!({
969                        "status": "error",
970                        "error": format!("Failed to read path: {}", e)
971                    }));
972                }
973            }
974        } else if field_name == "pre_run" {
975            match field.text().await {
976                Ok(text) => match serde_json::from_str::<Vec<models::HookOutputUpload>>(&text) {
977                    Ok(hooks) => {
978                        info!("Parsed {} pre-run hooks", hooks.len());
979                        pre_run_hooks = Some(hooks);
980                        continue;
981                    }
982                    Err(e) => {
983                        error!("Failed to parse pre_run JSON: {}", e);
984                        return Json(json!({
985                            "status": "error",
986                            "error": format!("Failed to parse pre_run JSON: {}", e)
987                        }));
988                    }
989                },
990                Err(e) => {
991                    error!("Failed to read pre_run field: {}", e);
992                    return Json(json!({
993                        "status": "error",
994                        "error": format!("Failed to read pre_run: {}", e)
995                    }));
996                }
997            }
998        } else if field_name == "post_run" {
999            match field.text().await {
1000                Ok(text) => match serde_json::from_str::<Vec<models::HookOutputUpload>>(&text) {
1001                    Ok(hooks) => {
1002                        info!("Parsed {} post-run hooks", hooks.len());
1003                        post_run_hooks = Some(hooks);
1004                        continue;
1005                    }
1006                    Err(e) => {
1007                        error!("Failed to parse post_run JSON: {}", e);
1008                        return Json(json!({
1009                            "status": "error",
1010                            "error": format!("Failed to parse post_run JSON: {}", e)
1011                        }));
1012                    }
1013                },
1014                Err(e) => {
1015                    error!("Failed to read post_run field: {}", e);
1016                    return Json(json!({
1017                        "status": "error",
1018                        "error": format!("Failed to read post_run: {}", e)
1019                    }));
1020                }
1021            }
1022        }
1023
1024        let file_name = field.file_name().unwrap_or("unknown").to_string();
1025        let content_type = field
1026            .content_type()
1027            .unwrap_or("application/octet-stream")
1028            .to_string();
1029
1030        info!(
1031            "Processing file: field_name={}, file_name={}, content_type={}",
1032            field_name, file_name, content_type
1033        );
1034
1035        match field.bytes().await {
1036            Ok(data) => {
1037                let size = data.len();
1038                total_bytes += size as u64;
1039                let Ok(size_i64) = i64::try_from(size) else {
1040                    error!("File too large to store size in database: {} bytes", size);
1041                    return Json(json!({
1042                        "status": "error",
1043                        "error": "File too large to store size in database"
1044                    }));
1045                };
1046
1047                let mut hasher = Sha256::new();
1048                hasher.update(&data);
1049                let hash = hex_encode(hasher.finalize().as_ref());
1050
1051                info!("File hash: {}, size: {} bytes", hash, size);
1052
1053                let hash_dir = &hash[0..2];
1054                let file_storage_dir = storage_path.join(hash_dir);
1055                if let Err(e) = tokio::fs::create_dir_all(&file_storage_dir).await {
1056                    error!(
1057                        "Failed to create hash directory at {}: {}",
1058                        file_storage_dir.display(),
1059                        e
1060                    );
1061                    return Json(json!({
1062                        "status": "error",
1063                        "error": format!("Failed to create storage directory: {}", e)
1064                    }));
1065                }
1066
1067                let file_storage_path = file_storage_dir.join(&hash);
1068                let storage_path_str = file_storage_path.to_string_lossy().to_string();
1069
1070                if file_storage_path.exists() {
1071                    info!("File already exists (deduplicated): {}", storage_path_str);
1072                } else {
1073                    if let Err(e) = tokio::fs::write(&file_storage_path, &data).await {
1074                        error!("Failed to write file: {}", e);
1075                        return Json(json!({
1076                            "status": "error",
1077                            "error": format!("Failed to write file: {}", e)
1078                        }));
1079                    }
1080                    info!("Saved new file: {}", storage_path_str);
1081                }
1082
1083                if let Some(ref rid) = run_id {
1084                    let candidate_path = pending_paths
1085                        .pop_front()
1086                        .or_else(|| {
1087                            if file_name == "unknown" {
1088                                None
1089                            } else {
1090                                Some(file_name.clone())
1091                            }
1092                        })
1093                        .unwrap_or_else(|| {
1094                            if field_name != "unknown" && field_name != "file" {
1095                                field_name.clone()
1096                            } else {
1097                                format!("file-{}", files_processed + 1)
1098                            }
1099                        });
1100
1101                    let relative_path = match sanitize_relative_path(&candidate_path) {
1102                        Ok(p) => p,
1103                        Err(reason) => {
1104                            warn!(
1105                                "Rejected captured-file path '{}': {}",
1106                                candidate_path, reason
1107                            );
1108                            return Json(json!({
1109                                "status": "error",
1110                                "error": format!("Invalid file path '{}': {}", candidate_path, reason)
1111                            }));
1112                        }
1113                    };
1114
1115                    let result = sqlx::query!(
1116                        r#"
1117                        INSERT INTO captured_files (run_id, path, size, hash, storage_path, content_type)
1118                        VALUES ($1, $2, $3, $4, $5, $6)
1119                        ON CONFLICT (run_id, path) DO UPDATE
1120                        SET size = EXCLUDED.size,
1121                            hash = EXCLUDED.hash,
1122                            storage_path = EXCLUDED.storage_path,
1123                            content_type = EXCLUDED.content_type
1124                        "#,
1125                        rid,
1126                        relative_path,
1127                        size_i64,
1128                        hash,
1129                        storage_path_str,
1130                        content_type
1131                    )
1132                    .execute(&state.pool)
1133                    .await;
1134
1135                    match result {
1136                        Ok(_) => {
1137                            info!("Stored file metadata in database");
1138                        }
1139                        Err(e) => {
1140                            error!("Failed to store file metadata: {}", e);
1141                            return Json(json!({
1142                                "status": "error",
1143                                "error": format!("Failed to store file metadata: {}", e)
1144                            }));
1145                        }
1146                    }
1147                }
1148
1149                files_processed += 1;
1150                info!("Successfully processed file: {} bytes", size);
1151            }
1152            Err(e) => {
1153                error!("Failed to read file field: {}", e);
1154                return Json(json!({
1155                    "status": "error",
1156                    "error": format!("Failed to read file: {}", e)
1157                }));
1158            }
1159        }
1160    }
1161
1162    info!(
1163        "Upload complete: {} files, {} bytes total",
1164        files_processed, total_bytes
1165    );
1166
1167    // Store hook outputs if provided
1168    let mut pre_run_count = 0;
1169    let mut post_run_count = 0;
1170
1171    if let Some(ref rid) = run_id {
1172        if let Some(hooks) = pre_run_hooks {
1173            // hook_index is the position of the hook in the capsula.toml
1174            // pre_run array — global within the phase, not per hook_id.
1175            // The unique key (run_id, phase, hook_index) combined with this
1176            // enumeration lets multiple instances of the same hook_id
1177            // (e.g., several `capture-json` or `capture-command` entries)
1178            // coexist as separate rows. See migration
1179            // 20260613000000_add_hook_index_to_run_outputs.sql.
1180            for (idx, hook) in hooks.iter().enumerate() {
1181                let hook_index = i32::try_from(idx).unwrap_or(i32::MAX);
1182
1183                let result = sqlx::query!(
1184                    r#"
1185                    INSERT INTO run_outputs (run_id, phase, hook_id, config, output, success, error, hook_index)
1186                    VALUES ($1, 'pre', $2, $3, $4, $5, $6, $7)
1187                    ON CONFLICT (run_id, phase, hook_index) DO UPDATE
1188                    SET hook_id = EXCLUDED.hook_id,
1189                        config = EXCLUDED.config,
1190                        output = EXCLUDED.output,
1191                        success = EXCLUDED.success,
1192                        error = EXCLUDED.error
1193                    "#,
1194                    rid,
1195                    hook.meta.id,
1196                    hook.meta.config,
1197                    hook.output,
1198                    hook.meta.success,
1199                    hook.meta.error,
1200                    hook_index
1201                )
1202                .execute(&state.pool)
1203                .await;
1204
1205                match result {
1206                    Ok(_) => {
1207                        pre_run_count += 1;
1208                        info!("Stored pre-run hook: {}", hook.meta.id);
1209                    }
1210                    Err(e) => {
1211                        error!("Failed to store pre-run hook {}: {}", hook.meta.id, e);
1212                        return Json(json!({
1213                            "status": "error",
1214                            "error": format!("Failed to store pre-run hook {}: {}", hook.meta.id, e)
1215                        }));
1216                    }
1217                }
1218            }
1219        }
1220
1221        if let Some(hooks) = post_run_hooks {
1222            for (idx, hook) in hooks.iter().enumerate() {
1223                let hook_index = i32::try_from(idx).unwrap_or(i32::MAX);
1224
1225                let result = sqlx::query!(
1226                    r#"
1227                    INSERT INTO run_outputs (run_id, phase, hook_id, config, output, success, error, hook_index)
1228                    VALUES ($1, 'post', $2, $3, $4, $5, $6, $7)
1229                    ON CONFLICT (run_id, phase, hook_index) DO UPDATE
1230                    SET hook_id = EXCLUDED.hook_id,
1231                        config = EXCLUDED.config,
1232                        output = EXCLUDED.output,
1233                        success = EXCLUDED.success,
1234                        error = EXCLUDED.error
1235                    "#,
1236                    rid,
1237                    hook.meta.id,
1238                    hook.meta.config,
1239                    hook.output,
1240                    hook.meta.success,
1241                    hook.meta.error,
1242                    hook_index
1243                )
1244                .execute(&state.pool)
1245                .await;
1246
1247                match result {
1248                    Ok(_) => {
1249                        post_run_count += 1;
1250                        info!("Stored post-run hook: {}", hook.meta.id);
1251                    }
1252                    Err(e) => {
1253                        error!("Failed to store post-run hook {}: {}", hook.meta.id, e);
1254                        return Json(json!({
1255                            "status": "error",
1256                            "error": format!("Failed to store post-run hook {}: {}", hook.meta.id, e)
1257                        }));
1258                    }
1259                }
1260            }
1261        }
1262    }
1263
1264    info!(
1265        "Hook outputs stored: {} pre-run, {} post-run",
1266        pre_run_count, post_run_count
1267    );
1268
1269    let response = capsula_api_types::UploadResponse {
1270        status: "ok".to_string(),
1271        files_processed,
1272        total_bytes,
1273        pre_run_hooks: pre_run_count,
1274        post_run_hooks: post_run_count,
1275    };
1276    json_response(response)
1277}
1278
1279async fn download_file(
1280    State(state): State<AppState>,
1281    Path((run_id, file_path)): Path<(String, String)>,
1282) -> Result<Response, StatusCode> {
1283    info!("Downloading file: run_id={}, path={}", run_id, file_path);
1284
1285    // Query the database for file metadata
1286    let file_record = sqlx::query!(
1287        r#"
1288        SELECT storage_path, content_type, path as file_path
1289        FROM captured_files
1290        WHERE run_id = $1 AND path = $2
1291        "#,
1292        run_id,
1293        file_path
1294    )
1295    .fetch_optional(&state.pool)
1296    .await
1297    .map_err(|e| {
1298        error!("Database error while fetching file metadata: {}", e);
1299        StatusCode::INTERNAL_SERVER_ERROR
1300    })?;
1301
1302    let Some(record) = file_record else {
1303        info!("File not found: run_id={}, path={}", run_id, file_path);
1304        return Err(StatusCode::NOT_FOUND);
1305    };
1306
1307    // Read the file from storage
1308    let file_data = tokio::fs::read(&record.storage_path).await.map_err(|e| {
1309        error!(
1310            "Failed to read file from storage {}: {}",
1311            record.storage_path, e
1312        );
1313        StatusCode::INTERNAL_SERVER_ERROR
1314    })?;
1315
1316    info!(
1317        "Successfully read file: {} bytes from {}",
1318        file_data.len(),
1319        record.storage_path
1320    );
1321
1322    // Determine content type (use stored value or guess from filename)
1323    let content_type = record.content_type.unwrap_or_else(|| {
1324        mime_guess::from_path(&record.file_path)
1325            .first_or_octet_stream()
1326            .to_string()
1327    });
1328
1329    // Extract filename from path for Content-Disposition
1330    let filename = std::path::Path::new(&record.file_path)
1331        .file_name()
1332        .and_then(|n| n.to_str())
1333        .unwrap_or("download");
1334
1335    // Build response with appropriate headers
1336    Response::builder()
1337        .status(StatusCode::OK)
1338        .header(header::CONTENT_TYPE, content_type)
1339        .header(
1340            header::CONTENT_DISPOSITION,
1341            content_disposition_inline(filename),
1342        )
1343        .body(Body::from(file_data))
1344        .map_err(|e| {
1345            error!("Failed to build response: {}", e);
1346            StatusCode::INTERNAL_SERVER_ERROR
1347        })
1348}
1349
1350// Build an RFC 6266 / RFC 5987 Content-Disposition header value for the given
1351// filename. Emits both a safe ASCII `filename=` fallback and a percent-encoded
1352// `filename*=UTF-8''...` parameter so arbitrary bytes (including quotes, CR/LF,
1353// and control characters) in the filename cannot inject additional headers.
1354fn content_disposition_inline(filename: &str) -> String {
1355    let ascii_fallback: String = filename
1356        .chars()
1357        .map(|c| {
1358            if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
1359                c
1360            } else {
1361                '_'
1362            }
1363        })
1364        .collect();
1365    let ascii_fallback = if ascii_fallback.is_empty() {
1366        "download".to_owned()
1367    } else {
1368        ascii_fallback
1369    };
1370    let encoded = utf8_percent_encode(filename, RFC5987_ATTR_CHAR);
1371    format!("inline; filename=\"{ascii_fallback}\"; filename*=UTF-8''{encoded}")
1372}
1373
1374// Validate a user-supplied relative path for the `captured_files.path` column.
1375// Rejects absolute paths, path-traversal segments, Windows drive prefixes, NUL
1376// bytes, and empty values so downstream callers (download URLs, local
1377// reconstruction) cannot be tricked into escaping the run's virtual root.
1378fn sanitize_relative_path(candidate: &str) -> Result<String, &'static str> {
1379    if candidate.is_empty() {
1380        return Err("path is empty");
1381    }
1382    if candidate.contains('\0') {
1383        return Err("path contains NUL byte");
1384    }
1385
1386    // Normalize backslashes to forward slashes so Windows-style separators are
1387    // parsed consistently on all platforms. `Component::Prefix` is only emitted
1388    // when `std::path` parses on Windows, so drive letters are detected
1389    // explicitly below rather than relying on the path parser alone.
1390    let normalized = candidate.replace('\\', "/");
1391    let first_segment = normalized.split('/').next().unwrap_or("");
1392    let mut first_chars = first_segment.chars();
1393    if first_chars.next().is_some_and(|c| c.is_ascii_alphabetic())
1394        && first_chars.next() == Some(':')
1395    {
1396        return Err("path must be relative");
1397    }
1398
1399    let mut out = String::with_capacity(normalized.len());
1400    for component in StdPath::new(&normalized).components() {
1401        match component {
1402            Component::Normal(seg) => {
1403                let seg = seg.to_str().ok_or("path contains invalid UTF-8")?;
1404                if !out.is_empty() {
1405                    out.push('/');
1406                }
1407                out.push_str(seg);
1408            }
1409            Component::CurDir => {}
1410            Component::ParentDir => return Err("path contains '..' segment"),
1411            Component::RootDir | Component::Prefix(_) => return Err("path must be relative"),
1412        }
1413    }
1414
1415    if out.is_empty() {
1416        return Err("path is empty");
1417    }
1418    Ok(out)
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423    use super::{content_disposition_inline, sanitize_relative_path};
1424
1425    #[test]
1426    fn sanitize_rejects_absolute_paths() {
1427        assert!(sanitize_relative_path("/etc/passwd").is_err());
1428        assert!(sanitize_relative_path("\\windows\\system32").is_err());
1429    }
1430
1431    #[test]
1432    fn sanitize_rejects_parent_traversal() {
1433        assert!(sanitize_relative_path("../etc/passwd").is_err());
1434        assert!(sanitize_relative_path("a/../b").is_err());
1435        assert!(sanitize_relative_path("a\\..\\b").is_err());
1436    }
1437
1438    #[test]
1439    fn sanitize_rejects_drive_prefix_and_nul() {
1440        assert!(sanitize_relative_path("C:\\Users").is_err());
1441        assert!(sanitize_relative_path("d:/foo").is_err());
1442        assert!(sanitize_relative_path("foo\0bar").is_err());
1443        assert!(sanitize_relative_path("").is_err());
1444    }
1445
1446    #[test]
1447    fn sanitize_accepts_normal_paths() {
1448        assert_eq!(sanitize_relative_path("foo.txt").unwrap(), "foo.txt");
1449        assert_eq!(
1450            sanitize_relative_path("dir/sub/file.log").unwrap(),
1451            "dir/sub/file.log"
1452        );
1453        // Backslashes are normalized to forward slashes.
1454        assert_eq!(
1455            sanitize_relative_path("dir\\sub\\file.log").unwrap(),
1456            "dir/sub/file.log"
1457        );
1458    }
1459
1460    #[test]
1461    fn sanitize_normalizes_redundant_segments() {
1462        // `Path::components()` collapses empty segments and drops `.` segments.
1463        assert_eq!(sanitize_relative_path("a//b").unwrap(), "a/b");
1464        assert_eq!(sanitize_relative_path("./a/b").unwrap(), "a/b");
1465        assert_eq!(sanitize_relative_path("a/./b").unwrap(), "a/b");
1466        assert_eq!(sanitize_relative_path("a/b/").unwrap(), "a/b");
1467    }
1468
1469    #[test]
1470    fn content_disposition_strips_dangerous_chars_in_fallback() {
1471        let header = content_disposition_inline("a\"b\r\nc.txt");
1472        assert!(header.starts_with("inline; filename=\"a_b__c.txt\";"));
1473        // Must not contain a bare CR or LF in the header.
1474        assert!(!header.contains('\r'));
1475        assert!(!header.contains('\n'));
1476    }
1477
1478    #[test]
1479    fn content_disposition_encodes_unicode_in_filename_star() {
1480        let header = content_disposition_inline("日本語.txt");
1481        assert!(header.contains("filename*=UTF-8''"));
1482        // UTF-8 for '日' starts with 0xE6 0x97 0xA5.
1483        assert!(header.contains("%E6%97%A5"));
1484    }
1485
1486    #[test]
1487    fn content_disposition_percent_encodes_attr_char_boundary() {
1488        // Unreserved ASCII round-trips; spaces and quotes are percent-encoded.
1489        let header = content_disposition_inline("abc.txt");
1490        assert!(header.contains("filename*=UTF-8''abc.txt"));
1491        let header = content_disposition_inline("a b\"c");
1492        assert!(header.contains("filename*=UTF-8''a%20b%22c"));
1493    }
1494}