diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 48c0514..fd43471 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -1412,6 +1412,8 @@ class SettingsStore { }; /// 지금 설정의 비밀 값 (빈 값은 넣지 않음 = 저장소에서 지움) + /// 규칙 (관리자 10/21): 새 종류의 비밀 값은 지금 꼴 ([secretForms]) 과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 로 만든다 - + /// 겹치면 그 꼴을 아는 예전 판이 저장할 때 "없어진 항목" 으로 보고 지운다. 새 꼴은 [secretForms] 에도 더한다 static Map _secretsOf(AppSettings s) => !s.rememberPasswords ? const {} : { for (final e in _osKeys.entries) if (_os(s, e.key).isNotEmpty) e.value: _os(s, e.key), @@ -1686,8 +1688,15 @@ class SettingsStore { /// 관리자 지시 (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): 새 종류의 비밀 값은 아래 꼴들과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 을 쓴다 (겹치면 예전 판이 저장할 때 지움) + static bool ownsSecret(String k) => secretForms.any((f) => f.hasMatch(k)); + + /// 이 판이 만드는 비밀 열쇠의 꼴 ([_secretsOf] 와 같아야 한다 - 시험이 모든 열쇠가 정확히 한 꼴에만 맞는지 본다) + static final secretForms = [ + for (final k in _osKeys.values) RegExp('^${RegExp.escape(k)}\$'), + RegExp('^${RegExp.escape(_davServers)}\$'), + for (final pre in const ['dav.pw', 'sftp.pw', 'sftp.key', 'sftp.kp', 'smb.pw', 'ftp.pw', 'ai.key']) RegExp('^${RegExp.escape(pre)}' r'\..+$'), + ]; /// 관리자 판단 (10/21): 이 판이 모르는 꼴의 비밀 값 (다른 판이 쓴 열쇠 - 머리글이 같아도) 을 지운다 - 사용자가 초기화 창에서 /// [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 부른다 (고르지 않았으면 남김). 마스터 · 최상 (sec.) 은 [마스터 비밀번호] 체크가 정해 여기서 남긴다. diff --git a/test/settings_store_test.dart b/test/settings_store_test.dart index 13038bc..48dea72 100644 --- a/test/settings_store_test.dart +++ b/test/settings_store_test.dart @@ -2,8 +2,11 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/ftp.dart'; import 'package:jj_mkvmaker/core/sftp.dart'; +import 'package:jj_mkvmaker/core/smb.dart'; import 'package:jj_mkvmaker/core/webdav.dart'; +import 'package:jj_mkvmaker/services/image_ai.dart' show AiService; import 'package:jj_mkvmaker/services/secret_store.dart'; import 'package:path/path.dart' as p; @@ -62,6 +65,37 @@ void main() { expect(secrets.values.keys.where((k) => !SettingsStore.ownsSecret(k)), ['sec.master']); }); + test('관리자 규칙 (10/21): 이 판이 만드는 비밀 열쇠는 모두 secretForms 의 꼴 중 정확히 하나에만 맞고, 꼴마다 쓰인다 (새 종류는 겹치지 않는 새 머리글로)', () async { + final tmp = Directory.systemTemp.createTempSync('jj_secret_rule_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final secrets = MemorySecretStore()..values['sec.master'] = 'hash'; + final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); + final s = await store.load() + ..openSubtitlesKey = 'k' + ..openSubtitlesUser = 'u' + ..openSubtitlesPassword = 'op' + ..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] + ..sftpServers = [const SftpServer(id: 's1', name: 'n', host: 'h', user: 'u', password: 'sp', privateKey: 'KEY', keyPassphrase: 'kp')] + ..smbServers = [const SmbServer(id: 'm1', name: 'n', host: 'h', share: 'v', password: 'mp')] + ..ftpServers = [const FtpServer(id: 'f1', name: 'n', host: 'h', password: 'fp')] + ..aiServices = [const AiService(id: 'a1', name: 'n', url: 'https://ai', kind: 'openai', apiKey: 'ak')]; + await store.save(s); + final made = secrets.values.keys.where((k) => !k.startsWith('sec.')).toList()..sort(); + expect(made, hasLength(11), reason: '$made'); // os 3 · dav.servers · dav.pw · sftp.pw · key · kp · smb.pw · ftp.pw · ai.key + final used = {}; + for (final k in made) { + final hits = SettingsStore.secretForms.where((f) => f.hasMatch(k)).toList(); + expect(hits, hasLength(1), reason: '$k 이 맞는 꼴: ${hits.map((f) => f.pattern)}'); + used.addAll(hits); + } + expect(used, hasLength(SettingsStore.secretForms.length), reason: '안 쓰인 꼴: ${SettingsStore.secretForms.where((f) => !used.contains(f)).map((f) => f.pattern)}'); + // 마스터 · 최상 (sec.) 은 설정의 꼴이 아니다 (설정 저장이 건드리지 않음) + expect(SettingsStore.ownsSecret('sec.master'), isFalse); + // 같은 머리글의 새 종류 (예: dav.token.x) 는 이 판의 꼴이 아니다 - 그래서 지우지 않지만, 새 종류는 새 머리글 (dav2.) 을 쓰는 것이 규칙 + expect(SettingsStore.ownsSecret('dav.token.x'), isFalse); + expect(SettingsStore.ownsSecret('dav.pwx'), isFalse, reason: '꼴의 점 (.) 이 아무 글자가 되지 않게'); + }); + test('깨진 설정 파일: 백업으로 되살리고, 깨진 파일은 보관 · 알림 정보', () async { final store = SettingsStore(file); await store.save(AppSettings()..homeUrl = 'https://good.example');