rc2: 보관함 형식 3 - 목록 항목 ↔ 내용 파일 짝 (id · 크기) · 꺼내기 .jjvtmp 기록 (28 지적, 관리자 지시 10/21, 데이터)

- 틈: 내용 파일 (blob) 열쇠를 그 파일 머리의 id 로 만들고 목록 항목에는 id 가 없어, B 의 내용 파일을 A 의 것으로 덮거나 같은 항목의
  예전 내용 파일로 되돌려도 오류 없이 다른 내용을 B 이름으로 꺼냈다
- 고침: 목록 항목에 내용 파일 id (16B, 16진) - 꺼내기 · 메모리로 풀기 (그림 보기) 에서 머리 id 가 목록과 다르거나 (모르면) 풀린 크기가
  목록 크기와 다르면 VaultAuthException, 결과 · .jjvtmp 는 남기지 않음. 머리 (id 포함) 는 이미 조각마다 AAD 라 내용 쪽은 id 에 묶여 있음
- 형식 번호 3: 1 · 2 는 비밀번호 (열쇠) 가 맞은 뒤 목록에 지금 내용 파일의 id 를 채워 다시 쓰고 3 으로 (아직 어느 판에도 나가지 않아 지금 것을 믿음)
- 꺼내기의 "이름.jjvtmp" (받는 폴더의 평문): 쓰기 시작할 때 앱 전용 폴더의 기록 (jj_vault_exporting.txt) 에 적고 끝나면 (성공 · 실패) 지움.
  앱을 켤 때 기록에 남은 .jjvtmp 만 지움 (사용자 폴더를 뒤지지 않음, .jjvtmp 가 아닌 줄은 지우지 않음)
- 시험: 다른 항목 내용 파일로 덮기 · 같은 원본의 다른 내용 파일로 되돌리기 · 목록 크기 다름 → 셋 다 실패 · 남은 파일 0,
  형식 2 → 3 (id 채움 뒤 꺼내기 됨), 꺼내기 기록 (끝나면 지움 · 끊긴 뒤 켤 때 기록의 .jjvtmp 만 지움). vault 22개 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rc2
RyuJongJin 2026-10-21 19:00:02 +09:00
parent 74e5e54be9
commit 0f5ed3517e
8 changed files with 225 additions and 23 deletions

View File

@ -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",

View File

@ -2668,6 +2668,7 @@
"지난 작업 기록": "過去の作業記録",
"지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "前回残った文書表示の一時フォルダーを削除: {0} 個",
"지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개": "前回残った保管庫表示の一時フォルダーを削除: {0} 個",
"지난번에 보관함에서 꺼내다 남은 임시 파일을 지움: {0}개": "前回保管庫から取り出す途中で残った一時ファイルを削除: {0} 個",
"지문 확인하고 연결": "フィンガープリントを確認して接続",
"지우고 맞추기": "削除して同期",
"지우고 초기화": "削除して初期化",

View File

@ -2668,6 +2668,7 @@
"지난 작업 기록": "旧任务记录",
"지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "已删除上次留下的文档查看临时文件夹: {0} 个",
"지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개": "已删除上次残留的保管库查看临时文件夹: {0} 个",
"지난번에 보관함에서 꺼내다 남은 임시 파일을 지움: {0}개": "已删除上次从保管库导出时残留的临时文件: {0} 个",
"지문 확인하고 연결": "确认指纹并连接",
"지우고 맞추기": "删除并同步",
"지우고 초기화": "删除并重置",

View File

@ -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) {

View File

@ -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<String, Object> 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<String, dynamic> 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<String>? 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<VaultEntry> get entries => List.unmodifiable(_entries);
@ -113,10 +120,6 @@ class Vault {
static Future<Vault> _open(Directory dir, Uint8List key, Map<String, dynamic> 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 = <String>{};
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<String, dynamic>)], 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<int> b) => b.map((x) => x.toRadixString(16).padLeft(2, '0')).join();
/// 목록 항목과 내용 파일의 짝: 머리 id 가 목록의 id 와 같아야 한다 (모르면 풀지 않음)
static void _checkId(VaultEntry e, List<int> 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<void> export(VaultEntry e, String outPath) async {
/// 풀어서 [outPath] 에 쓴다 (다 풀고 인증이 맞은 뒤에만 그 이름으로 - 바뀐 조각 · 다른 항목의 내용 파일 · 크기가 다르면
/// [VaultAuthException] 이고 파일을 남기지 않음). [onTemp] · [onTempDone]: 받는 폴더에 잠깐 쓰는 "이름.jjvtmp" (평문) 를
/// 앱 전용 기록에 적고 지우게 (강제로 꺼져 남으면 다음에 켤 때 지움 - 관리자 지시 10/21)
Future<void> 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<Uint8List> 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;
}
/// 보관함에서 지운다 (목록 먼저 - 그 뒤 내용 파일)

View File

@ -13,6 +13,55 @@ Future<Directory> 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<String> _read() {
try {
return _file.readAsLinesSync().where((x) => x.trim().isNotEmpty).toList();
} catch (_) {
return [];
}
}
void _write(List<String> 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<String> cleanVaultExportJournal(String root) {
final j = VaultExportJournal(root);
final removed = <String>[];
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<String> removeStaleVaultViews(String root) {
final removed = <String>[];

View File

@ -498,7 +498,8 @@ class _VaultPageState extends State<VaultPage> 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<VaultPage> 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++;

View File

@ -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<StateError>()));
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<VaultFormatException>()));
});
@ -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<VaultAuthException>()));
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<String> 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<VaultAuthException>()));
await expectLater(v.readBytes(b), throwsA(isA<VaultAuthException>()));
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<VaultAuthException>()));
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<VaultAuthException>()));
await expectLater(again.readBytes(a2), throwsA(isA<VaultAuthException>()));
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 = <String>[];
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);
});
});
}