JJ_MKVMaker/test/archive_ops_test.dart

278 lines
17 KiB
Dart

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<ArchivePasswordException>().having((e) => e.wrong, 'wrong', isFalse)));
expect(Directory(dest).existsSync(), isFalse, reason: '암호 글자를 그대로 쓰지 않음');
await expectLater(extractZipPlan(zip, dest, plan, password: 'nope'), throwsA(isA<ArchivePasswordException>()));
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<ArchiveCancelled>()));
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<ArchivePasswordException>()));
await expectLater(list7z(exe, enc, password: 'nope'), throwsA(isA<ArchivePasswordException>().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)');
for (final (label, pw) in [('영문 비밀번호 → 7-Zip', 'pw 123!'), ('한글 비밀번호 → archive (7-Zip 은 표준 입력의 한글을 못 받음)', '비번 123')]) {
test('zip_aes: 비밀번호 ZIP (AES-256) $label - 앱의 ZIP 풀기 (archive) · 7-Zip 둘 다 같은 비밀번호로 풀림', () async {
final src = Directory(p.join(tmp.path, '원본'))..createSync();
File(p.join(src.path, '가.txt')).writeAsStringSync('하나');
File(p.join(src.path, '속', '나.bin'))
..createSync(recursive: true)
..writeAsBytesSync(List.generate(300000, (i) => i % 251));
final other = File(p.join(tmp.path, '따로.txt'))..writeAsStringSync('둘');
var linked = 0;
try {
Link(p.join(src.path, '밖')).createSync(tmp.path);
linked = 1;
} catch (_) {}
final zip = p.join(tmp.path, 'out.zip');
final (n, links) = await createZipAes(exe, [src.path, other.path], zip, password: pw);
expect(n, 3);
expect(links, linked, reason: '링크 · junction 은 넣지 않음');
expect(File('$zip.jjpart').existsSync(), isFalse);
// 7-Zip 이 보기에 AES-256 으로 암호화됨 (목록은 비밀번호 없이 보임 - ZIP 형식의 한계, 창에 안내)
final raw = await Process.run(exe, ['l', '-slt', zip]);
expect('${raw.stdout}', contains('AES-256'));
expect('${raw.stdout}', isNot(contains('Encrypted = -')));
// 앱의 ZIP 풀기 (archive 패키지) 로: 비밀번호 없으면 묻고, 틀리면 틀렸다고, 맞으면 풀림
final entries = await listZip(zip);
expect(entries.where((e) => !e.isDir).map((e) => e.name.replaceAll('\\', '/')).toSet(), {'원본/가.txt', '원본/속/나.bin', '따로.txt'},
reason: '고른 것 기준 상대 이름');
final plan = planExtract(entries);
expect(plan.encrypted, isTrue);
final out = Directory(p.join(tmp.path, '풀림'))..createSync();
await expectLater(extractZipPlan(zip, out.path, plan), throwsA(isA<ArchivePasswordException>()));
await expectLater(extractZipPlan(zip, out.path, plan, password: '틀림'), throwsA(isA<ArchivePasswordException>()));
await extractZipPlan(zip, out.path, plan, password: pw);
expect(File(p.join(out.path, '원본', '가.txt')).readAsStringSync(), '하나');
expect(File(p.join(out.path, '원본', '속', '나.bin')).readAsBytesSync(), List.generate(300000, (i) => i % 251));
expect(File(p.join(out.path, '따로.txt')).readAsStringSync(), '둘');
// 7-Zip 으로도 (시험에서만 명령 줄 -p - 앱은 비밀번호를 명령 줄에 넣지 않는다).
// 한글 비밀번호는 7-Zip 의 ZIP 이 받지 않는다 (만들기도 풀기도 "매개 변수가 틀립니다", 10/17 확인) - 이 앱에서만 풀림 (창에 안내)
if (pw.codeUnits.any((c) => c > 0x7e)) return;
final out2 = p.join(tmp.path, '풀림2');
final x = await Process.run(exe, ['x', '-scsUTF-8', '-p$pw', '-o$out2', '-y', zip]);
expect(x.exitCode, 0, reason: '${x.stdout}${x.stderr}');
expect(File(p.join(out2, '원본', '가.txt')).readAsStringSync(), '하나');
}, skip: has ? false : '7-Zip 이 없음 (tool/fetch_7zip.ps1)');
}
test('zip_aes: 한글 비밀번호 7z 풀기 - 표준 입력을 UTF-8 로 (머리까지 암호 · 목록 · 풀기 · 틀린 비밀번호)', () async {
File(p.join(tmp.path, 'a.txt')).writeAsStringSync('안');
final arc = p.join(tmp.path, 'k.7z');
// 시험 압축만 명령 줄 -p 로 만든다 (앱은 비밀번호를 명령 줄에 넣지 않는다)
final mk = await Process.run(exe, ['a', '-t7z', '-mhe=on', '-p비번 123', arc, p.join(tmp.path, 'a.txt')]);
expect(mk.exitCode, 0, reason: '${mk.stdout}');
await expectLater(list7z(exe, arc, password: '틀림'), throwsA(isA<ArchivePasswordException>()));
final list = await list7z(exe, arc, password: '비번 123');
expect(list.map((e) => e.name), ['a.txt']);
final out = Directory(p.join(tmp.path, '풀림'))..createSync();
await extract7zPlan(exe, arc, out.path, planExtract(list), password: '비번 123');
expect(File(p.join(out.path, 'a.txt')).readAsStringSync(), '안');
}, skip: has ? false : '7-Zip 이 없음 (tool/fetch_7zip.ps1)');
test('zip_aes: 취소 · 실패면 만들다 만 ZIP 을 남기지 않고, 빈 비밀번호는 받지 않음', () async {
final f = File(p.join(tmp.path, 'a.txt'))..writeAsStringSync('A');
final zip = p.join(tmp.path, 'c.zip');
await expectLater(createZipAes(exe, [f.path], zip, password: ''), throwsArgumentError);
await expectLater(createZipAes(exe, [f.path], zip, password: 'x', cancelled: () => true), throwsA(isA<ArchiveCancelled>()));
expect(File(zip).existsSync() || File('$zip.jjpart').existsSync(), isFalse);
await expectLater(createZipAes(exe, [p.join(tmp.path, '없음')], zip, password: 'x'), throwsA(isA<FileSystemException>()));
expect(File(zip).existsSync() || File('$zip.jjpart').existsSync(), isFalse);
}, 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]);
});
});
}