비밀 열쇠 꼴 규칙: 새 종류는 겹치지 않는 새 머리글로 - 주석 · secretForms 목록 · 시험 (관리자 지시 10/21, 28 검토)

- SettingsStore.secretForms: 이 판이 만드는 비밀 열쇠의 꼴 목록 (os.key · os.user · os.password · dav.servers · dav.pw.<id> · sftp.pw|key|kp.<id> ·
  smb.pw.<id> · ftp.pw.<id> · ai.key.<id>). ownsSecret 은 이 목록으로 (54eb692 와 같은 뜻, 꼴의 . 은 글자 그대로)
- _secretsOf · ownsSecret 옆에 규칙 주석: 새 종류의 비밀 값은 지금 꼴과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) - 겹치면 예전 판이 저장할 때 지움.
  새 꼴은 secretForms 에도 더함
- 시험 settings_store_test: 모든 종류를 채워 저장한 열쇠 11개가 저마다 꼴 하나에만 맞고 꼴마다 쓰임 · sec. 은 설정 꼴이 아님 ·
  dav.token.x · dav.pwx 는 꼴 밖. 관련 7파일 79개 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rc2^2
RyuJongJin 2026-10-21 19:13:53 +09:00
parent f67e1e3a85
commit 73fcd97e90
2 changed files with 45 additions and 2 deletions

View File

@ -1412,6 +1412,8 @@ class SettingsStore {
};
/// 지금 설정의 비밀 값 (빈 값은 넣지 않음 = 저장소에서 지움)
/// 규칙 (관리자 10/21): 새 종류의 비밀 값은 지금 꼴 ([secretForms]) 과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 로 만든다 -
/// 겹치면 그 꼴을 아는 예전 판이 저장할 때 "없어진 항목" 으로 보고 지운다. 새 꼴은 [secretForms] 에도 더한다
static Map<String, String> _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.) 은 [마스터 비밀번호] 체크가 정해 여기서 남긴다.

View File

@ -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 = <RegExp>{};
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');