fix(config): treat a wrongly typed app.json value as invalid instead of converting it

PLAN 4.3 lets a field of the wrong type fall back to its default alone.
Newtonsoft converted such values implicitly, so "delete-card": 0 became
false and "custom-pixels": "500" became 500. Booleans, integers and
strings are now read only from tokens of exactly that type.
This commit is contained in:
doyaGu committed 2026-10-03 22:45:08 -04:00
1 parent e7076a76c2
commit a2c4e1853d
4 files changed
+48 -5

No files matched your search

@@ -222,6 +222,13 @@ public class AppConfigStoreTests
[DataRow("\"column-width\": { \"custom-pixels\": \"wide\" }")] [DataRow("\"column-width\": { \"custom-pixels\": \"wide\" }")]
[DataRow("\"confirmations\": { \"delete-card\": \"nope\" }")] [DataRow("\"confirmations\": { \"delete-card\": \"nope\" }")]
[DataRow("\"version\": \"one\"")] [DataRow("\"version\": \"one\"")]
[DataRow("\"version\": \"1\"")]
[DataRow("\"language\": [\"zh-Hans\"]")]
[DataRow("\"confirmations\": { \"delete-card\": 0 }")]
[DataRow("\"confirmations\": { \"delete-card\": \"false\" }")]
[DataRow("\"column-width\": { \"custom-pixels\": \"500\" }")]
[DataRow("\"column-width\": { \"custom-pixels\": 500.0 }")]
[DataRow("\"column-width\": { \"custom-pixels\": 99999999999 }")]
public void InvalidFieldFallsBackAloneWithoutBackup(string invalidMember) public void InvalidFieldFallsBackAloneWithoutBackup(string invalidMember)
{ {
using var directory = new TempDirectory(); using var directory = new TempDirectory();
+38 -2
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@@ -8,7 +9,7 @@ namespace YKanBan.Storage.AppData;
/// Loads and saves app.json with graceful degradation: a missing file yields /// Loads and saves app.json with graceful degradation: a missing file yields
/// defaults; a file that is not a JSON object is backed up to app.json.bak and /// defaults; a file that is not a JSON object is backed up to app.json.bak and
/// then defaults are used; an individual invalid field (unknown enum value or /// then defaults are used; an individual invalid field (unknown enum value or
/// language, out-of-range width, wrong type) falls back to its own default while /// language, out-of-range width, wrong JSON type — no implicit conversion) falls back to its own default while
/// the other fields are kept; a file that exists but cannot be read (I/O or /// the other fields are kept; a file that exists but cannot be read (I/O or
/// permission error) also yields defaults. Saving happens only when this session /// permission error) also yields defaults. Saving happens only when this session
/// changed a setting (<see cref="ConfigSection.IsDirty"/>), and writes the file directly (no atomic replace) — /// changed a setting (<see cref="ConfigSection.IsDirty"/>), and writes the file directly (no atomic replace) —
@@ -29,7 +30,7 @@ public sealed class AppConfigStore
private static readonly JsonSerializer FieldTolerantSerializer = JsonSerializer.Create(new JsonSerializerSettings private static readonly JsonSerializer FieldTolerantSerializer = JsonSerializer.Create(new JsonSerializerSettings
{ {
NullValueHandling = NullValueHandling.Ignore, NullValueHandling = NullValueHandling.Ignore,
Converters = { new StringEnumConverter { AllowIntegerValues = false } }, Converters = { new StringEnumConverter { AllowIntegerValues = false }, new StrictPrimitiveConverter() },
// The JSON text is already known to be well formed here, so every error is a // The JSON text is already known to be well formed here, so every error is a
// single member that fails to convert: skip it and keep that member's default. // single member that fails to convert: skip it and keep that member's default.
@@ -211,6 +212,41 @@ public sealed class AppConfigStore
File.WriteAllText(FilePath, json); File.WriteAllText(FilePath, json);
} }
/// <summary>
/// Reads booleans, integers and strings only from JSON tokens of exactly that
/// type, so a wrong type is an invalid field (falling back to its default)
/// instead of being converted: <c>0</c> or <c>"false"</c> is not a boolean,
/// <c>"500"</c> or <c>500.0</c> is not an integer, <c>5</c> is not a string.
/// </summary>
private sealed class StrictPrimitiveConverter : JsonConverter
{
/// <inheritdoc/>
public override bool CanWrite => false;
/// <inheritdoc/>
public override bool CanConvert(Type objectType) =>
objectType == typeof(bool) || objectType == typeof(int) || objectType == typeof(string);
/// <inheritdoc/>
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
JsonToken expected = objectType == typeof(bool) ? JsonToken.Boolean
: objectType == typeof(int) ? JsonToken.Integer
: JsonToken.String;
if (reader.TokenType != expected)
{
throw new JsonSerializationException($"Expected a {expected} token for {objectType.Name}, got {reader.TokenType}.");
}
// Integer tokens are read as Int64 (or BigInteger); a value outside the Int32 range is invalid too.
return objectType == typeof(int) ? checked((int)Convert.ToInt64(reader.Value, CultureInfo.InvariantCulture)) : reader.Value;
}
/// <inheritdoc/>
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) =>
throw new NotSupportedException();
}
/// <summary> /// <summary>
/// Serializes a configuration in the on-disk format. /// Serializes a configuration in the on-disk format.
/// </summary> /// </summary>
+1 -1
View File
@@ -44,7 +44,7 @@
- [ ] 删除 `app.json`,打开设置、不改任何项直接确认后退出 → `app.json` 不被创建;改一项再改回原值后退出 → `app.json` 被写出(脏标记按"改动过"判定) - [ ] 删除 `app.json`,打开设置、不改任何项直接确认后退出 → `app.json` 不被创建;改一项再改回原值后退出 → `app.json` 被写出(脏标记按"改动过"判定)
- [ ] 处于错误页(例如参数错误页)的实例退出时不写 `app.json`(可先删除 `app.json`,退出后确认文件未被创建) - [ ] 处于错误页(例如参数错误页)的实例退出时不写 `app.json`(可先删除 `app.json`,退出后确认文件未被创建)
- [ ] `app.json` 写成非法 JSON → 启动正常、使用默认值,生成 `app.json.bak`;改一次设置退出后 `app.json` 恢复为合法内容 - [ ] `app.json` 写成非法 JSON → 启动正常、使用默认值,生成 `app.json.bak`;改一次设置退出后 `app.json` 恢复为合法内容
- [ ] `app.json` 中单个字段非法(如 `"theme": "pink"`、列宽 `"custom-pixels": 9999`)→ 仅该字段回退默认,其余设置保留,不生成 `.bak` - [ ] `app.json` 中单个字段非法(如 `"theme": "pink"`、列宽 `"custom-pixels": 9999`、类型不符如 `"delete-card": 0`、`"custom-pixels": "500"`)→ 仅该字段回退默认,其余设置保留,不生成 `.bak`
## B. 启动与错误页(M4 / M4R) ## B. 启动与错误页(M4 / M4R)
+2 -2
View File
@@ -16,7 +16,7 @@
- 网络共享明确不支持;命令行参数边界;macOS 无参启动行为写入文档(§2.3、§4.1、附录) - 网络共享明确不支持;命令行参数边界;macOS 无参启动行为写入文档(§2.3、§4.1、附录)
- 补充:导出转义规则、卡片列表虚拟化、模态窗口 FlowDirection、`Resources` 类名使用规则、CI osx-x64 运行器(§5、§7、§8) - 补充:导出转义规则、卡片列表虚拟化、模态窗口 FlowDirection、`Resources` 类名使用规则、CI osx-x64 运行器(§5、§7、§8)
- 依赖:Avalonia 11.3.6 → 11.3.22,Semi.Avalonia 11.2.1.10 → 11.3.22(§7) - 依赖:Avalonia 11.3.6 → 11.3.22,Semi.Avalonia 11.2.1.10 → 11.3.22(§7)
- 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`、默认文件名剔除非法字符(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4);app.json 读不出 / 备份写不出 / 退出写盘失败均不阻断启动与退出(§4.3);主题下拉选中即预览、取消恢复(§5.6);空白新卡片确认不创建(§5.4);app.json 脏标记按"改动过"判定(改回原值仍写盘)(§4.3) - 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`、默认文件名剔除非法字符(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4);app.json 读不出 / 备份写不出 / 退出写盘失败均不阻断启动与退出(§4.3);主题下拉选中即预览、取消恢复(§5.6);空白新卡片确认不创建(§5.4);app.json 脏标记按"改动过"判定(改回原值仍写盘),字段类型不符不做隐式转换(§4.3)
- 里程碑:标注 M0–M4 已完成,新增 **M4R(v1.9 回补)**(§9) - 里程碑:标注 M0–M4 已完成,新增 **M4R(v1.9 回补)**(§9)
## 1. 项目概述 ## 1. 项目概述
@@ -141,7 +141,7 @@ card_tags: (card_id, tag_id) 复合主键,双向 ON DELETE CASCADE
- 读取: - 读取:
- 文件缺失 → 默认值启动照常运行(下次脏退出时自然创建) - 文件缺失 → 默认值启动照常运行(下次脏退出时自然创建)
- JSON 本身无法解析 → 备份为 `app.json.bak`(覆盖旧备份)→ 以默认值启动 - JSON 本身无法解析 → 备份为 `app.json.bak`(覆盖旧备份)→ 以默认值启动
- JSON 完好但个别字段非法(未知枚举值、未知语言、列宽越界 160–720、类型不符等)→ **仅该字段回退默认值**,其余字段保留,不生成备份 - JSON 完好但个别字段非法(未知枚举值、未知语言、列宽越界 160–720、类型不符等;类型须严格匹配,不做隐式转换——如布尔字段写 `0` / `"false"`、整数字段写 `"500"` / `500.0` 均视为非法)→ **仅该字段回退默认值**,其余字段保留,不生成备份
- 文件存在但无法读取(IO / 权限错误)→ 以默认值启动;`.bak` 备份写不出时跳过备份,照常以默认值启动 - 文件存在但无法读取(IO / 权限错误)→ 以默认值启动;`.bak` 备份写不出时跳过备份,照常以默认值启动
- 退出写盘失败(IO / 权限错误)→ 忽略,等同丢失当次会话的设置变更;无论写盘成败都释放工作区锁与连接 - 退出写盘失败(IO / 权限错误)→ 忽略,等同丢失当次会话的设置变更;无论写盘成败都释放工作区锁与连接