import 'dart:io'; import 'package:archive/archive_io.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/core/archive_ops.dart'; import 'package:jj_mkvmaker/core/file_ops.dart' show NameConflict; import 'package:path/path.dart' as p; /// 86: 압축 - ZIP 만들기 · 풀기 (archive), 7z · RAR 풀기 (7-Zip). 풀기 전 검사 (zip slip · 링크 · 비밀번호) void main() { late Directory tmp; setUp(() => tmp = Directory.systemTemp.createTempSync('jj_arch86_')); tearDown(() => tmp.deleteSync(recursive: true)); test('막는 이름: ../ · 절대 경로 · 드라이브 · UNC · ":" (괜찮은 것은 그대로)', () { for (final n in ['../evil.txt', 'a/../../x', '/etc/passwd', r'\x.txt', 'C:/Windows/x.dll', r'C:\x', r'\\nas\share\x', '//nas/x', 'a/b:stream']) { expect(unsafeArchivePath(n), isNotNull, reason: n); } for (final n in ['a.txt', 'dir/b.txt', r'dir\c.txt', './d.txt', 'a..b/c..txt']) { expect(unsafeArchivePath(n), isNull, reason: n); } }); test('풀기 전 검사: 막은 것 · 링크는 빼고, 크기 합 · 비밀번호 · 고른 이름만', () { final plan = planExtract([ const ArchiveEntry('ok/a.txt', size: 10), const ArchiveEntry('../evil.txt', size: 5), const ArchiveEntry('link', isLink: true), const ArchiveEntry('ok', isDir: true), const ArchiveEntry('ok/b.txt', size: 7, encrypted: true), const ArchiveEntry('other/c.txt', size: 100), ], names: ['ok', '../evil.txt', 'link']); expect(plan.files.map((e) => e.name), ['ok/a.txt', 'ok/b.txt']); expect(plan.unsafe.single.$1, '../evil.txt'); expect(plan.links, ['link']); expect(plan.totalSize, 17); expect(plan.encrypted, isTrue); }); test('86 보충: Windows 에서 쓸 수 없는 이름 (CON · ? · 끝 점) 은 미리 걸러 건너뛴다 (다른 것은 그대로)', () { final plan = planExtract([ const ArchiveEntry('ok/a.txt'), const ArchiveEntry('con.txt'), const ArchiveEntry('dir/what?.txt'), const ArchiveEntry('end./x.txt'), ], windowsRules: true); expect(plan.files.map((e) => e.name), ['ok/a.txt']); expect(plan.badNames.map((x) => x.$1), ['con.txt', 'dir/what?.txt', 'end./x.txt']); expect(planExtract([const ArchiveEntry('con.txt')], windowsRules: false).files, hasLength(1), reason: 'Android 는 지금처럼'); }); group('ZIP', () { test('만들기 (폴더 · 파일, 링크는 넣지 않음) → 목록 → 풀기 (같은 이름은 고른 대로)', () async { final src = Directory(p.join(tmp.path, 'src'))..createSync(); File(p.join(src.path, 'a.txt')).writeAsStringSync('A'); File(p.join(src.path, 'sub', 'b.txt')) ..createSync(recursive: true) ..writeAsStringSync('B'); final one = File(p.join(tmp.path, 'one.txt'))..writeAsStringSync('1'); var links = 0; if (Platform.isWindows) { final r = await Process.run('cmd', ['/c', 'mklink', '/J', p.join(src.path, 'jn'), tmp.path]); if (r.exitCode == 0) links = 1; } final zip = p.join(tmp.path, 'out.zip'); final (n, skipped) = await createZip([src.path, one.path], zip, level: 9); expect(n, 3); expect(skipped, links, reason: 'junction 은 따라가지 않는다 (바깥을 묶지 않게)'); expect(File('$zip.jjpart').existsSync(), isFalse); if (links > 0) Link(p.join(src.path, 'jn')).deleteSync(); final entries = await listZip(zip); expect(entries.where((e) => !e.isDir).map((e) => e.name).toSet(), {'src/a.txt', 'src/sub/b.txt', 'one.txt'}); final dest = Directory(p.join(tmp.path, 'dest'))..createSync(); File(p.join(dest.path, 'one.txt')).writeAsStringSync('old'); final plan = planExtract(entries); final r1 = await extractZipPlan(zip, dest.path, plan, conflict: NameConflict.skip); expect(r1.skippedExisting, ['one.txt']); expect(File(p.join(dest.path, 'one.txt')).readAsStringSync(), 'old'); expect(File(p.join(dest.path, 'src', 'sub', 'b.txt')).readAsStringSync(), 'B'); final r2 = await extractZipPlan(zip, dest.path, plan, conflict: NameConflict.rename); expect(r2.renamed, contains('one.txt')); expect(File(p.join(dest.path, 'one (2).txt')).readAsStringSync(), '1'); await extractZipPlan(zip, dest.path, plan, conflict: NameConflict.overwrite); expect(File(p.join(dest.path, 'one.txt')).readAsStringSync(), '1'); }); test('86 보충: 덮어쓰기에도 받는 쪽이 더 새 파일은 그대로 (229 와 같게)', () async { final a = Archive() ..addFile(ArchiveFile.string('old.txt', 'from zip')..lastModTime = DateTime(2020, 1, 1).millisecondsSinceEpoch ~/ 1000) ..addFile(ArchiveFile.string('new.txt', 'from zip')..lastModTime = DateTime(2020, 1, 1).millisecondsSinceEpoch ~/ 1000); final zip = p.join(tmp.path, 'n.zip'); File(zip).writeAsBytesSync(ZipEncoder().encode(a)); final dest = Directory(p.join(tmp.path, 'd'))..createSync(); final older = File(p.join(dest.path, 'old.txt'))..writeAsStringSync('mine'); older.setLastModifiedSync(DateTime(2019, 1, 1)); File(p.join(dest.path, 'new.txt')).writeAsStringSync('mine'); // 지금 = 더 새것 final plan = planExtract(await listZip(zip)); final r = await extractZipPlan(zip, dest.path, plan, conflict: NameConflict.overwrite, skipNewer: true); expect(r.keptNewer, ['new.txt']); expect(File(p.join(dest.path, 'new.txt')).readAsStringSync(), 'mine'); expect(File(p.join(dest.path, 'old.txt')).readAsStringSync(), 'from zip'); }); test('zip slip: ../ · 드라이브 경로 · 링크 항목은 대상 밖에 쓰지 않고 알린다', () async { final a = Archive() ..addFile(ArchiveFile.string('ok.txt', 'ok')) ..addFile(ArchiveFile.string('../evil.txt', 'evil')) ..addFile(ArchiveFile.string('C:/evil2.txt', 'evil')) // 유닉스 심볼릭 링크 항목 (파일 종류 0xA000 - 내용은 가리키는 곳) ..addFile(ArchiveFile.string('lnk', '../../outside')..mode = 0xA1FF); final zip = p.join(tmp.path, 'bad.zip'); File(zip).writeAsBytesSync(ZipEncoder().encode(a)); final plan = planExtract(await listZip(zip)); expect(plan.unsafe.map((x) => x.$1).toSet(), {'../evil.txt', 'C:/evil2.txt'}); expect(plan.links, ['lnk']); final dest = Directory(p.join(tmp.path, 'd', 'dest'))..createSync(recursive: true); final r = await extractZipPlan(zip, dest.path, plan); expect(r.done, 1); expect(File(p.join(tmp.path, 'd', 'evil.txt')).existsSync(), isFalse); expect(dest.listSync().map((e) => p.basename(e.path)), ['ok.txt']); }); test('비밀번호: 걸린 ZIP 은 비밀번호 없이는 쓰지 않고 묻는다 · 틀리면 틀림 · 맞으면 풀림', () async { final src = File(p.join(tmp.path, 's.txt'))..writeAsStringSync('secret text'); final zip = p.join(tmp.path, 'enc.zip'); final enc = ZipFileEncoder(password: 'pw1')..create(zip); await enc.addFile(src, 's.txt'); await enc.close(); final plan = planExtract(await listZip(zip)); expect(plan.encrypted, isTrue); final dest = p.join(tmp.path, 'out'); await expectLater(extractZipPlan(zip, dest, plan), throwsA(isA().having((e) => e.wrong, 'wrong', isFalse))); expect(Directory(dest).existsSync(), isFalse, reason: '암호 글자를 그대로 쓰지 않음'); await expectLater(extractZipPlan(zip, dest, plan, password: 'nope'), throwsA(isA())); await extractZipPlan(zip, dest, plan, password: 'pw1'); expect(File(p.join(dest, 's.txt')).readAsStringSync(), 'secret text'); }); test('만들기 취소: 만들다 만 ZIP 이 남지 않는다', () async { for (var i = 0; i < 5; i++) { File(p.join(tmp.path, 'f$i.txt')).writeAsStringSync('$i'); } final zip = p.join(tmp.path, 'c.zip'); var n = 0; await expectLater( createZip([for (var i = 0; i < 5; i++) p.join(tmp.path, 'f$i.txt')], zip, cancelled: () => ++n > 2), throwsA(isA())); expect(File(zip).existsSync(), isFalse); expect(File('$zip.jjpart').existsSync(), isFalse); }); }); group('7z (7-Zip 실행 파일)', () { final exe = p.join(Directory.current.path, 'third_party', '7zip', 'windows', '7z.exe'); final has = Platform.isWindows && File(exe).existsSync(); test('목록 · 풀기 · 비밀번호는 표준 입력으로 (머리까지 암호) · 틀린 비밀번호', () async { final src = Directory(p.join(tmp.path, 'src'))..createSync(); File(p.join(src.path, 'a.txt')).writeAsStringSync('A'); File(p.join(src.path, 'sub', 'b.txt')) ..createSync(recursive: true) ..writeAsStringSync('B'); final plain = p.join(tmp.path, 'p.7z'), enc = p.join(tmp.path, 'e.7z'); // (시험 파일 만들기에만 명령 줄 비밀번호) expect((await Process.run(exe, ['a', '-t7z', plain, src.path])).exitCode, 0); expect((await Process.run(exe, ['a', '-t7z', '-ppw2', '-mhe=on', enc, src.path])).exitCode, 0); final list = await list7z(exe, plain); expect(list.where((e) => !e.isDir).map((e) => e.name).toSet(), {'src/a.txt', 'src/sub/b.txt'}); final dest = p.join(tmp.path, 'out'); final r = await extract7zPlan(exe, plain, dest, planExtract(list)); expect(r.done, 2); expect(File(p.join(dest, 'src', 'sub', 'b.txt')).readAsStringSync(), 'B'); // 같은 이름 - 건너뛰기 · 이름 바꾸기 final again = await extract7zPlan(exe, plain, dest, planExtract(list), conflict: NameConflict.skip); expect(again.skippedExisting, hasLength(2)); await extract7zPlan(exe, plain, dest, planExtract(list), conflict: NameConflict.rename); expect(File(p.join(dest, 'src', 'a_1.txt')).existsSync() || File(p.join(dest, 'src', 'a (2).txt')).existsSync() || Directory(p.join(dest, 'src')).listSync().length > 3, isTrue); // 86 보충: 덮어쓰기에도 받는 쪽이 더 새 파일은 그대로 (7z) final mine = File(p.join(dest, 'src', 'a.txt'))..writeAsStringSync('mine'); mine.setLastModifiedSync(DateTime.now().add(const Duration(days: 1))); final keep = await extract7zPlan(exe, plain, dest, planExtract(list), conflict: NameConflict.overwrite, skipNewer: true); expect(keep.keptNewer, ['src/a.txt']); expect(mine.readAsStringSync(), 'mine'); await expectLater(list7z(exe, enc), throwsA(isA())); await expectLater(list7z(exe, enc, password: 'nope'), throwsA(isA().having((e) => e.wrong, 'wrong', isTrue))); final encList = await list7z(exe, enc, password: 'pw2'); final out2 = p.join(tmp.path, 'out2'); await extract7zPlan(exe, enc, out2, planExtract(encList), password: 'pw2'); expect(File(p.join(out2, 'src', 'a.txt')).readAsStringSync(), 'A'); }, skip: has ? false : '7-Zip 이 없음 (tool/fetch_7zip.ps1)'); test('7-Zip 목록 글 읽기: 링크 (유닉스 · 재분석 지점) · 폴더 · 비밀번호', () { final l = parse7zList('Path = a\\b.txt\nSize = 3\nAttributes = A\nEncrypted = +\n\n' 'Path = d\nSize = 0\nAttributes = D\n\n' 'Path = lnk\nSize = 9\nAttributes = A_ -lrwxrwxrwx\n\n' 'Path = j\nAttributes = D REPARSE\n'); expect(l.map((e) => e.name), ['a/b.txt', 'd', 'lnk', 'j']); expect(l[0].raw, 'a\\b.txt'); expect(l[0].encrypted, isTrue); expect(l[1].isDir, isTrue); expect([l[2].isLink, l[3].isLink], [true, true]); }); }); }