diff --git a/lib/app/settings.dart b/lib/app/settings.dart index aed7a6c..48c0514 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -1682,11 +1682,14 @@ class SettingsStore { }); } - /// 이 판의 설정이 맡는 안전 저장소 열쇠 (그 밖은 마스터 · 최상 (sec.) 이나 이 판이 모르는 것 - 새 판이 쓴 열쇠) - static bool ownsSecret(String k) => - k.startsWith('os.') || k.startsWith('dav.') || k.startsWith('ai.') || k.startsWith('sftp.') || k.startsWith('smb.') || k.startsWith('ftp.'); + /// 이 판의 설정이 맡는 안전 저장소 열쇠 - 이 판이 만드는 정확한 이름 꼴만 ([_secretsOf] 와 같은 꼴). + /// 관리자 지시 (10/21, 15 알림): 예전에는 머리글 (dav. · sftp. 등) 만 보아, 새 판이 같은 머리글로 새 종류의 열쇠 (예: dav.token.x) 를 + /// 쓰면 이 판에서 보통 저장할 때 지웠다 - 예전 판으로 되돌리면 사용자 비밀번호를 잃을 수 있었다 (40 의 비밀 값 쪽). + /// 그 밖은 마스터 · 최상 (sec.) 이거나 이 판이 모르는 꼴 (다른 판이 쓴 열쇠) - 보통 저장에서는 건드리지 않는다 + static bool ownsSecret(String k) => _osKeys.containsValue(k) || k == _davServers || _ownForm.hasMatch(k); + static final _ownForm = RegExp(r'^(dav\.pw|sftp\.pw|sftp\.key|sftp\.kp|smb\.pw|ftp\.pw|ai\.key)\..+$'); - /// 관리자 판단 (10/21): 이 판이 모르는 비밀 값 (다른 판이 쓴 열쇠) 을 지운다 - 사용자가 초기화 창에서 + /// 관리자 판단 (10/21): 이 판이 모르는 꼴의 비밀 값 (다른 판이 쓴 열쇠 - 머리글이 같아도) 을 지운다 - 사용자가 초기화 창에서 /// [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 부른다 (고르지 않았으면 남김). 마스터 · 최상 (sec.) 은 [마스터 비밀번호] 체크가 정해 여기서 남긴다. /// 지운 열쇠 수 Future clearForeignSecrets() async { diff --git a/test/settings_store_test.dart b/test/settings_store_test.dart index de2c96a..13038bc 100644 --- a/test/settings_store_test.dart +++ b/test/settings_store_test.dart @@ -2,6 +2,9 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/sftp.dart'; +import 'package:jj_mkvmaker/core/webdav.dart'; +import 'package:jj_mkvmaker/services/secret_store.dart'; import 'package:path/path.dart' as p; void main() { @@ -25,6 +28,40 @@ void main() { expect((await SettingsStore(file).load()).homeUrl, 'https://4.example'); }); + test('관리자 지시 (10/21): 보통 저장은 이 판이 만드는 꼴의 열쇠만 지운다 - 모르는 꼴 (새 판의 dav.token.x 등) 은 남김 · 지운 서버의 아는 열쇠만', () async { + final tmp = Directory.systemTemp.createTempSync('jj_secret_form_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final secrets = MemorySecretStore() + ..values['dav.token.d1'] = 'new-kind' // 새 판이 같은 머리글로 쓴 새 종류 + ..values['sftp.cert.s1'] = 'new-kind-2' + ..values['nfs.pw.n1'] = 'new-server-kind' + ..values['sec.master'] = 'hash'; + final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); + final s = await store.load() + ..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] + ..sftpServers = [ + const SftpServer(id: 's1', name: 'a', host: 'h', user: 'u', password: 'sp1', privateKey: 'KEY1', keyPassphrase: 'kp1'), + const SftpServer(id: 's2', name: 'b', host: 'h', user: 'u', password: 'sp2'), + ]; + await store.save(s); + expect( + {for (final k in ['dav.pw.d1', 'sftp.pw.s1', 'sftp.key.s1', 'sftp.kp.s1', 'sftp.pw.s2']) k: secrets.values[k]}, + {'dav.pw.d1': 'dp', 'sftp.pw.s1': 'sp1', 'sftp.key.s1': 'KEY1', 'sftp.kp.s1': 'kp1', 'sftp.pw.s2': 'sp2'}); + // 서버 s1 을 지움: s1 의 아는 열쇠 (pw · key · kp) 만 없어지고, s2 · 모르는 꼴 · 마스터는 그대로 + s.sftpServers = [s.sftpServers.last]; + await store.save(s); + expect(secrets.values.keys.where((k) => k.endsWith('.s1')), ['sftp.cert.s1']); + expect(secrets.values['sftp.pw.s2'], 'sp2'); + expect((secrets.values['dav.token.d1'], secrets.values['nfs.pw.n1'], secrets.values['sec.master']), ('new-kind', 'new-server-kind', 'hash')); + // 다시 읽어 저장해도 (새 SettingsStore = 앱을 다시 켬) 모르는 꼴은 남음 + final again = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); + await again.save(await again.load()); + expect((secrets.values['dav.token.d1'], secrets.values['sftp.cert.s1']), ('new-kind', 'new-kind-2')); + // 초기화의 [저장된 실제 비밀번호 · API 키] 쪽 (clearForeignSecrets) 은 모르는 꼴도 지우고 sec. 만 남김 + expect(await again.clearForeignSecrets(), 3); + expect(secrets.values.keys.where((k) => !SettingsStore.ownsSecret(k)), ['sec.master']); + }); + test('깨진 설정 파일: 백업으로 되살리고, 깨진 파일은 보관 · 알림 정보', () async { final store = SettingsStore(file); await store.save(AppSettings()..homeUrl = 'https://good.example');