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            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        // When wrangler is not installed and no API creds, there are 2 missing items
1590        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        // Create source structure
1790        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        // Two unique hashes should produce two uploads, not three files.
2032        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}