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 std::fs::copy(&canonical_target, &dst_path).with_context(|| {
1475 format!(
1476 "Failed copying symlink target {} to {} during deploy staging",
1477 canonical_target.display(),
1478 dst_path.display()
1479 )
1480 })?;
1481 continue;
1482 }
1483
1484 if file_type.is_dir() {
1485 copy_dir_recursive_inner(&src_path, &dst_path, canonical_base)?;
1486 } else if file_type.is_file() {
1487 std::fs::copy(&src_path, &dst_path)?;
1488 }
1489 }
1490
1491 Ok(())
1492}
1493
1494fn ensure_deploy_staging_dir(path: &Path) -> Result<()> {
1495 match std::fs::symlink_metadata(path) {
1496 Ok(metadata) => {
1497 let file_type = metadata.file_type();
1498 if file_type.is_symlink() {
1499 bail!(
1500 "Refusing to use deploy staging directory through symlink: {}",
1501 path.display()
1502 );
1503 }
1504 if !file_type.is_dir() {
1505 bail!(
1506 "Refusing to use deploy staging path because it is not a directory: {}",
1507 path.display()
1508 );
1509 }
1510 Ok(())
1511 }
1512 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1513 std::fs::create_dir_all(path)?;
1514 match std::fs::symlink_metadata(path) {
1515 Ok(metadata)
1516 if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() =>
1517 {
1518 Ok(())
1519 }
1520 Ok(_) => bail!(
1521 "Refusing to use deploy staging path after create because it is not a real directory: {}",
1522 path.display()
1523 ),
1524 Err(err) => Err(err).with_context(|| {
1525 format!(
1526 "Failed inspecting deploy staging directory after create: {}",
1527 path.display()
1528 )
1529 }),
1530 }
1531 }
1532 Err(err) => Err(err).with_context(|| {
1533 format!(
1534 "Failed inspecting deploy staging directory before copy: {}",
1535 path.display()
1536 )
1537 }),
1538 }
1539}
1540
1541fn deploy_staging_path_is_real_dir(path: &Path) -> Result<bool> {
1542 match std::fs::symlink_metadata(path) {
1543 Ok(metadata) => {
1544 let file_type = metadata.file_type();
1545 Ok(file_type.is_dir() && !file_type.is_symlink())
1546 }
1547 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
1548 Err(err) => Err(err).with_context(|| {
1549 format!(
1550 "Failed inspecting deploy staging directory before cleanup: {}",
1551 path.display()
1552 )
1553 }),
1554 }
1555}
1556
1557#[cfg(test)]
1558mod tests {
1559 use super::*;
1560
1561 #[test]
1562 fn test_prerequisites_is_ready() {
1563 let prereqs = Prerequisites {
1564 wrangler_version: Some("wrangler 3.0.0".to_string()),
1565 wrangler_authenticated: true,
1566 account_email: Some("test@example.com".to_string()),
1567 api_credentials_present: false,
1568 account_id: None,
1569 disk_space_mb: 1000,
1570 };
1571
1572 assert!(prereqs.is_ready());
1573 assert!(prereqs.missing().is_empty());
1574 }
1575
1576 #[test]
1577 fn test_prerequisites_not_ready() {
1578 let prereqs = Prerequisites {
1579 wrangler_version: None,
1580 wrangler_authenticated: false,
1581 account_email: None,
1582 api_credentials_present: false,
1583 account_id: None,
1584 disk_space_mb: 1000,
1585 };
1586
1587 assert!(!prereqs.is_ready());
1588 let missing = prereqs.missing();
1589 assert_eq!(missing.len(), 2);
1591 assert!(missing[0].contains("wrangler CLI not installed"));
1592 assert!(missing[1].contains("not authenticated"));
1593 }
1594
1595 #[test]
1596 fn test_prerequisites_ready_with_api_only() {
1597 let prereqs = Prerequisites {
1598 wrangler_version: None,
1599 wrangler_authenticated: false,
1600 account_email: None,
1601 api_credentials_present: true,
1602 account_id: Some("abc123".to_string()),
1603 disk_space_mb: 1000,
1604 };
1605
1606 assert!(prereqs.is_ready());
1607 assert!(prereqs.missing().is_empty());
1608 }
1609
1610 #[test]
1611 fn test_config_default() {
1612 let config = CloudflareConfig::default();
1613 assert_eq!(config.project_name, "cass-archive");
1614 assert!(config.custom_domain.is_none());
1615 assert!(config.create_if_missing);
1616 }
1617
1618 #[test]
1619 fn test_cloudflare_api_base_url_allows_official_https_and_loopback_http() {
1620 assert!(is_allowed_cloudflare_api_base_url(
1621 "https://api.cloudflare.com/client/v4"
1622 ));
1623 assert!(is_allowed_cloudflare_api_base_url(
1624 "https://api.cloudflare.com:443/client/v4/"
1625 ));
1626 assert!(is_allowed_cloudflare_api_base_url(
1627 "http://127.0.0.1:8787/client/v4"
1628 ));
1629 assert!(is_allowed_cloudflare_api_base_url(
1630 "http://localhost:8787/client/v4"
1631 ));
1632 assert!(is_allowed_cloudflare_api_base_url(
1633 "http://[::1]:8787/client/v4"
1634 ));
1635 }
1636
1637 #[test]
1638 fn test_cloudflare_api_base_url_rejects_untrusted_hosts_and_credentials() {
1639 assert!(!is_allowed_cloudflare_api_base_url(
1640 "https://attacker.example.com/client/v4"
1641 ));
1642 assert!(!is_allowed_cloudflare_api_base_url(
1643 "https://api.cloudflare.com.attacker.example/client/v4"
1644 ));
1645 assert!(!is_allowed_cloudflare_api_base_url(
1646 "http://api.cloudflare.com/client/v4"
1647 ));
1648 assert!(!is_allowed_cloudflare_api_base_url(
1649 "http://192.168.1.20:8787/client/v4"
1650 ));
1651 assert!(!is_allowed_cloudflare_api_base_url(
1652 "https://token@api.cloudflare.com/client/v4"
1653 ));
1654 assert!(!is_allowed_cloudflare_api_base_url(
1655 "file:///tmp/cloudflare-api"
1656 ));
1657 assert!(!is_allowed_cloudflare_api_base_url(
1658 "https://api.cloudflare.com/client/v4?redirect=https://attacker.example.com"
1659 ));
1660 assert!(!is_allowed_cloudflare_api_base_url(
1661 "https://api.cloudflare.com/client/v4#fragment"
1662 ));
1663 }
1664
1665 #[test]
1666 fn test_configured_cloudflare_api_base_url_ignores_untrusted_override() {
1667 assert_eq!(
1668 configured_cloudflare_api_base_url(Some("https://attacker.example.com/client/v4")),
1669 DEFAULT_CLOUDFLARE_API_BASE_URL
1670 );
1671 assert_eq!(
1672 configured_cloudflare_api_base_url(Some("https://api.cloudflare.com/client/v4/")),
1673 "https://api.cloudflare.com/client/v4"
1674 );
1675 assert_eq!(
1676 configured_cloudflare_api_base_url(None),
1677 DEFAULT_CLOUDFLARE_API_BASE_URL
1678 );
1679 }
1680
1681 #[test]
1682 fn test_cloudflare_api_url_encodes_dynamic_path_segments() {
1683 let url = cloudflare_api_url(
1684 "https://api.cloudflare.com/client/v4",
1685 &[
1686 "accounts",
1687 "acct/with space",
1688 "pages",
1689 "projects",
1690 "proj/name",
1691 "upload-token",
1692 ],
1693 )
1694 .unwrap();
1695
1696 assert_eq!(
1697 url,
1698 "https://api.cloudflare.com/client/v4/accounts/acct%2Fwith%20space/pages/projects/proj%2Fname/upload-token"
1699 );
1700 }
1701
1702 #[test]
1703 fn test_cloudflare_api_url_preserves_loopback_base_path() {
1704 let url = cloudflare_api_url(
1705 "http://127.0.0.1:8787/client/v4/",
1706 &["accounts", "acct", "pages", "projects"],
1707 )
1708 .unwrap();
1709
1710 assert_eq!(
1711 url,
1712 "http://127.0.0.1:8787/client/v4/accounts/acct/pages/projects"
1713 );
1714 }
1715
1716 #[test]
1717 fn test_project_create_body_shape() {
1718 let body = project_create_body("archive-prod", "main");
1719
1720 assert_eq!(body["name"], json!("archive-prod"));
1721 assert_eq!(body["production_branch"], json!("main"));
1722 assert_eq!(body["deployment_configs"]["production"], json!({}));
1723 assert_eq!(body["deployment_configs"]["preview"], json!({}));
1724 assert_eq!(body.as_object().expect("object").len(), 3);
1725 assert_eq!(
1726 body["deployment_configs"]
1727 .as_object()
1728 .expect("configs")
1729 .len(),
1730 2
1731 );
1732 }
1733
1734 #[test]
1735 fn test_deployer_builder() {
1736 let deployer = CloudflareDeployer::with_project_name("my-archive")
1737 .custom_domain("archive.example.com")
1738 .create_if_missing(false);
1739
1740 assert_eq!(deployer.config.project_name, "my-archive");
1741 assert_eq!(
1742 deployer.config.custom_domain,
1743 Some("archive.example.com".to_string())
1744 );
1745 assert!(!deployer.config.create_if_missing);
1746 }
1747
1748 #[test]
1749 fn test_generate_headers_file() {
1750 use tempfile::TempDir;
1751
1752 let temp = TempDir::new().unwrap();
1753 let deployer = CloudflareDeployer::default();
1754
1755 deployer.generate_headers_file(temp.path()).unwrap();
1756
1757 let headers_path = temp.path().join("_headers");
1758 assert!(headers_path.exists());
1759
1760 let content = std::fs::read_to_string(&headers_path).unwrap();
1761 assert!(content.contains("Cross-Origin-Opener-Policy: same-origin"));
1762 assert!(content.contains("Cross-Origin-Embedder-Policy: require-corp"));
1763 assert!(content.contains("X-Frame-Options: DENY"));
1764 }
1765
1766 #[test]
1767 fn test_generate_redirects_file() {
1768 use tempfile::TempDir;
1769
1770 let temp = TempDir::new().unwrap();
1771 let deployer = CloudflareDeployer::default();
1772
1773 deployer.generate_redirects_file(temp.path()).unwrap();
1774
1775 let redirects_path = temp.path().join("_redirects");
1776 assert!(redirects_path.exists());
1777
1778 let content = std::fs::read_to_string(&redirects_path).unwrap();
1779 assert!(content.contains("/* /index.html 200"));
1780 }
1781
1782 #[test]
1783 fn test_copy_dir_recursive() {
1784 use tempfile::TempDir;
1785
1786 let src = TempDir::new().unwrap();
1787 let dst = TempDir::new().unwrap();
1788
1789 std::fs::create_dir_all(src.path().join("subdir")).unwrap();
1791 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1792 std::fs::write(src.path().join("subdir/nested.txt"), "nested").unwrap();
1793
1794 copy_dir_recursive(src.path(), dst.path()).unwrap();
1795
1796 assert!(dst.path().join("root.txt").exists());
1797 assert!(dst.path().join("subdir/nested.txt").exists());
1798 }
1799
1800 #[test]
1801 #[cfg(unix)]
1802 fn test_copy_dir_recursive_materializes_in_tree_symlinked_files() {
1803 use std::os::unix::fs::symlink;
1804 use tempfile::TempDir;
1805
1806 let src = TempDir::new().unwrap();
1807 let dst = TempDir::new().unwrap();
1808
1809 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1810 symlink("root.txt", src.path().join("linked-file.txt")).unwrap();
1811
1812 copy_dir_recursive(src.path(), dst.path()).unwrap();
1813
1814 let linked_metadata =
1815 std::fs::symlink_metadata(dst.path().join("linked-file.txt")).unwrap();
1816 assert!(linked_metadata.file_type().is_file());
1817 assert!(!linked_metadata.file_type().is_symlink());
1818 assert_eq!(
1819 std::fs::read_to_string(dst.path().join("linked-file.txt")).unwrap(),
1820 "root"
1821 );
1822 }
1823
1824 #[test]
1825 #[cfg(unix)]
1826 fn test_copy_dir_recursive_rejects_symlinks_outside_root() {
1827 use std::os::unix::fs::symlink;
1828 use tempfile::TempDir;
1829
1830 let src = TempDir::new().unwrap();
1831 let dst = TempDir::new().unwrap();
1832 let outside = TempDir::new().unwrap();
1833
1834 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1835 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
1836 symlink(
1837 outside.path().join("secret.txt"),
1838 src.path().join("linked-file.txt"),
1839 )
1840 .unwrap();
1841
1842 let err = copy_dir_recursive(src.path(), dst.path()).unwrap_err();
1843 assert!(
1844 err.to_string()
1845 .contains("Refusing to deploy symlinked site entry outside deployment root"),
1846 "unexpected error: {err:#}"
1847 );
1848 }
1849
1850 #[test]
1851 #[cfg(unix)]
1852 fn test_copy_dir_recursive_rejects_symlinked_destination_root() {
1853 use std::os::unix::fs::symlink;
1854 use tempfile::TempDir;
1855
1856 let src = TempDir::new().unwrap();
1857 let parent = TempDir::new().unwrap();
1858 let outside = TempDir::new().unwrap();
1859 let dst = parent.path().join("deploy-site");
1860
1861 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1862 symlink(outside.path(), &dst).unwrap();
1863
1864 let err = copy_dir_recursive(src.path(), &dst).unwrap_err();
1865 assert!(
1866 err.to_string()
1867 .contains("deploy staging directory through symlink"),
1868 "unexpected error: {err:#}"
1869 );
1870 assert!(
1871 !outside.path().join("root.txt").exists(),
1872 "deploy staging must not copy through a symlinked destination"
1873 );
1874 assert!(
1875 std::fs::symlink_metadata(&dst)
1876 .unwrap()
1877 .file_type()
1878 .is_symlink()
1879 );
1880 }
1881
1882 #[test]
1883 fn test_temp_deploy_dir_cleans_up_on_drop() {
1884 let temp_path = {
1885 let temp = create_temp_dir().unwrap();
1886 let marker = temp.path().join("marker.txt");
1887 std::fs::write(&marker, "cleanup").unwrap();
1888 assert!(marker.exists());
1889 temp.path().to_path_buf()
1890 };
1891
1892 assert!(!temp_path.exists());
1893 }
1894
1895 #[test]
1896 #[cfg(unix)]
1897 fn test_temp_deploy_dir_drop_skips_symlinked_staging_path() {
1898 use std::os::unix::fs::symlink;
1899 use tempfile::TempDir;
1900
1901 let outside = TempDir::new().unwrap();
1902 std::fs::write(outside.path().join("sentinel.txt"), "keep").unwrap();
1903 let temp_path = {
1904 let temp = create_temp_dir().unwrap();
1905 let temp_path = temp.path().to_path_buf();
1906 let moved_path = temp_path.with_extension("moved-aside");
1907 std::fs::rename(&temp_path, &moved_path).unwrap();
1908 symlink(outside.path(), &temp_path).unwrap();
1909 temp_path
1910 };
1911
1912 assert_eq!(
1913 std::fs::read_to_string(outside.path().join("sentinel.txt")).unwrap(),
1914 "keep"
1915 );
1916 assert!(
1917 std::fs::symlink_metadata(&temp_path)
1918 .unwrap()
1919 .file_type()
1920 .is_symlink()
1921 );
1922 }
1923
1924 #[test]
1925 fn test_stage_deploy_dir_resolves_bundle_root_without_copying_private_artifacts() {
1926 use tempfile::TempDir;
1927
1928 let bundle_root = TempDir::new().unwrap();
1929 let site_dir = bundle_root.path().join("site");
1930 let private_dir = bundle_root.path().join("private");
1931 std::fs::create_dir_all(&site_dir).unwrap();
1932 std::fs::create_dir_all(&private_dir).unwrap();
1933 std::fs::write(site_dir.join("index.html"), "<html></html>").unwrap();
1934 std::fs::write(site_dir.join("config.json"), "{}").unwrap();
1935 std::fs::write(private_dir.join("master-key.json"), "{\"secret\":true}").unwrap();
1936
1937 let staged = stage_deploy_dir(bundle_root.path()).unwrap();
1938 let staged_site_dir = staged.path().join("site");
1939
1940 assert!(staged_site_dir.join("index.html").exists());
1941 assert!(staged_site_dir.join("config.json").exists());
1942 assert!(!staged_site_dir.join("private").exists());
1943 assert!(!staged.path().join("private").exists());
1944 }
1945
1946 #[test]
1947 fn test_resolve_deploy_site_dir_rejects_non_bundle_directory() {
1948 use tempfile::TempDir;
1949
1950 let temp = TempDir::new().unwrap();
1951 std::fs::write(temp.path().join("index.html"), "<html></html>").unwrap();
1952
1953 let err = resolve_deploy_site_dir(temp.path())
1954 .unwrap_err()
1955 .to_string();
1956 assert!(err.contains("expected a bundle root containing site/ or a site/ directory"));
1957 }
1958
1959 #[test]
1960 #[cfg(unix)]
1961 fn test_resolve_deploy_site_dir_rejects_symlinked_site_directory() {
1962 use std::os::unix::fs::symlink;
1963 use tempfile::TempDir;
1964
1965 let bundle_root = TempDir::new().unwrap();
1966 let outside = TempDir::new().unwrap();
1967 let outside_site = outside.path().join("site");
1968 std::fs::create_dir_all(&outside_site).unwrap();
1969 std::fs::write(outside_site.join("index.html"), "<html></html>").unwrap();
1970 symlink(&outside_site, bundle_root.path().join("site")).unwrap();
1971
1972 let err = resolve_deploy_site_dir(bundle_root.path())
1973 .unwrap_err()
1974 .to_string();
1975 assert!(err.contains("must not be a symlink"));
1976
1977 let direct_err = resolve_deploy_site_dir(&bundle_root.path().join("site"))
1978 .unwrap_err()
1979 .to_string();
1980 assert!(direct_err.contains("must not be a symlink"));
1981 }
1982
1983 #[test]
1984 fn test_output_contains_project_exact_match() {
1985 let list_output = "\
1986┌──────────────┬────────────┐
1987│ Name │ Production │
1988├──────────────┼────────────┤
1989│ cass-archive │ main │
1990│ cass-prod │ main │
1991└──────────────┴────────────┘";
1992
1993 assert!(output_contains_project(list_output, "cass-archive"));
1994 assert!(!output_contains_project(list_output, "cass"));
1995 }
1996
1997 #[test]
1998 fn test_select_missing_files_dedupes_by_hash() {
1999 let mut file_map = HashMap::new();
2000 file_map.insert(
2001 "a.txt".to_string(),
2002 AssetFile {
2003 path: PathBuf::from("/tmp/a.txt"),
2004 content_type: "text/plain".to_string(),
2005 size_bytes: 10,
2006 hash: "hash-shared".to_string(),
2007 },
2008 );
2009 file_map.insert(
2010 "b.txt".to_string(),
2011 AssetFile {
2012 path: PathBuf::from("/tmp/b.txt"),
2013 content_type: "text/plain".to_string(),
2014 size_bytes: 10,
2015 hash: "hash-shared".to_string(),
2016 },
2017 );
2018 file_map.insert(
2019 "c.txt".to_string(),
2020 AssetFile {
2021 path: PathBuf::from("/tmp/c.txt"),
2022 content_type: "text/plain".to_string(),
2023 size_bytes: 8,
2024 hash: "hash-unique".to_string(),
2025 },
2026 );
2027
2028 let missing = vec!["hash-shared".to_string(), "hash-unique".to_string()];
2029 let selected = select_missing_files(&file_map, &missing);
2030
2031 assert_eq!(selected.len(), 2);
2033 let hashes: std::collections::HashSet<_> =
2034 selected.iter().map(|f| f.hash.as_str()).collect();
2035 assert!(hashes.contains("hash-shared"));
2036 assert!(hashes.contains("hash-unique"));
2037 }
2038}