545 lines
24 KiB
Dart
545 lines
24 KiB
Dart
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, nameProblem;
|
|
|
|
/// 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;
|
|
|
|
/// 압축 안 파일의 바뀐 날 (모르면 null) - 덮어쓰기에서 받는 쪽이 더 새것인지 볼 때
|
|
final DateTime? modified;
|
|
const ArchiveEntry(this.name, {this.size = 0, this.isDir = false, this.isLink = false, this.encrypted = false, String? raw, this.modified})
|
|
: 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<ArchiveEntry> files;
|
|
|
|
/// 막은 것: (이름, 까닭)
|
|
final List<(String, String)> unsafe;
|
|
|
|
/// 만들지 않고 건너뛰는 링크
|
|
final List<String> links;
|
|
|
|
/// 이 기기에서 쓸 수 없는 이름 (Windows: CON · NUL · ? * " < > | · 끝 점 · 빈칸 …) - (이름, 까닭), 건너뜀
|
|
final List<(String, String)> badNames;
|
|
|
|
/// 풀린 크기 합 (바이트)
|
|
final int totalSize;
|
|
|
|
/// 비밀번호가 걸린 항목이 있음
|
|
final bool encrypted;
|
|
const ExtractPlan(this.files, this.unsafe, this.links, this.totalSize, this.encrypted, [this.badNames = const []]);
|
|
}
|
|
|
|
/// 목록 [entries] 에서 풀 것을 고른다. [names] 가 있으면 그 이름 (폴더면 그 안 모두) 만
|
|
/// 15 지적 · 관리자 판단: 리눅스 · 맥에서 만든 압축의 이름이 Windows 에서 쓸 수 없으면 (78 의 nameProblem) 미리 걸러 건너뛴다
|
|
ExtractPlan planExtract(List<ArchiveEntry> entries, {List<String> names = const [], bool? windowsRules}) {
|
|
final win = windowsRules ?? Platform.isWindows;
|
|
bool wanted(String n) => names.isEmpty || names.any((x) => n == x || n.startsWith(x.endsWith('/') ? x : '$x/'));
|
|
final files = <ArchiveEntry>[];
|
|
final unsafe = <(String, String)>[];
|
|
final links = <String>[];
|
|
final badNames = <(String, String)>[];
|
|
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;
|
|
// 이름의 각 부분이 이 기기에서 쓸 수 있는지 (풀다가 실패하거나 7-Zip 이 다른 이름으로 바꾸지 않게)
|
|
final why = [for (final s in e.name.split('/')) if (s.isNotEmpty && s != '.') nameProblem(s, windowsRules: win)].nonNulls.firstOrNull;
|
|
if (why != null) {
|
|
badNames.add((e.name, why));
|
|
continue;
|
|
}
|
|
files.add(e);
|
|
size += e.size;
|
|
enc |= e.encrypted;
|
|
}
|
|
return ExtractPlan(files, unsafe, links, size, enc, badNames);
|
|
}
|
|
|
|
/// 풀 때 [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 = <String>[];
|
|
final renamed = <String>[];
|
|
|
|
/// 덮어쓰기인데 받는 쪽이 더 새것이라 그대로 둔 것 (229 와 같은 규칙)
|
|
final keptNewer = <String>[];
|
|
}
|
|
|
|
/// 덮어쓰기에서 건너뛸지: 받는 쪽 파일이 압축 안의 것보다 2초 넘게 새것
|
|
bool _targetNewer(String target, DateTime? modified) {
|
|
if (modified == null) return false;
|
|
try {
|
|
return File(target).lastModifiedSync().isAfter(modified.add(const Duration(seconds: 2)));
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// ZIP 의 MS-DOS 날짜 · 시각 (지역 시각) → DateTime (0 이면 null)
|
|
DateTime? dosDateTime(int date, int time) {
|
|
if (date == 0) return null;
|
|
return DateTime(1980 + (date >> 9), (date >> 5) & 0xF, date & 0x1F, time >> 11, (time >> 5) & 0x3F, (time & 0x1F) * 2);
|
|
}
|
|
|
|
/// 취소 (풀기 · 묶기 도중)
|
|
class ArchiveCancelled implements Exception {
|
|
const ArchiveCancelled();
|
|
@override
|
|
String toString() => tr('취소했습니다');
|
|
}
|
|
|
|
// ───────── ZIP (archive 패키지, MIT) ─────────
|
|
|
|
/// ZIP 목록 - 가운데 목록 (central directory) 을 직접 읽어 비밀번호 · 링크를 안다
|
|
/// (archive 패키지의 항목에는 비밀번호 여부가 없고, 비밀번호 없이 읽으면 암호 글자가 그대로 나온다)
|
|
Future<List<ArchiveEntry>> 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,
|
|
modified: dosDateTime(h.lastModifiedFileDate, h.lastModifiedFileTime)),
|
|
];
|
|
} finally {
|
|
await input.close();
|
|
}
|
|
}
|
|
|
|
/// ZIP 풀기 - [plan] 의 파일만, 같은 이름은 [conflict] 대로
|
|
/// zip_aes: ZIP 비밀번호를 UTF-8 바이트로 (한 바이트 = 한 글자). archive 4.3.0 은 비밀번호를 password.codeUnits 를 바이트로 잘라 써서
|
|
/// 한글 등이 깨지고 7-Zip (UTF-8) 과 맞지 않았다 - UTF-8 바이트를 글자로 넣으면 archive 가 그 바이트를 그대로 쓴다
|
|
String zipPasswordBytes(String password) => String.fromCharCodes(utf8.encode(password));
|
|
|
|
/// ZIP 풀기 ([_extractZipPlanWith]). 비밀번호에 영문 밖 글자가 있으면 UTF-8 (7-Zip · 이 앱이 만든 것) 로 먼저,
|
|
/// 틀리면 예전 방식 (이 앱의 예전 판 · archive 그대로) 으로 한 번 더
|
|
Future<ExtractResult> extractZipPlan(String path, String dest, ExtractPlan plan,
|
|
{NameConflict conflict = NameConflict.rename,
|
|
bool skipNewer = false,
|
|
String? password,
|
|
void Function(int done, int total)? onProgress,
|
|
bool Function()? cancelled}) async {
|
|
final tries = [
|
|
if (password != null && password.isNotEmpty) zipPasswordBytes(password),
|
|
if (password != null && password.isNotEmpty && zipPasswordBytes(password) != password) password,
|
|
if (password == null || password.isEmpty) password,
|
|
];
|
|
for (var i = 0;; i++) {
|
|
try {
|
|
return await _extractZipPlanWith(path, dest, plan,
|
|
conflict: conflict, skipNewer: skipNewer, password: tries[i], onProgress: onProgress, cancelled: cancelled);
|
|
} on ArchivePasswordException catch (e) {
|
|
if (!e.wrong || i + 1 >= tries.length) rethrow;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<ExtractResult> _extractZipPlanWith(String path, String dest, ExtractPlan plan,
|
|
{NameConflict conflict = NameConflict.rename,
|
|
bool skipNewer = false,
|
|
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 modified = {for (final e in plan.files) e.name: e.modified};
|
|
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);
|
|
}
|
|
// 관리자 판단: 덮어쓰기에도 "받는 쪽이 더 새것이면 건너뛰기" (229)
|
|
if (conflict == NameConflict.overwrite && skipNewer && _targetNewer(target, modified[f.name.replaceAll('\\', '/')])) {
|
|
r.keptNewer.add(f.name);
|
|
continue;
|
|
}
|
|
}
|
|
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<String> sources, String zipPath,
|
|
{int level = 6, String? password, 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';
|
|
// zip_aes: [password] 가 있으면 archive 의 AES-256 (WinZip AE) - 파일마다 메모리에 올려 암호화하므로 큰 파일은 [createZipAes] 가 막는다
|
|
final enc = ZipFileEncoder(password: password == null || password.isEmpty ? null : zipPasswordBytes(password))..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 제한 - 풀기만) ─────────
|
|
|
|
/// zip_aes: 영문 밖 글자가 든 비밀번호로 (archive, 메모리에서) 암호화할 수 있는 파일 하나의 크기 한도
|
|
const aesInMemoryLimit = 512 * 1024 * 1024;
|
|
|
|
/// zip_aes: 영문 밖 글자가 든 비밀번호인데 [aesInMemoryLimit] 넘는 파일이 있음
|
|
class ZipPasswordTooBigException implements Exception {
|
|
final String name;
|
|
const ZipPasswordTooBigException(this.name);
|
|
@override
|
|
String toString() => trf('비밀번호에 한글 등 영문 밖 글자가 있으면 512MB 넘는 파일 ({0}) 은 묶을 수 없습니다. 영문 · 숫자 · 기호 비밀번호를 쓰거나 파일을 나눠 주세요.', [name]);
|
|
}
|
|
|
|
/// zip_aes (관리자 지시 10/17): 비밀번호 ZIP (AES-256) 을 동봉 7-Zip 으로 만든다 (Windows 7z.exe · Android lib7zz).
|
|
/// archive 패키지의 AES 는 파일마다 통째로 메모리에 올려 암호화해 큰 동영상에서 메모리가 모자랄 수 있어 7-Zip 으로 (흘려 가며).
|
|
/// 넣을 파일은 [createZip] 과 같게 앱이 고른다 (링크 · junction 빼고, 원본 폴더 기준 상대 이름). 비밀번호는 표준 입력으로만
|
|
/// (명령 줄 · 작업 기록 · 프로세스 목록에 남지 않게). 다 만든 뒤에만 이름을 바꾼다 (취소 · 실패면 만들다 만 파일을 지움).
|
|
/// 돌려주는 값: (넣은 파일 수, 건너뛴 링크 수)
|
|
Future<(int, int)> createZipAes(String exe, List<String> sources, String zipPath,
|
|
{required String password, int level = 6, void Function(double v)? onProgress, bool Function()? cancelled}) async {
|
|
if (password.isEmpty) throw ArgumentError('password');
|
|
// Windows 7-Zip 은 표준 입력으로 받은 비밀번호에 영문 밖 글자 (한글 등) 가 있으면 "매개 변수가 틀립니다" 로 실패한다
|
|
// (코드 페이지를 바꿔도 같음, 10/17 시험). 그때는 archive 의 AES-256 으로 - 파일을 통째로 메모리에 올리므로 큰 파일은 막고 알린다 (내 판단)
|
|
if (password.codeUnits.any((c) => c < 0x20 || c > 0x7e)) {
|
|
for (final s in sources) {
|
|
for (final f in [
|
|
if (FileSystemEntity.isFileSync(s)) File(s),
|
|
if (FileSystemEntity.isDirectorySync(s)) ...Directory(s).listSync(recursive: true, followLinks: false).whereType<File>(),
|
|
]) {
|
|
if (f.lengthSync() > aesInMemoryLimit) throw ZipPasswordTooBigException(p.basename(f.path));
|
|
}
|
|
}
|
|
return createZip(sources, zipPath,
|
|
level: level, password: password, cancelled: cancelled, onProgress: (d, t) => onProgress?.call(t == 0 ? 0 : d / t));
|
|
}
|
|
// 원본 폴더마다 (상대 이름이 그 폴더 기준이 되게) 넣을 파일 목록
|
|
final groups = <String, List<String>>{};
|
|
var links = 0, count = 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) {
|
|
groups.putIfAbsent(base, () => []).add(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) {
|
|
groups.putIfAbsent(base, () => []).add(p.relative(e.path, from: base));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (final l in groups.values) {
|
|
count += l.length;
|
|
}
|
|
final part = '$zipPath.jjpart';
|
|
final tmp = await Directory.systemTemp.createTemp('jj_7za_');
|
|
var done = false;
|
|
try {
|
|
var g = 0;
|
|
for (final MapEntry(key: base, value: rels) in groups.entries) {
|
|
if (cancelled?.call() ?? false) throw const ArchiveCancelled();
|
|
final list = File(p.join(tmp.path, 'list$g.txt'));
|
|
await list.writeAsString(rels.join('\n'), encoding: utf8);
|
|
final before = g;
|
|
final (code, out, err) = await _run7z(
|
|
exe,
|
|
['a', '-tzip', '-mem=AES256', '-mcu=on', '-mx=${level.clamp(0, 9)}', '-p', '-bsp1', '-bb0', '-y', part, '@${list.path}'],
|
|
password: password,
|
|
confirm: true,
|
|
workingDirectory: base,
|
|
cancelled: cancelled, onLine: (l) {
|
|
final m = RegExp(r'(\d+)%').firstMatch(l);
|
|
if (m != null) onProgress?.call((before + int.parse(m[1]!) / 100) / groups.length);
|
|
});
|
|
if (code != 0) throw ProcessException(exe, const ['a'], err.trim().isEmpty ? out.trim() : err.trim(), code);
|
|
g++;
|
|
}
|
|
if (count == 0) throw FileSystemException(tr('묶을 파일이 없습니다'), sources.first);
|
|
done = true;
|
|
await File(part).rename(zipPath);
|
|
} finally {
|
|
if (!done) {
|
|
try {
|
|
await File(part).delete();
|
|
} catch (_) {}
|
|
}
|
|
try {
|
|
await tmp.delete(recursive: true);
|
|
} catch (_) {}
|
|
}
|
|
return (count, links);
|
|
}
|
|
|
|
/// 7-Zip 의 "l -slt" 출력 → 목록. 링크: "Symbolic Link" · "Hard Link" 칸이 있거나 속성에 유닉스 링크 (-l…) · 재분석 지점
|
|
List<ArchiveEntry> parse7zList(String out) {
|
|
final entries = <ArchiveEntry>[];
|
|
for (final block in out.replaceAll('\r\n', '\n').split(RegExp(r'\n\s*\n'))) {
|
|
final m = <String, String>{};
|
|
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,
|
|
modified: DateTime.tryParse((m['Modified'] ?? '').replaceFirst(' ', 'T')),
|
|
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<String> args,
|
|
{String? password, void Function(String line)? onLine, bool Function()? cancelled, String? workingDirectory, bool confirm = false}) async {
|
|
final pr = await Process.start(exe, ['-sccUTF-8', '-scsUTF-8', ...args], workingDirectory: workingDirectory);
|
|
// -sccUTF-8 이면 7-Zip 은 표준 입력도 UTF-8 로 읽는다 - Dart 의 기본 (시스템 코드 페이지, 한국어 Windows 는 CP949) 으로 보내면
|
|
// 한글 비밀번호가 틀렸다고 나왔다 (관리자 지시로 10/18 시험: UTF-8 + -sccUTF-8 이면 7z · RAR 이 한글 비밀번호로 풀림)
|
|
pr.stdin.encoding = utf8;
|
|
// 묻지 않을 때도 빈 줄을 보내 기다리지 않게 (암호가 걸렸는데 비밀번호가 없으면 틀림으로 끝난다)
|
|
pr.stdin.writeln(password ?? '');
|
|
// 만들 때는 "다시 넣기" 도 묻는다 (zip_aes)
|
|
if (confirm) 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<List<ArchiveEntry>> 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<ExtractResult> extract7zPlan(String exe, String path, String dest, ExtractPlan plan,
|
|
{NameConflict conflict = NameConflict.rename,
|
|
bool skipNewer = false,
|
|
String? password,
|
|
void Function(double v)? onProgress,
|
|
bool Function()? cancelled}) async {
|
|
final r = ExtractResult();
|
|
// 관리자 판단: 덮어쓰기 (-aoa) 에도 받는 쪽이 더 새 파일은 목록에서 빼서 그대로 둔다 (229)
|
|
if (conflict == NameConflict.overwrite && skipNewer) {
|
|
final keep = [for (final e in plan.files) if (_targetNewer(extractTarget(dest, e.name), e.modified)) e];
|
|
r.keptNewer.addAll(keep.map((e) => e.name));
|
|
plan = ExtractPlan([for (final e in plan.files) if (!keep.contains(e)) e], plan.unsafe, plan.links, plan.totalSize, plan.encrypted, plan.badNames);
|
|
}
|
|
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 (_) {}
|
|
}
|
|
}
|