1use anyhow::{Context, Result, bail};
7use base64::prelude::*;
8use blake3::Hasher;
9use mime_guess::MimeGuess;
10use serde::de::DeserializeOwned;
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use std::collections::HashMap;
14use std::future::Future;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17use std::sync::mpsc::TryRecvError;
18use std::thread;
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20use url::Url;
21use walkdir::WalkDir;
22
23const MAX_RETRIES: u32 = 3;
25
26const BASE_DELAY_MS: u64 = 1000;
28
29const API_TIMEOUT_SECS: u64 = 30;
31
32const ENV_CLOUDFLARE_ACCOUNT_ID: &str = "CLOUDFLARE_ACCOUNT_ID";
33const ENV_CLOUDFLARE_API_TOKEN: &str = "CLOUDFLARE_API_TOKEN";
34const ENV_CLOUDFLARE_API_BASE_URL: &str = "CLOUDFLARE_API_BASE_URL";
35const ENV_CF_API_BASE_URL: &str = "CF_API_BASE_URL";
36const DEFAULT_CLOUDFLARE_API_BASE_URL: &str = "https://api.cloudflare.com/client/v4";
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Prerequisites {
41 pub wrangler_version: Option<String>,
43 pub wrangler_authenticated: bool,
45 pub account_email: Option<String>,
47 pub api_credentials_present: bool,
49 pub account_id: Option<String>,
51 pub disk_space_mb: u64,
53}
54
55impl Prerequisites {
56 pub fn is_ready(&self) -> bool {
61 self.api_credentials_present
62 || (self.wrangler_version.is_some() && self.wrangler_authenticated)
63 }
64
65 pub fn missing(&self) -> Vec<&'static str> {
67 if self.is_ready() {
68 return Vec::new();
69 }
70
71 let mut missing = Vec::new();
72 if self.wrangler_version.is_none() && !self.api_credentials_present {
73 missing.push(
74 "wrangler CLI not installed — run `npm install -g wrangler` or set CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN for direct API deploys",
75 );
76 }
77 if !self.wrangler_authenticated && !self.api_credentials_present {
78 missing.push(
79 "not authenticated — set CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN or run `wrangler login`",
80 );
81 }
82 missing
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct DeployResult {
89 pub project_name: String,
91 pub pages_url: String,
93 pub deployed: bool,
95 pub deployment_id: Option<String>,
97 pub custom_domain: Option<String>,
99}
100
101#[derive(Debug, Clone)]
103pub struct CloudflareConfig {
104 pub project_name: String,
106 pub custom_domain: Option<String>,
108 pub create_if_missing: bool,
110 pub branch: String,
112 pub account_id: Option<String>,
114 pub api_token: Option<String>,
116}
117
118impl Default for CloudflareConfig {
119 fn default() -> Self {
120 Self {
121 project_name: "cass-archive".to_string(),
122 custom_domain: None,
123 create_if_missing: true,
124 branch: "main".to_string(),
125 account_id: None,
126 api_token: None,
127 }
128 }
129}
130
131pub struct CloudflareDeployer {
133 config: CloudflareConfig,
134}
135
136impl Default for CloudflareDeployer {
137 fn default() -> Self {
138 Self::new(CloudflareConfig::default())
139 }
140}
141
142impl CloudflareDeployer {
143 pub fn new(config: CloudflareConfig) -> Self {
145 Self { config }
146 }
147
148 pub fn with_project_name(project_name: impl Into<String>) -> Self {
150 Self::new(CloudflareConfig {
151 project_name: project_name.into(),
152 ..Default::default()
153 })
154 }
155
156 pub fn custom_domain(mut self, domain: impl Into<String>) -> Self {
158 self.config.custom_domain = Some(domain.into());
159 self
160 }
161
162 pub fn create_if_missing(mut self, create: bool) -> Self {
164 self.config.create_if_missing = create;
165 self
166 }
167
168 pub fn branch(mut self, branch: impl Into<String>) -> Self {
170 self.config.branch = branch.into();
171 self
172 }
173
174 pub fn account_id(mut self, account_id: impl Into<String>) -> Self {
176 self.config.account_id = Some(account_id.into());
177 self
178 }
179
180 pub fn api_token(mut self, api_token: impl Into<String>) -> Self {
182 self.config.api_token = Some(api_token.into());
183 self
184 }
185
186 pub fn check_prerequisites(&self) -> Result<Prerequisites> {
188 let wrangler_version = get_wrangler_version();
189 let (wrangler_authenticated, account_email) = if wrangler_version.is_some() {
190 check_wrangler_auth()
191 } else {
192 (false, None)
193 };
194
195 let account_id = self
196 .config
197 .account_id
198 .clone()
199 .or_else(|| dotenvy::var(ENV_CLOUDFLARE_ACCOUNT_ID).ok());
200 let api_token = self
201 .config
202 .api_token
203 .clone()
204 .or_else(|| dotenvy::var(ENV_CLOUDFLARE_API_TOKEN).ok());
205 let api_credentials_present = account_id.is_some() && api_token.is_some();
206
207 let disk_space_mb = get_available_space_mb().unwrap_or(0);
208
209 Ok(Prerequisites {
210 wrangler_version,
211 wrangler_authenticated,
212 account_email,
213 api_credentials_present,
214 account_id,
215 disk_space_mb,
216 })
217 }
218
219 pub fn generate_headers_file(&self, site_dir: &Path) -> Result<()> {
221 let headers_content = r#"/*
222 Cross-Origin-Opener-Policy: same-origin
223 Cross-Origin-Embedder-Policy: require-corp
224 Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none';
225 X-Content-Type-Options: nosniff
226 X-Frame-Options: DENY
227 Referrer-Policy: no-referrer
228 X-Robots-Tag: noindex, nofollow
229 Cache-Control: public, max-age=31536000, immutable
230
231/index.html
232 Cache-Control: no-cache
233
234/config.json
235 Cache-Control: no-cache
236
237/*.html
238 Cache-Control: no-cache
239"#;
240
241 std::fs::write(site_dir.join("_headers"), headers_content)
242 .context("Failed to write _headers file")?;
243 Ok(())
244 }
245
246 pub fn generate_redirects_file(&self, site_dir: &Path) -> Result<()> {
248 let redirects_content = "/* /index.html 200\n";
251
252 std::fs::write(site_dir.join("_redirects"), redirects_content)
253 .context("Failed to write _redirects file")?;
254 Ok(())
255 }
256
257 pub fn deploy<P: AsRef<Path>>(
263 &self,
264 bundle_dir: P,
265 mut progress: impl FnMut(&str, &str),
266 ) -> Result<DeployResult> {
267 let branch = self.config.branch.clone();
268 let account_id = self
269 .config
270 .account_id
271 .clone()
272 .or_else(|| dotenvy::var(ENV_CLOUDFLARE_ACCOUNT_ID).ok());
273 let api_token = self
274 .config
275 .api_token
276 .clone()
277 .or_else(|| dotenvy::var(ENV_CLOUDFLARE_API_TOKEN).ok());
278 let account_id_ref = account_id.as_deref();
279 let api_token_ref = api_token.as_deref();
280
281 progress("prereq", "Checking prerequisites...");
283 let prereqs = self.check_prerequisites()?;
284
285 if !prereqs.is_ready() {
286 let missing = prereqs.missing();
287 bail!("Prerequisites not met:\n{}", missing.join("\n"));
288 }
289 let can_use_wrangler = prereqs.wrangler_version.is_some()
290 && (prereqs.wrangler_authenticated || prereqs.api_credentials_present);
291
292 progress("prepare", "Preparing deployment...");
294 let temp_dir = stage_deploy_dir(bundle_dir.as_ref())?;
295 let deploy_dir = temp_dir.path().join("site");
296
297 progress("headers", "Generating COOP/COEP headers...");
299 self.generate_headers_file(&deploy_dir)?;
300 self.generate_redirects_file(&deploy_dir)?;
301
302 progress("project", "Checking Cloudflare Pages project...");
304 if self.config.create_if_missing {
305 let exists = if can_use_wrangler {
306 check_project_exists(&self.config.project_name, account_id_ref, api_token_ref)
307 } else if let (Some(account_id), Some(api_token)) = (account_id_ref, api_token_ref) {
308 check_project_exists_api(&self.config.project_name, account_id, api_token)?
309 } else {
310 false
311 };
312 if !exists {
313 progress("create", "Creating new Pages project...");
314 if can_use_wrangler {
315 create_project(
316 &self.config.project_name,
317 &branch,
318 account_id_ref,
319 api_token_ref,
320 )?;
321 } else if let (Some(account_id), Some(api_token)) = (account_id_ref, api_token_ref)
322 {
323 create_project_api(&self.config.project_name, &branch, account_id, api_token)?;
324 } else {
325 bail!("Cloudflare API credentials required to create project");
326 }
327 }
328 }
329
330 progress("deploy", "Deploying to Cloudflare Pages...");
332 let (pages_url, deployment_id) = if can_use_wrangler {
333 deploy_with_wrangler(
334 &deploy_dir,
335 &self.config.project_name,
336 &branch,
337 account_id_ref,
338 api_token_ref,
339 )?
340 } else if let (Some(account_id), Some(api_token)) = (account_id_ref, api_token_ref) {
341 deploy_with_api(
342 &deploy_dir,
343 &self.config.project_name,
344 &branch,
345 account_id,
346 api_token,
347 &mut progress,
348 )?
349 } else {
350 bail!("Cloudflare API credentials required for direct API deployment");
351 };
352
353 if let Some(ref domain) = self.config.custom_domain {
355 progress(
356 "domain",
357 &format!("Configuring custom domain: {}...", domain),
358 );
359 configure_custom_domain(
360 &self.config.project_name,
361 domain,
362 account_id_ref,
363 api_token_ref,
364 )?;
365 }
366
367 progress("complete", "Deployment complete!");
368
369 Ok(DeployResult {
370 project_name: self.config.project_name.clone(),
371 pages_url,
372 deployed: true,
373 deployment_id: Some(deployment_id),
374 custom_domain: self.config.custom_domain.clone(),
375 })
376 }
377}
378
379struct TempDeployDir {
382 path: PathBuf,
383}
384
385impl TempDeployDir {
386 fn path(&self) -> &Path {
387 &self.path
388 }
389}
390
391impl Drop for TempDeployDir {
392 fn drop(&mut self) {
393 if deploy_staging_path_is_real_dir(&self.path).unwrap_or(false) {
394 let _ = std::fs::remove_dir_all(&self.path);
395 }
396 }
397}
398
399fn create_temp_dir() -> Result<TempDeployDir> {
401 let temp_base = std::env::temp_dir();
402 let pid = std::process::id();
403 for attempt in 0..100 {
404 let timestamp = SystemTime::now()
405 .duration_since(UNIX_EPOCH)
406 .map(|d| d.as_nanos())
407 .unwrap_or(0);
408 let dir_name = format!("cass-cf-deploy-{pid}-{timestamp}-{attempt}");
409 let temp_dir = temp_base.join(dir_name);
410 match std::fs::create_dir(&temp_dir) {
411 Ok(()) => return Ok(TempDeployDir { path: temp_dir }),
412 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
413 Err(err) => {
414 return Err(err).with_context(|| {
415 format!(
416 "Failed creating deploy staging directory {}",
417 temp_dir.display()
418 )
419 });
420 }
421 }
422 }
423 bail!(
424 "failed to allocate unique Cloudflare deploy staging directory under {}",
425 temp_base.display()
426 )
427}
428
429fn stage_deploy_dir(source_path: &Path) -> Result<TempDeployDir> {
430 let source_site_dir = resolve_deploy_site_dir(source_path)?;
431 let temp_dir = create_temp_dir()?;
432 let deploy_dir = temp_dir.path().join("site");
433 copy_dir_recursive(&source_site_dir, &deploy_dir)?;
434 Ok(temp_dir)
435}
436
437fn resolve_deploy_site_dir(path: &Path) -> Result<PathBuf> {
438 if path.file_name().map(|name| name == "site").unwrap_or(false) {
439 return super::resolve_site_dir(path);
440 }
441
442 let site_subdir = path.join("site");
443 match std::fs::symlink_metadata(&site_subdir) {
444 Ok(_) => return super::resolve_site_dir(path),
445 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
446 Err(err) => {
447 return Err(err).with_context(|| {
448 format!(
449 "Failed to inspect deployment site directory {}",
450 site_subdir.display()
451 )
452 });
453 }
454 }
455
456 bail!(
457 "expected a bundle root containing site/ or a site/ directory, got {}",
458 path.display()
459 );
460}
461
462fn apply_api_credentials(cmd: &mut Command, account_id: Option<&str>, api_token: Option<&str>) {
463 if let Some(id) = account_id {
464 cmd.env(ENV_CLOUDFLARE_ACCOUNT_ID, id);
465 }
466 if let Some(token) = api_token {
467 cmd.env(ENV_CLOUDFLARE_API_TOKEN, token);
468 }
469}
470
471fn get_wrangler_version() -> Option<String> {
473 Command::new("wrangler")
474 .arg("--version")
475 .output()
476 .ok()
477 .and_then(|out| {
478 if out.status.success() {
479 let stdout = String::from_utf8_lossy(&out.stdout);
480 Some(stdout.trim().to_string())
481 } else {
482 None
483 }
484 })
485}
486
487fn check_wrangler_auth() -> (bool, Option<String>) {
489 let output = Command::new("wrangler").args(["whoami"]).output();
490
491 match output {
492 Ok(out) if out.status.success() => {
493 let stdout = String::from_utf8_lossy(&out.stdout);
494
495 let email = stdout
497 .lines()
498 .find(|line| line.contains('@'))
499 .map(|line| line.trim().to_string());
500
501 (true, email)
502 }
503 _ => (false, None),
504 }
505}
506
507fn get_available_space_mb() -> Option<u64> {
509 #[cfg(unix)]
510 {
511 Command::new("df")
512 .args(["-m", "."])
513 .output()
514 .ok()
515 .and_then(|out| {
516 if out.status.success() {
517 let stdout = String::from_utf8_lossy(&out.stdout);
518 stdout
519 .lines()
520 .nth(1)
521 .and_then(|line| line.split_whitespace().nth(3))
522 .and_then(|s| s.parse().ok())
523 } else {
524 None
525 }
526 })
527 }
528 #[cfg(not(unix))]
529 {
530 None
531 }
532}
533
534fn check_project_exists(
536 project_name: &str,
537 account_id: Option<&str>,
538 api_token: Option<&str>,
539) -> bool {
540 let mut cmd = Command::new("wrangler");
541 cmd.args(["pages", "project", "list"]);
542 apply_api_credentials(&mut cmd, account_id, api_token);
543
544 cmd.output()
545 .map(|out| {
546 if out.status.success() {
547 let stdout = String::from_utf8_lossy(&out.stdout);
548 output_contains_project(&stdout, project_name)
549 } else {
550 false
551 }
552 })
553 .unwrap_or(false)
554}
555
556fn output_contains_project(stdout: &str, project_name: &str) -> bool {
557 stdout.lines().any(|line| {
558 let trimmed = line.trim();
559 if trimmed.is_empty()
560 || trimmed.starts_with('┌')
561 || trimmed.starts_with('├')
562 || trimmed.starts_with('└')
563 {
564 return false;
565 }
566
567 let trimmed_edges = trimmed.trim_matches(|c| matches!(c, '│' | '|'));
569 let first_cell = trimmed_edges
570 .split(['│', '|'])
571 .next()
572 .unwrap_or(trimmed_edges)
573 .trim();
574 if first_cell == project_name {
575 return true;
576 }
577
578 trimmed_edges
580 .split_whitespace()
581 .any(|token| token == project_name)
582 })
583}
584
585fn create_project(
587 project_name: &str,
588 branch: &str,
589 account_id: Option<&str>,
590 api_token: Option<&str>,
591) -> Result<()> {
592 let mut cmd = Command::new("wrangler");
593 cmd.args([
594 "pages",
595 "project",
596 "create",
597 project_name,
598 "--production-branch",
599 branch,
600 ]);
601 apply_api_credentials(&mut cmd, account_id, api_token);
602
603 let output = cmd
604 .output()
605 .context("Failed to run wrangler pages project create")?;
606
607 if !output.status.success() {
608 let stderr = String::from_utf8_lossy(&output.stderr);
609 if !stderr.contains("already exists")
611 && !stderr.contains("A project with this name already exists")
612 {
613 bail!("Failed to create project: {}", stderr);
614 }
615 }
616
617 Ok(())
618}
619
620fn retry_with_backoff<T, F>(operation_name: &str, mut f: F) -> Result<T>
622where
623 F: FnMut() -> Result<T>,
624{
625 let mut last_error = None;
626
627 for attempt in 0..MAX_RETRIES {
628 match f() {
629 Ok(result) => return Ok(result),
630 Err(e) => {
631 last_error = Some(e);
632 if attempt + 1 < MAX_RETRIES {
633 let delay_ms = BASE_DELAY_MS * (1 << attempt);
634 eprintln!(
635 "[{}] Attempt {} failed, retrying in {}ms...",
636 operation_name,
637 attempt + 1,
638 delay_ms
639 );
640 thread::sleep(Duration::from_millis(delay_ms));
641 }
642 }
643 }
644 }
645
646 Err(last_error.unwrap_or_else(|| {
647 anyhow::anyhow!("{} failed after {} attempts", operation_name, MAX_RETRIES)
648 }))
649}
650
651fn deploy_with_wrangler(
653 deploy_dir: &Path,
654 project_name: &str,
655 branch: &str,
656 account_id: Option<&str>,
657 api_token: Option<&str>,
658) -> Result<(String, String)> {
659 let deploy_dir_str = deploy_dir
660 .to_str()
661 .context("Invalid deploy directory path")?;
662
663 retry_with_backoff("wrangler deploy", || {
664 let mut cmd = Command::new("wrangler");
665 cmd.args([
666 "pages",
667 "deploy",
668 deploy_dir_str,
669 "--project-name",
670 project_name,
671 "--branch",
672 branch,
673 ]);
674 apply_api_credentials(&mut cmd, account_id, api_token);
675
676 let output = cmd
677 .output()
678 .context("Failed to run wrangler pages deploy")?;
679
680 if !output.status.success() {
681 let stderr = String::from_utf8_lossy(&output.stderr);
682 bail!("Deployment failed: {}", stderr);
683 }
684
685 let stdout = String::from_utf8_lossy(&output.stdout);
686
687 let pages_url = stdout
690 .lines()
691 .find_map(|line| {
692 if line.contains(".pages.dev") {
693 line.split_whitespace()
694 .find(|word| word.contains(".pages.dev"))
695 .map(|url| {
696 url.trim_matches(|c: char| {
697 !c.is_alphanumeric() && c != '.' && c != ':' && c != '/'
698 })
699 })
700 } else {
701 None
702 }
703 })
704 .map(|s| s.to_string())
705 .unwrap_or_else(|| format!("https://{}.pages.dev", project_name));
706
707 let deployment_id = stdout
709 .lines()
710 .find_map(|line| {
711 if line.contains("Deployment ID:") || line.contains("deployment_id") {
712 line.split_whitespace().last().map(|s| s.to_string())
713 } else {
714 None
715 }
716 })
717 .unwrap_or_else(|| "unknown".to_string());
718
719 Ok((pages_url, deployment_id))
720 })
721}
722
723#[derive(Debug, Deserialize)]
724struct ApiError {
725 code: i64,
726 message: String,
727}
728
729#[derive(Debug, Deserialize)]
730struct ApiEnvelope<T> {
731 success: bool,
732 #[serde(default)]
733 errors: Vec<ApiError>,
734 result: Option<T>,
735}
736
737#[derive(Debug, Deserialize)]
738struct UploadTokenResult {
739 jwt: String,
740}
741
742#[derive(Debug, Deserialize)]
743struct DeploymentResult {
744 id: String,
745 url: Option<String>,
746 #[serde(default)]
747 aliases: Vec<String>,
748}
749
750#[derive(Debug, Clone)]
751struct AssetFile {
752 path: PathBuf,
753 content_type: String,
754 size_bytes: u64,
755 hash: String,
756}
757
758const MAX_ASSET_COUNT_DEFAULT: usize = 20_000;
759const MAX_ASSET_SIZE_BYTES: u64 = 25 * 1024 * 1024;
760const MAX_BUCKET_SIZE_BYTES: u64 = 40 * 1024 * 1024;
761const MAX_BUCKET_FILE_COUNT: usize = if cfg!(windows) { 1000 } else { 2000 };
762
763fn api_base_url() -> String {
764 let override_url = dotenvy::var(ENV_CLOUDFLARE_API_BASE_URL)
765 .or_else(|_| dotenvy::var(ENV_CF_API_BASE_URL))
766 .ok();
767 configured_cloudflare_api_base_url(override_url.as_deref())
768}
769
770fn configured_cloudflare_api_base_url(override_url: Option<&str>) -> String {
771 let Some(url) = override_url.map(str::trim).filter(|url| !url.is_empty()) else {
772 return DEFAULT_CLOUDFLARE_API_BASE_URL.to_string();
773 };
774
775 if is_allowed_cloudflare_api_base_url(url) {
776 return url.trim_end_matches('/').to_string();
777 }
778
779 tracing::warn!(
780 "ignoring untrusted Cloudflare API base URL override; only https://api.cloudflare.com or http://localhost/127.0.0.1/[::1] test endpoints are allowed"
781 );
782 DEFAULT_CLOUDFLARE_API_BASE_URL.to_string()
783}
784
785fn is_allowed_cloudflare_api_base_url(url: &str) -> bool {
786 let Ok(parsed) = Url::parse(url) else {
787 return false;
788 };
789 if !parsed.username().is_empty() || parsed.password().is_some() {
790 return false;
791 }
792 if parsed.query().is_some() || parsed.fragment().is_some() {
793 return false;
794 }
795 let Some(host) = parsed.host_str() else {
796 return false;
797 };
798 match parsed.scheme() {
799 "https" => host == "api.cloudflare.com" && parsed.port().is_none_or(|port| port == 443),
800 "http" => matches!(host, "127.0.0.1" | "localhost" | "::1" | "[::1]"),
801 _ => false,
802 }
803}
804
805fn cloudflare_api_url(base_url: &str, path_segments: &[&str]) -> Result<String> {
806 let mut url = Url::parse(base_url)
807 .with_context(|| format!("Invalid Cloudflare API base URL: {base_url}"))?;
808 {
809 let mut segments = url
810 .path_segments_mut()
811 .map_err(|_| anyhow::anyhow!("Cloudflare API base URL cannot be used as a base"))?;
812 segments.pop_if_empty();
813 for segment in path_segments {
814 segments.push(segment);
815 }
816 }
817 Ok(url.to_string())
818}
819
820fn run_cloudflare_with_cx<T, F, Fut>(f: F) -> Result<T>
821where
822 T: Send + 'static,
823 F: FnOnce(asupersync::Cx) -> Fut + Send + 'static,
824 Fut: Future<Output = Result<T>> + Send + 'static,
825{
826 let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
827 .build()
828 .context("building Cloudflare API runtime")?;
829
830 runtime.block_on(async move {
831 let handle = asupersync::runtime::Runtime::current_handle()
832 .ok_or_else(|| anyhow::anyhow!("Cloudflare API runtime handle unavailable"))?;
833 let (tx, rx) = std::sync::mpsc::channel();
834 handle
835 .try_spawn_with_cx(move |cx| async move {
836 let _ = tx.send(f(cx).await);
837 })
838 .map_err(|e| anyhow::anyhow!("spawning Cloudflare API task: {e}"))?;
839
840 loop {
841 match rx.try_recv() {
842 Ok(result) => return result,
843 Err(TryRecvError::Empty) => asupersync::runtime::yield_now().await,
844 Err(TryRecvError::Disconnected) => {
845 bail!("Cloudflare API task exited before returning a result");
846 }
847 }
848 }
849 })
850}
851
852fn cloudflare_api_headers(
853 bearer_token: String,
854 mut extra_headers: Vec<(String, String)>,
855) -> Vec<(String, String)> {
856 let mut headers = vec![
857 (
858 "Authorization".to_string(),
859 format!("Bearer {bearer_token}"),
860 ),
861 ("Accept".to_string(), "application/json".to_string()),
862 ];
863 headers.append(&mut extra_headers);
864 headers
865}
866
867fn execute_cloudflare_request(
868 method: asupersync::http::h1::Method,
869 url: String,
870 bearer_token: String,
871 extra_headers: Vec<(String, String)>,
872 body: Vec<u8>,
873) -> Result<asupersync::http::h1::Response> {
874 run_cloudflare_with_cx(move |cx| async move {
875 let client = asupersync::http::h1::HttpClient::builder()
876 .user_agent(concat!(
877 "cass/",
878 env!("CARGO_PKG_VERSION"),
879 " (cloudflare-pages)"
880 ))
881 .build();
882 asupersync::time::timeout(
883 cx.now(),
884 Duration::from_secs(API_TIMEOUT_SECS),
885 client.request(
886 &cx,
887 method,
888 &url,
889 cloudflare_api_headers(bearer_token, extra_headers),
890 body,
891 ),
892 )
893 .await
894 .map_err(|e| anyhow::anyhow!("Cloudflare API request timed out: {e}"))?
895 .context("Failed to contact Cloudflare API")
896 })
897}
898
899fn execute_cloudflare_multipart_request(
900 url: String,
901 bearer_token: String,
902 extra_headers: Vec<(String, String)>,
903 form: asupersync::http::h1::MultipartForm,
904) -> Result<asupersync::http::h1::Response> {
905 run_cloudflare_with_cx(move |cx| async move {
906 let client = asupersync::http::h1::HttpClient::builder()
907 .user_agent(concat!(
908 "cass/",
909 env!("CARGO_PKG_VERSION"),
910 " (cloudflare-pages)"
911 ))
912 .build();
913 asupersync::time::timeout(
914 cx.now(),
915 Duration::from_secs(API_TIMEOUT_SECS),
916 client.request_multipart(
917 &cx,
918 asupersync::http::h1::Method::Post,
919 &url,
920 cloudflare_api_headers(bearer_token, extra_headers),
921 &form,
922 ),
923 )
924 .await
925 .map_err(|e| anyhow::anyhow!("Cloudflare multipart request timed out: {e}"))?
926 .context("Failed to contact Cloudflare API")
927 })
928}
929
930fn parse_api_response<T: DeserializeOwned>(
931 response: asupersync::http::h1::Response,
932 context_label: &str,
933) -> Result<T> {
934 let status = response.status;
935 let body = response.text().map_or_else(
936 |_| String::from_utf8_lossy(response.bytes()).into_owned(),
937 str::to_owned,
938 );
939 let envelope: ApiEnvelope<T> = serde_json::from_str(&body).with_context(|| {
940 format!(
941 "Failed to parse Cloudflare API response for {} (status {})",
942 context_label, status
943 )
944 })?;
945 if !envelope.success {
946 let detail = if envelope.errors.is_empty() {
947 body
948 } else {
949 envelope
950 .errors
951 .iter()
952 .map(|err| format!("{} ({})", err.message, err.code))
953 .collect::<Vec<_>>()
954 .join("; ")
955 };
956 bail!(
957 "Cloudflare API error for {} (status {}): {}",
958 context_label,
959 status,
960 detail
961 );
962 }
963 envelope.result.ok_or_else(|| {
964 anyhow::anyhow!("Cloudflare API response missing result for {context_label}")
965 })
966}
967
968fn check_project_exists_api(project_name: &str, account_id: &str, api_token: &str) -> Result<bool> {
969 let url = cloudflare_api_url(
970 &api_base_url(),
971 &["accounts", account_id, "pages", "projects", project_name],
972 )?;
973 let response = execute_cloudflare_request(
974 asupersync::http::h1::Method::Get,
975 url,
976 api_token.to_string(),
977 Vec::new(),
978 Vec::new(),
979 )?;
980 if response.status == 404 {
981 return Ok(false);
982 }
983 parse_api_response::<serde_json::Value>(response, "project lookup")?;
984 Ok(true)
985}
986
987fn create_project_api(
988 project_name: &str,
989 branch: &str,
990 account_id: &str,
991 api_token: &str,
992) -> Result<()> {
993 let url = cloudflare_api_url(
994 &api_base_url(),
995 &["accounts", account_id, "pages", "projects"],
996 )?;
997 let body = project_create_body(project_name, branch);
998 let response = execute_cloudflare_request(
999 asupersync::http::h1::Method::Post,
1000 url,
1001 api_token.to_string(),
1002 vec![("Content-Type".to_string(), "application/json".to_string())],
1003 serde_json::to_vec(&body).context("Failed to serialize project create body")?,
1004 )?;
1005 parse_api_response::<serde_json::Value>(response, "project create")?;
1006 Ok(())
1007}
1008
1009fn project_create_body(project_name: &str, branch: &str) -> serde_json::Value {
1010 json!({
1011 "name": project_name,
1012 "production_branch": branch,
1013 "deployment_configs": {
1014 "production": {},
1015 "preview": {}
1016 }
1017 })
1018}
1019
1020fn deploy_with_api(
1021 deploy_dir: &Path,
1022 project_name: &str,
1023 branch: &str,
1024 account_id: &str,
1025 api_token: &str,
1026 progress: &mut impl FnMut(&str, &str),
1027) -> Result<(String, String)> {
1028 let base_url = api_base_url();
1029
1030 progress("api-token", "Requesting Pages upload token...");
1031 let upload_jwt = fetch_upload_token(&base_url, account_id, project_name, api_token)?;
1032 let max_file_count = jwt_max_file_count(&upload_jwt).unwrap_or(MAX_ASSET_COUNT_DEFAULT);
1033
1034 progress("scan", "Scanning static assets...");
1035 let file_map = collect_asset_files(deploy_dir, max_file_count)?;
1036
1037 progress("upload", "Uploading Pages assets via API...");
1038 upload_assets(&base_url, &upload_jwt, &file_map, false)?;
1039
1040 progress("deploy", "Creating Pages deployment via API...");
1041 let manifest = build_manifest(&file_map);
1042 let manifest_json =
1043 serde_json::to_string(&manifest).context("Failed to serialize Pages asset manifest")?;
1044
1045 let mut form = asupersync::http::h1::MultipartForm::new().text("manifest", manifest_json);
1046 if !branch.is_empty() {
1047 form = form.text("branch", branch.to_string());
1048 }
1049 let headers_path = deploy_dir.join("_headers");
1050 if headers_path.exists() {
1051 let bytes = std::fs::read(&headers_path).context("Failed to read _headers")?;
1052 form = form.file("_headers", "_headers", "text/plain; charset=utf-8", bytes);
1053 }
1054 let redirects_path = deploy_dir.join("_redirects");
1055 if redirects_path.exists() {
1056 let bytes = std::fs::read(&redirects_path).context("Failed to read _redirects")?;
1057 form = form.file(
1058 "_redirects",
1059 "_redirects",
1060 "text/plain; charset=utf-8",
1061 bytes,
1062 );
1063 }
1064
1065 let deploy_url = cloudflare_api_url(
1066 &base_url,
1067 &[
1068 "accounts",
1069 account_id,
1070 "pages",
1071 "projects",
1072 project_name,
1073 "deployments",
1074 ],
1075 )?;
1076 let response =
1077 execute_cloudflare_multipart_request(deploy_url, api_token.to_string(), Vec::new(), form)?;
1078 let deployment = parse_api_response::<DeploymentResult>(response, "deployment create")?;
1079
1080 let pages_url = deployment
1081 .url
1082 .or_else(|| deployment.aliases.first().cloned())
1083 .unwrap_or_else(|| format!("https://{}.pages.dev", project_name));
1084
1085 Ok((pages_url, deployment.id))
1086}
1087
1088fn fetch_upload_token(
1089 base_url: &str,
1090 account_id: &str,
1091 project_name: &str,
1092 api_token: &str,
1093) -> Result<String> {
1094 let url = cloudflare_api_url(
1095 base_url,
1096 &[
1097 "accounts",
1098 account_id,
1099 "pages",
1100 "projects",
1101 project_name,
1102 "upload-token",
1103 ],
1104 )?;
1105 let response = execute_cloudflare_request(
1106 asupersync::http::h1::Method::Get,
1107 url,
1108 api_token.to_string(),
1109 Vec::new(),
1110 Vec::new(),
1111 )?;
1112 let result = parse_api_response::<UploadTokenResult>(response, "upload token")?;
1113 Ok(result.jwt)
1114}
1115
1116fn jwt_max_file_count(jwt: &str) -> Option<usize> {
1117 let claims_b64 = jwt.split('.').nth(1)?;
1118 let decoded = BASE64_URL_SAFE_NO_PAD.decode(claims_b64).ok()?;
1119 let value: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
1120 value
1121 .get("max_file_count_allowed")
1122 .and_then(|v| v.as_u64())
1123 .map(|v| v as usize)
1124}
1125
1126fn collect_asset_files(root: &Path, max_files: usize) -> Result<HashMap<String, AssetFile>> {
1127 let mut files = HashMap::new();
1128 for entry in WalkDir::new(root).follow_links(false) {
1129 let entry = entry.context("Failed to read Pages asset entry")?;
1130 let metadata = entry.metadata().context("Failed to read asset metadata")?;
1131 if metadata.is_dir() {
1132 continue;
1133 }
1134 if entry.file_type().is_symlink() {
1135 continue;
1136 }
1137 let rel_path = entry
1138 .path()
1139 .strip_prefix(root)
1140 .context("Failed to compute asset relative path")?;
1141 if should_ignore_path(rel_path) {
1142 continue;
1143 }
1144 let rel_string = normalize_rel_path(rel_path)?;
1145 let size_bytes = metadata.len();
1146 if size_bytes > MAX_ASSET_SIZE_BYTES {
1147 bail!(
1148 "Cloudflare Pages supports files up to {} bytes; '{}' is {} bytes",
1149 MAX_ASSET_SIZE_BYTES,
1150 rel_string,
1151 size_bytes
1152 );
1153 }
1154 let content_type = MimeGuess::from_path(entry.path())
1155 .first_or_octet_stream()
1156 .essence_str()
1157 .to_string();
1158 let hash = hash_asset_file(entry.path())?;
1159 files.insert(
1160 rel_string.clone(),
1161 AssetFile {
1162 path: entry.path().to_path_buf(),
1163 content_type,
1164 size_bytes,
1165 hash,
1166 },
1167 );
1168 if files.len() > max_files {
1169 bail!(
1170 "Cloudflare Pages supports up to {} files for this deployment",
1171 max_files
1172 );
1173 }
1174 }
1175 Ok(files)
1176}
1177
1178fn should_ignore_path(path: &Path) -> bool {
1179 if let Some(name) = path.file_name().and_then(|s| s.to_str())
1180 && matches!(
1181 name,
1182 "_worker.js" | "_redirects" | "_headers" | "_routes.json" | ".DS_Store"
1183 )
1184 {
1185 return true;
1186 }
1187 for component in path.components() {
1188 if let std::path::Component::Normal(os) = component
1189 && let Some(part) = os.to_str()
1190 && matches!(part, "node_modules" | ".git" | "functions")
1191 {
1192 return true;
1193 }
1194 }
1195 false
1196}
1197
1198fn normalize_rel_path(path: &Path) -> Result<String> {
1199 let mut parts = Vec::new();
1200 for component in path.components() {
1201 match component {
1202 std::path::Component::Normal(part) => {
1203 parts.push(
1204 part.to_str()
1205 .ok_or_else(|| anyhow::anyhow!("Invalid UTF-8 path segment"))?
1206 .to_string(),
1207 );
1208 }
1209 std::path::Component::CurDir => {}
1210 std::path::Component::ParentDir => {
1211 bail!("Parent directory segments are not allowed in Pages asset paths");
1212 }
1213 std::path::Component::RootDir | std::path::Component::Prefix(_) => {}
1214 }
1215 }
1216 Ok(parts.join("/"))
1217}
1218
1219fn hash_asset_file(path: &Path) -> Result<String> {
1220 let bytes = std::fs::read(path).context("Failed to read asset for hashing")?;
1221 let base64_contents = BASE64_STANDARD.encode(&bytes);
1222 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
1223 let mut hasher = Hasher::new();
1224 hasher.update(base64_contents.as_bytes());
1225 hasher.update(extension.as_bytes());
1226 let hash = hasher.finalize().to_hex().to_string();
1227 Ok(hash[..32].to_string())
1228}
1229
1230fn build_manifest(file_map: &HashMap<String, AssetFile>) -> HashMap<String, String> {
1231 file_map
1232 .iter()
1233 .map(|(name, file)| (format!("/{}", name), file.hash.clone()))
1234 .collect()
1235}
1236
1237fn upload_assets(
1238 base_url: &str,
1239 jwt: &str,
1240 file_map: &HashMap<String, AssetFile>,
1241 skip_caching: bool,
1242) -> Result<()> {
1243 let mut hashes: Vec<String> = file_map.values().map(|file| file.hash.clone()).collect();
1244 hashes.sort();
1245 hashes.dedup();
1246 let missing_hashes = if skip_caching {
1247 hashes.clone()
1248 } else {
1249 check_missing_hashes(base_url, jwt, &hashes)?
1250 };
1251 let mut missing_files = select_missing_files(file_map, &missing_hashes);
1252 missing_files.sort_by_key(|file| std::cmp::Reverse(file.size_bytes));
1253
1254 let buckets = build_upload_buckets(&missing_files);
1255 for bucket in buckets {
1256 upload_bucket(base_url, jwt, &bucket)?;
1257 }
1258
1259 upsert_hashes(base_url, jwt, &hashes)?;
1260 Ok(())
1261}
1262
1263fn check_missing_hashes(base_url: &str, jwt: &str, hashes: &[String]) -> Result<Vec<String>> {
1264 let url = format!("{}/pages/assets/check-missing", base_url);
1265 let response = execute_cloudflare_request(
1266 asupersync::http::h1::Method::Post,
1267 url,
1268 jwt.to_string(),
1269 vec![("Content-Type".to_string(), "application/json".to_string())],
1270 serde_json::to_vec(&json!({ "hashes": hashes }))
1271 .context("Failed to serialize missing-hashes request")?,
1272 )?;
1273 parse_api_response::<Vec<String>>(response, "asset check-missing")
1274}
1275
1276fn build_upload_buckets<'a>(files: &[&'a AssetFile]) -> Vec<Vec<&'a AssetFile>> {
1277 #[derive(Default)]
1278 struct Bucket<'a> {
1279 files: Vec<&'a AssetFile>,
1280 remaining: u64,
1281 }
1282
1283 let mut buckets: Vec<Bucket<'a>> = (0..3)
1284 .map(|_| Bucket {
1285 files: Vec::new(),
1286 remaining: MAX_BUCKET_SIZE_BYTES,
1287 })
1288 .collect();
1289 let mut offset = 0usize;
1290
1291 for file in files {
1292 let mut inserted = false;
1293 for i in 0..buckets.len() {
1294 let idx = (i + offset) % buckets.len();
1295 let bucket = &mut buckets[idx];
1296 if bucket.remaining >= file.size_bytes && bucket.files.len() < MAX_BUCKET_FILE_COUNT {
1297 bucket.remaining -= file.size_bytes;
1298 bucket.files.push(*file);
1299 inserted = true;
1300 break;
1301 }
1302 }
1303 if !inserted {
1304 buckets.push(Bucket {
1305 files: vec![*file],
1306 remaining: MAX_BUCKET_SIZE_BYTES.saturating_sub(file.size_bytes),
1307 });
1308 }
1309 offset = offset.saturating_add(1);
1310 }
1311
1312 buckets
1313 .into_iter()
1314 .filter(|bucket| !bucket.files.is_empty())
1315 .map(|bucket| bucket.files)
1316 .collect()
1317}
1318
1319fn select_missing_files<'a>(
1320 file_map: &'a HashMap<String, AssetFile>,
1321 missing_hashes: &[String],
1322) -> Vec<&'a AssetFile> {
1323 let missing_set: std::collections::HashSet<&str> =
1324 missing_hashes.iter().map(String::as_str).collect();
1325 let mut by_hash: HashMap<String, &'a AssetFile> = HashMap::new();
1326
1327 for file in file_map.values() {
1328 if missing_set.contains(file.hash.as_str()) {
1329 by_hash.entry(file.hash.clone()).or_insert(file);
1331 }
1332 }
1333
1334 by_hash.into_values().collect()
1335}
1336
1337fn upload_bucket(base_url: &str, jwt: &str, bucket: &[&AssetFile]) -> Result<()> {
1338 if bucket.is_empty() {
1339 return Ok(());
1340 }
1341 let payload: Vec<serde_json::Value> = bucket
1342 .iter()
1343 .map(|file| {
1344 let bytes = std::fs::read(&file.path)?;
1345 Ok(json!({
1346 "key": file.hash,
1347 "value": BASE64_STANDARD.encode(&bytes),
1348 "metadata": { "contentType": file.content_type },
1349 "base64": true
1350 }))
1351 })
1352 .collect::<Result<Vec<_>>>()?;
1353
1354 let url = format!("{}/pages/assets/upload", base_url);
1355 let response = execute_cloudflare_request(
1356 asupersync::http::h1::Method::Post,
1357 url,
1358 jwt.to_string(),
1359 vec![("Content-Type".to_string(), "application/json".to_string())],
1360 serde_json::to_vec(&payload).context("Failed to serialize asset upload bucket")?,
1361 )?;
1362 parse_api_response::<serde_json::Value>(response, "asset upload")?;
1363 Ok(())
1364}
1365
1366fn upsert_hashes(base_url: &str, jwt: &str, hashes: &[String]) -> Result<()> {
1367 let url = format!("{}/pages/assets/upsert-hashes", base_url);
1368 let response = execute_cloudflare_request(
1369 asupersync::http::h1::Method::Post,
1370 url,
1371 jwt.to_string(),
1372 vec![("Content-Type".to_string(), "application/json".to_string())],
1373 serde_json::to_vec(&json!({ "hashes": hashes }))
1374 .context("Failed to serialize asset hash upsert body")?,
1375 )?;
1376 parse_api_response::<serde_json::Value>(response, "asset upsert-hashes")?;
1377 Ok(())
1378}
1379
1380fn configure_custom_domain(
1382 project_name: &str,
1383 domain: &str,
1384 account_id: Option<&str>,
1385 api_token: Option<&str>,
1386) -> Result<()> {
1387 let mut cmd = Command::new("wrangler");
1392 cmd.args([
1393 "pages",
1394 "project",
1395 "edit",
1396 project_name,
1397 "--custom-domain",
1398 domain,
1399 ]);
1400 apply_api_credentials(&mut cmd, account_id, api_token);
1401
1402 let output = cmd.output();
1403
1404 match output {
1405 Ok(out) if out.status.success() => Ok(()),
1406 Ok(out) => {
1407 let stderr = String::from_utf8_lossy(&out.stderr);
1408 eprintln!(
1409 "Warning: Could not automatically configure custom domain. \
1410 Please configure '{}' manually in the Cloudflare dashboard.\nError: {}",
1411 domain, stderr
1412 );
1413 Ok(()) }
1415 Err(e) => {
1416 eprintln!(
1417 "Warning: Could not configure custom domain: {}. \
1418 Please configure '{}' manually in the Cloudflare dashboard.",
1419 e, domain
1420 );
1421 Ok(())
1422 }
1423 }
1424}
1425
1426fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
1428 let canonical_base = src.canonicalize().with_context(|| {
1429 format!(
1430 "Failed to resolve deployment source root {} before copying",
1431 src.display()
1432 )
1433 })?;
1434 copy_dir_recursive_inner(src, dst, &canonical_base)
1435}
1436
1437fn copy_dir_recursive_inner(src: &Path, dst: &Path, canonical_base: &Path) -> Result<()> {
1438 ensure_deploy_staging_dir(dst)?;
1439
1440 for entry in std::fs::read_dir(src)? {
1441 let entry = entry?;
1442 let src_path = entry.path();
1443 let dst_path = dst.join(entry.file_name());
1444 let metadata = std::fs::symlink_metadata(&src_path)?;
1445 let file_type = metadata.file_type();
1446
1447 if file_type.is_symlink() {
1448 let canonical_target = src_path.canonicalize().with_context(|| {
1449 format!(
1450 "Failed to resolve symlinked deploy entry {}",
1451 src_path.display()
1452 )
1453 })?;
1454 if !canonical_target.starts_with(canonical_base) {
1455 bail!(
1456 "Refusing to deploy symlinked site entry outside deployment root: {}",
1457 src_path.display()
1458 );
1459 }
1460
1461 let target_meta = std::fs::metadata(&src_path).with_context(|| {
1462 format!(
1463 "Failed to inspect symlink target for deploy entry {}",
1464 src_path.display()
1465 )
1466 })?;
1467 if !target_meta.is_file() {
1468 bail!(
1469 "Refusing to deploy symlinked site entry that does not point to a regular file: {}",
1470 src_path.display()
1471 );
1472 }
1473
1474 ensure_deploy_file_destination(&dst_path)?;
1475 std::fs::copy(&canonical_target, &dst_path).with_context(|| {
1476 format!(
1477 "Failed copying symlink target {} to {} during deploy staging",
1478 canonical_target.display(),
1479 dst_path.display()
1480 )
1481 })?;
1482 continue;
1483 }
1484
1485 if file_type.is_dir() {
1486 copy_dir_recursive_inner(&src_path, &dst_path, canonical_base)?;
1487 } else if file_type.is_file() {
1488 ensure_deploy_file_destination(&dst_path)?;
1489 std::fs::copy(&src_path, &dst_path)?;
1490 }
1491 }
1492
1493 Ok(())
1494}
1495
1496fn ensure_deploy_staging_dir(path: &Path) -> Result<()> {
1497 match std::fs::symlink_metadata(path) {
1498 Ok(metadata) => {
1499 let file_type = metadata.file_type();
1500 if file_type.is_symlink() {
1501 bail!(
1502 "Refusing to use deploy staging directory through symlink: {}",
1503 path.display()
1504 );
1505 }
1506 if !file_type.is_dir() {
1507 bail!(
1508 "Refusing to use deploy staging path because it is not a directory: {}",
1509 path.display()
1510 );
1511 }
1512 Ok(())
1513 }
1514 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1515 std::fs::create_dir_all(path)?;
1516 match std::fs::symlink_metadata(path) {
1517 Ok(metadata)
1518 if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() =>
1519 {
1520 Ok(())
1521 }
1522 Ok(_) => bail!(
1523 "Refusing to use deploy staging path after create because it is not a real directory: {}",
1524 path.display()
1525 ),
1526 Err(err) => Err(err).with_context(|| {
1527 format!(
1528 "Failed inspecting deploy staging directory after create: {}",
1529 path.display()
1530 )
1531 }),
1532 }
1533 }
1534 Err(err) => Err(err).with_context(|| {
1535 format!(
1536 "Failed inspecting deploy staging directory before copy: {}",
1537 path.display()
1538 )
1539 }),
1540 }
1541}
1542
1543fn ensure_deploy_file_destination(path: &Path) -> Result<()> {
1544 match std::fs::symlink_metadata(path) {
1545 Ok(metadata) => {
1546 let file_type = metadata.file_type();
1547 if file_type.is_symlink() {
1548 bail!(
1549 "Refusing to write deploy file through symlink: {}",
1550 path.display()
1551 );
1552 }
1553 if !file_type.is_file() {
1554 bail!(
1555 "Refusing to write deploy file over non-file path: {}",
1556 path.display()
1557 );
1558 }
1559 Ok(())
1560 }
1561 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1562 Err(err) => Err(err).with_context(|| {
1563 format!(
1564 "Failed inspecting deploy file destination before copy: {}",
1565 path.display()
1566 )
1567 }),
1568 }
1569}
1570
1571fn deploy_staging_path_is_real_dir(path: &Path) -> Result<bool> {
1572 match std::fs::symlink_metadata(path) {
1573 Ok(metadata) => {
1574 let file_type = metadata.file_type();
1575 Ok(file_type.is_dir() && !file_type.is_symlink())
1576 }
1577 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
1578 Err(err) => Err(err).with_context(|| {
1579 format!(
1580 "Failed inspecting deploy staging directory before cleanup: {}",
1581 path.display()
1582 )
1583 }),
1584 }
1585}
1586
1587#[cfg(test)]
1588mod tests {
1589 use super::*;
1590
1591 #[test]
1592 fn test_prerequisites_is_ready() {
1593 let prereqs = Prerequisites {
1594 wrangler_version: Some("wrangler 3.0.0".to_string()),
1595 wrangler_authenticated: true,
1596 account_email: Some("test@example.com".to_string()),
1597 api_credentials_present: false,
1598 account_id: None,
1599 disk_space_mb: 1000,
1600 };
1601
1602 assert!(prereqs.is_ready());
1603 assert!(prereqs.missing().is_empty());
1604 }
1605
1606 #[test]
1607 fn test_prerequisites_not_ready() {
1608 let prereqs = Prerequisites {
1609 wrangler_version: None,
1610 wrangler_authenticated: false,
1611 account_email: None,
1612 api_credentials_present: false,
1613 account_id: None,
1614 disk_space_mb: 1000,
1615 };
1616
1617 assert!(!prereqs.is_ready());
1618 let missing = prereqs.missing();
1619 assert_eq!(missing.len(), 2);
1621 assert!(missing[0].contains("wrangler CLI not installed"));
1622 assert!(missing[1].contains("not authenticated"));
1623 }
1624
1625 #[test]
1626 fn test_prerequisites_ready_with_api_only() {
1627 let prereqs = Prerequisites {
1628 wrangler_version: None,
1629 wrangler_authenticated: false,
1630 account_email: None,
1631 api_credentials_present: true,
1632 account_id: Some("abc123".to_string()),
1633 disk_space_mb: 1000,
1634 };
1635
1636 assert!(prereqs.is_ready());
1637 assert!(prereqs.missing().is_empty());
1638 }
1639
1640 #[test]
1641 fn test_config_default() {
1642 let config = CloudflareConfig::default();
1643 assert_eq!(config.project_name, "cass-archive");
1644 assert!(config.custom_domain.is_none());
1645 assert!(config.create_if_missing);
1646 }
1647
1648 #[test]
1649 fn test_cloudflare_api_base_url_allows_official_https_and_loopback_http() {
1650 assert!(is_allowed_cloudflare_api_base_url(
1651 "https://api.cloudflare.com/client/v4"
1652 ));
1653 assert!(is_allowed_cloudflare_api_base_url(
1654 "https://api.cloudflare.com:443/client/v4/"
1655 ));
1656 assert!(is_allowed_cloudflare_api_base_url(
1657 "http://127.0.0.1:8787/client/v4"
1658 ));
1659 assert!(is_allowed_cloudflare_api_base_url(
1660 "http://localhost:8787/client/v4"
1661 ));
1662 assert!(is_allowed_cloudflare_api_base_url(
1663 "http://[::1]:8787/client/v4"
1664 ));
1665 }
1666
1667 #[test]
1668 fn test_cloudflare_api_base_url_rejects_untrusted_hosts_and_credentials() {
1669 assert!(!is_allowed_cloudflare_api_base_url(
1670 "https://attacker.example.com/client/v4"
1671 ));
1672 assert!(!is_allowed_cloudflare_api_base_url(
1673 "https://api.cloudflare.com.attacker.example/client/v4"
1674 ));
1675 assert!(!is_allowed_cloudflare_api_base_url(
1676 "http://api.cloudflare.com/client/v4"
1677 ));
1678 assert!(!is_allowed_cloudflare_api_base_url(
1679 "http://192.168.1.20:8787/client/v4"
1680 ));
1681 assert!(!is_allowed_cloudflare_api_base_url(
1682 "https://token@api.cloudflare.com/client/v4"
1683 ));
1684 assert!(!is_allowed_cloudflare_api_base_url(
1685 "file:///tmp/cloudflare-api"
1686 ));
1687 assert!(!is_allowed_cloudflare_api_base_url(
1688 "https://api.cloudflare.com/client/v4?redirect=https://attacker.example.com"
1689 ));
1690 assert!(!is_allowed_cloudflare_api_base_url(
1691 "https://api.cloudflare.com/client/v4#fragment"
1692 ));
1693 }
1694
1695 #[test]
1696 fn test_configured_cloudflare_api_base_url_ignores_untrusted_override() {
1697 assert_eq!(
1698 configured_cloudflare_api_base_url(Some("https://attacker.example.com/client/v4")),
1699 DEFAULT_CLOUDFLARE_API_BASE_URL
1700 );
1701 assert_eq!(
1702 configured_cloudflare_api_base_url(Some("https://api.cloudflare.com/client/v4/")),
1703 "https://api.cloudflare.com/client/v4"
1704 );
1705 assert_eq!(
1706 configured_cloudflare_api_base_url(None),
1707 DEFAULT_CLOUDFLARE_API_BASE_URL
1708 );
1709 }
1710
1711 #[test]
1712 fn test_cloudflare_api_url_encodes_dynamic_path_segments() {
1713 let url = cloudflare_api_url(
1714 "https://api.cloudflare.com/client/v4",
1715 &[
1716 "accounts",
1717 "acct/with space",
1718 "pages",
1719 "projects",
1720 "proj/name",
1721 "upload-token",
1722 ],
1723 )
1724 .unwrap();
1725
1726 assert_eq!(
1727 url,
1728 "https://api.cloudflare.com/client/v4/accounts/acct%2Fwith%20space/pages/projects/proj%2Fname/upload-token"
1729 );
1730 }
1731
1732 #[test]
1733 fn test_cloudflare_api_url_preserves_loopback_base_path() {
1734 let url = cloudflare_api_url(
1735 "http://127.0.0.1:8787/client/v4/",
1736 &["accounts", "acct", "pages", "projects"],
1737 )
1738 .unwrap();
1739
1740 assert_eq!(
1741 url,
1742 "http://127.0.0.1:8787/client/v4/accounts/acct/pages/projects"
1743 );
1744 }
1745
1746 #[test]
1747 fn test_project_create_body_shape() {
1748 let body = project_create_body("archive-prod", "main");
1749
1750 assert_eq!(body["name"], json!("archive-prod"));
1751 assert_eq!(body["production_branch"], json!("main"));
1752 assert_eq!(body["deployment_configs"]["production"], json!({}));
1753 assert_eq!(body["deployment_configs"]["preview"], json!({}));
1754 assert_eq!(body.as_object().expect("object").len(), 3);
1755 assert_eq!(
1756 body["deployment_configs"]
1757 .as_object()
1758 .expect("configs")
1759 .len(),
1760 2
1761 );
1762 }
1763
1764 #[test]
1765 fn test_deployer_builder() {
1766 let deployer = CloudflareDeployer::with_project_name("my-archive")
1767 .custom_domain("archive.example.com")
1768 .create_if_missing(false);
1769
1770 assert_eq!(deployer.config.project_name, "my-archive");
1771 assert_eq!(
1772 deployer.config.custom_domain,
1773 Some("archive.example.com".to_string())
1774 );
1775 assert!(!deployer.config.create_if_missing);
1776 }
1777
1778 #[test]
1779 fn test_generate_headers_file() {
1780 use tempfile::TempDir;
1781
1782 let temp = TempDir::new().unwrap();
1783 let deployer = CloudflareDeployer::default();
1784
1785 deployer.generate_headers_file(temp.path()).unwrap();
1786
1787 let headers_path = temp.path().join("_headers");
1788 assert!(headers_path.exists());
1789
1790 let content = std::fs::read_to_string(&headers_path).unwrap();
1791 assert!(content.contains("Cross-Origin-Opener-Policy: same-origin"));
1792 assert!(content.contains("Cross-Origin-Embedder-Policy: require-corp"));
1793 assert!(content.contains("X-Frame-Options: DENY"));
1794 }
1795
1796 #[test]
1797 fn test_generate_redirects_file() {
1798 use tempfile::TempDir;
1799
1800 let temp = TempDir::new().unwrap();
1801 let deployer = CloudflareDeployer::default();
1802
1803 deployer.generate_redirects_file(temp.path()).unwrap();
1804
1805 let redirects_path = temp.path().join("_redirects");
1806 assert!(redirects_path.exists());
1807
1808 let content = std::fs::read_to_string(&redirects_path).unwrap();
1809 assert!(content.contains("/* /index.html 200"));
1810 }
1811
1812 #[test]
1813 fn test_copy_dir_recursive() {
1814 use tempfile::TempDir;
1815
1816 let src = TempDir::new().unwrap();
1817 let dst = TempDir::new().unwrap();
1818
1819 std::fs::create_dir_all(src.path().join("subdir")).unwrap();
1821 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1822 std::fs::write(src.path().join("subdir/nested.txt"), "nested").unwrap();
1823
1824 copy_dir_recursive(src.path(), dst.path()).unwrap();
1825
1826 assert!(dst.path().join("root.txt").exists());
1827 assert!(dst.path().join("subdir/nested.txt").exists());
1828 }
1829
1830 #[test]
1831 #[cfg(unix)]
1832 fn test_copy_dir_recursive_materializes_in_tree_symlinked_files() {
1833 use std::os::unix::fs::symlink;
1834 use tempfile::TempDir;
1835
1836 let src = TempDir::new().unwrap();
1837 let dst = TempDir::new().unwrap();
1838
1839 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1840 symlink("root.txt", src.path().join("linked-file.txt")).unwrap();
1841
1842 copy_dir_recursive(src.path(), dst.path()).unwrap();
1843
1844 let linked_metadata =
1845 std::fs::symlink_metadata(dst.path().join("linked-file.txt")).unwrap();
1846 assert!(linked_metadata.file_type().is_file());
1847 assert!(!linked_metadata.file_type().is_symlink());
1848 assert_eq!(
1849 std::fs::read_to_string(dst.path().join("linked-file.txt")).unwrap(),
1850 "root"
1851 );
1852 }
1853
1854 #[test]
1855 #[cfg(unix)]
1856 fn test_copy_dir_recursive_rejects_symlinks_outside_root() {
1857 use std::os::unix::fs::symlink;
1858 use tempfile::TempDir;
1859
1860 let src = TempDir::new().unwrap();
1861 let dst = TempDir::new().unwrap();
1862 let outside = TempDir::new().unwrap();
1863
1864 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1865 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
1866 symlink(
1867 outside.path().join("secret.txt"),
1868 src.path().join("linked-file.txt"),
1869 )
1870 .unwrap();
1871
1872 let err = copy_dir_recursive(src.path(), dst.path()).unwrap_err();
1873 assert!(
1874 err.to_string()
1875 .contains("Refusing to deploy symlinked site entry outside deployment root"),
1876 "unexpected error: {err:#}"
1877 );
1878 }
1879
1880 #[test]
1881 #[cfg(unix)]
1882 fn test_copy_dir_recursive_rejects_symlinked_destination_root() {
1883 use std::os::unix::fs::symlink;
1884 use tempfile::TempDir;
1885
1886 let src = TempDir::new().unwrap();
1887 let parent = TempDir::new().unwrap();
1888 let outside = TempDir::new().unwrap();
1889 let dst = parent.path().join("deploy-site");
1890
1891 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1892 symlink(outside.path(), &dst).unwrap();
1893
1894 let err = copy_dir_recursive(src.path(), &dst).unwrap_err();
1895 assert!(
1896 err.to_string()
1897 .contains("deploy staging directory through symlink"),
1898 "unexpected error: {err:#}"
1899 );
1900 assert!(
1901 !outside.path().join("root.txt").exists(),
1902 "deploy staging must not copy through a symlinked destination"
1903 );
1904 assert!(
1905 std::fs::symlink_metadata(&dst)
1906 .unwrap()
1907 .file_type()
1908 .is_symlink()
1909 );
1910 }
1911
1912 #[test]
1913 #[cfg(unix)]
1914 fn test_copy_dir_recursive_rejects_symlinked_file_destination() -> Result<()> {
1915 use std::os::unix::fs::symlink;
1916 use tempfile::TempDir;
1917
1918 let src = TempDir::new()?;
1919 let dst = TempDir::new()?;
1920 let outside = TempDir::new()?;
1921
1922 std::fs::write(src.path().join("root.txt"), "root")?;
1923 std::fs::write(outside.path().join("target.txt"), "outside")?;
1924 symlink(
1925 outside.path().join("target.txt"),
1926 dst.path().join("root.txt"),
1927 )?;
1928
1929 let err = match copy_dir_recursive(src.path(), dst.path()) {
1930 Ok(()) => bail!("copy unexpectedly succeeded through a symlinked destination"),
1931 Err(err) => err,
1932 };
1933 if !err
1934 .to_string()
1935 .contains("Refusing to write deploy file through symlink")
1936 {
1937 bail!("unexpected error: {err:#}");
1938 }
1939 let outside_contents = std::fs::read_to_string(outside.path().join("target.txt"))?;
1940 if outside_contents != "outside" {
1941 bail!("deploy copy overwrote symlink target outside staging directory");
1942 }
1943 Ok(())
1944 }
1945
1946 #[test]
1947 fn test_temp_deploy_dir_cleans_up_on_drop() {
1948 let temp_path = {
1949 let temp = create_temp_dir().unwrap();
1950 let marker = temp.path().join("marker.txt");
1951 std::fs::write(&marker, "cleanup").unwrap();
1952 assert!(marker.exists());
1953 temp.path().to_path_buf()
1954 };
1955
1956 assert!(!temp_path.exists());
1957 }
1958
1959 #[test]
1960 #[cfg(unix)]
1961 fn test_temp_deploy_dir_drop_skips_symlinked_staging_path() {
1962 use std::os::unix::fs::symlink;
1963 use tempfile::TempDir;
1964
1965 let outside = TempDir::new().unwrap();
1966 std::fs::write(outside.path().join("sentinel.txt"), "keep").unwrap();
1967 let temp_path = {
1968 let temp = create_temp_dir().unwrap();
1969 let temp_path = temp.path().to_path_buf();
1970 let moved_path = temp_path.with_extension("moved-aside");
1971 std::fs::rename(&temp_path, &moved_path).unwrap();
1972 symlink(outside.path(), &temp_path).unwrap();
1973 temp_path
1974 };
1975
1976 assert_eq!(
1977 std::fs::read_to_string(outside.path().join("sentinel.txt")).unwrap(),
1978 "keep"
1979 );
1980 assert!(
1981 std::fs::symlink_metadata(&temp_path)
1982 .unwrap()
1983 .file_type()
1984 .is_symlink()
1985 );
1986 }
1987
1988 #[test]
1989 fn test_stage_deploy_dir_resolves_bundle_root_without_copying_private_artifacts() {
1990 use tempfile::TempDir;
1991
1992 let bundle_root = TempDir::new().unwrap();
1993 let site_dir = bundle_root.path().join("site");
1994 let private_dir = bundle_root.path().join("private");
1995 std::fs::create_dir_all(&site_dir).unwrap();
1996 std::fs::create_dir_all(&private_dir).unwrap();
1997 std::fs::write(site_dir.join("index.html"), "<html></html>").unwrap();
1998 std::fs::write(site_dir.join("config.json"), "{}").unwrap();
1999 std::fs::write(private_dir.join("master-key.json"), "{\"secret\":true}").unwrap();
2000
2001 let staged = stage_deploy_dir(bundle_root.path()).unwrap();
2002 let staged_site_dir = staged.path().join("site");
2003
2004 assert!(staged_site_dir.join("index.html").exists());
2005 assert!(staged_site_dir.join("config.json").exists());
2006 assert!(!staged_site_dir.join("private").exists());
2007 assert!(!staged.path().join("private").exists());
2008 }
2009
2010 #[test]
2011 fn test_resolve_deploy_site_dir_rejects_non_bundle_directory() {
2012 use tempfile::TempDir;
2013
2014 let temp = TempDir::new().unwrap();
2015 std::fs::write(temp.path().join("index.html"), "<html></html>").unwrap();
2016
2017 let err = resolve_deploy_site_dir(temp.path())
2018 .unwrap_err()
2019 .to_string();
2020 assert!(err.contains("expected a bundle root containing site/ or a site/ directory"));
2021 }
2022
2023 #[test]
2024 #[cfg(unix)]
2025 fn test_resolve_deploy_site_dir_rejects_symlinked_site_directory() {
2026 use std::os::unix::fs::symlink;
2027 use tempfile::TempDir;
2028
2029 let bundle_root = TempDir::new().unwrap();
2030 let outside = TempDir::new().unwrap();
2031 let outside_site = outside.path().join("site");
2032 std::fs::create_dir_all(&outside_site).unwrap();
2033 std::fs::write(outside_site.join("index.html"), "<html></html>").unwrap();
2034 symlink(&outside_site, bundle_root.path().join("site")).unwrap();
2035
2036 let err = resolve_deploy_site_dir(bundle_root.path())
2037 .unwrap_err()
2038 .to_string();
2039 assert!(err.contains("must not be a symlink"));
2040
2041 let direct_err = resolve_deploy_site_dir(&bundle_root.path().join("site"))
2042 .unwrap_err()
2043 .to_string();
2044 assert!(direct_err.contains("must not be a symlink"));
2045 }
2046
2047 #[test]
2048 fn test_output_contains_project_exact_match() {
2049 let list_output = "\
2050┌──────────────┬────────────┐
2051│ Name │ Production │
2052├──────────────┼────────────┤
2053│ cass-archive │ main │
2054│ cass-prod │ main │
2055└──────────────┴────────────┘";
2056
2057 assert!(output_contains_project(list_output, "cass-archive"));
2058 assert!(!output_contains_project(list_output, "cass"));
2059 }
2060
2061 #[test]
2062 fn test_select_missing_files_dedupes_by_hash() {
2063 let mut file_map = HashMap::new();
2064 file_map.insert(
2065 "a.txt".to_string(),
2066 AssetFile {
2067 path: PathBuf::from("/tmp/a.txt"),
2068 content_type: "text/plain".to_string(),
2069 size_bytes: 10,
2070 hash: "hash-shared".to_string(),
2071 },
2072 );
2073 file_map.insert(
2074 "b.txt".to_string(),
2075 AssetFile {
2076 path: PathBuf::from("/tmp/b.txt"),
2077 content_type: "text/plain".to_string(),
2078 size_bytes: 10,
2079 hash: "hash-shared".to_string(),
2080 },
2081 );
2082 file_map.insert(
2083 "c.txt".to_string(),
2084 AssetFile {
2085 path: PathBuf::from("/tmp/c.txt"),
2086 content_type: "text/plain".to_string(),
2087 size_bytes: 8,
2088 hash: "hash-unique".to_string(),
2089 },
2090 );
2091
2092 let missing = vec!["hash-shared".to_string(), "hash-unique".to_string()];
2093 let selected = select_missing_files(&file_map, &missing);
2094
2095 assert_eq!(selected.len(), 2);
2097 let hashes: std::collections::HashSet<_> =
2098 selected.iter().map(|f| f.hash.as_str()).collect();
2099 assert!(hashes.contains("hash-shared"));
2100 assert!(hashes.contains("hash-unique"));
2101 }
2102}