Skip to main content

presolve_cli/
cloudflare_deployment.rs

1//! Cloudflare Workers Static Assets projection over compiler-published routes.
2//!
3//! The adapter validates compiler artifacts and executes only a compiler-issued
4//! route table. It never reads application source or adds a framework runtime.
5
6use std::collections::BTreeSet;
7use std::fs;
8use std::path::Path;
9
10use presolve_compiler::{ApplicationPublicationArtifactV1, FileRoutePublicationManifestV1};
11use serde::Serialize;
12use sha2::{Digest as _, Sha256};
13
14pub const CLOUDFLARE_WORKERS_STATIC_DEPLOYMENT_SCHEMA_VERSION: u32 = 1;
15/// Tested Workers compatibility target for the current Presolve release train.
16pub const CLOUDFLARE_WORKERS_COMPATIBILITY_DATE_V1: &str = "2026-07-23";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct CloudflareWorkersDeploymentOptionsV1 {
20    pub worker_name: String,
21    pub compatibility_date: String,
22    pub required_secrets: Vec<String>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CloudflareWorkersDeploymentErrorV1 {
27    pub code: &'static str,
28    pub message: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct CloudflareWorkersStaticDeploymentPlanV1 {
33    pub schema_version: u32,
34    pub provider: String,
35    pub worker_name: String,
36    pub compatibility_date: String,
37    pub release_id: String,
38    pub required_secrets: Vec<String>,
39    pub assets_binding: String,
40    pub routes: Vec<CloudflareWorkersStaticRouteV1>,
41    pub artifacts: Vec<CloudflareWorkersArtifactV1>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct CloudflareWorkersStaticRouteV1 {
46    pub path: String,
47    pub artifact_root: String,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
51pub struct CloudflareWorkersArtifactV1 {
52    pub path: String,
53    pub digest: String,
54}
55
56/// Projects one compiler file-route manifest into immutable Cloudflare facts.
57///
58/// # Errors
59///
60/// Returns stable errors for invalid public configuration or unsafe compiler
61/// artifact paths. This does not read application source.
62pub fn build_cloudflare_workers_static_deployment_plan_v1(
63    manifest: &FileRoutePublicationManifestV1,
64    options: &CloudflareWorkersDeploymentOptionsV1,
65) -> Result<CloudflareWorkersStaticDeploymentPlanV1, CloudflareWorkersDeploymentErrorV1> {
66    if !is_worker_name(&options.worker_name) {
67        return Err(CloudflareWorkersDeploymentErrorV1 {
68            code: "PSCFL1001_WORKER_NAME_INVALID",
69            message: options.worker_name.clone(),
70        });
71    }
72    if !is_compatibility_date(&options.compatibility_date) {
73        return Err(CloudflareWorkersDeploymentErrorV1 {
74            code: "PSCFL1002_COMPATIBILITY_DATE_INVALID",
75            message: options.compatibility_date.clone(),
76        });
77    }
78    let required_secrets = canonical_secret_names(&options.required_secrets)?;
79    let mut artifacts = manifest
80        .artifacts
81        .iter()
82        .map(cloudflare_artifact)
83        .collect::<Result<Vec<_>, _>>()?;
84    artifacts.sort_by(|left, right| left.path.cmp(&right.path));
85    let routes = manifest
86        .routes
87        .iter()
88        .map(|route| CloudflareWorkersStaticRouteV1 {
89            path: route.path.clone(),
90            artifact_root: route.artifact_root.clone(),
91        })
92        .collect::<Vec<_>>();
93    if routes.is_empty() {
94        return Err(CloudflareWorkersDeploymentErrorV1 {
95            code: "PSCFL1003_ROUTE_SET_EMPTY",
96            message: "Cloudflare deployment requires at least one compiler route".into(),
97        });
98    }
99    if routes
100        .iter()
101        .any(|route| !is_route_path(&route.path) || !is_artifact_root(&route.artifact_root))
102    {
103        return Err(CloudflareWorkersDeploymentErrorV1 {
104            code: "PSCFL1004_ROUTE_PROJECTION_INVALID",
105            message: "compiler route manifest contains an unsafe Cloudflare projection".into(),
106        });
107    }
108    let release_id = format!(
109        "sha256:{:x}",
110        Sha256::digest(
111            artifacts
112                .iter()
113                .map(|artifact| format!("{}:{}\n", artifact.path, artifact.digest))
114                .collect::<String>(),
115        )
116    );
117    Ok(CloudflareWorkersStaticDeploymentPlanV1 {
118        schema_version: CLOUDFLARE_WORKERS_STATIC_DEPLOYMENT_SCHEMA_VERSION,
119        provider: "cloudflare_workers_static_assets".into(),
120        worker_name: options.worker_name.clone(),
121        compatibility_date: options.compatibility_date.clone(),
122        release_id,
123        required_secrets,
124        assets_binding: "ASSETS".into(),
125        routes,
126        artifacts,
127    })
128}
129
130/// Confirms that provider-upload bytes still equal the compiler inventory.
131///
132/// # Errors
133///
134/// Returns an error for a missing or modified artifact.
135pub fn validate_cloudflare_workers_artifacts_v1(
136    output_root: &Path,
137    plan: &CloudflareWorkersStaticDeploymentPlanV1,
138) -> Result<(), CloudflareWorkersDeploymentErrorV1> {
139    for artifact in &plan.artifacts {
140        let path = output_root.join(&artifact.path);
141        let bytes = fs::read(&path).map_err(|error| CloudflareWorkersDeploymentErrorV1 {
142            code: "PSCFL1005_ARTIFACT_READ_FAILED",
143            message: format!("{}: {error}", path.display()),
144        })?;
145        if format!("sha256:{:x}", Sha256::digest(&bytes)) != artifact.digest {
146            return Err(CloudflareWorkersDeploymentErrorV1 {
147                code: "PSCFL1006_ARTIFACT_INTEGRITY_MISMATCH",
148                message: artifact.path.clone(),
149            });
150        }
151    }
152    Ok(())
153}
154
155#[must_use]
156pub fn cloudflare_workers_static_plan_json_v1(
157    plan: &CloudflareWorkersStaticDeploymentPlanV1,
158) -> String {
159    serde_json::to_string_pretty(plan).expect("Cloudflare static plan serializes") + "\n"
160}
161
162/// Generates a Worker that consumes only the canonical route table. It is a
163/// static asset executor, not a generic server or client router.
164#[must_use]
165pub fn cloudflare_workers_static_worker_module_v1(
166    plan: &CloudflareWorkersStaticDeploymentPlanV1,
167) -> String {
168    let routes = serde_json::to_string(&plan.routes).expect("Cloudflare routes serialize");
169    format!(
170        "// Generated by Presolve. Do not edit.\n\
171const routes = {routes};\n\
172function segments(pathname) {{ return pathname.split('/').filter(Boolean); }}\n\
173function score(parts) {{ return [parts.filter((value) => !value.startsWith(':')).length, parts.length]; }}\n\
174function routeFor(requested) {{\n\
175  let selected;\n\
176  for (const route of routes) {{\n\
177    const parts = segments(route.path);\n\
178    if (requested.length < parts.length) continue;\n\
179    if (!parts.every((part, index) => part.startsWith(':') ? requested[index].length > 0 : part === requested[index])) continue;\n\
180    const candidate = score(parts);\n\
181    if (!selected || candidate[0] > selected.score[0] || (candidate[0] === selected.score[0] && candidate[1] > selected.score[1])) selected = {{ route, parts, score: candidate }};\n\
182  }}\n\
183  return selected;\n\
184}}\n\
185function safeAssetSegments(values) {{ return values.every((value) => value && value !== '.' && value !== '..' && !value.includes('\\\\')); }}\n\
186export default {{ async fetch(request, env) {{\n\
187  if (request.method !== 'GET' && request.method !== 'HEAD') return new Response('Method Not Allowed\\n', {{ status: 405, headers: {{ Allow: 'GET, HEAD' }} }});\n\
188  const url = new URL(request.url);\n\
189  const direct = await env.ASSETS.fetch(request);\n\
190  if (direct.status !== 404) return direct;\n\
191  const requested = segments(url.pathname);\n\
192  const selected = routeFor(requested);\n\
193  if (!selected) return new Response('Not Found\\n', {{ status: 404 }});\n\
194  if (requested.length === selected.parts.length) {{\n\
195    if (selected.route.path !== '/' && !url.pathname.endsWith('/')) return Response.redirect(new URL(`${{url.pathname}}/`, url).toString(), 308);\n\
196    return env.ASSETS.fetch(new Request(new URL(`/${{selected.route.artifact_root}}/index.html`, url), request));\n\
197  }}\n\
198  const suffix = requested.slice(selected.parts.length);\n\
199  if (!safeAssetSegments(suffix)) return new Response('Not Found\\n', {{ status: 404 }});\n\
200  return env.ASSETS.fetch(new Request(new URL(`/${{selected.route.artifact_root}}/${{suffix.join('/')}}`, url), request));\n\
201}} }};\n"
202    )
203}
204
205#[must_use]
206pub fn cloudflare_workers_wrangler_jsonc_v1(
207    plan: &CloudflareWorkersStaticDeploymentPlanV1,
208    assets_directory: &str,
209) -> String {
210    let mut value = serde_json::json!({
211        "name": plan.worker_name,
212        "main": "worker.mjs",
213        "compatibility_date": plan.compatibility_date,
214        "assets": {
215            "directory": assets_directory,
216            "binding": plan.assets_binding,
217            "run_worker_first": true,
218        },
219    });
220    if !plan.required_secrets.is_empty() {
221        value["secrets"] = serde_json::json!({ "required": plan.required_secrets });
222    }
223    serde_json::to_string_pretty(&value).expect("Wrangler configuration serializes") + "\n"
224}
225
226fn cloudflare_artifact(
227    artifact: &ApplicationPublicationArtifactV1,
228) -> Result<CloudflareWorkersArtifactV1, CloudflareWorkersDeploymentErrorV1> {
229    if !is_artifact_path(&artifact.path) || !artifact.digest.starts_with("sha256:") {
230        return Err(CloudflareWorkersDeploymentErrorV1 {
231            code: "PSCFL1007_ARTIFACT_PROJECTION_INVALID",
232            message: artifact.path.clone(),
233        });
234    }
235    Ok(CloudflareWorkersArtifactV1 {
236        path: artifact.path.clone(),
237        digest: artifact.digest.clone(),
238    })
239}
240
241fn canonical_secret_names(
242    values: &[String],
243) -> Result<Vec<String>, CloudflareWorkersDeploymentErrorV1> {
244    let mut names = BTreeSet::new();
245    for value in values {
246        if !is_secret_name(value) {
247            return Err(CloudflareWorkersDeploymentErrorV1 {
248                code: "PSCFL1008_SECRET_NAME_INVALID",
249                message: value.clone(),
250            });
251        }
252        names.insert(value.clone());
253    }
254    Ok(names.into_iter().collect())
255}
256
257fn is_worker_name(value: &str) -> bool {
258    !value.is_empty()
259        && value.len() <= 63
260        && value.chars().all(|character| {
261            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
262        })
263}
264
265fn is_compatibility_date(value: &str) -> bool {
266    value.len() == 10
267        && value.as_bytes()[4] == b'-'
268        && value.as_bytes()[7] == b'-'
269        && value
270            .bytes()
271            .enumerate()
272            .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit())
273}
274
275fn is_secret_name(value: &str) -> bool {
276    value
277        .chars()
278        .next()
279        .is_some_and(|character| character.is_ascii_uppercase() || character == '_')
280        && value.chars().all(|character| {
281            character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'
282        })
283}
284
285fn is_route_path(value: &str) -> bool {
286    value.starts_with('/') && !value.contains('?') && !value.contains('#') && !value.contains('\\')
287}
288
289fn is_artifact_root(value: &str) -> bool {
290    is_artifact_path(value) && value.starts_with("routes/")
291}
292
293fn is_artifact_path(value: &str) -> bool {
294    !value.is_empty()
295        && !value.starts_with('/')
296        && value.split('/').all(|segment| {
297            !segment.is_empty() && segment != "." && segment != ".." && !segment.contains('\\')
298        })
299}
300
301#[cfg(test)]
302mod tests {
303    use std::fs;
304
305    use presolve_compiler::{
306        ApplicationPublicationArtifactV1, FileRoutePublicationManifestV1,
307        FileRoutePublicationRouteV1,
308    };
309    use sha2::{Digest as _, Sha256};
310
311    use super::{
312        build_cloudflare_workers_static_deployment_plan_v1,
313        cloudflare_workers_static_worker_module_v1, cloudflare_workers_wrangler_jsonc_v1,
314        validate_cloudflare_workers_artifacts_v1, CloudflareWorkersDeploymentOptionsV1,
315        CLOUDFLARE_WORKERS_COMPATIBILITY_DATE_V1,
316    };
317
318    fn manifest(digest: String) -> FileRoutePublicationManifestV1 {
319        FileRoutePublicationManifestV1 {
320            schema_version: 1,
321            compiler_contract: "presolve-file-route-publication:1".into(),
322            profile: "production".into(),
323            routes: vec![FileRoutePublicationRouteV1 {
324                path: "/posts/:slug".into(),
325                entry_component_id: "component:post".into(),
326                artifact_root: "routes/segment-posts/parameter-slug".into(),
327                layout_component_ids: Vec::new(),
328            }],
329            artifacts: vec![ApplicationPublicationArtifactV1 {
330                path: "routes/segment-posts/parameter-slug/index.html".into(),
331                digest,
332            }],
333        }
334    }
335
336    #[test]
337    fn projects_only_compiler_owned_routes_artifacts_and_public_bindings() {
338        let plan = build_cloudflare_workers_static_deployment_plan_v1(
339            &manifest(
340                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
341            ),
342            &CloudflareWorkersDeploymentOptionsV1 {
343                worker_name: "presolve-docs".into(),
344                compatibility_date: CLOUDFLARE_WORKERS_COMPATIBILITY_DATE_V1.into(),
345                required_secrets: vec!["DOCS_TOKEN".into()],
346            },
347        )
348        .unwrap();
349        assert_eq!(plan.routes[0].path, "/posts/:slug");
350        assert_eq!(plan.required_secrets, ["DOCS_TOKEN"]);
351        let worker = cloudflare_workers_static_worker_module_v1(&plan);
352        assert!(worker.contains("routeFor"));
353        assert!(worker.contains("routes/segment-posts/parameter-slug"));
354        assert!(!worker.contains("component:post"));
355        let config = cloudflare_workers_wrangler_jsonc_v1(&plan, "../../dist");
356        assert!(config.contains("run_worker_first"));
357        assert!(config.contains("DOCS_TOKEN"));
358    }
359
360    #[test]
361    fn rejects_mutated_compiler_artifacts_before_upload() {
362        let bytes = b"<main>post</main>".to_vec();
363        let digest = format!("sha256:{:x}", Sha256::digest(&bytes));
364        let plan = build_cloudflare_workers_static_deployment_plan_v1(
365            &manifest(digest),
366            &CloudflareWorkersDeploymentOptionsV1 {
367                worker_name: "presolve-docs".into(),
368                compatibility_date: CLOUDFLARE_WORKERS_COMPATIBILITY_DATE_V1.into(),
369                required_secrets: Vec::new(),
370            },
371        )
372        .unwrap();
373        let root =
374            std::env::temp_dir().join(format!("presolve-cloudflare-plan-{}", std::process::id()));
375        let path = root.join("routes/segment-posts/parameter-slug/index.html");
376        fs::create_dir_all(path.parent().unwrap()).unwrap();
377        fs::write(&path, &bytes).unwrap();
378        assert!(validate_cloudflare_workers_artifacts_v1(&root, &plan).is_ok());
379        fs::write(&path, b"tampered").unwrap();
380        assert_eq!(
381            validate_cloudflare_workers_artifacts_v1(&root, &plan)
382                .unwrap_err()
383                .code,
384            "PSCFL1006_ARTIFACT_INTEGRITY_MISMATCH"
385        );
386        fs::remove_dir_all(root).unwrap();
387    }
388}