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:
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("\"confirmations\": { \"delete-card\": \"nope\" }")]
|
||||
[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)
|
||||
{
|
||||
using var directory = new TempDirectory();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Newtonsoft.Json.Linq;
|
||||
@@ -8,7 +9,7 @@ namespace YKanBan.Storage.AppData;
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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) —
|
||||
@@ -29,7 +30,7 @@ public sealed class AppConfigStore
|
||||
private static readonly JsonSerializer FieldTolerantSerializer = JsonSerializer.Create(new JsonSerializerSettings
|
||||
{
|
||||
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
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Serializes a configuration in the on-disk format.
|
||||
/// </summary>
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@
|
||||
- [ ] 删除 `app.json`,打开设置、不改任何项直接确认后退出 → `app.json` 不被创建;改一项再改回原值后退出 → `app.json` 被写出(脏标记按"改动过"判定)
|
||||
- [ ] 处于错误页(例如参数错误页)的实例退出时不写 `app.json`(可先删除 `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)
|
||||
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@
|
||||
- 网络共享明确不支持;命令行参数边界;macOS 无参启动行为写入文档(§2.3、§4.1、附录)
|
||||
- 补充:导出转义规则、卡片列表虚拟化、模态窗口 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)
|
||||
- 实现回补:搜索括号嵌套上限 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)
|
||||
|
||||
## 1. 项目概述
|
||||
@@ -141,7 +141,7 @@ card_tags: (card_id, tag_id) 复合主键,双向 ON DELETE CASCADE
|
||||
- 读取:
|
||||
- 文件缺失 → 默认值启动照常运行(下次脏退出时自然创建)
|
||||
- JSON 本身无法解析 → 备份为 `app.json.bak`(覆盖旧备份)→ 以默认值启动
|
||||
- JSON 完好但个别字段非法(未知枚举值、未知语言、列宽越界 160–720、类型不符等)→ **仅该字段回退默认值**,其余字段保留,不生成备份
|
||||
- JSON 完好但个别字段非法(未知枚举值、未知语言、列宽越界 160–720、类型不符等;类型须严格匹配,不做隐式转换——如布尔字段写 `0` / `"false"`、整数字段写 `"500"` / `500.0` 均视为非法)→ **仅该字段回退默认值**,其余字段保留,不生成备份
|
||||
- 文件存在但无法读取(IO / 权限错误)→ 以默认值启动;`.bak` 备份写不出时跳过备份,照常以默认值启动
|
||||
- 退出写盘失败(IO / 权限错误)→ 忽略,等同丢失当次会话的设置变更;无论写盘成败都释放工作区锁与连接
|
||||
|
||||
|
||||
Reference in new issue
Block a user