Skip to main content

nuwax_cli/utils/
mod.rs

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
11// 导入匹配器模块
12pub mod env_manager;
13
14// 重新导出匹配器模块
15// pub use matcher::*;
16
17/// 判断是否应该跳过某个文件(智能过滤)
18///
19/// 跳过的文件类型:
20/// - macOS 系统文件:__MACOSX, .DS_Store, ._*
21/// - 版本控制文件:.git/, .gitignore, .gitattributes
22/// - 临时文件:.tmp, .temp, .bak
23/// - IDE 文件:.vscode/, .idea/
24///
25/// 保留的重要配置文件:
26/// - Docker 配置:.env, .env.*, .dockerignore
27/// - 其他配置:.editorconfig, .prettier*, .eslint*
28fn should_skip_file(file_name: &str) -> bool {
29    // 跳过 macOS 系统文件和临时文件
30    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    // 跳过版本控制相关文件
41    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    // 跳过 IDE 和编辑器配置目录
50    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    // 保留重要的配置文件(即使以.开头)
58    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    // 其他以.开头的文件,谨慎起见也保留(除非明确知道要跳过)
69    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/// # Nuwax Cli  日志系统使用说明
137///
138/// 本项目遵循 Rust CLI 应用的日志最佳实践:
139///
140/// ## 基本原则
141/// 1. **库代码只使用 `tracing` 宏**:`info!()`, `warn!()`, `error!()`, `debug!()`
142/// 2. **应用入口控制日志配置**:在 `main.rs` 中调用 `setup_logging()`
143/// 3. **用户界面输出与日志分离**:备份列表等用户友好信息通过其他方式输出
144///
145/// ## 日志配置选项
146///
147/// ### 命令行参数
148/// - `-v, --verbose`:启用详细日志模式(DEBUG 级别)
149///
150/// ### 环境变量
151/// - `RUST_LOG`:标准的 Rust 日志级别控制(如 `debug`, `info`, `warn`, `error`)
152/// - `DUCK_LOG_FILE`:日志文件路径,设置后日志输出到文件而非终端
153///
154/// ## 使用示例
155///
156/// ```bash
157/// # 标准日志输出到终端
158/// nuwax-cli auto-backup status
159///
160/// # 详细日志输出到终端
161/// nuwax-cli -v auto-backup status
162///
163/// # 日志输出到文件
164/// DUCK_LOG_FILE=duck.log nuwax-cli auto-backup status
165///
166/// # 使用 RUST_LOG 控制特定模块的日志级别
167/// RUST_LOG=duck_cli::commands::auto_backup=debug nuwax-cli auto-backup status
168/// ```
169///
170/// ## 作为库使用
171///
172/// 当 nuwax-cli 作为库被其他项目使用时,可以:
173/// 1. 让使用者完全控制日志配置(推荐)
174/// 2. 或调用 `setup_minimal_logging()` 进行最小化配置
175///
176/// ## 日志格式
177/// - **终端输出**:人类可读格式,不显示模块路径
178/// - **文件输出**:包含完整模块路径和更多调试信息
179///
180/// 带进度显示的文件复制
181#[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]; // 8KB 缓冲区
189    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        // 显示大文件的复制进度(每10%或每100MB显示一次)
202        if total_size > 100 * 1024 * 1024 {
203            // 只对大于100MB的文件显示详细进度
204            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            // 每10%或每100MB更新一次进度
209            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
224/// 强制覆盖文件/目录:先删除再创建(彻底解决 Directory not empty 错误)
225fn force_extract_file(
226    entry: &mut ZipFile<std::fs::File>,
227    target_path: &std::path::Path,
228) -> Result<()> {
229    // 如果目标存在,先彻底删除
230    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    // 确保父目录存在
241    if let Some(parent) = target_path.parent()
242        && !parent.exists()
243    {
244        std::fs::create_dir_all(parent)?;
245    }
246
247    // 创建新文件/目录
248    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
291/// 确保父目录存在
292fn 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
301/// 判断路径是否属于保护目录 (upload, data 等)
302fn is_upload_directory_path(path: &std::path::Path) -> bool {
303    // 判断 [upload, project_workspace, project_zips, project_nginx, project_init, data] 目录
304    path.components().any(|component| {
305        client_core::constants::docker::EXCLUDE_DIRS
306            .iter()
307            .any(|d| component.as_os_str() == *d)
308    })
309}
310
311/// 安全删除 docker 目录,保留 upload 目录
312fn 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    // 遍历 docker 目录,删除除了 upload 之外的所有内容
323    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        // 跳过 [upload, project_workspace, project_zips, project_nginx, project_init, data] 目录
329        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        // 删除其他文件或目录
338        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
351/// 解压Docker服务包 - 支持 ZIP 和 TAR.GZ
352pub 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    // 检查文件是否存在
364    if !archive_path.exists() {
365        return Err(anyhow::anyhow!(
366            "{}",
367            t!("utils.file_not_exists", path = archive_path.display())
368        ));
369    }
370
371    // 检测文件格式
372    let format = archive::detect_format_by_magic(archive_path)?;
373    info!("✅ Detected archive format: {:?}", format);
374
375    validate_archive_paths(archive_path)?;
376
377    // 根据格式选择解压方法
378    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
388/// 解压 ZIP 格式归档
389async fn extract_zip_archive(
390    zip_path: &std::path::Path,
391    upgrade_strategy: &UpgradeStrategy,
392    extract_start: Instant,
393) -> Result<()> {
394    // 打开ZIP文件
395    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            // 目标解压目录
406            let output_dir = std::path::Path::new("docker");
407            // 如果目标目录已存在,安全清理它(保留upload目录)
408            if output_dir.exists() {
409                safe_remove_docker_directory(output_dir)?;
410            } else {
411                // 创建输出目录
412                std::fs::create_dir_all(output_dir)?;
413            }
414
415            // 统计解压进度
416            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                // 跳过系统文件和临时文件
430                if should_skip_file(&file_name) {
431                    info!("⏩ Skipping file: {}", file_name);
432                    continue;
433                }
434
435                // 处理路径:移除可能的顶层docker目录前缀
436                let clean_path = if enclosed_name.starts_with("docker") {
437                    // 如果ZIP内已有docker/前缀,移除它
438                    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                // 检查是否为 upload 目录路径
448                if is_upload_directory_path(&target_path) {
449                    // 如果 upload 目录已存在,跳过解压以保护用户数据
450                    // 如果 upload 目录不存在,正常解压以创建目录结构
451                    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                    // 创建目录
467                    std::fs::create_dir_all(&target_path)?;
468                } else {
469                    // 强制覆盖:先删除再解压(彻底解决 Directory not empty 错误)
470                    force_extract_file(&mut file, &target_path)?;
471
472                    extracted_files += 1;
473                    extracted_size += file.size();
474
475                    // 每解压10%的文件显示进度
476                    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            // 增量升级:根据操作的文件和目录进行操作
504            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            // 清理即将被替换或删除的文件/目录(跳过upload目录)
512            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            // 统计解压进度
535            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            //根据 operations 的 replace, delete 进行操作
542            if let Some(replace) = operations.replace {
543                let replace_files = replace.files;
544                let replace_dirs = replace.directories;
545
546                // 处理替换文件
547                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                    // 检查是否为保护目录路径
565                    if is_upload_directory_path(&dst) {
566                        // 如果保护目录已存在,跳过解压以保护用户数据
567                        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                    // 强制覆盖:先删除再解压(彻底解决 Directory not empty 错误)
582                    force_extract_file(&mut entry, &dst)?;
583
584                    extracted_files += 1;
585                    extracted_size += entry.size();
586                }
587
588                // 处理替换目录
589                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                    // 清理现有目录(跳过保护目录)
594                    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                    // 解压该目录下的所有条目
609                    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                // 处理删除操作(跳过upload目录)
638                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                // 删除目录(跳过upload目录)
657                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            // 🔧 强制更新关键配置文件(无论是否在变更列表中)
678            // 这些文件对于数据库升级至关重要,必须始终保持最新
679            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                        // 压缩包中没有这个文件,跳过
692                        info!("⏭️  Critical file not present in archive: {}", zip_path);
693                    }
694                }
695            }
696        }
697        UpgradeStrategy::NoUpgrade { .. } => {
698            // 无需升级,不应该走到这里的解压逻辑
699            return Err(anyhow::anyhow!(
700                "{}",
701                t!("utils.no_upgrade_extract_unsupported")
702            ));
703        }
704    }
705
706    Ok(())
707}
708
709/// 解压 TAR.GZ 格式归档
710async 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
725/// TAR.GZ 解压实现(阻塞)
726fn 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            // 全量升级:清空 docker 目录(保留 upload 目录)
745            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                // 跳过 __MACOSX 等系统文件
771                if should_skip_tar_entry(&path) {
772                    continue;
773                }
774
775                // 移除 docker/ 前缀(如果存在)
776                let clean_path = path.strip_prefix("docker").unwrap_or(&path);
777                let target_path = output_dir.join(clean_path);
778
779                // 保护 upload 目录
780                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                // 确保父目录存在
789                if let Some(parent) = target_path.parent()
790                    && !parent.exists()
791                {
792                    std::fs::create_dir_all(parent)?;
793                }
794
795                // 解压文件
796                entry.unpack(&target_path)?;
797                extracted_files += 1;
798                extracted_size += entry.size();
799
800                // 每解压10%的文件显示进度
801                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            // 增量升级目前不支持 TAR.GZ
821            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
834/// 判断 TAR 条目是否应该跳过
835fn 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
840/// 设置日志记录系统
841///
842/// 这个函数遵循Rust CLI应用的最佳实践:
843/// - 库代码只使用 tracing 宏记录日志
844/// - 在应用入口配置日志输出行为
845/// - 支持 RUST_LOG 环境变量控制日志级别
846/// - 默认输出到stderr,避免与程序输出混淆
847/// - 终端输出简洁格式,文件输出详细格式
848pub fn setup_logging(verbose: bool) {
849    #[allow(unused_imports)]
850    use tracing_subscriber::{EnvFilter, fmt, util::SubscriberInitExt};
851
852    // 根据verbose参数和环境变量确定日志级别
853    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        // 过滤掉第三方库的详细日志,减少噪音
857        .add_directive("reqwest=warn".parse().unwrap())
858        .add_directive("tokio=warn".parse().unwrap())
859        .add_directive("hyper=warn".parse().unwrap());
860
861    // 检查环境变量,决定是否输出到文件
862    if let Ok(log_file) = std::env::var("DUCK_LOG_FILE") {
863        // 输出到文件 - 使用详细格式便于调试
864        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        // 输出到终端 - 使用简洁格式,用户友好
879        fmt()
880            .with_env_filter(env_filter)
881            .with_target(false) // 不显示模块路径
882            .with_thread_names(false) // 不显示线程名
883            .with_line_number(false) // 不显示行号
884            .without_time() // 不显示时间戳
885            .compact() // 使用紧凑格式
886            .init();
887    }
888}
889
890/// 为库使用提供的简化日志初始化
891///
892/// 当nuwax-cli作为库使用时,可以调用此函数进行最小化的日志配置
893/// 或者让库的使用者完全控制日志配置
894#[allow(dead_code)]
895pub fn setup_minimal_logging() {
896    #[allow(unused_imports)]
897    use tracing_subscriber::{EnvFilter, fmt, util::SubscriberInitExt};
898
899    // 尝试初始化一个简单的订阅者
900    // 如果已经有全局订阅者,这会返回错误,我们忽略它
901    let _ = fmt()
902        .with_env_filter(EnvFilter::from_default_env())
903        .with_target(false)
904        .compact() // 使用紧凑格式
905        .try_init();
906}