1use anyhow::{Context, Result, bail};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::thread;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13const MAX_RETRIES: u32 = 3;
15
16const BASE_DELAY_MS: u64 = 1000;
18
19const MAX_SITE_SIZE_BYTES: u64 = 1024 * 1024 * 1024;
21
22const FILE_SIZE_WARNING_BYTES: u64 = 50 * 1024 * 1024;
24
25const MAX_FILE_SIZE_BYTES: u64 = 100 * 1024 * 1024;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Prerequisites {
31 pub gh_version: Option<String>,
33 pub gh_authenticated: bool,
35 pub gh_username: Option<String>,
37 pub git_version: Option<String>,
39 pub disk_space_mb: u64,
41 pub estimated_size_mb: u64,
43}
44
45impl Prerequisites {
46 pub fn is_ready(&self) -> bool {
48 self.gh_version.is_some() && self.gh_authenticated && self.git_version.is_some()
49 }
50
51 pub fn missing(&self) -> Vec<&'static str> {
53 let mut missing = Vec::new();
54 if self.gh_version.is_none() {
55 missing.push("gh CLI not installed (install from https://cli.github.com)");
56 }
57 if !self.gh_authenticated {
58 missing.push("gh CLI not authenticated (run 'gh auth login')");
59 }
60 if self.git_version.is_none() {
61 missing.push("git not installed");
62 }
63 missing
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct SizeCheck {
70 pub total_bytes: u64,
72 pub file_count: usize,
74 pub large_files: Vec<(String, u64)>,
76 pub exceeds_limit: bool,
78 pub has_oversized_files: bool,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct DeployResult {
85 pub repo_url: String,
87 pub pages_url: String,
89 pub pages_enabled: bool,
91 pub commit_sha: String,
93}
94
95pub struct GitHubDeployer {
97 repo_name: String,
99 description: String,
101 public: bool,
103 force: bool,
105}
106
107impl Default for GitHubDeployer {
108 fn default() -> Self {
109 Self::new("cass-archive")
110 }
111}
112
113impl GitHubDeployer {
114 pub fn new(repo_name: impl Into<String>) -> Self {
116 Self {
117 repo_name: repo_name.into(),
118 description: "Encrypted cass archive".to_string(),
119 public: true,
120 force: false,
121 }
122 }
123
124 pub fn description(mut self, desc: impl Into<String>) -> Self {
126 self.description = desc.into();
127 self
128 }
129
130 pub fn public(mut self, public: bool) -> Self {
132 self.public = public;
133 self
134 }
135
136 pub fn force(mut self, force: bool) -> Self {
138 self.force = force;
139 self
140 }
141
142 pub fn check_prerequisites(&self) -> Result<Prerequisites> {
144 let gh_version = get_gh_version();
146 let (gh_authenticated, gh_username) = if gh_version.is_some() {
147 check_gh_auth()
148 } else {
149 (false, None)
150 };
151
152 let git_version = get_git_version();
154
155 let disk_space_mb = get_available_space_mb().unwrap_or(0);
157
158 Ok(Prerequisites {
159 gh_version,
160 gh_authenticated,
161 gh_username,
162 git_version,
163 disk_space_mb,
164 estimated_size_mb: 0, })
166 }
167
168 pub fn check_size(&self, bundle_dir: &Path) -> Result<SizeCheck> {
170 let bundle_dir = super::resolve_site_dir(bundle_dir)?;
171 let mut total_bytes = 0u64;
172 let mut file_count = 0usize;
173 let mut large_files = Vec::new();
174 let mut has_oversized = false;
175
176 visit_files(&bundle_dir, &mut |path, size| {
177 total_bytes += size;
178 file_count += 1;
179
180 if size > MAX_FILE_SIZE_BYTES {
181 has_oversized = true;
182 let rel_path = path
183 .strip_prefix(bundle_dir.as_path())
184 .unwrap_or(path)
185 .to_string_lossy()
186 .to_string();
187 large_files.push((rel_path, size));
188 } else if size > FILE_SIZE_WARNING_BYTES {
189 let rel_path = path
190 .strip_prefix(bundle_dir.as_path())
191 .unwrap_or(path)
192 .to_string_lossy()
193 .to_string();
194 large_files.push((rel_path, size));
195 }
196 })?;
197
198 Ok(SizeCheck {
199 total_bytes,
200 file_count,
201 large_files,
202 exceeds_limit: total_bytes > MAX_SITE_SIZE_BYTES,
203 has_oversized_files: has_oversized,
204 })
205 }
206
207 pub fn deploy<P: AsRef<Path>>(
213 &self,
214 bundle_dir: P,
215 mut progress: impl FnMut(&str, &str),
216 ) -> Result<DeployResult> {
217 let bundle_dir = super::resolve_site_dir(bundle_dir.as_ref())?;
218
219 progress("prereq", "Checking prerequisites...");
221 let prereqs = self.check_prerequisites()?;
222
223 if !prereqs.is_ready() {
224 let missing = prereqs.missing();
225 bail!("Prerequisites not met:\n{}", missing.join("\n"));
226 }
227
228 let username = prereqs
229 .gh_username
230 .as_ref()
231 .context("Could not determine GitHub username")?;
232
233 progress("size", "Checking bundle size...");
235 let size_check = self.check_size(&bundle_dir)?;
236
237 if size_check.exceeds_limit {
238 bail!(
239 "Bundle size ({:.1} MB) exceeds GitHub Pages limit ({:.1} MB)",
240 size_check.total_bytes as f64 / (1024.0 * 1024.0),
241 MAX_SITE_SIZE_BYTES as f64 / (1024.0 * 1024.0)
242 );
243 }
244
245 if size_check.has_oversized_files {
246 let oversized: Vec<_> = size_check
247 .large_files
248 .iter()
249 .filter(|(_, size)| *size > MAX_FILE_SIZE_BYTES)
250 .map(|(path, size)| {
251 format!(" {} ({:.1} MB)", path, *size as f64 / (1024.0 * 1024.0))
252 })
253 .collect();
254 bail!(
255 "Files exceed GitHub's 100 MiB limit:\n{}",
256 oversized.join("\n")
257 );
258 }
259
260 let warning_files: Vec<_> = size_check
262 .large_files
263 .iter()
264 .filter(|(_, size)| *size <= MAX_FILE_SIZE_BYTES && *size > FILE_SIZE_WARNING_BYTES)
265 .collect();
266 if !warning_files.is_empty() {
267 let warnings: Vec<_> = warning_files
268 .iter()
269 .map(|(path, size)| {
270 format!("{} ({:.1} MB)", path, *size as f64 / (1024.0 * 1024.0))
271 })
272 .collect();
273 progress(
274 "warning",
275 &format!(
276 "Large files detected (may slow deployment): {}",
277 warnings.join(", ")
278 ),
279 );
280 }
281
282 progress("repo", "Creating repository...");
284 let repo_url = self.ensure_repository(username)?;
285
286 progress("clone", "Cloning repository...");
288 let temp_dir = create_temp_dir()?;
289 clone_repo(&repo_url, temp_dir.path())?;
290
291 progress("copy", "Copying bundle files...");
293 let work_dir = temp_dir.path().join(&self.repo_name);
294 copy_bundle_to_repo(&bundle_dir, &work_dir)?;
295 configure_git_identity(&work_dir, username)?;
296
297 progress("push", "Pushing to gh-pages branch...");
299 let commit_sha = push_gh_pages(&work_dir)?;
300
301 progress("pages", "Enabling GitHub Pages...");
303 let pages_enabled = enable_github_pages(username, &self.repo_name);
304
305 let pages_url = format!("https://{}.github.io/{}", username, self.repo_name);
307
308 progress("complete", "Deployment complete!");
309
310 Ok(DeployResult {
311 repo_url,
312 pages_url,
313 pages_enabled,
314 commit_sha,
315 })
316 }
317
318 fn ensure_repository(&self, username: &str) -> Result<String> {
320 let repo_full_name = format!("{}/{}", username, self.repo_name);
321
322 let exists = check_repo_exists(&repo_full_name);
324
325 if exists && !self.force {
326 bail!(
327 "Repository {} already exists. Use --force to overwrite.",
328 repo_full_name
329 );
330 }
331
332 if !exists {
333 let visibility = if self.public { "--public" } else { "--private" };
335 let output = Command::new("gh")
336 .args([
337 "repo",
338 "create",
339 &self.repo_name,
340 visibility,
341 "--description",
342 &self.description,
343 ])
344 .output()
345 .context("Failed to run gh repo create")?;
346
347 if !output.status.success() {
348 let stderr = String::from_utf8_lossy(&output.stderr);
349 bail!("Failed to create repository: {}", stderr);
350 }
351 }
352
353 Ok(format!("https://github.com/{}", repo_full_name))
354 }
355}
356
357struct TempDeployDir {
360 path: PathBuf,
361}
362
363impl TempDeployDir {
364 fn path(&self) -> &Path {
365 &self.path
366 }
367}
368
369impl Drop for TempDeployDir {
370 fn drop(&mut self) {
371 if deploy_staging_path_is_real_dir(&self.path).unwrap_or(false) {
372 let _ = std::fs::remove_dir_all(&self.path);
373 }
374 }
375}
376
377fn create_temp_dir() -> Result<TempDeployDir> {
379 let temp_base = std::env::temp_dir();
380 let pid = std::process::id();
381 for attempt in 0..100 {
382 let timestamp = SystemTime::now()
383 .duration_since(UNIX_EPOCH)
384 .map(|d| d.as_nanos())
385 .unwrap_or(0);
386 let dir_name = format!("cass-deploy-{pid}-{timestamp}-{attempt}");
387 let temp_dir = temp_base.join(dir_name);
388 match std::fs::create_dir(&temp_dir) {
389 Ok(()) => return Ok(TempDeployDir { path: temp_dir }),
390 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
391 Err(err) => {
392 return Err(err).with_context(|| {
393 format!(
394 "Failed creating GitHub deploy staging directory {}",
395 temp_dir.display()
396 )
397 });
398 }
399 }
400 }
401 bail!(
402 "failed to allocate unique GitHub deploy staging directory under {}",
403 temp_base.display()
404 )
405}
406
407fn deploy_staging_path_is_real_dir(path: &Path) -> Result<bool> {
408 match std::fs::symlink_metadata(path) {
409 Ok(metadata) => {
410 let file_type = metadata.file_type();
411 Ok(file_type.is_dir() && !file_type.is_symlink())
412 }
413 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
414 Err(err) => Err(err).with_context(|| {
415 format!(
416 "Failed inspecting GitHub deploy staging directory before cleanup: {}",
417 path.display()
418 )
419 }),
420 }
421}
422
423fn get_gh_version() -> Option<String> {
425 Command::new("gh")
426 .arg("--version")
427 .output()
428 .ok()
429 .and_then(|out| {
430 if out.status.success() {
431 let stdout = String::from_utf8_lossy(&out.stdout);
432 stdout.lines().next().map(|s| s.to_string())
433 } else {
434 None
435 }
436 })
437}
438
439fn check_gh_auth() -> (bool, Option<String>) {
441 let output = Command::new("gh").args(["auth", "status"]).output();
442
443 match output {
444 Ok(out) if out.status.success() => {
445 let stdout = String::from_utf8_lossy(&out.stdout);
446 let stderr = String::from_utf8_lossy(&out.stderr);
447 let combined = format!("{}{}", stdout, stderr);
448
449 let username = combined
451 .lines()
452 .find(|line| line.contains("Logged in to"))
453 .and_then(|line| line.split(" as ").nth(1))
454 .map(|s| s.split_whitespace().next().unwrap_or(s).to_string());
455
456 (true, username)
457 }
458 _ => (false, None),
459 }
460}
461
462fn get_git_version() -> Option<String> {
464 Command::new("git")
465 .arg("--version")
466 .output()
467 .ok()
468 .and_then(|out| {
469 if out.status.success() {
470 let stdout = String::from_utf8_lossy(&out.stdout);
471 Some(stdout.trim().to_string())
472 } else {
473 None
474 }
475 })
476}
477
478fn get_available_space_mb() -> Option<u64> {
480 #[cfg(unix)]
482 {
483 Command::new("df")
484 .args(["-m", "."])
485 .output()
486 .ok()
487 .and_then(|out| {
488 if out.status.success() {
489 let stdout = String::from_utf8_lossy(&out.stdout);
490 stdout
492 .lines()
493 .nth(1)
494 .and_then(|line| line.split_whitespace().nth(3))
495 .and_then(|s| s.parse().ok())
496 } else {
497 None
498 }
499 })
500 }
501 #[cfg(not(unix))]
502 {
503 None
504 }
505}
506
507fn check_repo_exists(repo_full_name: &str) -> bool {
509 Command::new("gh")
510 .args(["repo", "view", repo_full_name])
511 .output()
512 .map(|out| out.status.success())
513 .unwrap_or(false)
514}
515
516fn retry_with_backoff<T, F>(operation_name: &str, mut f: F) -> Result<T>
522where
523 F: FnMut() -> Result<T>,
524{
525 let mut last_error = None;
526
527 for attempt in 0..MAX_RETRIES {
528 match f() {
529 Ok(result) => return Ok(result),
530 Err(e) => {
531 last_error = Some(e);
532 if attempt + 1 < MAX_RETRIES {
533 let delay_ms = BASE_DELAY_MS * (1 << attempt); eprintln!(
535 "[{}] Attempt {} failed, retrying in {}ms...",
536 operation_name,
537 attempt + 1,
538 delay_ms
539 );
540 thread::sleep(Duration::from_millis(delay_ms));
541 }
542 }
543 }
544 }
545
546 Err(last_error.unwrap_or_else(|| {
547 anyhow::anyhow!("{} failed after {} attempts", operation_name, MAX_RETRIES)
548 }))
549}
550
551fn clone_repo(repo_url: &str, dest: &Path) -> Result<()> {
553 retry_with_backoff("git clone", || {
554 let output = Command::new("git")
555 .args(["clone", repo_url])
556 .current_dir(dest)
557 .output()
558 .context("Failed to run git clone")?;
559
560 if !output.status.success() {
561 let stderr = String::from_utf8_lossy(&output.stderr);
562 if !stderr.contains("empty repository") {
564 bail!("Failed to clone repository: {}", stderr);
565 }
566 }
567
568 Ok(())
569 })
570}
571
572fn copy_bundle_to_repo(bundle_dir: &Path, repo_dir: &Path) -> Result<()> {
574 let bundle_dir = super::resolve_site_dir(bundle_dir)?;
575
576 ensure_deploy_staging_dir(repo_dir)?;
577
578 for entry in std::fs::read_dir(repo_dir)? {
580 let entry = entry?;
581 let path = entry.path();
582 if path.file_name().map(|n| n != ".git").unwrap_or(true) {
583 remove_repo_deploy_entry(&path)?;
584 }
585 }
586
587 copy_dir_recursive(&bundle_dir, repo_dir)?;
589
590 let nojekyll = repo_dir.join(".nojekyll");
592 if !nojekyll.exists() {
593 std::fs::write(&nojekyll, "")?;
594 }
595
596 Ok(())
597}
598
599fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
601 let canonical_base = src.canonicalize().with_context(|| {
602 format!(
603 "Failed to resolve deployment source root {} before copying",
604 src.display()
605 )
606 })?;
607 copy_dir_recursive_inner(src, dst, &canonical_base)
608}
609
610fn copy_dir_recursive_inner(src: &Path, dst: &Path, canonical_base: &Path) -> Result<()> {
611 ensure_deploy_staging_dir(dst)?;
612
613 for entry in std::fs::read_dir(src)? {
614 let entry = entry?;
615 let file_name = entry.file_name();
616 if file_name == std::ffi::OsStr::new(".git") {
617 continue;
618 }
619 let src_path = entry.path();
620 let dst_path = dst.join(file_name);
621 let metadata = std::fs::symlink_metadata(&src_path)?;
622 let file_type = metadata.file_type();
623
624 if file_type.is_symlink() {
625 let canonical_target = src_path.canonicalize().with_context(|| {
626 format!(
627 "Failed to resolve symlinked deploy entry {}",
628 src_path.display()
629 )
630 })?;
631 if !canonical_target.starts_with(canonical_base) {
632 bail!(
633 "Refusing to deploy symlinked site entry outside deployment root: {}",
634 src_path.display()
635 );
636 }
637
638 let target_meta = std::fs::metadata(&src_path).with_context(|| {
639 format!(
640 "Failed to inspect symlink target for deploy entry {}",
641 src_path.display()
642 )
643 })?;
644 if !target_meta.is_file() {
645 bail!(
646 "Refusing to deploy symlinked site entry that does not point to a regular file: {}",
647 src_path.display()
648 );
649 }
650
651 ensure_deploy_file_destination(&dst_path)?;
652 std::fs::copy(&canonical_target, &dst_path).with_context(|| {
653 format!(
654 "Failed copying symlink target {} to {} during deploy staging",
655 canonical_target.display(),
656 dst_path.display()
657 )
658 })?;
659 continue;
660 }
661
662 if file_type.is_dir() {
663 copy_dir_recursive_inner(&src_path, &dst_path, canonical_base)?;
664 } else if file_type.is_file() {
665 ensure_deploy_file_destination(&dst_path)?;
666 std::fs::copy(&src_path, &dst_path)?;
667 }
668 }
669
670 Ok(())
671}
672
673fn ensure_deploy_staging_dir(path: &Path) -> Result<()> {
674 match std::fs::symlink_metadata(path) {
675 Ok(metadata) => {
676 let file_type = metadata.file_type();
677 if file_type.is_symlink() {
678 bail!(
679 "Refusing to use deploy staging directory through symlink: {}",
680 path.display()
681 );
682 }
683 if !file_type.is_dir() {
684 bail!(
685 "Refusing to use deploy staging path because it is not a directory: {}",
686 path.display()
687 );
688 }
689 Ok(())
690 }
691 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
692 std::fs::create_dir_all(path)?;
693 match std::fs::symlink_metadata(path) {
694 Ok(metadata)
695 if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() =>
696 {
697 Ok(())
698 }
699 Ok(_) => bail!(
700 "Refusing to use deploy staging path after create because it is not a real directory: {}",
701 path.display()
702 ),
703 Err(err) => Err(err).with_context(|| {
704 format!(
705 "Failed inspecting deploy staging directory after create: {}",
706 path.display()
707 )
708 }),
709 }
710 }
711 Err(err) => Err(err).with_context(|| {
712 format!(
713 "Failed inspecting deploy staging directory before copy: {}",
714 path.display()
715 )
716 }),
717 }
718}
719
720fn ensure_deploy_file_destination(path: &Path) -> Result<()> {
721 match std::fs::symlink_metadata(path) {
722 Ok(metadata) => {
723 let file_type = metadata.file_type();
724 if file_type.is_symlink() {
725 bail!(
726 "Refusing to write deploy file through symlink: {}",
727 path.display()
728 );
729 }
730 if !file_type.is_file() {
731 bail!(
732 "Refusing to write deploy file over non-file path: {}",
733 path.display()
734 );
735 }
736 Ok(())
737 }
738 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
739 Err(err) => Err(err).with_context(|| {
740 format!(
741 "Failed inspecting deploy file destination before copy: {}",
742 path.display()
743 )
744 }),
745 }
746}
747
748fn remove_repo_deploy_entry(path: &Path) -> Result<()> {
749 let metadata = std::fs::symlink_metadata(path).with_context(|| {
750 format!(
751 "Failed inspecting existing GitHub deploy repository entry {}",
752 path.display()
753 )
754 })?;
755 let file_type = metadata.file_type();
756 if file_type.is_dir() && !file_type.is_symlink() {
757 std::fs::remove_dir_all(path).with_context(|| {
758 format!(
759 "Failed removing existing GitHub deploy directory {}",
760 path.display()
761 )
762 })
763 } else {
764 std::fs::remove_file(path).with_context(|| {
765 format!(
766 "Failed removing existing GitHub deploy file {}",
767 path.display()
768 )
769 })
770 }
771}
772
773fn configure_git_identity(repo_dir: &Path, username: &str) -> Result<()> {
775 let email = format!("{username}@users.noreply.github.com");
776
777 for (key, value) in [("user.name", username), ("user.email", email.as_str())] {
778 let output = Command::new("git")
779 .args(["config", key, value])
780 .current_dir(repo_dir)
781 .output()
782 .with_context(|| format!("Failed to set git {key}"))?;
783
784 if !output.status.success() {
785 let stderr = String::from_utf8_lossy(&output.stderr);
786 bail!("Failed to set git {key}: {stderr}");
787 }
788 }
789
790 Ok(())
791}
792
793fn push_gh_pages(repo_dir: &Path) -> Result<String> {
795 let output = Command::new("git")
797 .args(["checkout", "--orphan", "gh-pages"])
798 .current_dir(repo_dir)
799 .output()
800 .context("Failed to create orphan branch")?;
801
802 if !output.status.success() {
803 let stderr = String::from_utf8_lossy(&output.stderr);
804 bail!("Failed to create gh-pages branch: {}", stderr);
805 }
806
807 let output = Command::new("git")
809 .args(["add", "-A"])
810 .current_dir(repo_dir)
811 .output()
812 .context("Failed to git add")?;
813
814 if !output.status.success() {
815 let stderr = String::from_utf8_lossy(&output.stderr);
816 bail!("Failed to add files: {}", stderr);
817 }
818
819 let output = Command::new("git")
821 .args(["commit", "-m", "Deploy cass archive"])
822 .current_dir(repo_dir)
823 .output()
824 .context("Failed to git commit")?;
825
826 if !output.status.success() {
827 let stderr = String::from_utf8_lossy(&output.stderr);
828 bail!("Failed to commit: {}", stderr);
829 }
830
831 let sha_output = Command::new("git")
833 .args(["rev-parse", "HEAD"])
834 .current_dir(repo_dir)
835 .output()
836 .context("Failed to get commit SHA")?;
837
838 let commit_sha = String::from_utf8_lossy(&sha_output.stdout)
839 .trim()
840 .to_string();
841
842 let repo_dir_owned = repo_dir.to_owned();
844 retry_with_backoff("git push", move || {
845 let output = Command::new("git")
846 .args(["push", "-f", "origin", "gh-pages"])
847 .current_dir(&repo_dir_owned)
848 .output()
849 .context("Failed to git push")?;
850
851 if !output.status.success() {
852 let stderr = String::from_utf8_lossy(&output.stderr);
853 bail!("Failed to push: {}", stderr);
854 }
855
856 Ok(())
857 })?;
858
859 Ok(commit_sha)
860}
861
862fn enable_github_pages(username: &str, repo_name: &str) -> bool {
864 let api_path = format!("repos/{}/{}/pages", username, repo_name);
865
866 let result = retry_with_backoff("enable Pages", || {
868 let output = Command::new("gh")
869 .args([
870 "api",
871 &api_path,
872 "-X",
873 "POST",
874 "-f",
875 "source[branch]=gh-pages",
876 "-f",
877 "source[path]=/",
878 ])
879 .output()
880 .context("Failed to call GitHub API")?;
881
882 if output.status.success() {
883 Ok(true)
884 } else {
885 let stderr = String::from_utf8_lossy(&output.stderr);
886 if stderr.contains("already exists") || stderr.contains("409") {
888 Ok(true)
889 } else {
890 bail!("Failed to enable Pages: {}", stderr);
891 }
892 }
893 });
894
895 result.unwrap_or(false)
896}
897
898fn visit_files(dir: &Path, f: &mut impl FnMut(&Path, u64)) -> Result<()> {
900 let canonical_base = dir.canonicalize().with_context(|| {
901 format!(
902 "Failed to resolve deployment source root {} before sizing",
903 dir.display()
904 )
905 })?;
906 visit_files_inner(dir, &canonical_base, f)
907}
908
909fn visit_files_inner(
910 dir: &Path,
911 canonical_base: &Path,
912 f: &mut impl FnMut(&Path, u64),
913) -> Result<()> {
914 for entry in std::fs::read_dir(dir)? {
915 let entry = entry?;
916 let path = entry.path();
917 let metadata = std::fs::symlink_metadata(&path)?;
918 let file_type = metadata.file_type();
919
920 if file_type.is_symlink() {
921 let canonical_target = path.canonicalize().with_context(|| {
922 format!(
923 "Failed to resolve symlinked deploy entry {}",
924 path.display()
925 )
926 })?;
927 if !canonical_target.starts_with(canonical_base) {
928 bail!(
929 "Refusing to deploy symlinked site entry outside deployment root: {}",
930 path.display()
931 );
932 }
933
934 let target_meta = std::fs::metadata(&path).with_context(|| {
935 format!(
936 "Failed to inspect symlink target for deploy entry {}",
937 path.display()
938 )
939 })?;
940 if !target_meta.is_file() {
941 bail!(
942 "Refusing to deploy symlinked site entry that does not point to a regular file: {}",
943 path.display()
944 );
945 }
946
947 f(&path, target_meta.len());
948 continue;
949 }
950
951 if file_type.is_dir() {
952 visit_files_inner(&path, canonical_base, f)?;
953 } else if file_type.is_file() {
954 f(&path, metadata.len());
955 }
956 }
957 Ok(())
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963
964 #[test]
965 fn test_prerequisites_is_ready() {
966 let prereqs = Prerequisites {
967 gh_version: Some("gh version 2.0.0".to_string()),
968 gh_authenticated: true,
969 gh_username: Some("testuser".to_string()),
970 git_version: Some("git version 2.30.0".to_string()),
971 disk_space_mb: 1000,
972 estimated_size_mb: 100,
973 };
974
975 assert!(prereqs.is_ready());
976 assert!(prereqs.missing().is_empty());
977 }
978
979 #[test]
980 fn test_prerequisites_not_ready() {
981 let prereqs = Prerequisites {
982 gh_version: None,
983 gh_authenticated: false,
984 gh_username: None,
985 git_version: None,
986 disk_space_mb: 1000,
987 estimated_size_mb: 100,
988 };
989
990 assert!(!prereqs.is_ready());
991 let missing = prereqs.missing();
992 assert_eq!(missing.len(), 3);
993 }
994
995 #[test]
996 fn test_deployer_builder() {
997 let deployer = GitHubDeployer::new("my-archive")
998 .description("My archive")
999 .public(false)
1000 .force(true);
1001
1002 assert_eq!(deployer.repo_name, "my-archive");
1003 assert_eq!(deployer.description, "My archive");
1004 assert!(!deployer.public);
1005 assert!(deployer.force);
1006 }
1007
1008 #[test]
1009 fn test_size_check() {
1010 use tempfile::TempDir;
1011
1012 let temp = TempDir::new().unwrap();
1013 let file1 = temp.path().join("small.txt");
1014 let file2 = temp.path().join("medium.txt");
1015
1016 std::fs::write(&file1, vec![0u8; 1000]).unwrap();
1017 std::fs::write(&file2, vec![0u8; 10000]).unwrap();
1018
1019 let deployer = GitHubDeployer::default();
1020 let check = deployer.check_size(temp.path()).unwrap();
1021
1022 assert_eq!(check.file_count, 2);
1023 assert_eq!(check.total_bytes, 11000);
1024 assert!(!check.exceeds_limit);
1025 assert!(!check.has_oversized_files);
1026 }
1027
1028 #[test]
1029 fn test_size_check_resolves_bundle_root_without_counting_private_artifacts() {
1030 use tempfile::TempDir;
1031
1032 let temp = TempDir::new().unwrap();
1033 let site_dir = temp.path().join("site");
1034 let private_dir = temp.path().join("private");
1035 std::fs::create_dir_all(&site_dir).unwrap();
1036 std::fs::create_dir_all(&private_dir).unwrap();
1037 std::fs::write(site_dir.join("index.html"), "abcd").unwrap();
1038 std::fs::write(private_dir.join("master-key.json"), "secret").unwrap();
1039
1040 let deployer = GitHubDeployer::default();
1041 let check = deployer.check_size(temp.path()).unwrap();
1042
1043 assert_eq!(check.file_count, 1);
1044 assert_eq!(check.total_bytes, 4);
1045 }
1046
1047 #[test]
1048 #[cfg(unix)]
1049 fn test_size_check_counts_in_tree_symlinked_files() {
1050 use std::os::unix::fs::symlink;
1051 use tempfile::TempDir;
1052
1053 let temp = TempDir::new().unwrap();
1054 let site_dir = temp.path().join("site");
1055 std::fs::create_dir_all(&site_dir).unwrap();
1056 std::fs::write(site_dir.join("root.txt"), "root").unwrap();
1057 symlink("root.txt", site_dir.join("linked-file.txt")).unwrap();
1058
1059 let deployer = GitHubDeployer::default();
1060 let check = deployer.check_size(temp.path()).unwrap();
1061
1062 assert_eq!(check.file_count, 2);
1063 assert_eq!(check.total_bytes, 8);
1064 }
1065
1066 #[test]
1067 fn test_resolve_site_dir_accepts_direct_site_directory() {
1068 use tempfile::TempDir;
1069
1070 let temp = TempDir::new().unwrap();
1071 std::fs::write(temp.path().join("index.html"), "<html></html>").unwrap();
1072
1073 let resolved = super::super::resolve_site_dir(temp.path()).unwrap();
1074 assert_eq!(resolved, temp.path());
1075 }
1076
1077 #[test]
1078 #[cfg(unix)]
1079 fn test_resolve_site_dir_rejects_symlinked_site_directory() {
1080 use std::os::unix::fs::symlink;
1081 use tempfile::TempDir;
1082
1083 let bundle_root = TempDir::new().unwrap();
1084 let outside = TempDir::new().unwrap();
1085 let outside_site = outside.path().join("site");
1086 std::fs::create_dir_all(&outside_site).unwrap();
1087 std::fs::write(outside_site.join("index.html"), "<html></html>").unwrap();
1088 symlink(&outside_site, bundle_root.path().join("site")).unwrap();
1089
1090 let err = super::super::resolve_site_dir(bundle_root.path())
1091 .unwrap_err()
1092 .to_string();
1093 assert!(err.contains("must not be a symlink"));
1094
1095 let direct_err = super::super::resolve_site_dir(&bundle_root.path().join("site"))
1096 .unwrap_err()
1097 .to_string();
1098 assert!(direct_err.contains("must not be a symlink"));
1099 }
1100
1101 #[test]
1102 fn test_copy_dir_recursive() {
1103 use tempfile::TempDir;
1104
1105 let src = TempDir::new().unwrap();
1106 let dst = TempDir::new().unwrap();
1107
1108 std::fs::create_dir_all(src.path().join("subdir")).unwrap();
1110 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1111 std::fs::write(src.path().join("subdir/nested.txt"), "nested").unwrap();
1112
1113 copy_dir_recursive(src.path(), dst.path()).unwrap();
1114
1115 assert!(dst.path().join("root.txt").exists());
1116 assert!(dst.path().join("subdir/nested.txt").exists());
1117 }
1118
1119 #[test]
1120 fn test_copy_bundle_to_repo_resolves_bundle_root_without_copying_private_artifacts() {
1121 use tempfile::TempDir;
1122
1123 let bundle_root = TempDir::new().unwrap();
1124 let repo_dir = TempDir::new().unwrap();
1125 let site_dir = bundle_root.path().join("site");
1126 let private_dir = bundle_root.path().join("private");
1127 std::fs::create_dir_all(&site_dir).unwrap();
1128 std::fs::create_dir_all(&private_dir).unwrap();
1129 std::fs::write(site_dir.join("index.html"), "<html></html>").unwrap();
1130 std::fs::write(site_dir.join("config.json"), "{}").unwrap();
1131 std::fs::write(private_dir.join("master-key.json"), "{\"secret\":true}").unwrap();
1132
1133 copy_bundle_to_repo(bundle_root.path(), repo_dir.path()).unwrap();
1134
1135 assert!(repo_dir.path().join("index.html").exists());
1136 assert!(repo_dir.path().join("config.json").exists());
1137 assert!(repo_dir.path().join(".nojekyll").exists());
1138 assert!(!repo_dir.path().join("private").exists());
1139 assert!(!repo_dir.path().join("site").exists());
1140 }
1141
1142 #[test]
1143 fn test_copy_bundle_to_repo_ignores_bundle_git_directory_without_modifying_repo_git() {
1144 use tempfile::TempDir;
1145
1146 let bundle_root = TempDir::new().unwrap();
1147 let repo_dir = TempDir::new().unwrap();
1148 let site_dir = bundle_root.path().join("site");
1149 let bundle_git_dir = site_dir.join(".git");
1150 let repo_git_dir = repo_dir.path().join(".git");
1151
1152 std::fs::create_dir_all(&bundle_git_dir).unwrap();
1153 std::fs::create_dir_all(&repo_git_dir).unwrap();
1154 std::fs::write(site_dir.join("index.html"), "<html></html>").unwrap();
1155 std::fs::write(bundle_git_dir.join("config"), "bundle git config").unwrap();
1156 std::fs::write(repo_git_dir.join("config"), "repo git config").unwrap();
1157
1158 copy_bundle_to_repo(bundle_root.path(), repo_dir.path()).unwrap();
1159
1160 assert_eq!(
1161 std::fs::read_to_string(repo_git_dir.join("config")).unwrap(),
1162 "repo git config"
1163 );
1164 assert!(repo_dir.path().join("index.html").exists());
1165 }
1166
1167 #[test]
1168 fn test_temp_deploy_dir_removes_real_staging_dir_on_drop() {
1169 let temp = create_temp_dir().unwrap();
1170 let temp_path = temp.path().to_path_buf();
1171 std::fs::write(temp_path.join("marker.txt"), "temporary clone").unwrap();
1172
1173 drop(temp);
1174
1175 assert!(
1176 !temp_path.exists(),
1177 "GitHub deploy temp clone directory should be removed on drop"
1178 );
1179 }
1180
1181 #[test]
1182 #[cfg(unix)]
1183 fn test_temp_deploy_dir_drop_skips_symlinked_staging_path() {
1184 use std::os::unix::fs::symlink;
1185 use tempfile::TempDir;
1186
1187 let parent = TempDir::new().unwrap();
1188 let outside = TempDir::new().unwrap();
1189 let temp_path = parent.path().join("cass-deploy-link");
1190 let sentinel = outside.path().join("sentinel.txt");
1191 std::fs::write(&sentinel, "keep").unwrap();
1192 symlink(outside.path(), &temp_path).unwrap();
1193
1194 drop(TempDeployDir {
1195 path: temp_path.clone(),
1196 });
1197
1198 assert_eq!(std::fs::read_to_string(&sentinel).unwrap(), "keep");
1199 assert!(
1200 std::fs::symlink_metadata(&temp_path)
1201 .unwrap()
1202 .file_type()
1203 .is_symlink(),
1204 "drop must leave symlinked staging paths untouched"
1205 );
1206 }
1207
1208 #[test]
1209 #[cfg(unix)]
1210 fn test_copy_dir_recursive_materializes_in_tree_symlinked_files() {
1211 use std::os::unix::fs::symlink;
1212 use tempfile::TempDir;
1213
1214 let src = TempDir::new().unwrap();
1215 let dst = TempDir::new().unwrap();
1216
1217 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1218 symlink("root.txt", src.path().join("linked-file.txt")).unwrap();
1219
1220 copy_dir_recursive(src.path(), dst.path()).unwrap();
1221
1222 let linked_metadata =
1223 std::fs::symlink_metadata(dst.path().join("linked-file.txt")).unwrap();
1224 assert!(linked_metadata.file_type().is_file());
1225 assert!(!linked_metadata.file_type().is_symlink());
1226 assert_eq!(
1227 std::fs::read_to_string(dst.path().join("linked-file.txt")).unwrap(),
1228 "root"
1229 );
1230 }
1231
1232 #[test]
1233 #[cfg(unix)]
1234 fn test_copy_dir_recursive_rejects_symlinks_outside_root() {
1235 use std::os::unix::fs::symlink;
1236 use tempfile::TempDir;
1237
1238 let src = TempDir::new().unwrap();
1239 let dst = TempDir::new().unwrap();
1240 let outside = TempDir::new().unwrap();
1241
1242 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1243 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
1244 symlink(
1245 outside.path().join("secret.txt"),
1246 src.path().join("linked-file.txt"),
1247 )
1248 .unwrap();
1249
1250 let err = copy_dir_recursive(src.path(), dst.path()).unwrap_err();
1251 assert!(
1252 err.to_string()
1253 .contains("Refusing to deploy symlinked site entry outside deployment root"),
1254 "unexpected error: {err:#}"
1255 );
1256 }
1257
1258 #[test]
1259 #[cfg(unix)]
1260 fn test_copy_dir_recursive_rejects_symlinked_destination_root() {
1261 use std::os::unix::fs::symlink;
1262 use tempfile::TempDir;
1263
1264 let src = TempDir::new().unwrap();
1265 let outside = TempDir::new().unwrap();
1266 let dst_parent = TempDir::new().unwrap();
1267 let dst = dst_parent.path().join("linked-dst");
1268
1269 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1270 symlink(outside.path(), &dst).unwrap();
1271
1272 let err = copy_dir_recursive(src.path(), &dst).unwrap_err();
1273 assert!(
1274 err.to_string()
1275 .contains("deploy staging directory through symlink"),
1276 "unexpected error: {err:#}"
1277 );
1278 assert!(
1279 !outside.path().join("root.txt").exists(),
1280 "deploy staging must not copy through a symlinked destination"
1281 );
1282 assert!(
1283 std::fs::symlink_metadata(&dst)
1284 .unwrap()
1285 .file_type()
1286 .is_symlink(),
1287 "rejected destination symlink should be left untouched"
1288 );
1289 }
1290
1291 #[test]
1292 #[cfg(unix)]
1293 fn test_copy_dir_recursive_rejects_symlinked_file_destination() {
1294 use std::os::unix::fs::symlink;
1295 use tempfile::TempDir;
1296
1297 let src = TempDir::new().unwrap();
1298 let dst = TempDir::new().unwrap();
1299 let outside = TempDir::new().unwrap();
1300 let outside_file = outside.path().join("sentinel.txt");
1301
1302 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1303 std::fs::write(&outside_file, "keep").unwrap();
1304 symlink(&outside_file, dst.path().join("root.txt")).unwrap();
1305
1306 let err = copy_dir_recursive(src.path(), dst.path()).unwrap_err();
1307 assert!(
1308 err.to_string()
1309 .contains("write deploy file through symlink"),
1310 "unexpected error: {err:#}"
1311 );
1312 assert_eq!(std::fs::read_to_string(&outside_file).unwrap(), "keep");
1313 }
1314
1315 #[test]
1316 #[cfg(unix)]
1317 fn test_copy_bundle_to_repo_rejects_symlinked_repo_root() {
1318 use std::os::unix::fs::symlink;
1319 use tempfile::TempDir;
1320
1321 let bundle_root = TempDir::new().unwrap();
1322 let site_dir = bundle_root.path().join("site");
1323 let outside = TempDir::new().unwrap();
1324 let repo_parent = TempDir::new().unwrap();
1325 let repo_link = repo_parent.path().join("repo");
1326
1327 std::fs::create_dir_all(&site_dir).unwrap();
1328 std::fs::write(site_dir.join("index.html"), "<html></html>").unwrap();
1329 symlink(outside.path(), &repo_link).unwrap();
1330
1331 let err = copy_bundle_to_repo(bundle_root.path(), &repo_link).unwrap_err();
1332 assert!(
1333 err.to_string()
1334 .contains("deploy staging directory through symlink"),
1335 "unexpected error: {err:#}"
1336 );
1337 assert!(
1338 !outside.path().join("index.html").exists(),
1339 "deploy staging must not copy through a symlinked repo root"
1340 );
1341 }
1342
1343 #[test]
1344 #[cfg(unix)]
1345 fn test_copy_bundle_to_repo_removes_repo_symlink_entry_without_touching_target() {
1346 use std::os::unix::fs::symlink;
1347 use tempfile::TempDir;
1348
1349 let bundle_root = TempDir::new().unwrap();
1350 let repo_dir = TempDir::new().unwrap();
1351 let outside = TempDir::new().unwrap();
1352 let site_dir = bundle_root.path().join("site");
1353 let outside_dir = outside.path().join("old-dir-target");
1354 let outside_file = outside_dir.join("sentinel.txt");
1355
1356 std::fs::create_dir_all(&site_dir).unwrap();
1357 std::fs::write(site_dir.join("index.html"), "<html></html>").unwrap();
1358 std::fs::create_dir_all(&outside_dir).unwrap();
1359 std::fs::write(&outside_file, "keep").unwrap();
1360 symlink(&outside_dir, repo_dir.path().join("old-dir")).unwrap();
1361
1362 copy_bundle_to_repo(bundle_root.path(), repo_dir.path()).unwrap();
1363
1364 assert_eq!(std::fs::read_to_string(&outside_file).unwrap(), "keep");
1365 assert!(!repo_dir.path().join("old-dir").exists());
1366 assert!(repo_dir.path().join("index.html").exists());
1367 assert!(repo_dir.path().join(".nojekyll").exists());
1368 }
1369
1370 #[test]
1371 #[cfg(unix)]
1372 fn test_visit_files_counts_in_tree_symlinked_files() {
1373 use std::os::unix::fs::symlink;
1374 use tempfile::TempDir;
1375
1376 let src = TempDir::new().unwrap();
1377
1378 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1379 symlink("root.txt", src.path().join("linked-file.txt")).unwrap();
1380
1381 let mut visited = Vec::new();
1382 visit_files(src.path(), &mut |path, size| {
1383 visited.push((
1384 path.strip_prefix(src.path())
1385 .unwrap()
1386 .to_string_lossy()
1387 .to_string(),
1388 size,
1389 ));
1390 })
1391 .unwrap();
1392
1393 assert!(visited.contains(&("root.txt".to_string(), 4)));
1394 assert!(visited.contains(&("linked-file.txt".to_string(), 4)));
1395 }
1396
1397 #[test]
1398 #[cfg(unix)]
1399 fn test_visit_files_rejects_symlink_paths_outside_root() {
1400 use std::os::unix::fs::symlink;
1401 use tempfile::TempDir;
1402
1403 let src = TempDir::new().unwrap();
1404 let outside = TempDir::new().unwrap();
1405
1406 std::fs::write(src.path().join("root.txt"), "root").unwrap();
1407 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
1408 std::fs::create_dir_all(outside.path().join("nested")).unwrap();
1409 std::fs::write(outside.path().join("nested/hidden.txt"), "hidden").unwrap();
1410
1411 symlink(
1412 outside.path().join("secret.txt"),
1413 src.path().join("linked-file.txt"),
1414 )
1415 .unwrap();
1416 symlink(outside.path().join("nested"), src.path().join("linked-dir")).unwrap();
1417
1418 let err = visit_files(src.path(), &mut |_path, _size| {}).unwrap_err();
1419 assert!(
1420 err.to_string()
1421 .contains("Refusing to deploy symlinked site entry outside deployment root"),
1422 "unexpected error: {err:#}"
1423 );
1424 }
1425
1426 #[test]
1427 fn test_configure_git_identity_sets_local_commit_metadata() {
1428 use tempfile::TempDir;
1429
1430 let repo = TempDir::new().unwrap();
1431 let init = Command::new("git")
1432 .args(["init"])
1433 .current_dir(repo.path())
1434 .output()
1435 .unwrap();
1436 assert!(
1437 init.status.success(),
1438 "git init failed: {}",
1439 String::from_utf8_lossy(&init.stderr)
1440 );
1441
1442 configure_git_identity(repo.path(), "cass-test").unwrap();
1443
1444 let name = Command::new("git")
1445 .args(["config", "user.name"])
1446 .current_dir(repo.path())
1447 .output()
1448 .unwrap();
1449 assert_eq!(String::from_utf8_lossy(&name.stdout).trim(), "cass-test");
1450
1451 let email = Command::new("git")
1452 .args(["config", "user.email"])
1453 .current_dir(repo.path())
1454 .output()
1455 .unwrap();
1456 assert_eq!(
1457 String::from_utf8_lossy(&email.stdout).trim(),
1458 "cass-test@users.noreply.github.com"
1459 );
1460
1461 std::fs::write(repo.path().join("index.html"), "<html></html>").unwrap();
1462
1463 let add = Command::new("git")
1464 .args(["add", "-A"])
1465 .current_dir(repo.path())
1466 .output()
1467 .unwrap();
1468 assert!(
1469 add.status.success(),
1470 "git add failed: {}",
1471 String::from_utf8_lossy(&add.stderr)
1472 );
1473
1474 let commit = Command::new("git")
1475 .args(["commit", "-m", "Test commit"])
1476 .current_dir(repo.path())
1477 .output()
1478 .unwrap();
1479 assert!(
1480 commit.status.success(),
1481 "git commit failed: {}",
1482 String::from_utf8_lossy(&commit.stderr)
1483 );
1484 }
1485}