From 16f33b3b71525872d26fe5ff768e5189f1a7c3f2 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:09:10 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20zip=5Faes:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20ZIP=20(AES-256)=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=ED=95=9C=EA=B8=80=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20ZIP=20=ED=92=80=EA=B8=B0=20=EA=B3=A0=EC=B9=A8=20(?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/17, k): 86 [ZIP 으로 묶기] 창에 "비밀번호" 칸. 비밀번호는 기억 · 기록하지 않음. - 창: [비밀번호 (선택)] (보기 · 숨기기) → 넣으면 [비밀번호 다시 넣기], 같아야 [암호로 묶기]. 잊으면 못 푼다 · 이름 목록은 암호화 안 됨 안내 - 영문 · 숫자 · 기호 비밀번호: 동봉 7-Zip (Windows 7z.exe · Android lib7zz) 로 -tzip -mem=AES256 -mcu=on, 비밀번호는 표준 입력으로만. 흘려 가며 암호화해 큰 동영상도 메모리에 다 올리지 않음. 넣을 파일은 앱이 고름 (링크 · junction 빼고, 고른 것 기준 상대 이름) - 한글 등 영문 밖 글자 비밀번호: Windows 7-Zip 은 받지 않는다 (표준 입력 · 명령 줄 모두 "매개 변수가 틀립니다", 10/17 시험) → archive 의 AES-256 (WinZip AE) 으로. 파일을 통째로 메모리에 올리므로 512MB 넘는 파일이 있으면 미리 알리고 묶지 않음 (내 판단). 창에 "이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있음" 안내 (막지는 않음) - 버그 고침: archive 4.3.0 은 비밀번호를 password.codeUnits 를 바이트로 잘라 써서 한글이 깨졌다 (다른 프로그램과 맞지 않음). UTF-8 바이트로 넘기고 (zipPasswordBytes), 풀 때는 UTF-8 로 먼저 · 틀리면 예전 방식으로 한 번 더 - -mcu=on: 7-Zip 이 만든 ZIP 의 한글 이름을 UTF-8 로 (안 하면 앱 · Android 에서 이름이 깨졌다 - 시험에서 찾음) 따로 알릴 것 (이 커밋에서 고치지 않음): 86 의 7z · RAR 풀기도 비밀번호에 한글이 있으면 Windows 7-Zip 이 표준 입력을 받지 않아 실패할 것 (관리자 판단 필요). 시험: archive_ops (영문 비밀번호 → 7-Zip, 한글 비밀번호 → archive: 앱 풀기 · 틀린 비밀번호 · 7-Zip 풀기, 취소 · 실패 · 빈 비밀번호), archive_ui (두 번 같아야 누름 · 한글 안내 · [취소] 는 아무 일 없음), licenses - 25 통과, analyze 문제 없음, l10n 빠짐 0. 위젯 시험 안에서는 7-Zip 이 가짜 시간 때문에 끝나지 않아 창 동작만 본다 (만들기 · 풀기는 단위 시험). 7z 의 표준 입력 비밀번호는 실제 7z.exe 로 확인. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 12 ++++ assets/l10n/ja.json | 12 ++++ assets/l10n/zh-Hans.json | 12 ++++ lib/core/archive_ops.dart | 133 +++++++++++++++++++++++++++++++++++-- lib/ui/archive_ui.dart | 67 ++++++++++++++++++- test/archive_ops_test.dart | 55 +++++++++++++++ test/archive_ui_test.dart | 35 ++++++++++ 7 files changed, 319 insertions(+), 7 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index e92ab26..8bb35f1 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -797,6 +797,7 @@ "내장 플레이어로 재생": "Play in built-in player", "넘기는 방향: 오른쪽 → 왼쪽 (만화)": "Page direction: right → left (manga)", "넘기는 방향: 왼쪽 → 오른쪽": "Page direction: left → right", + "넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "If set, encrypts with AES-256. Leave empty for a normal ZIP", "넣지 못했습니다: {0}": "Could not add: {0}", "네덜란드어": "Dutch", "네트워크 · DNS": "Network · DNS", @@ -978,6 +979,7 @@ "되살리고 다시 시작": "Restore and restart", "되살리기": "Restore", "두 번 넣은 비밀번호가 다릅니다": "The two passwords don't match", + "두 비밀번호가 다릅니다": "The passwords don't match", "두 쪽 나눔 끄기 (한 장으로 보기)": "Turn off two-page split (show as one)", "두 쪽 나눠 보기": "Split two-page spreads", "두 쪽 나눠 보기: 끄기 (누르면 자동)": "Split two-page spreads: off (tap for auto)", @@ -1111,6 +1113,7 @@ "무엇이 바뀌는지 비교하는 중…": "Comparing what will change…", "무한": "Unlimited", "묶기": "Compress", + "묶을 파일이 없습니다": "There are no files to add", "문서 미리보기": "Document preview", "문서 미리보기 컴포넌트를 먼저 설치하세요": "Install the document preview component first", "문자셋 경고": "Charset warning", @@ -1291,9 +1294,13 @@ "비교하지 못했습니다: {0}": "Couldn't compare: {0}", "비밀번호": "Password", "비밀번호 (선택)": "Password (optional)", + "비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.": "This version has no 7-Zip to make password ZIPs. Make the ZIP without a password or reinstall the app.", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "Passwords · API keys (WebDAV · OpenSubtitles · AI services)", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "Passwords and the subtitle API key aren't backed up, so please enter them again. Auto Backup runs about once a day, so changes made after it may be missing.", "비밀번호 기억 (안전 저장소)": "Remember passwords (secure storage)", + "비밀번호 다시 넣기": "Enter password again", + "비밀번호 보기": "Show password", + "비밀번호 숨기기": "Hide password", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)", "비밀번호가 저장된 기능을 쓸 때": "When using features with saved passwords", "비밀번호가 틀렸습니다": "Wrong password", @@ -1302,6 +1309,8 @@ "비밀번호를 넣으세요": "Enter a password", "비밀번호를 안전 저장소에 저장했습니다": "Password saved to secure storage", "비밀번호를 잊었습니다": "I forgot the password", + "비밀번호를 잊으면 이 ZIP 을 풀 수 없습니다 (앱이 기억하지 않습니다). 파일 이름 목록은 암호화되지 않습니다.": "If you forget the password, this ZIP can't be extracted (the app doesn't remember it). The list of file names is not encrypted.", + "비밀번호에 한글 등 영문 밖 글자가 있으면 512MB 넘는 파일 ({0}) 은 묶을 수 없습니다. 영문 · 숫자 · 기호 비밀번호를 쓰거나 파일을 나눠 주세요.": "With a password containing non-English letters (e.g. Korean), files over 512MB ({0}) can't be added. Use a password of English letters, digits and symbols, or split the file.", "비어 있는 압축 파일입니다.": "The archive is empty.", "비어 있는 폴더": "Empty folder", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.", @@ -1521,6 +1530,8 @@ "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)", + "암호 ZIP 으로 묶는 중 · {0}": "Making password ZIP · {0}", + "암호로 묶기": "Make password ZIP", "암호화 안 됨 (http)": "Not encrypted (http)", "압축 옆 새 폴더 \"{0}\"": "New folder \"{0}\" next to the archive", "압축 파일 안에 없습니다": "Not in the archive", @@ -2449,6 +2460,7 @@ "한 창": "One pane", "한국어": "Korean", "한글": "Hangul", + "한글 등 영문 밖 글자가 든 비밀번호는 이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다. 다른 기기 · 프로그램과 나눌 ZIP 은 영문 · 숫자 · 기호 비밀번호를 쓰세요.": "A password with non-English letters (e.g. Korean) works in this app, but other programs such as 7-Zip may not open it. For ZIPs you share with other devices or programs, use English letters, digits and symbols.", "할 일": "Task", "합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} merged folders can't be undone (what was moved isn't known).", "항목 {0}/{1}": "Items {0}/{1}", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index ed9d71b..2434709 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -797,6 +797,7 @@ "내장 플레이어로 재생": "内蔵プレーヤーで再生", "넘기는 방향: 오른쪽 → 왼쪽 (만화)": "めくる方向: 右 → 左 (マンガ)", "넘기는 방향: 왼쪽 → 오른쪽": "めくる方向: 左 → 右", + "넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "入力すると AES-256 で暗号化します。空欄なら通常の ZIP", "넣지 못했습니다: {0}": "追加できませんでした: {0}", "네덜란드어": "オランダ語", "네트워크 · DNS": "ネットワーク・DNS", @@ -978,6 +979,7 @@ "되살리고 다시 시작": "復元して再起動", "되살리기": "元に戻す", "두 번 넣은 비밀번호가 다릅니다": "2 回入力したパスワードが違います", + "두 비밀번호가 다릅니다": "2 つのパスワードが一致しません", "두 쪽 나눔 끄기 (한 장으로 보기)": "見開き分割をオフ (1 枚で表示)", "두 쪽 나눠 보기": "見開きを分けて表示", "두 쪽 나눠 보기: 끄기 (누르면 자동)": "見開きを分けて表示: オフ (押すと自動)", @@ -1111,6 +1113,7 @@ "무엇이 바뀌는지 비교하는 중…": "何が変わるか比較中…", "무한": "無制限", "묶기": "まとめる", + "묶을 파일이 없습니다": "まとめるファイルがありません", "문서 미리보기": "文書プレビュー", "문서 미리보기 컴포넌트를 먼저 설치하세요": "先にドキュメントプレビューのコンポーネントをインストールしてください", "문자셋 경고": "文字コードの警告", @@ -1291,9 +1294,13 @@ "비교하지 못했습니다: {0}": "比較できませんでした: {0}", "비밀번호": "パスワード", "비밀번호 (선택)": "パスワード(任意)", + "비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.": "このバージョンにはパスワード付き ZIP を作る 7-Zip がありません。パスワードなしでまとめるか、アプリを再インストールしてください。", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。", "비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)", + "비밀번호 다시 넣기": "パスワードをもう一度入力", + "비밀번호 보기": "パスワードを表示", + "비밀번호 숨기기": "パスワードを隠す", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)", "비밀번호가 저장된 기능을 쓸 때": "パスワードを保存した機能を使うとき", "비밀번호가 틀렸습니다": "パスワードが違います", @@ -1302,6 +1309,8 @@ "비밀번호를 넣으세요": "パスワードを入力してください", "비밀번호를 안전 저장소에 저장했습니다": "パスワードを安全な保存領域に保存しました", "비밀번호를 잊었습니다": "パスワードを忘れました", + "비밀번호를 잊으면 이 ZIP 을 풀 수 없습니다 (앱이 기억하지 않습니다). 파일 이름 목록은 암호화되지 않습니다.": "パスワードを忘れるとこの ZIP は展開できません (アプリは記憶しません)。ファイル名の一覧は暗号化されません。", + "비밀번호에 한글 등 영문 밖 글자가 있으면 512MB 넘는 파일 ({0}) 은 묶을 수 없습니다. 영문 · 숫자 · 기호 비밀번호를 쓰거나 파일을 나눠 주세요.": "パスワードに英字以外の文字 (ハングルなど) があると、512MB を超えるファイル ({0}) はまとめられません。英数字・記号のパスワードを使うか、ファイルを分けてください。", "비어 있는 압축 파일입니다.": "空の圧縮ファイルです。", "비어 있는 폴더": "空のフォルダー", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。", @@ -1521,6 +1530,8 @@ "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)", + "암호 ZIP 으로 묶는 중 · {0}": "パスワード付き ZIP にまとめています · {0}", + "암호로 묶기": "パスワード付きでまとめる", "암호화 안 됨 (http)": "暗号化なし (http)", "압축 옆 새 폴더 \"{0}\"": "アーカイブの隣の新しいフォルダー「{0}」", "압축 파일 안에 없습니다": "圧縮ファイル内にありません", @@ -2449,6 +2460,7 @@ "한 창": "1 ペイン", "한국어": "韓国語", "한글": "ハングル", + "한글 등 영문 밖 글자가 든 비밀번호는 이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다. 다른 기기 · 프로그램과 나눌 ZIP 은 영문 · 숫자 · 기호 비밀번호를 쓰세요.": "英字以外の文字 (ハングルなど) を含むパスワードは、このアプリでは展開できますが、7-Zip など他のプログラムでは開けないことがあります。他の端末やプログラムと共有する ZIP には英数字・記号のパスワードを使ってください。", "할 일": "内容", "합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "結合したフォルダー {0} 件は、何を移動したか分からないため元に戻しません。", "항목 {0}/{1}": "項目 {0}/{1}", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 0d736d4..4b0f5bc 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -797,6 +797,7 @@ "내장 플레이어로 재생": "用内置播放器播放", "넘기는 방향: 오른쪽 → 왼쪽 (만화)": "翻页方向: 右 → 左 (漫画)", "넘기는 방향: 왼쪽 → 오른쪽": "翻页方向: 左 → 右", + "넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "填写后使用 AES-256 加密。留空则为普通 ZIP", "넣지 못했습니다: {0}": "无法加入: {0}", "네덜란드어": "荷兰语", "네트워크 · DNS": "网络 · DNS", @@ -978,6 +979,7 @@ "되살리고 다시 시작": "恢复并重新启动", "되살리기": "恢复", "두 번 넣은 비밀번호가 다릅니다": "两次输入的密码不一致", + "두 비밀번호가 다릅니다": "两次输入的密码不一致", "두 쪽 나눔 끄기 (한 장으로 보기)": "关闭双页拆分(整张显示)", "두 쪽 나눠 보기": "拆分双页", "두 쪽 나눠 보기: 끄기 (누르면 자동)": "拆分双页: 关闭 (点按切换为自动)", @@ -1111,6 +1113,7 @@ "무엇이 바뀌는지 비교하는 중…": "正在比较将要更改的内容…", "무한": "无限制", "묶기": "压缩", + "묶을 파일이 없습니다": "没有可压缩的文件", "문서 미리보기": "文档预览", "문서 미리보기 컴포넌트를 먼저 설치하세요": "请先安装文档预览组件", "문자셋 경고": "字符集警告", @@ -1291,9 +1294,13 @@ "비교하지 못했습니다: {0}": "无法比较: {0}", "비밀번호": "密码", "비밀번호 (선택)": "密码(可选)", + "비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.": "此版本没有用于创建加密 ZIP 的 7-Zip。请不设密码压缩,或重新安装应用。", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。", "비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)", + "비밀번호 다시 넣기": "再次输入密码", + "비밀번호 보기": "显示密码", + "비밀번호 숨기기": "隐藏密码", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)", "비밀번호가 저장된 기능을 쓸 때": "使用保存了密码的功能时", "비밀번호가 틀렸습니다": "密码错误", @@ -1302,6 +1309,8 @@ "비밀번호를 넣으세요": "请输入密码", "비밀번호를 안전 저장소에 저장했습니다": "已将密码保存到安全存储", "비밀번호를 잊었습니다": "忘记密码", + "비밀번호를 잊으면 이 ZIP 을 풀 수 없습니다 (앱이 기억하지 않습니다). 파일 이름 목록은 암호화되지 않습니다.": "忘记密码将无法解压此 ZIP (应用不会记住密码)。文件名列表不会加密。", + "비밀번호에 한글 등 영문 밖 글자가 있으면 512MB 넘는 파일 ({0}) 은 묶을 수 없습니다. 영문 · 숫자 · 기호 비밀번호를 쓰거나 파일을 나눠 주세요.": "密码含有英文以外的字符 (如韩文) 时,无法压缩超过 512MB 的文件 ({0})。请使用英文字母、数字和符号组成的密码,或拆分文件。", "비어 있는 압축 파일입니다.": "压缩文件为空。", "비어 있는 폴더": "空文件夹", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。", @@ -1521,6 +1530,8 @@ "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)", + "암호 ZIP 으로 묶는 중 · {0}": "正在创建加密 ZIP · {0}", + "암호로 묶기": "加密压缩", "암호화 안 됨 (http)": "未加密 (http)", "압축 옆 새 폴더 \"{0}\"": "压缩包旁的新文件夹“{0}”", "압축 파일 안에 없습니다": "压缩文件中没有", @@ -2449,6 +2460,7 @@ "한 창": "单窗格", "한국어": "韩语", "한글": "韩文", + "한글 등 영문 밖 글자가 든 비밀번호는 이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다. 다른 기기 · 프로그램과 나눌 ZIP 은 영문 · 숫자 · 기호 비밀번호를 쓰세요.": "含有英文以外字符 (如韩文) 的密码可在本应用中解压,但 7-Zip 等其他程序可能无法打开。要与其他设备或程序共享的 ZIP,请使用英文字母、数字和符号组成的密码。", "할 일": "任务", "합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} 个合并的文件夹无法撤销 (不清楚移动了哪些内容)。", "항목 {0}/{1}": "项目 {0}/{1}", diff --git a/lib/core/archive_ops.dart b/lib/core/archive_ops.dart index 416177a..cedfd1f 100644 --- a/lib/core/archive_ops.dart +++ b/lib/core/archive_ops.dart @@ -186,12 +186,39 @@ Future> listZip(String path) async { } /// 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 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 _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); @@ -249,7 +276,7 @@ Future extractZipPlan(String path, String dest, ExtractPlan plan, /// [level] 0 (묶기만) ~ 9 (가장 작게). 다 만든 뒤에만 이름을 바꾼다 (취소 · 실패면 만들다 만 파일을 지움). /// 돌려주는 값: (넣은 파일 수, 건너뛴 링크 수) Future<(int, int)> createZip(List sources, String zipPath, - {int level = 6, void Function(int done, int total)? onProgress, bool Function()? cancelled}) async { + {int level = 6, String? password, void Function(int done, int total)? onProgress, bool Function()? cancelled}) async { // 넣을 파일 (상대 이름) - 링크는 세기만 final files = <(String, String)>[]; var links = 0; @@ -271,7 +298,8 @@ Future<(int, int)> createZip(List sources, String zipPath, } } final part = '$zipPath.jjpart'; - final enc = ZipFileEncoder()..create(part, level: level); + // 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) { @@ -297,6 +325,101 @@ Future<(int, int)> createZip(List sources, String zipPath, // ───────── 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 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(), + ]) { + 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 = >{}; + 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 parse7zList(String out) { final entries = []; @@ -326,10 +449,12 @@ List parse7zList(String out) { /// 7-Zip 실행. 비밀번호는 명령 줄이 아니라 표준 입력으로만 (작업 기록 · 프로세스 목록에 남지 않게 - 관리자 판단) Future<(int, String, String)> _run7z(String exe, List args, - {String? password, void Function(String line)? onLine, bool Function()? cancelled}) async { - final pr = await Process.start(exe, ['-sccUTF-8', '-scsUTF-8', ...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); // 묻지 않을 때도 빈 줄을 보내 기다리지 않게 (암호가 걸렸는데 비밀번호가 없으면 틀림으로 끝난다) pr.stdin.writeln(password ?? ''); + // 만들 때는 "다시 넣기" 도 묻는다 (zip_aes) + if (confirm) pr.stdin.writeln(password ?? ''); await pr.stdin.close(); final out = StringBuffer(), err = StringBuffer(); // 끝을 기다리는 Future 는 바로 만든다 (프로세스가 끝난 뒤 asFuture 를 부르면 이미 끝난 흐름은 영영 끝나지 않았다) diff --git a/lib/ui/archive_ui.dart b/lib/ui/archive_ui.dart index b6f65a3..b1a33ae 100644 --- a/lib/ui/archive_ui.dart +++ b/lib/ui/archive_ui.dart @@ -316,6 +316,10 @@ Future createZipFlow(BuildContext context, AppController c, List[(dir, tr('지금 폴더')), if (otherDir != null && !isDav(otherDir)) (otherDir, tr('다른 창 폴더'))]; String target() { var n = name.text.trim(); @@ -349,22 +353,79 @@ Future createZipFlow(BuildContext context, AppController c, List set(() => where = d)), Text(trf('만들 파일: {0}', [p.basename(target())]), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(tr('링크 · junction 은 넣지 않습니다'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 10), + TextField( + controller: pass, + obscureText: !showPass, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: tr('비밀번호 (선택)'), + helperText: tr('넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP'), + isDense: true, + suffixIcon: IconButton( + tooltip: showPass ? tr('비밀번호 숨기기') : tr('비밀번호 보기'), + icon: Icon(showPass ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18), + onPressed: () => set(() => showPass = !showPass), + ), + ), + onChanged: (_) => set(() {}), + ), + if (pass.text.isNotEmpty) ...[ + const SizedBox(height: 6), + TextField( + controller: pass2, + obscureText: !showPass, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: tr('비밀번호 다시 넣기'), + isDense: true, + errorText: pass2.text.isNotEmpty && pass2.text != pass.text ? tr('두 비밀번호가 다릅니다') : null, + ), + onChanged: (_) => set(() {}), + ), + const SizedBox(height: 4), + Text(tr('비밀번호를 잊으면 이 ZIP 을 풀 수 없습니다 (앱이 기억하지 않습니다). 파일 이름 목록은 암호화되지 않습니다.'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + // zip_aes: 7-Zip 등 다른 프로그램은 영문 밖 글자 ZIP 비밀번호를 받지 않는다 (10/17 시험) - 막지는 않고 알린다 + if (pass.text.codeUnits.any((c) => c > 0x7e)) + Text(tr('한글 등 영문 밖 글자가 든 비밀번호는 이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다. 다른 기기 · 프로그램과 나눌 ZIP 은 영문 · 숫자 · 기호 비밀번호를 쓰세요.'), + style: const TextStyle(fontSize: 12, color: JjColors.danger)), + ], ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('묶기'))), + FilledButton( + onPressed: pass.text.isNotEmpty && pass.text != pass2.text ? null : () => Navigator.pop(ctx, true), + child: Text(pass.text.isEmpty ? tr('묶기') : tr('암호로 묶기')), + ), ], ), ), ); final zip = target(); + final password = pass.text; name.dispose(); + pass.dispose(); + pass2.dispose(); if (go != true || !context.mounted) return null; if (level != c.settings.zipLevel) await c.updateSettings((x) => x.zipLevel = level); if (!context.mounted) return null; + // zip_aes: 비밀번호 ZIP 은 동봉 7-Zip 으로 (흘려 가며 AES-256 - 큰 동영상도 메모리에 다 올리지 않게) + final exe = password.isEmpty ? null : await bundledSevenZip(); + if (!context.mounted) return null; + if (password.isNotEmpty && exe == null) { + say(tr('비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.')); + return null; + } try { - final (n, links) = await _withProgress(context, trf('ZIP 으로 묶는 중 · {0}', [p.basename(zip)]), - (set, cancelled) => createZip(sources, zip, level: level, cancelled: cancelled, onProgress: (d, t) => set(t == 0 ? null : d / t))); + final (n, links) = await _withProgress( + context, + password.isEmpty ? trf('ZIP 으로 묶는 중 · {0}', [p.basename(zip)]) : trf('암호 ZIP 으로 묶는 중 · {0}', [p.basename(zip)]), + (set, cancelled) => exe == null + ? createZip(sources, zip, level: level, cancelled: cancelled, onProgress: (d, t) => set(t == 0 ? null : d / t)) + : createZipAes(exe, sources, zip, password: password, level: level, cancelled: cancelled, onProgress: set)); say([trf('{0}개를 묶었습니다: {1}', [n, p.basename(zip)]), if (links > 0) trf('링크 {0}개는 넣지 않음', [links])].join(' · ')); return zip; } on ArchiveCancelled { diff --git a/test/archive_ops_test.dart b/test/archive_ops_test.dart index 992861c..ca5f5a0 100644 --- a/test/archive_ops_test.dart +++ b/test/archive_ops_test.dart @@ -193,6 +193,61 @@ void main() { 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())); + await expectLater(extractZipPlan(zip, out.path, plan, password: '틀림'), throwsA(isA())); + 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: 취소 · 실패면 만들다 만 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())); + expect(File(zip).existsSync() || File('$zip.jjpart').existsSync(), isFalse); + await expectLater(createZipAes(exe, [p.join(tmp.path, '없음')], zip, password: 'x'), throwsA(isA())); + 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' diff --git a/test/archive_ui_test.dart b/test/archive_ui_test.dart index 3a82fe0..0d43b29 100644 --- a/test/archive_ui_test.dart +++ b/test/archive_ui_test.dart @@ -124,4 +124,39 @@ void main() { expect(c.settings.zipLevel, 9, reason: '고른 압축 정도를 기억'); expect(File(made!).lengthSync() > 0, isTrue); }); + + testWidgets('zip_aes: 비밀번호 칸 - 두 번 넣어 같아야 [암호로 묶기], 한글이면 다른 프로그램 안내 · [취소] 는 아무 일 없음', (tester) async { + final src = Directory(p.join(tmp.path, 'docs'))..createSync(); + File(p.join(src.path, 'a.txt')).writeAsStringSync('A'); + final run = await host(tester); + String? made; + var finished = false; + run((ctx) => createZipFlow(ctx, c, [src.path], dir: tmp.path)).then((v) { + made = v; + finished = true; + }); + await wait(tester, () => find.text('묶기').evaluate().isNotEmpty); + final fields = find.byType(TextField); + await tester.enterText(fields.at(1), '비번'); + await tester.pump(); + expect(find.textContaining('7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다'), findsOneWidget); + await tester.enterText(fields.at(1), 'pw1'); + await tester.pump(); + expect(find.textContaining('다른 프로그램에서는'), findsNothing); + await tester.enterText(find.byType(TextField).at(2), 'pw2'); + await tester.pump(); + expect(find.text('두 비밀번호가 다릅니다'), findsOneWidget); + expect(tester.widget(find.widgetWithText(FilledButton, '암호로 묶기')).onPressed, isNull, reason: '다르면 못 누름'); + await tester.enterText(find.byType(TextField).at(2), 'pw1'); + await tester.pump(); + expect(find.text('두 비밀번호가 다릅니다'), findsNothing); + expect(tester.widget(find.widgetWithText(FilledButton, '암호로 묶기')).onPressed, isNotNull, reason: '같으면 누를 수 있음'); + // 실제로 만들고 푸는 것은 archive_ops_test (7-Zip 은 위젯 시험의 가짜 시간 안에서 끝나지 않아 여기서는 창만 본다) + await tester.ensureVisible(find.text('취소')); + await tester.pump(); + await tester.tap(find.text('취소')); + await wait(tester, () => finished); + expect(made, isNull); + expect(File(p.join(tmp.path, 'docs.zip')).existsSync(), isFalse, reason: '[취소] 는 아무 일 없음'); + }); }