import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:archive/archive_io.dart'; import 'package:path/path.dart' as p; import '../l10n/tr.dart'; import 'file_ops.dart' show NameConflict; /// 86: 압축 종류 (확장자로) enum ArchiveKind { zip, sevenZip, rar } ArchiveKind? archiveKindOf(String path) => switch (p.extension(path).toLowerCase()) { '.zip' || '.cbz' => ArchiveKind.zip, '.7z' || '.cb7' => ArchiveKind.sevenZip, '.rar' || '.cbr' => ArchiveKind.rar, _ => null, }; /// 압축 안의 항목 하나 (이름은 압축 안 그대로 - 나누는 글자는 '/' 로 맞춤) class ArchiveEntry { final String name; final int size; final bool isDir; final bool isLink; final bool encrypted; /// 7-Zip 에 넘길 원래 이름 (Windows 의 7z 는 '\' 로 보여 준다) final String raw; const ArchiveEntry(this.name, {this.size = 0, this.isDir = false, this.isLink = false, this.encrypted = false, String? raw}) : raw = raw ?? name; } /// 비밀번호가 필요하거나 틀림 class ArchivePasswordException implements Exception { final bool wrong; const ArchivePasswordException({this.wrong = false}); @override String toString() => wrong ? tr('비밀번호가 틀렸습니다') : tr('비밀번호가 필요합니다'); } /// 86 (보안 · 관리자 판단): 풀면 대상 폴더 밖으로 나가는 이름인지 - 까닭 (괜찮으면 null). /// ../ · 절대 경로 (/ · \) · 드라이브 문자 (C:) · UNC (\\서버) · 이름 안의 ':' (Windows 의 다른 데이터 흐름) 을 막는다 (zip slip) String? unsafeArchivePath(String name) { final n = name.replaceAll('\\', '/'); if (n.startsWith('//')) return tr('네트워크 경로 (UNC)'); if (n.startsWith('/')) return tr('절대 경로'); if (RegExp(r'^[A-Za-z]:').hasMatch(n)) return tr('드라이브 경로'); final parts = n.split('/'); if (parts.any((s) => s == '..')) return tr('바깥 폴더 (../)'); if (parts.any((s) => s.contains(':'))) return tr('쓸 수 없는 글자 (:)'); if (parts.every((s) => s.isEmpty || s == '.')) return tr('빈 이름'); return null; } /// 풀기 전 검사 결과 class ExtractPlan { /// 풀 파일 (폴더는 파일을 풀며 만든다) final List files; /// 막은 것: (이름, 까닭) final List<(String, String)> unsafe; /// 만들지 않고 건너뛰는 링크 final List links; /// 풀린 크기 합 (바이트) final int totalSize; /// 비밀번호가 걸린 항목이 있음 final bool encrypted; const ExtractPlan(this.files, this.unsafe, this.links, this.totalSize, this.encrypted); } /// 목록 [entries] 에서 풀 것을 고른다. [names] 가 있으면 그 이름 (폴더면 그 안 모두) 만 ExtractPlan planExtract(List entries, {List names = const []}) { bool wanted(String n) => names.isEmpty || names.any((x) => n == x || n.startsWith(x.endsWith('/') ? x : '$x/')); final files = []; final unsafe = <(String, String)>[]; final links = []; var size = 0; var enc = false; for (final e in entries) { if (!wanted(e.name)) continue; final bad = unsafeArchivePath(e.name); if (bad != null) { unsafe.add((e.name, bad)); continue; } if (e.isLink) { links.add(e.name); continue; } if (e.isDir) continue; files.add(e); size += e.size; enc |= e.encrypted; } return ExtractPlan(files, unsafe, links, size, enc); } /// 풀 때 [dest] 안의 자리 ([name] 은 [unsafeArchivePath] 를 지난 것) String extractTarget(String dest, String name) => p.joinAll([dest, ...name.replaceAll('\\', '/').split('/').where((s) => s.isNotEmpty && s != '.')]); /// 같은 이름이면 "이름 (2).확장자" … String _freeName(String path) { if (FileSystemEntity.typeSync(path) == FileSystemEntityType.notFound) return path; final dir = p.dirname(path), base = p.basenameWithoutExtension(path), ext = p.extension(path); for (var n = 2;; n++) { final c = p.join(dir, '$base ($n)$ext'); if (FileSystemEntity.typeSync(c) == FileSystemEntityType.notFound) return c; } } /// 푼 결과 class ExtractResult { int done = 0; final skippedExisting = []; final renamed = []; } /// 취소 (풀기 · 묶기 도중) class ArchiveCancelled implements Exception { const ArchiveCancelled(); @override String toString() => tr('취소했습니다'); } // ───────── ZIP (archive 패키지, MIT) ───────── /// ZIP 목록 - 가운데 목록 (central directory) 을 직접 읽어 비밀번호 · 링크를 안다 /// (archive 패키지의 항목에는 비밀번호 여부가 없고, 비밀번호 없이 읽으면 암호 글자가 그대로 나온다) Future> listZip(String path) async { final input = InputFileStream(path); try { final dir = ZipDirectory()..read(input); return [ for (final h in dir.fileHeaders) ArchiveEntry(h.filename.replaceAll('\\', '/'), size: h.uncompressedSize, isDir: h.filename.endsWith('/') || h.filename.endsWith('\\'), // 유닉스 파일 종류 (상위 16비트) 가 심볼릭 링크 (0xA000) isLink: (h.externalFileAttributes >> 16) & 0xF000 == 0xA000, encrypted: h.generalPurposeBitFlag & 0x1 != 0), ]; } finally { await input.close(); } } /// ZIP 풀기 - [plan] 의 파일만, 같은 이름은 [conflict] 대로 Future extractZipPlan(String path, String dest, ExtractPlan plan, {NameConflict conflict = NameConflict.rename, String? password, void Function(int done, int total)? onProgress, bool Function()? cancelled}) async { // 비밀번호가 걸린 항목인데 비밀번호가 없으면 (암호 글자를 그대로 쓰지 않게) 먼저 묻는다 if (plan.encrypted && (password == null || password.isEmpty)) throw const ArchivePasswordException(); final input = InputFileStream(path); final r = ExtractResult(); try { // verify: CRC 를 맞춰 보아 비밀번호가 틀리면 (풀린 내용이 다르면) 실패로 final a = ZipDecoder().decodeStream(input, password: password, verify: plan.encrypted); final want = {for (final e in plan.files) e.name}; final todo = [for (final f in a.files) if (f.isFile && want.contains(f.name.replaceAll('\\', '/'))) f]; for (final f in todo) { if (cancelled?.call() ?? false) throw const ArchiveCancelled(); var target = extractTarget(dest, f.name); if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound) { if (conflict == NameConflict.skip) { r.skippedExisting.add(f.name); continue; } if (conflict == NameConflict.rename) { target = _freeName(target); r.renamed.add(f.name); } } await Directory(p.dirname(target)).create(recursive: true); final bytes = f.readBytes(); if (bytes == null) continue; // 반쯤 쓴 파일이 완성된 이름으로 남지 않게 (.jjpart 에 쓴 뒤 이름 바꾸기) final part = File('$target.jjpart'); await part.writeAsBytes(bytes, flush: true); if (await File(target).exists()) await File(target).delete(); await part.rename(target); r.done++; onProgress?.call(r.done, todo.length); } } on ArchivePasswordException { rethrow; } catch (e) { // archive 는 틀린 비밀번호를 ArchiveException · 그냥 Exception ("password error") 으로 알린다 if (plan.encrypted && (e is ArchiveException || '$e'.toLowerCase().contains('password'))) { throw const ArchivePasswordException(wrong: true); } rethrow; } finally { await input.close(); } return r; } /// ZIP 만들기: [sources] (파일 · 폴더) 를 [zipPath] 로. 링크 · junction 은 넣지 않는다 (밖을 가리키는 것을 따라가지 않게). /// [level] 0 (묶기만) ~ 9 (가장 작게). 다 만든 뒤에만 이름을 바꾼다 (취소 · 실패면 만들다 만 파일을 지움). /// 돌려주는 값: (넣은 파일 수, 건너뛴 링크 수) Future<(int, int)> createZip(List sources, String zipPath, {int level = 6, void Function(int done, int total)? onProgress, bool Function()? cancelled}) async { // 넣을 파일 (상대 이름) - 링크는 세기만 final files = <(String, String)>[]; var links = 0; for (final s in sources) { final base = p.dirname(s); final t = FileSystemEntity.typeSync(s, followLinks: false); if (t == FileSystemEntityType.link) { links++; } else if (t == FileSystemEntityType.file) { files.add((s, p.relative(s, from: base))); } else if (t == FileSystemEntityType.directory) { await for (final e in Directory(s).list(recursive: true, followLinks: false)) { if (e is Link || FileSystemEntity.isLinkSync(e.path)) { links++; } else if (e is File) { files.add((e.path, p.relative(e.path, from: base))); } } } } final part = '$zipPath.jjpart'; final enc = ZipFileEncoder()..create(part, level: level); var done = false; try { for (final (i, (path, rel)) in files.indexed) { if (cancelled?.call() ?? false) throw const ArchiveCancelled(); await enc.addFile(File(path), rel.replaceAll('\\', '/'), level); onProgress?.call(i + 1, files.length); } await enc.close(); done = true; await File(part).rename(zipPath); } finally { if (!done) { try { await enc.close(); } catch (_) {} try { await File(part).delete(); } catch (_) {} } } return (files.length, links); } // ───────── 7z · RAR (7-Zip 25.01 실행 파일, LGPL 2.1+ · unRAR 제한 - 풀기만) ───────── /// 7-Zip 의 "l -slt" 출력 → 목록. 링크: "Symbolic Link" · "Hard Link" 칸이 있거나 속성에 유닉스 링크 (-l…) · 재분석 지점 List parse7zList(String out) { final entries = []; for (final block in out.replaceAll('\r\n', '\n').split(RegExp(r'\n\s*\n'))) { final m = {}; for (final line in block.split('\n')) { final i = line.indexOf(' = '); if (i > 0) m[line.substring(0, i).trim()] = line.substring(i + 3); } final raw = m['Path']; if (raw == null || raw.isEmpty) continue; final attr = m['Attributes'] ?? ''; final link = (m['Symbolic Link'] ?? '').isNotEmpty || (m['Hard Link'] ?? '').isNotEmpty || RegExp(r'(^|\s)-?l[r-][w-][x-]').hasMatch(attr) || attr.contains('REPARSE'); entries.add(ArchiveEntry(raw.replaceAll('\\', '/'), raw: raw, size: int.tryParse(m['Size'] ?? '') ?? 0, isDir: (m['Folder'] ?? '') == '+' || RegExp(r'^[A-Z_]*D').hasMatch(attr), isLink: link, encrypted: (m['Encrypted'] ?? '') == '+')); } return entries; } /// 7-Zip 실행. 비밀번호는 명령 줄이 아니라 표준 입력으로만 (작업 기록 · 프로세스 목록에 남지 않게 - 관리자 판단) Future<(int, String, String)> _run7z(String exe, List args, {String? password, void Function(String line)? onLine, bool Function()? cancelled}) async { final pr = await Process.start(exe, ['-sccUTF-8', '-scsUTF-8', ...args]); // 묻지 않을 때도 빈 줄을 보내 기다리지 않게 (암호가 걸렸는데 비밀번호가 없으면 틀림으로 끝난다) pr.stdin.writeln(password ?? ''); await pr.stdin.close(); final out = StringBuffer(), err = StringBuffer(); // 끝을 기다리는 Future 는 바로 만든다 (프로세스가 끝난 뒤 asFuture 를 부르면 이미 끝난 흐름은 영영 끝나지 않았다) final a = pr.stdout.transform(const Utf8Decoder(allowMalformed: true)).forEach((s) { out.write(s); if (onLine != null) { for (final l in s.split(RegExp(r'[\r\n\b]+'))) { if (l.trim().isNotEmpty) onLine(l); } } }); final b = pr.stderr.transform(const Utf8Decoder(allowMalformed: true)).forEach(err.write); Timer? watch; if (cancelled != null) { watch = Timer.periodic(const Duration(milliseconds: 200), (_) { if (cancelled()) pr.kill(); }); } final code = await pr.exitCode; watch?.cancel(); await a.catchError((_) {}); await b.catchError((_) {}); if (cancelled?.call() ?? false) throw const ArchiveCancelled(); return (code, '$out', '$err'); } bool _wrongPassword(String text) { final t = text.toLowerCase(); return t.contains('wrong password') || t.contains('can not open encrypted') || t.contains('cannot open encrypted'); } /// 7z · RAR 목록 (머리까지 암호가 걸렸으면 [password] 없이 [ArchivePasswordException]) Future> list7z(String exe, String path, {String? password}) async { final (code, out, err) = await _run7z(exe, ['l', '-slt', '-ba', path], password: password); if (code != 0) { if (_wrongPassword('$out\n$err')) throw ArchivePasswordException(wrong: password != null && password.isNotEmpty); throw ProcessException(exe, const ['l'], err.trim().isEmpty ? out.trim() : err.trim(), code); } return parse7zList(out); } /// 7z · RAR 풀기 - [plan] 의 파일만 (목록 파일로 넘김, 링크 · 막은 것은 빠짐). 같은 이름은 [conflict] 대로 Future extract7zPlan(String exe, String path, String dest, ExtractPlan plan, {NameConflict conflict = NameConflict.rename, String? password, void Function(double v)? onProgress, bool Function()? cancelled}) async { final r = ExtractResult(); if (plan.files.isEmpty) return r; final tmp = await Directory.systemTemp.createTemp('jj_7z_'); try { final list = File(p.join(tmp.path, 'list.txt')); await list.writeAsString(plan.files.map((e) => e.raw).join('\n'), encoding: utf8); final existing = [for (final e in plan.files) if (FileSystemEntity.typeSync(extractTarget(dest, e.name)) != FileSystemEntityType.notFound) e.name]; final ao = switch (conflict) { NameConflict.overwrite => '-aoa', NameConflict.skip => '-aos', _ => '-aou' }; final (code, out, err) = await _run7z(exe, ['x', path, '-o$dest', ao, '-bsp1', '-bb0', '@${list.path}'], password: password, cancelled: cancelled, onLine: (l) { final m = RegExp(r'(\d+)%').firstMatch(l); if (m != null) onProgress?.call(int.parse(m[1]!) / 100); }); if (code != 0) { if (_wrongPassword('$out\n$err')) throw ArchivePasswordException(wrong: password != null && password.isNotEmpty); throw ProcessException(exe, const ['x'], err.trim().isEmpty ? out.trim() : err.trim(), code); } if (conflict == NameConflict.skip) { r.skippedExisting.addAll(existing); } else if (conflict == NameConflict.rename) { r.renamed.addAll(existing); } r.done = plan.files.length - (conflict == NameConflict.skip ? existing.length : 0); return r; } finally { try { await tmp.delete(recursive: true); } catch (_) {} } }