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\
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}