1use 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;
15pub 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
56pub 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
130pub 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#[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\
186async function fetchCompilerAsset(request, env, artifactUrl) {{\n\
187 const response = await env.ASSETS.fetch(new Request(artifactUrl, request));\n\
188 if (response.status < 300 || response.status > 399) return response;\n\
189 const location = response.headers.get('Location');\n\
190 if (!location) return response;\n\
191 const redirected = new URL(location, artifactUrl);\n\
192 const internal = new URL(artifactUrl);\n\
193 if (redirected.origin !== internal.origin || !redirected.pathname.startsWith('/routes/')) return response;\n\
194 return env.ASSETS.fetch(new Request(redirected, request));\n\
195}}\n\
196export default {{ async fetch(request, env) {{\n\
197 if (request.method !== 'GET' && request.method !== 'HEAD') return new Response('Method Not Allowed\\n', {{ status: 405, headers: {{ Allow: 'GET, HEAD' }} }});\n\
198 const url = new URL(request.url);\n\
199 const direct = await env.ASSETS.fetch(request);\n\
200 if (direct.status !== 404) {{\n\
201 const location = direct.headers.get('Location');\n\
202 if (url.pathname === '/' && location) {{\n\
203 const redirected = new URL(location, url);\n\
204 if (redirected.origin === url.origin && redirected.pathname.startsWith('/routes/')) return fetchCompilerAsset(request, env, redirected);\n\
205 }}\n\
206 return direct;\n\
207 }}\n\
208 const requested = segments(url.pathname);\n\
209 const selected = routeFor(requested);\n\
210 if (!selected) return new Response('Not Found\\n', {{ status: 404 }});\n\
211 if (requested.length === selected.parts.length) {{\n\
212 if (selected.route.path !== '/' && !url.pathname.endsWith('/')) return Response.redirect(new URL(`${{url.pathname}}/`, url).toString(), 308);\n\
213 return fetchCompilerAsset(request, env, new URL(`/${{selected.route.artifact_root}}/index.html`, url));\n\
214 }}\n\
215 const suffix = requested.slice(selected.parts.length);\n\
216 if (!safeAssetSegments(suffix)) return new Response('Not Found\\n', {{ status: 404 }});\n\
217 return fetchCompilerAsset(request, env, new URL(`/${{selected.route.artifact_root}}/${{suffix.join('/')}}`, url));\n\
218}} }};\n"
219 )
220}
221
222#[must_use]
223pub fn cloudflare_workers_wrangler_jsonc_v1(
224 plan: &CloudflareWorkersStaticDeploymentPlanV1,
225 assets_directory: &str,
226) -> String {
227 let mut value = serde_json::json!({
228 "name": plan.worker_name,
229 "main": "worker.mjs",
230 "compatibility_date": plan.compatibility_date,
231 "assets": {
232 "directory": assets_directory,
233 "binding": plan.assets_binding,
234 "run_worker_first": true,
235 },
236 });
237 if !plan.required_secrets.is_empty() {
238 value["secrets"] = serde_json::json!({ "required": plan.required_secrets });
239 }
240 serde_json::to_string_pretty(&value).expect("Wrangler configuration serializes") + "\n"
241}
242
243fn cloudflare_artifact(
244 artifact: &ApplicationPublicationArtifactV1,
245) -> Result<CloudflareWorkersArtifactV1, CloudflareWorkersDeploymentErrorV1> {
246 if !is_artifact_path(&artifact.path) || !artifact.digest.starts_with("sha256:") {
247 return Err(CloudflareWorkersDeploymentErrorV1 {
248 code: "PSCFL1007_ARTIFACT_PROJECTION_INVALID",
249 message: artifact.path.clone(),
250 });
251 }
252 Ok(CloudflareWorkersArtifactV1 {
253 path: artifact.path.clone(),
254 digest: artifact.digest.clone(),
255 })
256}
257
258fn canonical_secret_names(
259 values: &[String],
260) -> Result<Vec<String>, CloudflareWorkersDeploymentErrorV1> {
261 let mut names = BTreeSet::new();
262 for value in values {
263 if !is_secret_name(value) {
264 return Err(CloudflareWorkersDeploymentErrorV1 {
265 code: "PSCFL1008_SECRET_NAME_INVALID",
266 message: value.clone(),
267 });
268 }
269 names.insert(value.clone());
270 }
271 Ok(names.into_iter().collect())
272}
273
274fn is_worker_name(value: &str) -> bool {
275 !value.is_empty()
276 && value.len() <= 63
277 && value.chars().all(|character| {
278 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
279 })
280}
281
282fn is_compatibility_date(value: &str) -> bool {
283 value.len() == 10
284 && value.as_bytes()[4] == b'-'
285 && value.as_bytes()[7] == b'-'
286 && value
287 .bytes()
288 .enumerate()
289 .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit())
290}
291
292fn is_secret_name(value: &str) -> bool {
293 value
294 .chars()
295 .next()
296 .is_some_and(|character| character.is_ascii_uppercase() || character == '_')
297 && value.chars().all(|character| {
298 character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'
299 })
300}
301
302fn is_route_path(value: &str) -> bool {
303 value.starts_with('/') && !value.contains('?') && !value.contains('#') && !value.contains('\\')
304}
305
306fn is_artifact_root(value: &str) -> bool {
307 is_artifact_path(value) && value.starts_with("routes/")
308}
309
310fn is_artifact_path(value: &str) -> bool {
311 !value.is_empty()
312 && !value.starts_with('/')
313 && value.split('/').all(|segment| {
314 !segment.is_empty() && segment != "." && segment != ".." && !segment.contains('\\')
315 })
316}
317
318#[cfg(test)]
319mod tests {
320 use std::fs;
321
322 use presolve_compiler::{
323 ApplicationPublicationArtifactV1, FileRoutePublicationManifestV1,
324 FileRoutePublicationRouteV1,
325 };
326 use sha2::{Digest as _, Sha256};
327
328 use super::{
329 build_cloudflare_workers_static_deployment_plan_v1,
330 cloudflare_workers_static_worker_module_v1, cloudflare_workers_wrangler_jsonc_v1,
331 validate_cloudflare_workers_artifacts_v1, CloudflareWorkersDeploymentOptionsV1,
332 CLOUDFLARE_WORKERS_COMPATIBILITY_DATE_V1,
333 };
334
335 fn manifest(digest: String) -> FileRoutePublicationManifestV1 {
336 FileRoutePublicationManifestV1 {
337 schema_version: 1,
338 compiler_contract: "presolve-file-route-publication:1".into(),
339 profile: "production".into(),
340 routes: vec![FileRoutePublicationRouteV1 {
341 path: "/posts/:slug".into(),
342 entry_component_id: "component:post".into(),
343 artifact_root: "routes/segment-posts/parameter-slug".into(),
344 layout_component_ids: Vec::new(),
345 }],
346 artifacts: vec![ApplicationPublicationArtifactV1 {
347 path: "routes/segment-posts/parameter-slug/index.html".into(),
348 digest,
349 }],
350 }
351 }
352
353 #[test]
354 fn projects_only_compiler_owned_routes_artifacts_and_public_bindings() {
355 let plan = build_cloudflare_workers_static_deployment_plan_v1(
356 &manifest(
357 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
358 ),
359 &CloudflareWorkersDeploymentOptionsV1 {
360 worker_name: "presolve-docs".into(),
361 compatibility_date: CLOUDFLARE_WORKERS_COMPATIBILITY_DATE_V1.into(),
362 required_secrets: vec!["DOCS_TOKEN".into()],
363 },
364 )
365 .unwrap();
366 assert_eq!(plan.routes[0].path, "/posts/:slug");
367 assert_eq!(plan.required_secrets, ["DOCS_TOKEN"]);
368 let worker = cloudflare_workers_static_worker_module_v1(&plan);
369 assert!(worker.contains("routeFor"));
370 assert!(worker.contains("routes/segment-posts/parameter-slug"));
371 assert!(worker.contains("fetchCompilerAsset"));
372 assert!(worker.contains("redirected.pathname.startsWith('/routes/')"));
373 assert!(worker.contains("url.pathname === '/' && location"));
374 assert!(!worker.contains("component:post"));
375 let config = cloudflare_workers_wrangler_jsonc_v1(&plan, "../../dist");
376 assert!(config.contains("run_worker_first"));
377 assert!(config.contains("DOCS_TOKEN"));
378 }
379
380 #[test]
381 fn rejects_mutated_compiler_artifacts_before_upload() {
382 let bytes = b"<main>post</main>".to_vec();
383 let digest = format!("sha256:{:x}", Sha256::digest(&bytes));
384 let plan = build_cloudflare_workers_static_deployment_plan_v1(
385 &manifest(digest),
386 &CloudflareWorkersDeploymentOptionsV1 {
387 worker_name: "presolve-docs".into(),
388 compatibility_date: CLOUDFLARE_WORKERS_COMPATIBILITY_DATE_V1.into(),
389 required_secrets: Vec::new(),
390 },
391 )
392 .unwrap();
393 let root =
394 std::env::temp_dir().join(format!("presolve-cloudflare-plan-{}", std::process::id()));
395 let path = root.join("routes/segment-posts/parameter-slug/index.html");
396 fs::create_dir_all(path.parent().unwrap()).unwrap();
397 fs::write(&path, &bytes).unwrap();
398 assert!(validate_cloudflare_workers_artifacts_v1(&root, &plan).is_ok());
399 fs::write(&path, b"tampered").unwrap();
400 assert_eq!(
401 validate_cloudflare_workers_artifacts_v1(&root, &plan)
402 .unwrap_err()
403 .code,
404 "PSCFL1006_ARTIFACT_INTEGRITY_MISMATCH"
405 );
406 fs::remove_dir_all(root).unwrap();
407 }
408}