diff --git a/assets/l10n/en.json b/assets/l10n/en.json index c833ac7..ccccfd8 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -2668,6 +2668,7 @@ "지난 작업 기록": "Old job logs", "지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "Removed {0} leftover document-view temp folders", "지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개": "Removed {0} leftover vault view temp folders", + "지난번에 보관함에서 꺼내다 남은 임시 파일을 지움: {0}개": "Deleted temporary files left from a previous vault export: {0}", "지문 확인하고 연결": "Check fingerprint and connect", "지우고 맞추기": "Delete and sync", "지우고 초기화": "Delete and reset", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 6c32ed6..ff443b2 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -2668,6 +2668,7 @@ "지난 작업 기록": "過去の作業記録", "지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "前回残った文書表示の一時フォルダーを削除: {0} 個", "지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개": "前回残った保管庫表示の一時フォルダーを削除: {0} 個", + "지난번에 보관함에서 꺼내다 남은 임시 파일을 지움: {0}개": "前回保管庫から取り出す途中で残った一時ファイルを削除: {0} 個", "지문 확인하고 연결": "フィンガープリントを確認して接続", "지우고 맞추기": "削除して同期", "지우고 초기화": "削除して初期化", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index c207fa7..4addf7c 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -2668,6 +2668,7 @@ "지난 작업 기록": "旧任务记录", "지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "已删除上次留下的文档查看临时文件夹: {0} 个", "지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개": "已删除上次残留的保管库查看临时文件夹: {0} 个", + "지난번에 보관함에서 꺼내다 남은 임시 파일을 지움: {0}개": "已删除上次从保管库导出时残留的临时文件: {0} 个", "지문 확인하고 연결": "确认指纹并连接", "지우고 맞추기": "删除并同步", "지우고 초기화": "删除并重置", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 6e50744..a7016ff 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -33,7 +33,7 @@ import '../services/system_usage.dart'; import 'settings.dart'; import 'cleanup.dart' show removeUpdateLeftovers; import '../core/hwp_source.dart' show removeStaleHwpWork; -import '../core/vault_view.dart' show removeStaleVaultViews, vaultViewRootOf; +import '../core/vault_view.dart' show cleanVaultExportJournal, removeStaleVaultViews, vaultViewRootOf; import '../l10n/tr.dart'; import '../core/remote_idle.dart'; @@ -227,6 +227,11 @@ class AppController extends ChangeNotifier { final vault = removeStaleVaultViews((await vaultViewRootOf()).path); if (vault.isNotEmpty) _log(trf('지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개', [vault.length])); } catch (_) {} + // 관리자 지시 (10/21): 꺼내다 강제로 꺼져 받는 폴더에 남은 평문 "이름.jjvtmp" (기록에 있는 것만) + try { + final left = cleanVaultExportJournal((await vaultViewRootOf()).path); + if (left.isNotEmpty) _log(trf('지난번에 보관함에서 꺼내다 남은 임시 파일을 지움: {0}개', [left.length])); + } catch (_) {} }()); ffmpegVersion = await _tool.version(); if (ffmpegVersion == null) { diff --git a/lib/core/vault_store.dart b/lib/core/vault_store.dart index 41d3c41..598e9af 100644 --- a/lib/core/vault_store.dart +++ b/lib/core/vault_store.dart @@ -18,19 +18,25 @@ class VaultEntry { /// 2단계: 보관함 안 폴더 ("" = 맨 위, "가/나" 처럼 / 로 이음). 바깥 폴더에는 만들지 않는다 - 목록에만 final String folder; - const VaultEntry(this.blob, this.name, this.size, this.added, {this.folder = ''}); + + /// 형식 3 (28 지적 · 관리자 지시 10/21): 내용 파일 머리의 무작위 id (16진). 풀 때 머리 id 와 같아야 한다 - + /// 다른 항목의 내용 파일로 바꿔 끼우거나 같은 항목의 예전 내용 파일로 되돌린 것을 알아챈다. "" = 모름 (풀지 않음) + final String id; + const VaultEntry(this.blob, this.name, this.size, this.added, {this.folder = '', this.id = ''}); /// 보관함 안 경로 (폴더/이름) String get path => folder.isEmpty ? name : '$folder/$name'; - VaultEntry copyWith({String? name, String? folder}) => VaultEntry(blob, name ?? this.name, size, added, folder: folder ?? this.folder); + VaultEntry copyWith({String? name, String? folder, String? id}) => + VaultEntry(blob, name ?? this.name, size, added, folder: folder ?? this.folder, id: id ?? this.id); // 맨 위 파일은 'folder' 를 쓰지 않는다 (1단계 판이 읽는 목록과 같게) Map toJson() => - {'blob': blob, 'name': name, 'size': size, 'added': added.millisecondsSinceEpoch, if (folder.isNotEmpty) 'folder': folder}; + {'blob': blob, 'name': name, 'size': size, 'added': added.millisecondsSinceEpoch, if (folder.isNotEmpty) 'folder': folder, if (id.isNotEmpty) 'id': id}; factory VaultEntry.fromJson(Map j) => VaultEntry( '${j['blob']}', '${j['name']}', (j['size'] as num?)?.toInt() ?? 0, DateTime.fromMillisecondsSinceEpoch((j['added'] as num?)?.toInt() ?? 0), - folder: Vault.cleanFolder('${j['folder'] ?? ''}') ?? ''); + folder: Vault.cleanFolder('${j['folder'] ?? ''}') ?? '', + id: RegExp(r'^[0-9a-f]{32}$').hasMatch('${j['id'] ?? ''}') ? '${j['id']}' : ''); } /// X-plore 비교 5-2 암호 보관함 - 폴더 하나 (처음 자리: Android Download/JJ_MKVMaker/vault, Windows 문서\JJ_MKVMaker\vault). @@ -50,7 +56,8 @@ class Vault { Vault._(this.dir, this._key, this._entries, [Set? folders]) : _folders = folders ?? {}; /// 1 = 1단계, 2 = 2단계 (보관함 안 폴더 - 관리자 지시 10/21: 1단계만 아는 판은 "새 판으로" 를 알리게) - static const format = 2; + /// 3 = 목록 항목에 내용 파일 id (28 지적 · 관리자 지시 10/21, 1 · 2 는 열 때 목록에 id 를 채우고 올림) + static const format = 3; static const _json = 'vault.json', _index = 'index.jjv', _ext = '.jjv', _folderFile = 'folders.jjd'; List get entries => List.unmodifiable(_entries); @@ -113,10 +120,6 @@ class Vault { static Future _open(Directory dir, Uint8List key, Map header) async { final data = await File(p.join(dir.path, _index)).readAsBytes(); final list = jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, data))) as List; - // 1단계 보관함은 열쇠가 맞은 뒤 형식 번호만 올린다 (감싼 열쇠 · 솔트는 그대로) - if ((header['format'] as int) < format) { - await _atomicWrite(File(p.join(dir.path, _json)), utf8.encode(const JsonEncoder.withIndent(' ').convert({...header, 'format': format}))); - } final folders = {}; final ff = File(p.join(dir.path, _folderFile)); if (await ff.exists()) { @@ -126,6 +129,27 @@ class Vault { } } final v = Vault._(dir, key, [for (final x in list) VaultEntry.fromJson(x as Map)], folders); + // 예전 형식 (1 · 2) 은 열쇠가 맞은 뒤 목록에 내용 파일 id 를 채워 다시 쓰고 형식 번호를 올린다 (감싼 열쇠 · 솔트는 그대로). + // 아직 어느 판에도 나가지 않은 형식이라 지금 내용 파일을 그대로 믿는다 (관리자 지시 10/21) + if ((header['format'] as int) < format) { + for (var i = 0; i < v._entries.length; i++) { + final e = v._entries[i]; + if (e.id.isNotEmpty) continue; + try { + final raf = await File(p.join(dir.path, '${e.blob}$_ext')).open(); + try { + final (id, _) = VaultCrypto.parseHeader(await raf.read(VaultCrypto.headerLength)); + v._entries[i] = e.copyWith(id: _hex(id)); + } finally { + await raf.close(); + } + } catch (_) { + // 없거나 깨진 내용 파일은 id 를 모른 채 둔다 (풀 때 실패) + } + } + await v._saveIndex(); + await _atomicWrite(File(p.join(dir.path, _json)), utf8.encode(const JsonEncoder.withIndent(' ').convert({...header, 'format': format}))); + } await v.recover(); return v; } @@ -169,6 +193,13 @@ class Vault { } static String _join(String folder, String name) => folder.isEmpty ? name : '$folder/$name'; + + static String _hex(List b) => b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); + + /// 목록 항목과 내용 파일의 짝: 머리 id 가 목록의 id 와 같아야 한다 (모르면 풀지 않음) + static void _checkId(VaultEntry e, List headerId) { + if (e.id.isEmpty || e.id != _hex(headerId)) throw const VaultAuthException(); + } static bool _inside(String folder, String parent) => parent.isEmpty || folder == parent || folder.startsWith('$parent/'); /// 있는 폴더 모두 (만든 것 + 파일이 든 것 + 그 위 폴더) @@ -355,7 +386,7 @@ class Vault { } await w.close(); await tmp.rename(out.path); - final e = VaultEntry(blob, freeName(f, want), len, DateTime.now(), folder: f); + final e = VaultEntry(blob, freeName(f, want), len, DateTime.now(), folder: f, id: _hex(id)); _entries.add(e); try { await _saveIndex(); @@ -367,43 +398,67 @@ class Vault { return e; } - /// 풀어서 [outPath] 에 쓴다 (다 풀고 인증이 맞은 뒤에만 그 이름으로 - 바뀐 조각이 있으면 [VaultAuthException] 이고 파일을 남기지 않음) - Future export(VaultEntry e, String outPath) async { + /// 풀어서 [outPath] 에 쓴다 (다 풀고 인증이 맞은 뒤에만 그 이름으로 - 바뀐 조각 · 다른 항목의 내용 파일 · 크기가 다르면 + /// [VaultAuthException] 이고 파일을 남기지 않음). [onTemp] · [onTempDone]: 받는 폴더에 잠깐 쓰는 "이름.jjvtmp" (평문) 를 + /// 앱 전용 기록에 적고 지우게 (강제로 꺼져 남으면 다음에 켤 때 지움 - 관리자 지시 10/21) + Future export(VaultEntry e, String outPath, {void Function(String tmp)? onTemp, void Function(String tmp)? onTempDone}) async { final f = await File(p.join(dir.path, '${e.blob}$_ext')).open(); final tmp = File('$outPath.jjvtmp'); - final w = await tmp.open(mode: FileMode.write); + onTemp?.call(tmp.path); + RandomAccessFile w; + try { + w = await tmp.open(mode: FileMode.write); + } catch (_) { + await f.close(); + onTempDone?.call(tmp.path); + rethrow; + } try { final len = await f.length(); final h = await f.read(VaultCrypto.headerLength); final (id, chunk) = VaultCrypto.parseHeader(h); + _checkId(e, id); final key = VaultCrypto.fileKey(_key, id); final full = VaultCrypto.sealedChunkLength(chunk); var off = VaultCrypto.headerLength; var i = 0; + var written = 0; if (off >= len) throw const VaultAuthException(); while (off < len) { final sealed = await f.read(min(full, len - off)); off += sealed.length; - await w.writeFrom(VaultCrypto.openChunk(key, h, i, sealed, last: off >= len)); + final plain = VaultCrypto.openChunk(key, h, i, sealed, last: off >= len); + written += plain.length; + await w.writeFrom(plain); i++; } + if (written != e.size) throw const VaultAuthException(); await w.flush(); } catch (_) { await w.close(); await tmp.delete().catchError((_) => tmp); + onTempDone?.call(tmp.path); rethrow; } finally { await f.close(); } await w.close(); - await tmp.rename(outPath); + try { + await tmp.rename(outPath); + } finally { + onTempDone?.call(tmp.path); + } } /// 메모리로 푼다 (관리자 지시 10/21: 그림 보기는 파일로 풀지 않음). [maxBytes] 보다 크면 [StateError] Future readBytes(VaultEntry e, {int maxBytes = 256 * 1024 * 1024}) async { if (e.size > maxBytes) throw StateError('too big'); final data = await File(p.join(dir.path, '${e.blob}$_ext')).readAsBytes(); - return VaultCrypto.decryptAll(_key, data); + final (id, _) = VaultCrypto.parseHeader(Uint8List.sublistView(data, 0, min(data.length, VaultCrypto.headerLength))); + _checkId(e, id); + final out = VaultCrypto.decryptAll(_key, data); + if (out.length != e.size) throw const VaultAuthException(); + return out; } /// 보관함에서 지운다 (목록 먼저 - 그 뒤 내용 파일) diff --git a/lib/core/vault_view.dart b/lib/core/vault_view.dart index bf47d21..3c6d2ce 100644 --- a/lib/core/vault_view.dart +++ b/lib/core/vault_view.dart @@ -13,6 +13,55 @@ Future Function() vaultViewRootOf = () async { /// 푼 파일을 두는 폴더 이름 앞 (실행마다 새 폴더) const vaultViewPrefix = 'jj_vault_view_'; +/// 보관함 [꺼내기] 가 받는 폴더에 잠깐 쓰는 평문 "이름.jjvtmp" 기록 (관리자 지시 10/21): 쓰기 시작할 때 적고 끝나면 지운다. +/// 앱이 강제로 꺼져 남은 것은 다음에 켤 때 [cleanVaultExportJournal] 이 지운다 (기록에 있는 것만 - 사용자 폴더를 뒤지지 않음) +class VaultExportJournal { + final String root; + VaultExportJournal(this.root); + static const name = 'jj_vault_exporting.txt'; + File get _file => File(p.join(root, name)); + + List _read() { + try { + return _file.readAsLinesSync().where((x) => x.trim().isNotEmpty).toList(); + } catch (_) { + return []; + } + } + + void _write(List lines) { + try { + if (lines.isEmpty) { + if (_file.existsSync()) _file.deleteSync(); + } else { + Directory(root).createSync(recursive: true); + _file.writeAsStringSync('${lines.join('\n')}\n', flush: true); + } + } catch (_) {} + } + + void add(String tmp) => _write([..._read().where((x) => x != tmp), tmp]); + void remove(String tmp) => _write(_read().where((x) => x != tmp).toList()); +} + +/// 앱을 켤 때: 꺼내기 기록에 남은 ".jjvtmp" 를 지우고 기록도 지운다. 지운 경로 +List cleanVaultExportJournal(String root) { + final j = VaultExportJournal(root); + final removed = []; + for (final x in j._read()) { + if (!x.endsWith('.jjvtmp')) continue; // 기록이 깨졌어도 다른 파일은 지우지 않음 + try { + final f = File(x); + if (f.existsSync()) { + f.deleteSync(); + removed.add(x); + } + } catch (_) {} + } + j._write([]); + return removed; +} + /// 앱이 강제로 꺼져 남은 보기 폴더를 지운다 (앱을 켤 때 - 그때는 보관함이 열려 있지 않음). 지운 이름 List removeStaleVaultViews(String root) { final removed = []; diff --git a/lib/ui/vault_page.dart b/lib/ui/vault_page.dart index e3264ea..7305854 100644 --- a/lib/ui/vault_page.dart +++ b/lib/ui/vault_page.dart @@ -498,7 +498,8 @@ class _VaultPageState extends State with WidgetsBindingObserver { } setState(() => _busy = true); try { - await _vault!.export(e, out); + final j = VaultExportJournal((await vaultViewRootOf()).path); + await _vault!.export(e, out, onTemp: j.add, onTempDone: j.remove); _snack(trf('꺼냈습니다: {0}', [vDisplay(out)])); } catch (err) { _snack(err is VaultAuthException ? tr('이 파일은 바뀌었거나 깨져 풀 수 없습니다') : trf('꺼내지 못했습니다: {0}', [err])); @@ -660,12 +661,13 @@ class _VaultPageState extends State with WidgetsBindingObserver { await Directory(local(sub)).create(recursive: true); } final files = _vault!.filesUnder(f); + final journal = VaultExportJournal((await vaultViewRootOf()).path); for (var i = 0; i < files.length; i++) { final e = files[i]; if (_vault == null) break; if (mounted) setState(() => _progress = '${i + 1} / ${files.length} · ${e.name}'); try { - await _vault!.export(e, p.join(local(e.folder), e.name)); + await _vault!.export(e, p.join(local(e.folder), e.name), onTemp: journal.add, onTempDone: journal.remove); done++; } catch (_) { failed++; diff --git a/test/vault_test.dart b/test/vault_test.dart index 997d403..1f18941 100644 --- a/test/vault_test.dart +++ b/test/vault_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/master_lock.dart' show pbkdf2Sha256; import 'package:jj_mkvmaker/core/vault_crypto.dart'; import 'package:jj_mkvmaker/core/vault_store.dart'; +import 'package:jj_mkvmaker/core/vault_view.dart'; import 'package:path/path.dart' as p; /// X-plore 비교 5-2 암호 보관함 (관리자 결정 10/21): 알려진 시험 벡터 · 조각 변조 · 틀린 비밀번호 · 끊긴 쓰기 복구 @@ -195,7 +196,7 @@ void main() { expect(dir.listSync().length, 2, reason: 'vault.json · index.jjv 만'); await expectLater(Vault.create(dir, 'x', iterations: 1000), throwsA(isA())); final j = File(p.join(dir.path, 'vault.json')); - j.writeAsStringSync(j.readAsStringSync().replaceFirst('"format": 2', '"format": 3')); + j.writeAsStringSync(j.readAsStringSync().replaceFirst('"format": 3', '"format": 4')); await expectLater(Vault.unlock(dir, 'pw'), throwsA(isA())); }); @@ -262,16 +263,103 @@ void main() { File(p.join(dir.path, 'index.jjv')).writeAsBytesSync(VaultCrypto.encryptAll(key, Uint8List.fromList(utf8.encode(jsonEncode(bad))))); final again = await Vault.unlock(dir, 'pw'); expect(again.entries.last.folder, ''); - // 1단계 (형식 1) 보관함: 열리고, 열쇠가 맞은 뒤 형식 번호만 2 로 (1단계만 아는 판은 "새 판으로" 를 알림) + // 1단계 (형식 1) 보관함: 열리고, 열쇠가 맞은 뒤 형식 번호를 지금 것 (3) 으로 (예전 판은 "새 판으로" 를 알림) final j = File(p.join(dir.path, 'vault.json')); final before = jsonDecode(j.readAsStringSync()) as Map; - j.writeAsStringSync(j.readAsStringSync().replaceFirst('"format": 2', '"format": 1')); + j.writeAsStringSync(j.readAsStringSync().replaceFirst('"format": 3', '"format": 1')); await expectLater(Vault.unlock(dir, 'wrong'), throwsA(isA())); expect((jsonDecode(j.readAsStringSync()) as Map)['format'], 1, reason: '틀린 비밀번호면 바꾸지 않음'); await Vault.unlock(dir, 'pw'); final after = jsonDecode(j.readAsStringSync()) as Map; - expect(after['format'], 2); + expect(after['format'], 3); expect([after['salt'], after['key'], after['nonce']], [before['salt'], before['key'], before['nonce']]); }); + + // 28 지적 · 관리자 지시 (10/21): 목록 항목 ↔ 내용 파일의 짝 (id · 크기) + List leftovers(String outDir) => Directory(outDir).listSync().map((e) => p.basename(e.path)).toList(); + + test('형식 3: 다른 항목의 내용 파일로 덮기 · 같은 내용의 다른 내용 파일 (예전 것) 로 되돌리기 · 크기 다름 - 모두 VaultAuthException, 결과 · .jjvtmp 없음', () async { + final v = await Vault.create(dir, 'pw', iterations: 1000); + final a = await v.add(src('a.txt', 43).path, chunk: 1024); + final b = await v.add(src('b.bin', 2500).path, chunk: 1024); + expect(a.id, hasLength(32)); + final out = Directory(p.join(tmp.path, 'out'))..createSync(); + File blob(VaultEntry e) => File(p.join(dir.path, '${e.blob}.jjv')); + final bBytes = blob(b).readAsBytesSync(); + // ① B 의 내용 파일을 A 의 것으로 + blob(b).writeAsBytesSync(blob(a).readAsBytesSync()); + await expectLater(v.export(b, p.join(out.path, 'b.bin')), throwsA(isA())); + await expectLater(v.readBytes(b), throwsA(isA())); + expect(leftovers(out.path), isEmpty, reason: '결과 · .jjvtmp 를 남기지 않음'); + // ② 같은 원본을 다시 넣어 만든 내용 파일 (다른 id) 로 되돌리기 + final b2 = await v.add(src('b.bin', 2500).path, chunk: 1024); + blob(b).writeAsBytesSync(blob(b2).readAsBytesSync()); + await expectLater(v.export(b, p.join(out.path, 'b.bin')), throwsA(isA())); + expect(leftovers(out.path), isEmpty); + // 제 내용 파일이면 그대로 풀림 + blob(b).writeAsBytesSync(bBytes); + await v.export(b, p.join(out.path, 'b.bin')); + expect(File(p.join(out.path, 'b.bin')).readAsBytesSync(), src('b.bin', 2500).readAsBytesSync()); + File(p.join(out.path, 'b.bin')).deleteSync(); + // ③ 목록의 크기가 다름 (목록을 그대로 다시 암호화해 크기만 바꿈) + final key = v.keyForMasterCopy; + final idx = File(p.join(dir.path, 'index.jjv')); + final list = jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, idx.readAsBytesSync()))) as List; + for (final x in list) { + if ((x as Map)['blob'] == a.blob) x['size'] = 44; + } + idx.writeAsBytesSync(VaultCrypto.encryptAll(key, Uint8List.fromList(utf8.encode(jsonEncode(list))))); + final again = await Vault.unlock(dir, 'pw'); + final a2 = again.entries.firstWhere((e) => e.blob == a.blob); + await expectLater(again.export(a2, p.join(out.path, 'a.txt')), throwsA(isA())); + await expectLater(again.readBytes(a2), throwsA(isA())); + expect(leftovers(out.path), isEmpty); + }); + + test('형식 2 보관함 (목록에 id 없음) 은 비밀번호가 맞을 때 목록에 id 를 채우고 3 으로 · 그 뒤 풀림', () async { + final v = await Vault.create(dir, 'pw', iterations: 1000); + final a = await v.add(src('a.txt', 300).path, chunk: 1024); + final key = v.keyForMasterCopy; + final idx = File(p.join(dir.path, 'index.jjv')); + final list = jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, idx.readAsBytesSync()))) as List; + for (final x in list) { + (x as Map).remove('id'); + } + idx.writeAsBytesSync(VaultCrypto.encryptAll(key, Uint8List.fromList(utf8.encode(jsonEncode(list))))); + final j = File(p.join(dir.path, 'vault.json')); + j.writeAsStringSync(j.readAsStringSync().replaceFirst('"format": 3', '"format": 2')); + final again = await Vault.unlock(dir, 'pw'); + expect(again.entries.single.id, a.id); + expect((jsonDecode(j.readAsStringSync()) as Map)['format'], 3); + final out = p.join(tmp.path, 'a_out.txt'); + await again.export(again.entries.single, out); + expect(File(out).readAsBytesSync(), src('a.txt', 300).readAsBytesSync()); + }); + + test('꺼내기 기록: 쓰기 시작에 .jjvtmp 를 적고 끝나면 지움 · 강제로 꺼져 남은 것은 켤 때 기록에 있는 것만 지움', () async { + final v = await Vault.create(dir, 'pw', iterations: 1000); + final a = await v.add(src('a.txt', 3000).path, chunk: 1024); + final root = Directory(p.join(tmp.path, 'app'))..createSync(); + final journal = VaultExportJournal(root.path); + final seen = []; + await v.export(a, p.join(tmp.path, 'out.txt'), onTemp: (x) { + seen.add(x); + journal.add(x); + expect(File(p.join(root.path, VaultExportJournal.name)).readAsStringSync(), contains('out.txt.jjvtmp')); + }, onTempDone: journal.remove); + expect(seen.single, endsWith('out.txt.jjvtmp')); + expect(File(p.join(root.path, VaultExportJournal.name)).existsSync(), isFalse, reason: '끝나면 기록도 지움'); + // 강제로 꺼진 것처럼: 기록만 남고 .jjvtmp 가 받는 폴더에 남음 + 기록에 없는 사용자 파일 · 이상한 줄 + final left = File(p.join(tmp.path, 'big.mkv.jjvtmp'))..writeAsStringSync('plain'); + final user = File(p.join(tmp.path, 'mine.jjvtmp'))..writeAsStringSync('user'); + final other = File(p.join(tmp.path, 'notes.txt'))..writeAsStringSync('keep'); + journal.add(left.path); + journal.add(other.path); + expect(cleanVaultExportJournal(root.path), [left.path]); + expect(left.existsSync(), isFalse); + expect(user.existsSync(), isTrue, reason: '기록에 없는 것은 건드리지 않음'); + expect(other.existsSync(), isTrue, reason: '.jjvtmp 가 아니면 지우지 않음'); + expect(File(p.join(root.path, VaultExportJournal.name)).existsSync(), isFalse); + }); }); }