diff --git a/Cargo.lock b/Cargo.lock index cf18b5616..30f0bd0e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,6 +153,21 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -315,6 +330,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-lock" version = "3.4.2" @@ -1070,6 +1097,27 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" +[[package]] +name = "brotli" +version = "8.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bs58" version = "0.5.1" @@ -1294,6 +1342,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2173,7 +2239,7 @@ dependencies = [ [[package]] name = "fabro-acp" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-tokio", @@ -2192,7 +2258,7 @@ dependencies = [ [[package]] name = "fabro-agent" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2234,7 +2300,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "chrono", "fabro-automation", @@ -2257,7 +2323,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2281,7 +2347,7 @@ dependencies = [ [[package]] name = "fabro-automation" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "croner", "hex", @@ -2296,11 +2362,11 @@ dependencies = [ [[package]] name = "fabro-build-support" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" [[package]] name = "fabro-checkpoint" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -2316,7 +2382,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2418,7 +2484,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -2447,7 +2513,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2476,7 +2542,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "async-trait", "fabro-types", @@ -2491,7 +2557,7 @@ dependencies = [ [[package]] name = "fabro-db" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "sqlx", @@ -2501,7 +2567,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2520,7 +2586,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -2534,7 +2600,7 @@ dependencies = [ [[package]] name = "fabro-environment" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2556,7 +2622,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2578,7 +2644,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -2592,7 +2658,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "async-trait", "fabro-agent", @@ -2615,7 +2681,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "fabro-static", "http 1.4.0", @@ -2625,7 +2691,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2643,7 +2709,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "async-trait", "dialoguer", @@ -2658,7 +2724,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2699,7 +2765,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "clap", "fabro-options-metadata", @@ -2710,7 +2776,7 @@ dependencies = [ [[package]] name = "fabro-manifest" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "fabro-api", @@ -2728,7 +2794,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2748,7 +2814,7 @@ dependencies = [ [[package]] name = "fabro-mcp-server" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2775,7 +2841,7 @@ dependencies = [ [[package]] name = "fabro-mcp-store" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "fabro-types", "serde", @@ -2787,7 +2853,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "fabro-static", "http 1.4.0", @@ -2803,7 +2869,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2825,7 +2891,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "serde", "serde_json", @@ -2833,7 +2899,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "cc", "libc", @@ -2842,7 +2908,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "aho-corasick", "ref-cast", @@ -2858,7 +2924,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2903,7 +2969,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2994,7 +3060,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "fabro-http", "fabro-interview", @@ -3016,18 +3082,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" [[package]] name = "fabro-store" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "async-trait", "bytes", @@ -3054,7 +3120,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -3080,7 +3146,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -3094,7 +3160,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -3119,7 +3185,7 @@ dependencies = [ [[package]] name = "fabro-tool" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3140,7 +3206,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3154,7 +3220,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "chrono", "clap", @@ -3176,7 +3242,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "console 0.15.11", @@ -3197,7 +3263,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "fabro-acp", "fabro-graphviz", @@ -3210,7 +3276,7 @@ dependencies = [ [[package]] name = "fabro-variable" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -3227,7 +3293,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "chrono", "fabro-static", @@ -3240,7 +3306,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -3383,6 +3449,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -5105,6 +5181,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -7302,6 +7379,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "similar" version = "2.7.0" @@ -8239,13 +8322,17 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags", "bytes", + "futures-core", "futures-util", "http 1.4.0", "http-body 1.0.1", "iri-string", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -8382,7 +8469,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "axum", "base64", @@ -8401,7 +8488,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index c1a17580c..415dc26b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.281.0-nightly.0" +version = "0.283.0-nightly.0" license = "MIT" [workspace.dependencies] diff --git a/apps/fabro-web/app/routes/run-overview.test.tsx b/apps/fabro-web/app/routes/run-overview.test.tsx index 0601acf80..4d2e5c692 100644 --- a/apps/fabro-web/app/routes/run-overview.test.tsx +++ b/apps/fabro-web/app/routes/run-overview.test.tsx @@ -19,6 +19,7 @@ mock.module("../lib/queries", () => ({ isLoading: currentGraphLoading, mutate: graphMutateMock, }), + useRunGraphSource: () => ({ data: undefined }), useRunStageEvents: () => ({ data: [] }), })); diff --git a/apps/fabro-web/app/routes/run-overview.tsx b/apps/fabro-web/app/routes/run-overview.tsx index 7abe80333..45ef67061 100644 --- a/apps/fabro-web/app/routes/run-overview.tsx +++ b/apps/fabro-web/app/routes/run-overview.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router"; import { ApiError } from "../lib/api-client"; -import { useRun, useRunGraph, useRunStages } from "../lib/queries"; +import { useRun, useRunGraph, useRunGraphSource, useRunStages } from "../lib/queries"; import { FloatingTooltip } from "../components/floating-tooltip"; import { RunSummaryPanel } from "../components/run-summary-panel"; import { StagePopover } from "../components/stage-popover"; @@ -20,13 +20,24 @@ import { type RunGraphNodeHover, } from "../hooks/use-annotated-run-graph-svg"; -export const handle = { wide: true }; +export const handle = { wide: true, fullHeight: true }; type Direction = "LR" | "TB"; +// Mirrors fabro-graphviz's RANKDIR_RE (lib/crates/fabro-graphviz/src/render.rs) — +// keep the accepted `rankdir=` syntax in sync with that regex. +const RANKDIR_RE = /rankdir\s*=\s*(\w+)/; + +function parseSourceDirection(source: string | undefined): Direction | undefined { + const value = source?.match(RANKDIR_RE)?.[1]; + return value === "LR" || value === "TB" ? value : undefined; +} + export default function RunOverview() { const { id } = useParams(); - const [direction, setDirection] = useState("LR"); + const [direction, setDirection] = useState(undefined); + const sourceQuery = useRunGraphSource(id, direction === undefined); + const activeDirection = direction ?? parseSourceDirection(sourceQuery.data ?? undefined) ?? "TB"; const stagesQuery = useRunStages(id); const graphQuery = useRunGraph(id, direction); const runQuery = useRun(id); @@ -113,17 +124,21 @@ export default function RunOverview() { }, []); return ( -
- +
+
+ +
-
- +
+
+ +
{graphSvg === undefined && graphQuery.isLoading ? ( -
+
) : graphSvg ? ( -
+
diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index b2824d0cc..aa0ae43af 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -1920,7 +1920,7 @@ export default function RunStages() { return (
-
+
@@ -1933,7 +1933,7 @@ export default function RunStages() { {isAgentStage && ( <> -
+
relative(buildDir, output.path)), + result.outputs.map((output: any) => ({ + kind: output.kind, + path: relative(buildDir, output.path), + })), ); await publishBuild(buildDir); @@ -100,15 +103,30 @@ async function copyPierreWorkerAssets(targetDir: string) { } } -async function writeIndexHtml(buildDir: string, outputs: string[]) { +// `kind` mirrors Bun's `BuildArtifact.kind`; the union keeps the +// "entry-point" comparison below typo-safe. +type IndexHtmlOutput = { + kind: "entry-point" | "chunk" | "asset" | "sourcemap" | "bytecode"; + path: string; +}; + +async function writeIndexHtml(buildDir: string, outputs: IndexHtmlOutput[]) { const template = await readFile(templatePath, "utf8"); + // Only entry points get `) + .filter((output) => output.kind === "entry-point" && output.path.endsWith(".js")) + .map((output) => ``) .join("\n "); const styles = [ "/assets/app.css", - ...outputs.filter((path) => path.endsWith(".css")).map((path) => `/${path.replaceAll("\\\\", "/")}`), + ...outputs + .filter((output) => output.path.endsWith(".css")) + .map((output) => `/${output.path.replaceAll("\\\\", "/")}`), ] .filter((value, index, array) => array.indexOf(value) === index) .map((path) => ``) diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index d5c2145e4..6c74b31a4 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -948,7 +948,9 @@ fn build_github_app_manifest(app_name: &str, port: u16, web_url: &str) -> serde_ "pull_requests": "write", "checks": "write", "issues": "write", - "emails": "read" + "emails": "read", + "vulnerability_alerts": "write", + "organization_projects": "write" }, "default_events": [] }) @@ -2662,6 +2664,14 @@ client_id = "client-id" manifest["setup_url"], serde_json::json!("https://app.example.com/setup"), ); + assert_eq!( + manifest["default_permissions"]["vulnerability_alerts"], + serde_json::json!("write"), + ); + assert_eq!( + manifest["default_permissions"]["organization_projects"], + serde_json::json!("write"), + ); } #[tokio::test] diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml index 8627bebb9..3d54fb216 100644 --- a/lib/crates/fabro-server/Cargo.toml +++ b/lib/crates/fabro-server/Cargo.toml @@ -62,7 +62,7 @@ cookie.workspace = true dirs.workspace = true globset.workspace = true tower = "0.5" -tower-http = { version = "0.6", features = ["trace"] } +tower-http = { version = "0.6", features = ["trace", "compression-br", "compression-gzip"] } tokio-stream = { workspace = true, features = ["sync"] } tokio-util.workspace = true base64.workspace = true diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index aae91785a..7afb7a8b2 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -52,7 +52,7 @@ use zeroize::Zeroizing; use crate::error::ApiError; use crate::serve::{self, DEFAULT_TCP_PORT}; use crate::server_secrets::{ServerSecrets, process_env_snapshot}; -use crate::{security_headers, static_files}; +use crate::{security_headers, server, static_files}; #[derive(Clone)] pub struct InstallAppState { @@ -667,6 +667,10 @@ pub fn build_install_router(state: InstallAppState) -> Router { } } })) + // Install mode serves the same multi-megabyte SPA bundle as the main + // router; a first-run setup over a slow link needs compression just + // as much. + .layer(server::compression_layer()) .layer(middleware::from_fn(security_headers::layer)) } @@ -2053,7 +2057,9 @@ fn build_github_app_manifest( "pull_requests": "write", "checks": "write", "issues": "write", - "emails": "read" + "emails": "read", + "vulnerability_alerts": "write", + "organization_projects": "write" }, "default_events": [] }) diff --git a/lib/crates/fabro-server/src/security_headers.rs b/lib/crates/fabro-server/src/security_headers.rs index 9963fa43d..4ca1e6a9e 100644 --- a/lib/crates/fabro-server/src/security_headers.rs +++ b/lib/crates/fabro-server/src/security_headers.rs @@ -72,9 +72,17 @@ fn apply_defaults(headers: &mut HeaderMap, is_https: bool) { // Conservative cache defaults. Routes that deliberately want to cache // (hashed static assets, public GETs) set their own Cache-Control before - // this middleware runs, which prevents the default from being applied. - set_default(headers, header::CACHE_CONTROL, "no-store"); - set_default(headers, header::PRAGMA, "no-cache"); + // this middleware runs. When they have, we must not also stamp the no-cache + // pair: `Pragma: no-cache` next to a long-lived `Cache-Control: immutable` + // is contradictory, and browsers resolve it by revalidating on every load. + // Since these assets carry no ETag/Last-Modified, that revalidation + // degrades into a full re-download each time. Apply the no-store/no-cache + // defaults only to responses that haven't opted into caching; a present + // Cache-Control is the signal that the handler chose its own policy. + if !headers.contains_key(header::CACHE_CONTROL) { + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache")); + } set_default(headers, header::VARY, "Accept-Encoding"); // HSTS is a no-op over plain HTTP per RFC 6797, but only emit it on @@ -202,7 +210,10 @@ mod tests { #[test] fn existing_cache_control_is_not_overridden() { // Static assets set their own cache-control with long immutability. - // The middleware default must not clobber it. + // The middleware default must not clobber it, and must not stamp a + // contradictory `Pragma: no-cache` alongside it — that combination + // forces browsers to revalidate (and, absent validators, re-download) + // supposedly-immutable assets on every load. let headers = headers_after(&req("/assets/app-abc.js", &[]), &[( "cache-control", "public, max-age=31536000, immutable", @@ -211,6 +222,10 @@ mod tests { headers.get("cache-control").unwrap(), "public, max-age=31536000, immutable" ); + assert!( + !headers.contains_key("pragma"), + "cacheable responses must not carry Pragma: no-cache" + ); } #[test] diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 254dc9658..c246e396d 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -137,6 +137,7 @@ use tokio_stream::StreamExt; use tokio_stream::wrappers::{BroadcastStream, UnboundedReceiverStream}; use tokio_util::sync::CancellationToken; use tower::{ServiceExt, service_fn}; +use tower_http::compression::{CompressionLayer, CompressionLevel}; use tracing::{Instrument, debug, error, info, warn}; use ulid::Ulid; @@ -1852,6 +1853,10 @@ pub fn build_router_with_options( } router + // Innermost of the outer layers so every response body — static SPA + // assets and JSON API alike — is compressed before the header/log + // middlewares see it. + .layer(compression_layer()) .layer(middleware::from_fn_with_state( canonical_host::Config { state: state_for_canonical_host, @@ -1864,6 +1869,17 @@ pub fn build_router_with_options( .layer(middleware::from_fn(request_id::layer)) } +/// Response-compression layer shared by the main and install-mode routers. +/// +/// The default predicate skips streaming SSE (`text/event-stream`), gRPC, +/// images, and tiny bodies. The quality is pinned because tower-http's +/// default defers to each codec's own default, and brotli's is quality 11 — +/// seconds of CPU on a multi-megabyte asset. Level 4 keeps both codecs fast +/// at a near-optimal ratio. +pub(crate) fn compression_layer() -> CompressionLayer { + CompressionLayer::new().quality(CompressionLevel::Precise(4)) +} + async fn http_log_middleware(mut req: axum_extract::Request, next: Next) -> Response { let path = req.uri().path(); if path.starts_with("/assets/") || path.starts_with("/images/") { diff --git a/lib/crates/fabro-server/src/static_files.rs b/lib/crates/fabro-server/src/static_files.rs index 590a97f82..cb5c500a2 100644 --- a/lib/crates/fabro-server/src/static_files.rs +++ b/lib/crates/fabro-server/src/static_files.rs @@ -1,10 +1,12 @@ +use std::borrow::Cow; use std::path::{Path, PathBuf}; use std::sync::OnceLock; -use axum::body::Body; +use axum::body::{Body, Bytes}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use fabro_static::EnvVars; +use sha2::{Digest, Sha256}; use tokio::fs; use crate::csp; @@ -100,9 +102,8 @@ async fn load_injected_install_shell( asset_root: Option<&Path>, dev_disk_only: bool, ) -> Option> { - Some(inject_install_mode( - load_asset("index.html", asset_root, dev_disk_only).await?, - )) + let shell = load_asset("index.html", asset_root, dev_disk_only).await?; + Some(inject_install_mode(shell.bytes.into())) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -125,7 +126,7 @@ async fn serve_with_mode( } if let Some(asset) = load_asset_for_mode(&normalized, mode, asset_root, dev_disk_only).await { - return asset_response(&normalized, asset); + return asset_response(&normalized, asset, headers); } // SPA fallback: serve index.html only for browser navigations that @@ -136,7 +137,7 @@ async fn serve_with_mode( if let Some(index) = load_asset_for_mode("index.html", mode, asset_root, dev_disk_only).await { - return asset_response("index.html", index); + return asset_response("index.html", index, headers); } if dev_disk_only { return build_in_progress_response(); @@ -187,7 +188,39 @@ fn normalize(path: &str) -> String { } } -async fn load_asset(path: &str, asset_root: Option<&Path>, dev_disk_only: bool) -> Option> { +/// An asset body plus, when the source precomputed it (the embedded SPA +/// snapshot), its SHA-256. Carrying the hash lets mutable-asset ETags reuse +/// rust-embed's compile-time digest instead of rehashing process-lifetime +/// bytes on every revalidation. +struct Asset { + bytes: Bytes, + sha256: Option<[u8; 32]>, +} + +impl Asset { + fn from_vec(bytes: Vec) -> Self { + Self { + bytes: bytes.into(), + sha256: None, + } + } + + fn from_embedded(asset: fabro_spa::AssetBytes) -> Self { + let sha256 = asset.sha256(); + let bytes = match asset.into_cow() { + // Release builds embed assets as statics; serve them without + // copying the (potentially multi-megabyte) body per request. + Cow::Borrowed(bytes) => Bytes::from_static(bytes), + Cow::Owned(bytes) => Bytes::from(bytes), + }; + Self { + bytes, + sha256: Some(sha256), + } + } +} + +async fn load_asset(path: &str, asset_root: Option<&Path>, dev_disk_only: bool) -> Option { if spa_assets_disabled_for_test() { return None; } @@ -196,11 +229,11 @@ async fn load_asset(path: &str, asset_root: Option<&Path>, dev_disk_only: bool) // workspace's live `dist/` fallback or test isolation breaks. if let Some(root) = asset_root { if let Some(bytes) = read_disk_asset_from_root(root, path).await { - return Some(bytes); + return Some(Asset::from_vec(bytes)); } } else if cfg!(debug_assertions) { if let Some(bytes) = read_disk_asset(path).await { - return Some(bytes); + return Some(Asset::from_vec(bytes)); } } @@ -211,7 +244,7 @@ async fn load_asset(path: &str, asset_root: Option<&Path>, dev_disk_only: bool) return None; } - fabro_spa::get(path).map(fabro_spa::AssetBytes::into_vec) + fabro_spa::get(path).map(Asset::from_embedded) } async fn load_asset_for_mode( @@ -219,9 +252,11 @@ async fn load_asset_for_mode( mode: SpaMode, asset_root: Option<&Path>, dev_disk_only: bool, -) -> Option> { +) -> Option { if mode == SpaMode::Install && path == "index.html" { - return cached_install_mode_shell(asset_root, dev_disk_only).await; + return cached_install_mode_shell(asset_root, dev_disk_only) + .await + .map(Asset::from_vec); } load_asset(path, asset_root, dev_disk_only).await } @@ -276,43 +311,113 @@ fn disk_asset_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../apps/fabro-web/dist") } -fn asset_response(path: &str, bytes: Vec) -> Response { +const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable"; +const REVALIDATE_CACHE_CONTROL: &str = "no-cache"; + +fn asset_response(path: &str, asset: Asset, request_headers: &HeaderMap) -> Response { + let content_hashed = is_content_hashed(path); + let cache_control = cache_control(content_hashed); + // Mutable assets keep stable names across deploys, so their `no-cache` + // policy needs a validator to revalidate as a cheap 304 instead of a full + // body download on every use. Hashed immutable assets never revalidate, + // so an ETag would be dead weight. + let etag = (!content_hashed).then(|| asset_etag(&asset)); + + if let Some(etag) = &etag { + if if_none_match_matches(request_headers, etag) { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::NOT_MODIFIED; + apply_cache_headers(response.headers_mut(), cache_control, Some(etag)); + return response; + } + } + let mime = mime_guess::from_path(path).first_or_octet_stream(); - let mut response = Response::new(Body::from(bytes)); + let mut response = Response::new(Body::from(asset.bytes)); *response.status_mut() = StatusCode::OK; response.headers_mut().insert( header::CONTENT_TYPE, HeaderValue::from_str(mime.as_ref()) .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")), ); - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static(cache_control(path)), - ); + apply_cache_headers(response.headers_mut(), cache_control, etag.as_deref()); response } -fn cache_control(path: &str) -> &'static str { - if path.contains("/assets/") || path.contains('-') && has_hashed_extension(path) { - "public, max-age=31536000, immutable" - } else { - "no-cache" +fn apply_cache_headers(headers: &mut HeaderMap, cache_control: &'static str, etag: Option<&str>) { + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(cache_control), + ); + if let Some(etag) = etag { + if let Ok(value) = HeaderValue::from_str(etag) { + headers.insert(header::ETAG, value); + } } } -fn has_hashed_extension(path: &str) -> bool { - Path::new(path) - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| { - let mut parts = name.split('.'); - let Some(stem) = parts.next() else { - return false; - }; - stem.split('-').count() > 1 +fn asset_etag(asset: &Asset) -> String { + let digest = asset + .sha256 + .unwrap_or_else(|| Sha256::digest(&asset.bytes).into()); + format!("\"{}\"", hex::encode(digest)) +} + +fn if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool { + headers + .get(header::IF_NONE_MATCH) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value.split(',').map(str::trim).any(|candidate| { + candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag + }) }) } +fn cache_control(content_hashed: bool) -> &'static str { + if content_hashed { + IMMUTABLE_CACHE_CONTROL + } else { + REVALIDATE_CACHE_CONTROL + } +} + +/// True only for the bundler's content-hashed outputs: files directly under +/// `assets/` named `-.js|css` with an 8-char lowercase base-36 +/// hash (e.g. `assets/entry-0sv53bs3.js`). Only those names change whenever +/// their bytes change, which is what makes a year-long `immutable` policy +/// safe. +/// +/// Stable-named files must NOT match — `index.html`, `assets/app.css`, the +/// pierre-diffs worker under `assets/pierre-diffs-worker/`, images — because +/// caching those immutably pins stale copies in browsers across deploys. +/// When in doubt this classifier says "not hashed": the cost of a false +/// negative is one 304 revalidation, the cost of a false positive is a +/// wrongly-pinned asset for up to a year. +fn is_content_hashed(path: &str) -> bool { + let Some(file_name) = path.trim_start_matches('/').strip_prefix("assets/") else { + return false; + }; + if file_name.contains('/') { + // Subdirectories under assets/ (the pierre-diffs worker) hold + // stable-named files copied verbatim from their package. + return false; + } + let Some((stem, extension)) = file_name.rsplit_once('.') else { + return false; + }; + if !matches!(extension, "js" | "css") { + return false; + } + let Some((_, hash)) = stem.rsplit_once('-') else { + return false; + }; + hash.len() == 8 + && hash + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) +} + fn is_source_map(path: &str) -> bool { Path::new(path) .extension() @@ -328,8 +433,8 @@ mod tests { use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use super::{ - accepts_html, cache_control, inject_install_mode, is_source_map, read_disk_asset_from_root, - serve_with_asset_root, + accepts_html, cache_control, inject_install_mode, is_content_hashed, is_source_map, + read_disk_asset_from_root, serve_with_asset_root, }; fn headers_with_accept(value: &str) -> HeaderMap { @@ -364,11 +469,126 @@ mod tests { #[test] fn hashed_assets_are_cached_immutably() { + for path in [ + "assets/entry-0sv53bs3.js", + "assets/chunk-4tr91ktd.js", + "assets/chunk-x912wb67.css", + ] { + assert!(is_content_hashed(path), "{path} should be content-hashed"); + } + assert_eq!(cache_control(true), "public, max-age=31536000, immutable"); + } + + #[test] + fn stable_named_assets_must_revalidate() { + // Files whose names do NOT change when their bytes change would be + // pinned stale in browsers for a year if marked immutable. + for path in [ + "index.html", + "assets/app.css", + "assets/pierre-diffs-worker/worker-portable.js", + "images/apple-touch-icon.png", + // Dash segment that isn't an 8-char lowercase base-36 hash. + "assets/entry-abc123.js", + // Right hash shape, but not a bundler output extension. + "assets/photo-a1b2c3d4.png", + ] { + assert!( + !is_content_hashed(path), + "{path} should not be content-hashed" + ); + } + assert_eq!(cache_control(false), "no-cache"); + } + + #[tokio::test] + async fn mutable_assets_serve_etag_and_conditional_304() { + let temp_dir = tempfile::tempdir().unwrap(); + let asset_path = temp_dir.path().join("assets/app.css"); + std::fs::create_dir_all(asset_path.parent().unwrap()).unwrap(); + std::fs::write(&asset_path, b"body { color: red }").unwrap(); + + let first = serve_with_asset_root( + "/assets/app.css", + &HeaderMap::new(), + Some(temp_dir.path()), + false, + ) + .await; + assert_eq!(first.status(), StatusCode::OK); assert_eq!( - cache_control("assets/entry-abc123.js"), - "public, max-age=31536000, immutable" + first.headers().get(header::CACHE_CONTROL).unwrap(), + "no-cache" + ); + let etag = first + .headers() + .get(header::ETAG) + .expect("mutable assets should carry an ETag validator") + .clone(); + + let mut conditional = HeaderMap::new(); + conditional.insert(header::IF_NONE_MATCH, etag.clone()); + let second = serve_with_asset_root( + "/assets/app.css", + &conditional, + Some(temp_dir.path()), + false, + ) + .await; + assert_eq!(second.status(), StatusCode::NOT_MODIFIED); + assert_eq!(second.headers().get(header::ETAG).unwrap(), &etag); + assert_eq!( + second.headers().get(header::CACHE_CONTROL).unwrap(), + "no-cache" + ); + let bytes = axum::body::to_bytes(second.into_body(), usize::MAX) + .await + .unwrap(); + assert!(bytes.is_empty(), "304 must not carry a body"); + } + + #[tokio::test] + async fn stale_if_none_match_gets_full_response() { + let temp_dir = tempfile::tempdir().unwrap(); + let asset_path = temp_dir.path().join("assets/app.css"); + std::fs::create_dir_all(asset_path.parent().unwrap()).unwrap(); + std::fs::write(&asset_path, b"body { color: red }").unwrap(); + + let mut conditional = HeaderMap::new(); + conditional.insert( + header::IF_NONE_MATCH, + HeaderValue::from_static("\"0000stale0000\""), + ); + let response = serve_with_asset_root( + "/assets/app.css", + &conditional, + Some(temp_dir.path()), + false, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().contains_key(header::ETAG)); + } + + #[tokio::test] + async fn immutable_assets_skip_etag() { + let temp_dir = tempfile::tempdir().unwrap(); + let asset_path = temp_dir.path().join("assets/entry-0sv53bs3.js"); + std::fs::create_dir_all(asset_path.parent().unwrap()).unwrap(); + std::fs::write(&asset_path, b"console.log(1)").unwrap(); + + let response = serve_with_asset_root( + "/assets/entry-0sv53bs3.js", + &HeaderMap::new(), + Some(temp_dir.path()), + false, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert!( + !response.headers().contains_key(header::ETAG), + "immutable assets never revalidate, so a validator is dead weight" ); - assert_eq!(cache_control("index.html"), "no-cache"); } #[tokio::test] diff --git a/lib/crates/fabro-server/tests/it/api/compression.rs b/lib/crates/fabro-server/tests/it/api/compression.rs new file mode 100644 index 000000000..fe3872840 --- /dev/null +++ b/lib/crates/fabro-server/tests/it/api/compression.rs @@ -0,0 +1,202 @@ +//! Response compression on the outer router. +//! +//! The compression layer sits at the outermost edge of `build_router`, so +//! these tests exercise it through the full middleware stack rather than in +//! isolation. `/api/v1/openapi.json` is used as the probe response: it is a +//! multi-hundred-KB JSON body, comfortably above the compression size floor. + +#![expect( + clippy::disallowed_methods, + reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path" +)] + +use std::net::SocketAddr; + +use axum::Router; +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use fabro_server::server::RouterOptions; +use tempfile::TempDir; +use tower::ServiceExt; + +use crate::helpers::{api, test_app_state}; + +/// Router serving an SPA shell comfortably above the compression size floor, +/// through the same fallback service production uses for static assets. +fn spa_router_with_big_index() -> (Router, TempDir) { + let temp_dir = tempfile::tempdir().expect("SPA fixture tempdir should create"); + std::fs::write( + temp_dir.path().join("index.html"), + format!("spa{}", "x".repeat(8192)), + ) + .expect("SPA fixture index.html should write"); + let app = fabro_server::test_support::build_test_router_with_options( + test_app_state(), + RouterOptions { + web_enabled: true, + static_asset_root: Some(temp_dir.path().to_path_buf()), + ..RouterOptions::default() + }, + ); + (app, temp_dir) +} + +async fn serve_on_ephemeral_port(app: Router) -> SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("test TCP listener should bind"); + let addr = listener + .local_addr() + .expect("test TCP listener should have a local address"); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + addr +} + +fn openapi_request(accept_encoding: Option<&str>) -> Request { + let mut builder = Request::builder().method("GET").uri(api("/openapi.json")); + if let Some(encoding) = accept_encoding { + builder = builder.header(header::ACCEPT_ENCODING, encoding); + } + builder + .body(Body::empty()) + .expect("openapi request should build") +} + +#[tokio::test] +async fn responses_are_gzip_compressed_when_client_accepts_gzip() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + let response = app.oneshot(openapi_request(Some("gzip"))).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_ENCODING) + .and_then(|v| v.to_str().ok()), + Some("gzip"), + "large JSON responses should be gzip-compressed when the client asks" + ); +} + +#[tokio::test] +async fn responses_are_brotli_compressed_when_client_prefers_br() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + let response = app + .oneshot(openapi_request(Some("gzip, br"))) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_ENCODING) + .and_then(|v| v.to_str().ok()), + Some("br"), + "brotli should win encoding negotiation when offered" + ); +} + +#[tokio::test] +async fn spa_assets_are_compressed() { + // SPA assets are served by the router's fallback service, not a regular + // route — this test pins that compression covers that path too, since the + // multi-megabyte JS bundle is the single largest thing the server sends. + let (app, _temp_dir) = spa_router_with_big_index(); + let request = Request::builder() + .method("GET") + .uri("/") + .header(header::ACCEPT, "text/html") + .header(header::ACCEPT_ENCODING, "gzip") + .body(Body::empty()) + .expect("spa request should build"); + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_ENCODING) + .and_then(|v| v.to_str().ok()), + Some("gzip"), + "SPA shell served through the fallback must be compressed" + ); +} + +#[tokio::test] +async fn compression_applies_over_a_real_tcp_connection() { + // `oneshot` exercises the tower stack directly; this pins the same + // behavior through hyper's real connection handling, matching how the + // production server actually serves (`axum::serve`). + let app = fabro_server::test_support::build_test_router(test_app_state()); + let addr = serve_on_ephemeral_port(app).await; + + let response = fabro_test::test_http_client() + .get(format!("http://{addr}/api/v1/openapi.json")) + // Setting the header manually also disables reqwest's transparent + // decompression, so Content-Encoding stays visible on the response. + .header(header::ACCEPT_ENCODING.as_str(), "gzip") + .send() + .await + .expect("openapi request should succeed"); + + assert_eq!(response.status(), fabro_http::StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_ENCODING.as_str()) + .and_then(|v| v.to_str().ok()), + Some("gzip"), + "compression must survive real hyper serving, not just oneshot" + ); +} + +#[tokio::test] +async fn spa_assets_compress_over_a_real_tcp_connection() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let (app, _temp_dir) = spa_router_with_big_index(); + let addr = serve_on_ephemeral_port(app).await; + + // Raw HTTP/1.1 over the socket: no client-side redirect following or + // transparent decompression can distort what the server actually sent. + // Host matches the test state's canonical origin so the canonical-host + // redirect stays out of the way. + let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap(); + stream + .write_all( + b"GET / HTTP/1.1\r\nHost: localhost:3000\r\nAccept: text/html\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + let mut raw = Vec::new(); + stream.read_to_end(&mut raw).await.unwrap(); + let head_len = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .expect("response should have a header block"); + let head = String::from_utf8_lossy(&raw[..head_len]).to_lowercase(); + + assert!( + head.starts_with("http/1.1 200"), + "unexpected response: {head}" + ); + assert!( + head.contains("content-encoding: gzip"), + "fallback-served SPA shell must compress over real TCP; got:\n{head}" + ); +} + +#[tokio::test] +async fn responses_stay_identity_encoded_without_accept_encoding() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + let response = app.oneshot(openapi_request(None)).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert!( + !response.headers().contains_key(header::CONTENT_ENCODING), + "clients that don't advertise Accept-Encoding must get identity bodies" + ); +} diff --git a/lib/crates/fabro-server/tests/it/api/mod.rs b/lib/crates/fabro-server/tests/it/api/mod.rs index 9fe4bcbc0..7fcdf254a 100644 --- a/lib/crates/fabro-server/tests/it/api/mod.rs +++ b/lib/crates/fabro-server/tests/it/api/mod.rs @@ -1,6 +1,7 @@ mod auth_sessions; mod automations; mod cli_auth_token; +mod compression; mod docs; mod environments; mod events; diff --git a/lib/crates/fabro-spa/src/lib.rs b/lib/crates/fabro-spa/src/lib.rs index 66defab64..dd050d0c9 100644 --- a/lib/crates/fabro-spa/src/lib.rs +++ b/lib/crates/fabro-spa/src/lib.rs @@ -8,24 +8,43 @@ use rust_embed::RustEmbed; #[exclude = "**/*.map"] struct EmbeddedAssets; -pub struct AssetBytes(Cow<'static, [u8]>); +pub struct AssetBytes { + data: Cow<'static, [u8]>, + sha256: [u8; 32], +} impl AssetBytes { #[must_use] pub fn into_vec(self) -> Vec { - self.0.into_owned() + self.data.into_owned() + } + + #[must_use] + pub fn into_cow(self) -> Cow<'static, [u8]> { + self.data + } + + /// SHA-256 of the asset bytes. rust-embed computes it at compile time in + /// release builds, so callers can use it as a validator without rehashing + /// the body per request. + #[must_use] + pub fn sha256(&self) -> [u8; 32] { + self.sha256 } } impl AsRef<[u8]> for AssetBytes { fn as_ref(&self) -> &[u8] { - self.0.as_ref() + self.data.as_ref() } } #[must_use] pub fn get(path: &str) -> Option { - EmbeddedAssets::get(path).map(|file| AssetBytes(file.data)) + EmbeddedAssets::get(path).map(|file| AssetBytes { + sha256: file.metadata.sha256_hash(), + data: file.data, + }) } #[cfg(test)] diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index c2d803c28..463a55609 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -397,7 +397,7 @@ impl RunSession { .iter() .map(|(key, entry)| match entry { ResolvedMcpEntry::Resolved(server) => { - runtime_mcp_server(server, process_env_var, |name| secret_lookup(name)) + runtime_mcp_server(server, process_env_var, secret_lookup) } // References must be resolved to concrete servers before the run // spec is persisted (server-side run-preparation pass). Reaching @@ -429,9 +429,7 @@ impl RunSession { SandboxSpec::Local { working_directory } } SandboxProviderKind::Docker => SandboxSpec::Docker { - config: Box::new(resolve_docker_config(resolved, |name| { - secret_lookup(name) - })?), + config: Box::new(resolve_docker_config(resolved, secret_lookup)?), github_app: services.github_app.clone(), run_id: Some(record.run_id), clone_origin_url: record.repo_origin_url().map(str::to_string), @@ -455,7 +453,7 @@ impl RunSession { let toml_env = resolved .environment - .resolve_env(process_env_var, |name| secret_lookup(name)) + .resolve_env(process_env_var, secret_lookup) .map_err(|err| Error::engine_with_source("failed to resolve run environment", err))?; let github_permissions: Option> = (!services.github_permissions.is_empty()).then(|| services.github_permissions.clone()); @@ -473,9 +471,8 @@ impl RunSession { }; let pr_config = resolved.pull_request.clone(); - let setup_commands = runtime_setup_commands(&resolved.prepare, process_env_var, |name| { - secret_lookup(name) - })?; + let setup_commands = + runtime_setup_commands(&resolved.prepare, process_env_var, secret_lookup)?; drop(vault_guard); Ok(Self {