fix(m174): 复审 r2 —— 写入口收敛成一个(语言入口同缺陷)+ 只读文件上保存不再谎报成功

P2-1(同一类缺陷的第三个入口):`persist_ui_language` 与 `persist` 共用同一份长生命
store、`save()` 同样整份落盘,但语言入口没先 `reload()` ⇒ 保存 AI 配置后 1 秒内切界面语言,
`ai.*` 连密钥一起被 1 秒前的旧快照盖掉,而且函数返回 true。

修法选「收敛成唯一写入口」而不是「再补一次 reload()」:
- 新文件 `presenter/src/settings_bridge/write.rs` 提供 `SettingsBridge::write_with(|store| Edit)`
  ——现读 → 改 → 落盘 → 刷 mtime,`Edit::{Changed,Unchanged,Rejected}` 表达闭包的结果,
  `WriteOutcome::{NoStorage,Unchanged,Saved,Skipped(reason),Failed}` 表达写的结果(只有 `Saved`
  算成功)。两个入口都改成传闭包,**入口自己再也拿不到「不重读的 save」**——下一个写入口不会
  再靠「记得」。r1 就是「补一次 reload」的做法,隔一个文件又漏了一次。

第二条(用户可见谎话):`ai::store::save` 注释写着「落盘后再读一遍才算数」,实际返回的是内存
doc;`SettingStore::save` 还有 `Skipped(ReadOnly/Unreadable/NotDirty)` 一档(`Ok`,不是 `Err`),
旧代码 `.is_ok()` 会把它当成功 ⇒ 在「配置文件被更新版本启动器写过」的机器上什么都没写,界面报
「已保存」,重启回旧值。现在按 `SaveOutcome` 分支(只有 `Saved` 算成功,`Skipped` 给具名原因),
并且真的 `SettingStore::load` 一次从磁盘回读。

回归用例(都做了变异验证):
- `settings_bridge_lang::tests::persisting_the_language_keeps_keys_another_writer_just_saved`
  (去掉 `write_with` 的 `reload()` ⇒ 红,输出即「语言写回把刚保存的密钥盖掉了」+ 被覆盖的文件)
- `settings_bridge_lang::tests::persisting_the_language_reports_false_when_the_file_is_read_only`
  (`persist_ui_language` 恒返回 true ⇒ 红)
- `ai::store::tests::read_only_config_is_reported_as_a_failure`(`save()` 恒返回 Ok ⇒ 红)
- `ai::store::tests::skip_reasons_are_named`
This commit is contained in:
AskaEth committed 2026-11-08 17:03:13 +08:00
1 parent 93d02ab12b
commit 6f78e0cf7e
5 files changed
+301 -53

No files matched your search

+26 -4
View File
@@ -17,7 +17,7 @@
use std::path::Path;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use unlucky_core::setting::store::{SaveOutcome, SettingStore};
use super::state::{ProviderId, Snapshot, State};
@@ -51,9 +51,31 @@ pub fn save(path: &Path, state: &State) -> Result<Snapshot, String> {
.map_err(|error| format!("{error}"))?;
}
store.save().map_err(|error| format!("{error}"))?;
// 落盘后再读一遍:`set` 只证明「schema 收了」,`save` 之后文件里到底是什么才算数。
Ok(snapshot(store.doc()))
// **只有 `SaveOutcome::Saved` 算成功**(M174 r2 复审):`save()` 还有一档 `Skipped(reason)`
// ——只读(`$schema` 认不出 / 文件被更新版本的启动器写过)、不可信文件、没有脏键。旧代码
// 用 `.is_ok()` 把这一档也当成功,于是「什么都没写进磁盘」在界面上是「已保存」,重启回旧值。
match store.save() {
Ok(SaveOutcome::Saved(_)) => {}
Ok(SaveOutcome::Skipped(reason)) => {
return Err(format!("未写入磁盘({})", skip_reason(reason)));
}
Err(error) => return Err(format!("{error}")),
}
// 落盘之后**真的从磁盘再读一份**:`store.doc()` 是这次改过的内存视图,它证明不了「文件里
// 现在是什么」(上面那句注释在 r1 就有,代码却没照做——r2 复审按这句判的)。
let reloaded = SettingStore::load(path, launcher_schema());
Ok(snapshot(reloaded.doc()))
}
/// `SkipReason` → 人话(`core` 的稳定名对用户没意义)。
fn skip_reason(reason: unlucky_core::setting::store::SkipReason) -> &'static str {
use unlucky_core::setting::store::SkipReason;
match reason {
SkipReason::NotDirty => "没有改动",
SkipReason::ReadOnly => "配置文件只读:它被更新版本的启动器写过,或被标记为不可信",
SkipReason::Unreadable => "配置文件不可信(加载时就降级成只读)",
}
}
/// 文档 → 快照(密钥只取长度,值不出本函数)。
@@ -8,6 +8,7 @@
//! | `schema_rejects_and_nothing_is_written` | 非法值不保存 | 忽略 `set` 的 `Err` 直接 `save` |
//! | `external_edit_is_read_back` | 现读现写(别人的改动不被旧快照盖掉) | 缓存在内存里不回读 |
//! | `appearance_write_back_keeps_the_ai_keys` | **用户场景**:存完 AI 再改主题,值还在 | 两处任一方不重读 |
//! | `read_only_config_is_reported_as_a_failure` | 只读配置上保存**不许**报成功 | `save()` 恒返回 Ok |
use std::path::PathBuf;
@@ -161,3 +162,50 @@ fn appearance_write_back_keeps_the_ai_keys() {
assert_eq!(load(&path).key_chars, "sk-must-survive".chars().count());
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// **P2-1(r2)第二条**:只读配置上的保存必须报**失败**,而不是「已保存」。
///
/// 现场:`$schema` 认不出(或文件被更新版本的启动器写过)⇒ `FileAccess::ReadOnly`。
/// `SettingStore::save` 在这种文件上会 `Skipped(ReadOnly)`(**不是** Err),旧代码用 `.is_ok()`
/// 判定,于是「什么都没写进磁盘」在界面上是「已保存」,重启回旧值。
///
/// 抓得住:①把 `save()` 的 `SaveOutcome` 分支去掉(恒返回 Ok);②把只读档当成功。
/// 抓不住:`Skipped` 分支自身的文案(见 [`super::skip_reason`] 的单测)。
#[test]
fn read_only_config_is_reported_as_a_failure() {
let path = temp_config("read-only");
// `Invalid` 兼容档(`$schema` 前缀认不出)⇒ `FileAccess::ReadOnly`:值照读,不许改写。
std::fs::write(
&path,
r#"{"$schema":"https://example.invalid/ull/launcher/9.9.9","ai":{"model":"keep-me"}}"#,
)
.expect("写配置");
let before = std::fs::read_to_string(&path).expect("读配置");
let error = save(&path, &draft()).expect_err("只读配置上的保存必须报失败");
assert!(
error.contains("只读") || error.contains("只读文件") || error.contains("9.9.9"),
"错误文案要说得清为什么没写:{error}"
);
assert_eq!(
std::fs::read_to_string(&path).expect("读配置"),
before,
"被只读闸门拦下的保存不许改动文件"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// `skip_reason` 的三档都有人话(`Skipped` 不许被静默当成成功)。
#[test]
fn skip_reasons_are_named() {
use super::skip_reason;
use unlucky_core::setting::store::SkipReason;
for reason in [
SkipReason::NotDirty,
SkipReason::ReadOnly,
SkipReason::Unreadable,
] {
assert!(!skip_reason(reason).is_empty(), "{reason:?} 没有文案");
}
}