1use anyhow::Result;
2use client_core::{
3 constants::docker::get_docker_work_dir, upgrade_strategy::UpgradeStrategy, utils::archive,
4};
5use std::io::{Read, Write};
6use std::path::{Component, Path};
7use std::time::Instant;
8use tracing::{error, info};
9use zip::read::ZipFile;
10
11pub mod env_manager;
13
14fn should_skip_file(file_name: &str) -> bool {
29 if file_name.starts_with("__MACOSX")
31 || file_name.ends_with(".DS_Store")
32 || file_name.starts_with("._")
33 || file_name.ends_with(".tmp")
34 || file_name.ends_with(".temp")
35 || file_name.ends_with(".bak")
36 {
37 return true;
38 }
39
40 if file_name.starts_with(".git/")
42 || file_name == ".gitignore"
43 || file_name == ".gitattributes"
44 || file_name == ".gitmodules"
45 {
46 return true;
47 }
48
49 if file_name.starts_with(".vscode/")
51 || file_name.starts_with(".idea/")
52 || file_name.starts_with(".vs/")
53 {
54 return true;
55 }
56
57 if file_name == ".env"
59 || file_name.starts_with(".env.")
60 || file_name == ".dockerignore"
61 || file_name == ".editorconfig"
62 || file_name.starts_with(".prettier")
63 || file_name.starts_with(".eslint")
64 {
65 return false;
66 }
67
68 false
70}
71
72fn contains_unsafe_component(path: &Path) -> bool {
73 path.components().any(|component| {
74 matches!(
75 component,
76 Component::ParentDir | Component::RootDir | Component::Prefix(_)
77 )
78 })
79}
80
81pub fn validate_archive_paths(archive_path: &Path) -> Result<()> {
82 let format = archive::detect_format_by_magic(archive_path)?;
83
84 match format {
85 client_core::utils::archive::ArchiveFormat::Zip => {
86 let file = std::fs::File::open(archive_path)?;
87 let mut archive = zip::ZipArchive::new(file)?;
88
89 for i in 0..archive.len() {
90 let file = archive.by_index(i)?;
91 let raw_name = file.name().to_string();
92 let Some(enclosed_name) = file.enclosed_name() else {
93 return Err(anyhow::anyhow!(
94 "Unsafe archive path detected: {}",
95 raw_name
96 ));
97 };
98
99 if contains_unsafe_component(&enclosed_name) {
100 return Err(anyhow::anyhow!(
101 "Unsafe archive path detected: {}",
102 raw_name
103 ));
104 }
105 }
106 }
107 client_core::utils::archive::ArchiveFormat::TarGz => {
108 let tar_gz = std::fs::File::open(archive_path)?;
109 let decoder = flate2::read::GzDecoder::new(tar_gz);
110 let mut archive = tar::Archive::new(decoder);
111
112 for entry in archive.entries()? {
113 let entry = entry?;
114 let entry_type = entry.header().entry_type();
115 if entry_type.is_symlink() || entry_type.is_hard_link() {
116 return Err(anyhow::anyhow!(
117 "Archive links are not allowed: {}",
118 entry.path()?.display()
119 ));
120 }
121
122 let path = entry.path()?;
123 if contains_unsafe_component(&path) {
124 return Err(anyhow::anyhow!(
125 "Unsafe archive path detected: {}",
126 path.display()
127 ));
128 }
129 }
130 }
131 }
132
133 Ok(())
134}
135
136#[allow(dead_code)]
182pub fn copy_with_progress<R: Read, W: Write>(
183 mut reader: R,
184 mut writer: W,
185 total_size: u64,
186 file_name: &str,
187) -> std::io::Result<u64> {
188 let mut buf = [0u8; 8192]; let mut copied = 0u64;
190 let mut last_percent = 0;
191
192 loop {
193 let bytes_read = reader.read(&mut buf)?;
194 if bytes_read == 0 {
195 break;
196 }
197
198 writer.write_all(&buf[..bytes_read])?;
199 copied += bytes_read as u64;
200
201 if total_size > 100 * 1024 * 1024 {
203 let percent = (copied * 100).checked_div(total_size).unwrap_or(0);
205 let mb_copied = copied as f64 / 1024.0 / 1024.0;
206 let mb_total = total_size as f64 / 1024.0 / 1024.0;
207
208 if (percent != last_percent && percent.is_multiple_of(10))
210 || (copied.is_multiple_of(100 * 1024 * 1024) && copied > 0)
211 {
212 info!(
213 " ⏳ {} copy progress: {:.1}% ({:.1}/{:.1} MB)",
214 file_name, percent as f64, mb_copied, mb_total
215 );
216 last_percent = percent;
217 }
218 }
219 }
220
221 Ok(copied)
222}
223
224fn force_extract_file(
226 entry: &mut ZipFile<std::fs::File>,
227 target_path: &std::path::Path,
228) -> Result<()> {
229 if target_path.exists() {
231 if target_path.is_dir() {
232 info!("🗑️ Force removing directory: {}", target_path.display());
233 std::fs::remove_dir_all(target_path)?;
234 } else {
235 info!("🗑️ Force removing file: {}", target_path.display());
236 std::fs::remove_file(target_path)?;
237 }
238 }
239
240 if let Some(parent) = target_path.parent()
242 && !parent.exists()
243 {
244 std::fs::create_dir_all(parent)?;
245 }
246
247 if entry.is_dir() {
249 std::fs::create_dir_all(target_path).map_err(|e| {
250 error!(
251 "❌ Failed to create directory: {} - error: {}",
252 target_path.display(),
253 e
254 );
255 e
256 })?;
257 } else {
258 let mut outfile = std::fs::File::create(target_path).map_err(|e| {
259 error!(
260 "❌ Failed to create file: {} - error: {}",
261 target_path.display(),
262 e
263 );
264 e
265 })?;
266 std::io::copy(entry, &mut outfile).map_err(|e| {
267 error!(
268 "❌ Failed to write file: {} - error: {}",
269 target_path.display(),
270 e
271 );
272 e
273 })?;
274 }
275
276 Ok(())
277}
278
279fn handle_extraction(
280 entry: &mut ZipFile<std::fs::File>,
281 dst: &std::path::Path,
282 extracted_files: &mut usize,
283 extracted_size: &mut u64,
284) -> Result<()> {
285 force_extract_file(entry, dst)?;
286 *extracted_files += 1;
287 *extracted_size += entry.size();
288 Ok(())
289}
290
291fn ensure_parent_dir(path: &std::path::Path) -> Result<()> {
293 if let Some(parent) = path.parent()
294 && !parent.exists()
295 {
296 std::fs::create_dir_all(parent)?;
297 }
298 Ok(())
299}
300
301fn is_upload_directory_path(path: &std::path::Path) -> bool {
303 path.components().any(|component| {
305 client_core::constants::docker::EXCLUDE_DIRS
306 .iter()
307 .any(|d| component.as_os_str() == *d)
308 })
309}
310
311fn safe_remove_docker_directory(output_dir: &std::path::Path) -> Result<()> {
313 if !output_dir.exists() {
314 return Ok(());
315 }
316
317 info!(
318 "🧹 Safely cleaning docker directory (keeping upload): {}",
319 output_dir.display()
320 );
321
322 for entry in std::fs::read_dir(output_dir)? {
324 let entry = entry?;
325 let path = entry.path();
326 let file_name = entry.file_name();
327
328 if client_core::constants::docker::EXCLUDE_DIRS
330 .iter()
331 .any(|d| file_name.as_os_str() == *d)
332 {
333 info!("🛡️ Keeping directory: {}", path.display());
334 continue;
335 }
336
337 if path.is_dir() {
339 info!("🗑️ Removing directory: {}", path.display());
340 std::fs::remove_dir_all(&path)?;
341 } else {
342 info!("🗑️ Removing file: {}", path.display());
343 std::fs::remove_file(&path)?;
344 }
345 }
346
347 info!("✅ Docker directory cleanup completed, upload directory preserved");
348 Ok(())
349}
350
351pub async fn extract_docker_service(
353 archive_path: &std::path::Path,
354 upgrade_strategy: &UpgradeStrategy,
355) -> Result<()> {
356 let extract_start = Instant::now();
357
358 info!(
359 "📦 Starting Docker service package extraction: {}",
360 archive_path.display()
361 );
362
363 if !archive_path.exists() {
365 return Err(anyhow::anyhow!(
366 "{}",
367 t!("utils.file_not_exists", path = archive_path.display())
368 ));
369 }
370
371 let format = archive::detect_format_by_magic(archive_path)?;
373 info!("✅ Detected archive format: {:?}", format);
374
375 validate_archive_paths(archive_path)?;
376
377 match format {
379 client_core::utils::archive::ArchiveFormat::Zip => {
380 extract_zip_archive(archive_path, upgrade_strategy, extract_start).await
381 }
382 client_core::utils::archive::ArchiveFormat::TarGz => {
383 extract_tar_gz_archive(archive_path, upgrade_strategy, extract_start).await
384 }
385 }
386}
387
388async fn extract_zip_archive(
390 zip_path: &std::path::Path,
391 upgrade_strategy: &UpgradeStrategy,
392 extract_start: Instant,
393) -> Result<()> {
394 let file = std::fs::File::open(zip_path)?;
396 let mut archive = zip::ZipArchive::new(file)?;
397
398 info!(
399 "✅ ZIP opened successfully, contains {} files",
400 archive.len()
401 );
402
403 match upgrade_strategy {
404 UpgradeStrategy::FullUpgrade { .. } => {
405 let output_dir = std::path::Path::new("docker");
407 if output_dir.exists() {
409 safe_remove_docker_directory(output_dir)?;
410 } else {
411 std::fs::create_dir_all(output_dir)?;
413 }
414
415 let mut extracted_files = 0;
417 let mut extracted_size = 0u64;
418 let total_files = archive.len();
419
420 info!("🚀 Starting extraction of {} files...", total_files);
421
422 for i in 0..archive.len() {
423 let mut file = archive.by_index(i)?;
424 let file_name = file.name().to_string();
425 let enclosed_name = file.enclosed_name().ok_or_else(|| {
426 anyhow::anyhow!("Unsafe archive path detected: {}", file_name)
427 })?;
428
429 if should_skip_file(&file_name) {
431 info!("⏩ Skipping file: {}", file_name);
432 continue;
433 }
434
435 let clean_path = if enclosed_name.starts_with("docker") {
437 enclosed_name
439 .strip_prefix("docker")
440 .unwrap_or(&enclosed_name)
441 } else {
442 enclosed_name.as_path()
443 };
444
445 let target_path = output_dir.join(clean_path);
446
447 if is_upload_directory_path(&target_path) {
449 if target_path.exists() {
452 info!(
453 "🛡️ Keeping existing upload directory, skipping extraction: {}",
454 target_path.display()
455 );
456 continue;
457 } else {
458 info!(
459 "📁 Creating new upload directory structure: {}",
460 target_path.display()
461 );
462 }
463 }
464
465 if file.is_dir() {
466 std::fs::create_dir_all(&target_path)?;
468 } else {
469 force_extract_file(&mut file, &target_path)?;
471
472 extracted_files += 1;
473 extracted_size += file.size();
474
475 if extracted_files % (total_files / 10).max(1) == 0 {
477 let percentage = (extracted_files * 100) / total_files;
478 info!(
479 "📁 Extraction progress: {}% ({}/{} files, {:.1} MB)",
480 percentage,
481 extracted_files,
482 total_files,
483 extracted_size as f64 / 1024.0 / 1024.0
484 );
485 }
486 }
487 }
488
489 let elapsed = extract_start.elapsed();
490 info!("🎉 Docker service package extraction completed!");
491 info!(" 📁 Extracted files: {}", extracted_files);
492 info!(
493 " 📏 Total data size: {:.1} MB",
494 extracted_size as f64 / 1024.0 / 1024.0
495 );
496 info!(" ⏱️ Elapsed: {:.2} seconds", elapsed.as_secs_f64());
497 }
498 UpgradeStrategy::PatchUpgrade {
499 patch_info,
500 download_type: _,
501 ..
502 } => {
503 let change_files = patch_info.get_changed_files();
505 let work_dir = get_docker_work_dir();
506 let upgrade_change_file_or_dir = change_files
507 .iter()
508 .map(|path| work_dir.join(path))
509 .collect::<Vec<_>>();
510
511 for file_or_dir in upgrade_change_file_or_dir {
513 if is_upload_directory_path(&file_or_dir) {
514 info!(
515 "🛡️ Keeping upload directory, skipping deletion: {}",
516 file_or_dir.display()
517 );
518 continue;
519 }
520
521 if file_or_dir.is_file() {
522 std::fs::remove_file(file_or_dir)?;
523 } else if file_or_dir.is_dir() {
524 std::fs::remove_dir_all(file_or_dir)?;
525 } else {
526 info!(
527 "File or directory does not exist, skipping: {}",
528 file_or_dir.display()
529 );
530 }
531 }
532
533 let operations = patch_info.operations.clone();
534 let mut extracted_files = 0;
536 let mut extracted_size = 0u64;
537 let total_files = archive.len();
538
539 info!("🚀 Starting extraction of {} files...", total_files);
540
541 if let Some(replace) = operations.replace {
543 let replace_files = replace.files;
544 let replace_dirs = replace.directories;
545
546 for file in replace_files {
548 let zip_path = format!("docker/{}", file.trim_start_matches('/'));
549 info!("🔍 Locating file: {} -> {}", file, zip_path);
550
551 let mut entry = archive.by_name(&zip_path).map_err(|e| {
552 anyhow::anyhow!(
553 "{}",
554 t!(
555 "utils.file_not_found_in_archive",
556 path = zip_path,
557 error = e.to_string()
558 )
559 )
560 })?;
561
562 let dst = work_dir.join(&file);
563
564 if is_upload_directory_path(&dst) {
566 if dst.exists() {
568 info!(
569 "🛡️ Keeping existing directory, skipping replacement: {}",
570 dst.display()
571 );
572 continue;
573 } else {
574 info!(
575 "📁 Creating new protected directory structure: {}",
576 dst.display()
577 );
578 }
579 }
580
581 force_extract_file(&mut entry, &dst)?;
583
584 extracted_files += 1;
585 extracted_size += entry.size();
586 }
587
588 for dir in replace_dirs {
590 let zip_dir_path = format!("docker/{}", dir.trim_start_matches('/'));
591 info!("📁 Processing directory: {} -> {}", dir, zip_dir_path);
592
593 let target_dir = work_dir.join(&dir);
595 if is_upload_directory_path(&target_dir) && target_dir.exists() {
596 info!(
597 "🛡️ Keeping existing directory, skipping directory replacement: {}",
598 target_dir.display()
599 );
600 continue;
601 }
602
603 if target_dir.exists() {
604 info!("🗑️ Force removing directory: {}", target_dir.display());
605 std::fs::remove_dir_all(&target_dir)?;
606 }
607
608 for i in 0..archive.len() {
610 let mut entry = archive.by_index(i)?;
611 let entry_name = entry.name();
612
613 if entry_name.starts_with(&zip_dir_path) {
614 let relative_path = entry_name
615 .strip_prefix(&zip_dir_path)
616 .unwrap_or("")
617 .trim_start_matches('/');
618
619 if relative_path.is_empty() && entry.is_dir() {
620 continue;
621 }
622
623 let dst = target_dir.join(relative_path);
624 ensure_parent_dir(&dst)?;
625
626 handle_extraction(
627 &mut entry,
628 &dst,
629 &mut extracted_files,
630 &mut extracted_size,
631 )?;
632 }
633 }
634 }
635 }
636 if let Some(delete) = operations.delete {
637 for file in delete.files {
639 let path = work_dir.join(file);
640 if is_upload_directory_path(&path) {
641 info!(
642 "🛡️ Keeping upload directory, skipping file deletion: {}",
643 path.display()
644 );
645 continue;
646 }
647 info!("🗑️ Removing file: {}", path.display());
648 if path.is_file() {
649 std::fs::remove_file(&path)?;
650 } else if path.exists() {
651 std::fs::remove_file(&path).or_else(|_| std::fs::remove_dir_all(&path))?;
652 } else {
653 info!("File does not exist, skipping: {}", path.display());
654 }
655 }
656 for dir in delete.directories {
658 let path = work_dir.join(dir);
659 if is_upload_directory_path(&path) {
660 info!(
661 "🛡️ Keeping upload directory, skipping directory deletion: {}",
662 path.display()
663 );
664 continue;
665 }
666 info!("🗑️ Removing directory: {}", path.display());
667 if path.is_dir() {
668 std::fs::remove_dir_all(&path)?;
669 } else if path.exists() {
670 std::fs::remove_file(&path).or_else(|_| std::fs::remove_dir_all(&path))?;
671 } else {
672 info!("Directory does not exist, skipping: {}", path.display());
673 }
674 }
675 }
676
677 use client_core::constants::sql::CRITICAL_UPGRADE_FILES;
680 for critical_file in CRITICAL_UPGRADE_FILES {
681 let zip_path = format!("docker/{}", critical_file);
682 let dst_path = work_dir.join(critical_file);
683
684 match archive.by_name(&zip_path) {
685 Ok(mut entry) => {
686 info!("🔧 Force updating critical file: {}", critical_file);
687 force_extract_file(&mut entry, &dst_path)?;
688 info!("✅ Critical file updated: {}", critical_file);
689 }
690 Err(_) => {
691 info!("⏭️ Critical file not present in archive: {}", zip_path);
693 }
694 }
695 }
696 }
697 UpgradeStrategy::NoUpgrade { .. } => {
698 return Err(anyhow::anyhow!(
700 "{}",
701 t!("utils.no_upgrade_extract_unsupported")
702 ));
703 }
704 }
705
706 Ok(())
707}
708
709async fn extract_tar_gz_archive(
711 tar_gz_path: &std::path::Path,
712 upgrade_strategy: &UpgradeStrategy,
713 extract_start: Instant,
714) -> Result<()> {
715 let tar_gz_path = tar_gz_path.to_path_buf();
716 let strategy = upgrade_strategy.clone();
717
718 tokio::task::spawn_blocking(move || {
719 extract_tar_gz_blocking(&tar_gz_path, &strategy, extract_start)
720 })
721 .await
722 .map_err(|e| anyhow::anyhow!("{}", t!("utils.extract_task_failed", error = e.to_string())))?
723}
724
725fn extract_tar_gz_blocking(
727 tar_gz_path: &std::path::Path,
728 upgrade_strategy: &UpgradeStrategy,
729 extract_start: Instant,
730) -> Result<()> {
731 use flate2::read::GzDecoder;
732 use tar::Archive;
733
734 let tar_gz = std::fs::File::open(tar_gz_path)?;
735 let decoder = GzDecoder::new(tar_gz);
736 let mut archive = Archive::new(decoder);
737
738 let output_dir = std::path::Path::new("docker");
739 let mut extracted_files = 0;
740 let mut extracted_size = 0u64;
741
742 match upgrade_strategy {
743 UpgradeStrategy::FullUpgrade { .. } => {
744 if output_dir.exists() {
746 safe_remove_docker_directory(output_dir)?;
747 } else {
748 std::fs::create_dir_all(output_dir)?;
749 }
750
751 info!("🚀 Starting TAR.GZ extraction...");
752
753 for entry in archive.entries()? {
754 let mut entry: tar::Entry<flate2::read::GzDecoder<std::fs::File>> = entry?;
755 let path = entry.path()?;
756 let entry_type = entry.header().entry_type();
757 if entry_type.is_symlink() || entry_type.is_hard_link() {
758 return Err(anyhow::anyhow!(
759 "Archive links are not allowed: {}",
760 path.display()
761 ));
762 }
763 if contains_unsafe_component(&path) {
764 return Err(anyhow::anyhow!(
765 "Unsafe archive path detected: {}",
766 path.display()
767 ));
768 }
769
770 if should_skip_tar_entry(&path) {
772 continue;
773 }
774
775 let clean_path = path.strip_prefix("docker").unwrap_or(&path);
777 let target_path = output_dir.join(clean_path);
778
779 if is_upload_directory_path(&target_path) && target_path.exists() {
781 info!(
782 "🛡️ Keeping existing directory, skipping: {}",
783 target_path.display()
784 );
785 continue;
786 }
787
788 if let Some(parent) = target_path.parent()
790 && !parent.exists()
791 {
792 std::fs::create_dir_all(parent)?;
793 }
794
795 entry.unpack(&target_path)?;
797 extracted_files += 1;
798 extracted_size += entry.size();
799
800 if extracted_files % 10 == 0 {
802 info!(
803 "📁 Extraction progress: {} files ({:.1} MB)",
804 extracted_files,
805 extracted_size as f64 / 1024.0 / 1024.0
806 );
807 }
808 }
809
810 let elapsed = extract_start.elapsed();
811 info!("🎉 Docker service package extraction completed!");
812 info!(" 📁 Extracted files: {}", extracted_files);
813 info!(
814 " 📏 Total data size: {:.1} MB",
815 extracted_size as f64 / 1024.0 / 1024.0
816 );
817 info!(" ⏱️ Elapsed: {:.2} seconds", elapsed.as_secs_f64());
818 }
819 UpgradeStrategy::PatchUpgrade { .. } => {
820 return Err(anyhow::anyhow!("{}", t!("utils.tar_gz_patch_unsupported")));
822 }
823 UpgradeStrategy::NoUpgrade { .. } => {
824 return Err(anyhow::anyhow!(
825 "{}",
826 t!("utils.no_upgrade_extract_unsupported")
827 ));
828 }
829 }
830
831 Ok(())
832}
833
834fn should_skip_tar_entry(path: &std::path::Path) -> bool {
836 let s = path.to_string_lossy();
837 s.contains("__MACOSX") || s.contains(".DS_Store") || s.contains("._")
838}
839
840pub fn setup_logging(verbose: bool) {
849 #[allow(unused_imports)]
850 use tracing_subscriber::{EnvFilter, fmt, util::SubscriberInitExt};
851
852 let default_level = if verbose { "debug" } else { "info" };
854 let env_filter = EnvFilter::try_from_default_env()
855 .unwrap_or_else(|_| EnvFilter::new(default_level))
856 .add_directive("reqwest=warn".parse().unwrap())
858 .add_directive("tokio=warn".parse().unwrap())
859 .add_directive("hyper=warn".parse().unwrap());
860
861 if let Ok(log_file) = std::env::var("DUCK_LOG_FILE") {
863 let file = std::fs::OpenOptions::new()
865 .create(true)
866 .append(true)
867 .open(log_file)
868 .expect("Failed to create log file");
869
870 fmt()
871 .with_env_filter(env_filter)
872 .with_writer(file)
873 .with_target(true)
874 .with_thread_names(true)
875 .with_line_number(true)
876 .init();
877 } else {
878 fmt()
880 .with_env_filter(env_filter)
881 .with_target(false) .with_thread_names(false) .with_line_number(false) .without_time() .compact() .init();
887 }
888}
889
890#[allow(dead_code)]
895pub fn setup_minimal_logging() {
896 #[allow(unused_imports)]
897 use tracing_subscriber::{EnvFilter, fmt, util::SubscriberInitExt};
898
899 let _ = fmt()
902 .with_env_filter(EnvFilter::from_default_env())
903 .with_target(false)
904 .compact() .try_init();
906}