Skip to main content

coding_agent_search/pages/
deploy_cloudflare.rs

1//! Cloudflare Pages deployment module.
2//!
3//! Deploys encrypted archives to Cloudflare Pages using wrangler or direct API calls.
4//! Supports native COOP/COEP headers, no file size limits, and private repos.
5
6use 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
23/// Maximum number of retry attempts for network operations
24const MAX_RETRIES: u32 = 3;
25
26/// Base delay for exponential backoff (milliseconds)
27const BASE_DELAY_MS: u64 = 1000;
28
29/// Timeout for direct Cloudflare API calls.
30const 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/// Prerequisites for Cloudflare Pages deployment
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Prerequisites {
41    /// wrangler CLI version if installed
42    pub wrangler_version: Option<String>,
43    /// Whether wrangler CLI is authenticated
44    pub wrangler_authenticated: bool,
45    /// Cloudflare account email if authenticated
46    pub account_email: Option<String>,
47    /// Whether API credentials (token + account ID) are available
48    pub api_credentials_present: bool,
49    /// Account ID if provided (safe to display)
50    pub account_id: Option<String>,
51    /// Available disk space in MB
52    pub disk_space_mb: u64,
53}
54
55impl Prerequisites {
56    /// Check if all prerequisites are met.
57    ///
58    /// Either wrangler must be installed and authenticated, or direct API
59    /// credentials must be present.
60    pub fn is_ready(&self) -> bool {
61        self.api_credentials_present
62            || (self.wrangler_version.is_some() && self.wrangler_authenticated)
63    }
64
65    /// Get a list of missing prerequisites
66    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/// Deployment result
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct DeployResult {
89    /// Project name
90    pub project_name: String,
91    /// Pages URL (where the site is accessible)
92    pub pages_url: String,
93    /// Whether deployment was successful
94    pub deployed: bool,
95    /// Deployment ID if available
96    pub deployment_id: Option<String>,
97    /// Custom domain if configured
98    pub custom_domain: Option<String>,
99}
100
101/// Cloudflare Pages deployer configuration
102#[derive(Debug, Clone)]
103pub struct CloudflareConfig {
104    /// Project name for Cloudflare Pages
105    pub project_name: String,
106    /// Optional custom domain
107    pub custom_domain: Option<String>,
108    /// Whether to create project if it doesn't exist
109    pub create_if_missing: bool,
110    /// Production branch for Pages deployments
111    pub branch: String,
112    /// Optional Cloudflare account ID (fallback auth for CI)
113    pub account_id: Option<String>,
114    /// Optional Cloudflare API token (fallback auth for CI)
115    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
131/// Cloudflare Pages deployer
132pub 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    /// Create a new deployer with the given configuration
144    pub fn new(config: CloudflareConfig) -> Self {
145        Self { config }
146    }
147
148    /// Create a deployer with just a project name
149    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    /// Set custom domain
157    pub fn custom_domain(mut self, domain: impl Into<String>) -> Self {
158        self.config.custom_domain = Some(domain.into());
159        self
160    }
161
162    /// Set whether to create project if missing
163    pub fn create_if_missing(mut self, create: bool) -> Self {
164        self.config.create_if_missing = create;
165        self
166    }
167
168    /// Set deployment branch (defaults to "main")
169    pub fn branch(mut self, branch: impl Into<String>) -> Self {
170        self.config.branch = branch.into();
171        self
172    }
173
174    /// Set Cloudflare account ID (for API-token auth)
175    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    /// Set Cloudflare API token (for API-token auth)
181    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    /// Check deployment prerequisites
187    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    /// Generate _headers file for Cloudflare Pages
220    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    /// Generate _redirects file for SPA support
247    pub fn generate_redirects_file(&self, site_dir: &Path) -> Result<()> {
248        // For hash-based routing, no redirects needed
249        // But we can add a fallback for direct URL access
250        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    /// Deploy bundle to Cloudflare Pages
258    ///
259    /// # Arguments
260    /// * `bundle_dir` - Path to the site/ directory from bundle builder
261    /// * `progress` - Progress callback (phase, message)
262    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        // Step 1: Check prerequisites
282        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        // Step 2: Copy bundle to temp directory and add Cloudflare files
293        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        // Step 3: Generate Cloudflare-specific files
298        progress("headers", "Generating COOP/COEP headers...");
299        self.generate_headers_file(&deploy_dir)?;
300        self.generate_redirects_file(&deploy_dir)?;
301
302        // Step 4: Create project if needed
303        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        // Step 5: Deploy using wrangler
331        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        // Step 6: Configure custom domain if specified
354        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
379// Helper functions
380
381struct 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
399/// Create a temporary directory
400fn 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
471/// Get wrangler CLI version
472fn 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
487/// Check wrangler authentication status
488fn 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            // Parse email from output
496            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
507/// Get available disk space in MB
508fn 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
534/// Check if Cloudflare Pages project exists
535fn 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        // Wrangler table output usually places the project name in the first column.
568        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        // Fallback for non-table output.
579        trimmed_edges
580            .split_whitespace()
581            .any(|token| token == project_name)
582    })
583}
584
585/// Create a new Cloudflare Pages project
586fn 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        // Ignore if project already exists
610        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
620/// Retry a fallible operation with exponential backoff
621fn 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
651/// Deploy using wrangler CLI with retry logic
652fn 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        // Parse URL from output
688        // Typical output: "Deployment complete! ... https://xxx.project.pages.dev"
689        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        // Parse deployment ID if available
708        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            // Only one upload per content hash is needed.
1330            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
1380/// Configure custom domain for project
1381fn configure_custom_domain(
1382    project_name: &str,
1383    domain: &str,
1384    account_id: Option<&str>,
1385    api_token: Option<&str>,
1386) -> Result<()> {
1387    // Note: Custom domain configuration typically requires manual setup
1388    // in the Cloudflare dashboard due to DNS verification requirements.
1389    // This is a best-effort attempt using wrangler.
1390
1391    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(()) // Don't fail deployment for domain config issues
1414        }
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
1426/// Copy directory recursively
1427fn 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        // When wrangler is not installed and no API creds, there are 2 missing items
1620        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        // Create source structure
1820        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        // Two unique hashes should produce two uploads, not three files.
2096        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}