Rust in Everything
基于 serde 1.0.229

本站怎么用 serde

主线位置:三条主线在本站的落点。site.json、前置元数据、server 函数参数和 LLM 响应走反序列化主线;LLM 请求和 server 函数返回值走序列化主线;缓冲主线只有一处派生出来却从没被调用的 Deserialize(内部标签的 LlmContentBlock)。

flowchart TB
  file["site.json"] -->|from_str| cfg["SiteConfig"]
  cfg -->|"to_value 后写回"| file
  md["Markdown 前置元数据"] -->|serde_yaml| meta["PostMetadata 等"]
  cli["wasm 客户端"] -->|"to_vec 参数"| srv["server 函数"]
  srv -->|"to_string 返回值"| cli
  site["站点"] -->|"请求 to_string"| api["LLM 服务"]
  api -->|"响应 from_str"| site

本页的站点源码链接都指向本站仓库的提交 89a61b1。那个提交的 Cargo.lock 锁的是 serde 1.0.228、serde_json 1.0.150、serde_yaml 0.9.34+deprecated、dioxus-fullstack 0.7.9、axum 0.8.9;依赖的源码按「crate + 版本 + 文件」引用。工作区只在根 Cargo.toml 里写了 serde = { version = "1.0", features = ["derive"] }、serde_json = "1.0"、serde_yaml = "0.9",除 widgets 自己写了 serde_yaml = "0.9" 外,各 crate 用 workspace = true 引用。

本页的实验把站点的类型定义原样抄进一个独立的 crate(只去掉注释),用本卷的版本(serde 1.0.229、serde_json 1.0.154、serde_yaml 0.9.34)在 M2 Max、macOS、rustc 1.99.0 上跑;同样的程序在站点锁的 1.0.228 / 1.0.150 下也跑了一遍,除了 server 函数实验里非法 UTF-8 那一行的列号(见升级一页),本页引用的输出两边逐字相同。

site.json:后加的字段都有缺省

站点配置是 assets/site.json,由 SiteConfig::from_file 用 serde_json::from_str 读成 SiteConfig:

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SiteConfig {
  pub site_name: String,
  pub site_description: String,
  #[serde(default = "default_theme")]
  pub theme: String,
  #[serde(default = "default_layout")]
  pub active_layout: String,
  pub default_language: String,
  pub author: String,
  pub navigation: Vec<NavItem>,
  #[serde(default)]
  pub auth: AuthSettings,
  #[serde(default)]
  pub modules: HashMap<String, ModuleSettings>,
  #[serde(default)]
  pub moderation: ModerationSettings,
}

前五个没有属性的字段是必填的;后面加进来的字段(主题、布局、登录、模块开关、审核)都带 default,旧的 site.json 不改也能读。没有 deny_unknown_fields,所以多出来的键直接被丢掉:仓库里的 site.json 有一个 SiteConfig 没声明的 annotations,插件运行时移除前的配置还有 paths、plugins_lock,站点的测试 legacy_plugin_fields_are_ignored 靠的就是这一点。未知键怎么被丢掉(__Field::__ignore 加 IgnoredAny)见生成的 Deserialize。

default 有两种写法,生成的代码不同:default = "default_theme" 调那个函数,default 调字段类型的 Default::default()。容器上没有 default,字段上的 default 只管「这个键整个不在」的情况。把站点的这几个类型原样抄过来,喂几份不完整的配置:

const BASE: &str = r#""site_name":"X","site_description":"","default_language":"zh","author":"","navigation":[]"#;

fn try_parse(label: &str, extra: &str) {
  let json = if extra.is_empty() { format!("{{{BASE}}}") } else { format!("{{{BASE},{extra}}}") };
  match serde_json::from_str::<SiteConfig>(&json) {
    Ok(c) => println!(
      "{label}: ok auth={:?} modules={:?} moderation.on_llm_failure={:?}",
      c.auth, c.modules, c.moderation.on_llm_failure
    ),
    Err(e) => println!("{label}: Err({e})"),
  }
}

fn main() {
  let site_json = std::fs::read_to_string(std::env::args().nth(1).expect("path to site.json")).unwrap();
  let c: SiteConfig = serde_json::from_str(&site_json).unwrap();
  println!("assets/site.json: ok theme={} providers={:?}", c.theme, c.auth.providers);

  try_parse("没有 auth", "");
  try_parse("auth 只写 enabled", r#""auth":{"enabled":true}"#);
  try_parse("modules.blog 空对象", r#""modules":{"blog":{}}"#);
  try_parse("on_llm_failure 拼错", r#""moderation":{"on_llm_failure":"allow"}"#);
  try_parse("thresholds 写成字符串", r#""moderation":{"thresholds":{"block_above":"0.9"}}"#);
  println!("ModuleSettings::default().enabled = {}", ModuleSettings::default().enabled);
}
assets/site.json: ok theme=ocean providers=["github", "google", "discord", "twitter"]
没有 auth: ok auth=AuthSettings { enabled: false, providers: [] } modules={} moderation.on_llm_failure=Review
auth 只写 enabled: Err(missing field `providers` at line 1 column 114)
modules.blog 空对象: ok auth=AuthSettings { enabled: false, providers: [] } modules={"blog": ModuleSettings { enabled: true }} moderation.on_llm_failure=Review
on_llm_failure 拼错: Err(unknown variant `allow`, expected `review` or `reject` at line 1 column 129)
thresholds 写成字符串: Err(invalid type: string "0.9", expected f32 at line 1 column 138)
ModuleSettings::default().enabled = false

逐行看:

  • auth 只写 enabled:SiteConfig.auth 上的 default 只在 auth 这个键缺席时生效。键在,就交给 AuthSettings 自己的 Deserialize,而它的两个字段都没有 default,providers 一缺就是 missing field。外层的 default 不会传到内层。报错的列号 114 是 auth 这个对象结尾的 }:缺字段要等 visit_map 把对象读完才知道。机制见生成的 Deserialize 的「缺的字段」一节,坑本身见 missing field。
  • modules.blog 是空对象:ModuleSettings 的 enabled 写的是 default = "default_enabled",得到 true;同一个类型派生的 Default 却给 false。serde 只看属性,不看 Default。站点代码里没有地方调 ModuleSettings::default(),这处不一致目前不会表现出来。
  • on_llm_failure 拼错:LlmFailureAction 是 rename_all = "lowercase" 的外部标签 enum,没认出的变体名报 unknown variant,并列出全部合法的名字。站点的测试 llm_failure_defaults_to_review 就断言 "allow" 必须报错:这个 enum 故意没有「放行」,拼错也不能悄悄落到某个默认值上。
  • thresholds 写成字符串:"0.9" 不是数字,f32 的 Visitor 不收字符串,见 invalid type。

from_file 的错误经站点的 From<serde_json::Error> 变成 AppError::Validation("JSON parse error: ..."),serde_json 的原文(含行列号)保留在里面。

写回 site.json:经过 Value 的两处变化

管理后台保存审核设置时,write_moderation_settings_to_site_json 不经 SiteConfig 往返,而是把文件读成 serde_json::Value,只替换 moderation 子树,好保留 SiteConfig 没声明的键:

  let mut value: serde_json::Value =
    serde_json::from_str(&raw).map_err(|e| format!("site.json 不是合法 JSON:{}", e))?;
  let moderation =
    serde_json::to_value(settings).map_err(|e| format!("序列化 ModerationSettings 失败:{}", e))?;
  value["moderation"] = moderation;
  let pretty =
    serde_json::to_string_pretty(&value).map_err(|e| format!("序列化 site.json 失败:{}", e))?;

未知的键确实保留了,但写回去的文件和原来的不一样。把这几行原样放进实验程序,对仓库里的 site.json 跑一次,阈值设成 flag_above: 0.4、block_above: 0.85:

  let settings = ModerationSettings {
    enabled: true,
    llm_review: true,
    on_llm_failure: LlmFailureAction::Reject,
    thresholds: Some(ModerationThresholdsConfig { block_above: Some(0.85), flag_above: Some(0.4) }),
    url_blocklist: vec![],
  };
  // ... 上面那几行
  println!("{pretty}");
  println!("--- 直接 to_string(&settings.thresholds):");
  println!("{}", serde_json::to_string(&settings.thresholds).unwrap());
{
  "active_layout": "classic",
  "annotations": {
    "blog": true,
    "course": true,
    "doc": true
  },
  "auth": {
    "enabled": true,
    "providers": [
      "github",
      "google",
      "discord",
      "twitter"
    ]
  },
  "author": "yuxuetr",
  "default_language": "zh",
  "moderation": {
    "enabled": true,
    "llm_review": true,
    "on_llm_failure": "reject",
    "thresholds": {
      "block_above": 0.8500000238418579,
      "flag_above": 0.4000000059604645
    },
    "url_blocklist": []
  },
  "navigation": [
    {
      "key": "nav-blog",
      "route": "/blog"
    },
    {
      "key": "nav-podcast",
      "route": "/podcast"
    },
    {
      "key": "nav-forum",
      "route": "/topics"
    }
  ],
  "site_description": "专注 Rust 技术栈的学习与实战",
  "site_name": "Rust in Everything",
  "theme": "ocean"
}
--- 直接 to_string(&settings.thresholds):
{"block_above":0.85,"flag_above":0.4}

两处变化:

  • 键按字母排序了。 原文件以 site_name 开头,写回后以 active_layout 开头,annotations 里的 course、doc、blog 也变成了 blog、course、doc。没开 preserve_order 时,Value::Object 里的 Map 底层是 MapImpl,也就是 BTreeMap,读进来的顺序当场就丢了;见 Value 的 Map 一节。保存一次之后,文件里几乎每个键都换了位置。
  • f32 多出了尾数。 to_value 遇到 f32 走 Value 一侧的 serialize_f32,最后到 Number::from_f32:默认配置下 Number 只存 f64,0.4f32 被原样转成 f64,也就是 0.4000000059604645。直接 to_string 时,写出器的 write_f32 按 f32 的最短表示写,得到 0.4。差别在 Value 这一跳,不在 JSON。
📝NOTE
冷知识:preserve_order 换过一次底层的 map,原因也是「没人维护了」

Map 的两种实现由 map.rs 里一对 cfg 选出:默认 BTreeMap,开 preserve_order 时是 IndexMap。IndexMap 不是一开始就在的。serde-rs/json#451「Use IndexMap instead of LinkedHashMap」之前,preserve_order 用的是 linked-hash-map;这个 PR 首次发布于 serde_json 1.0.20,发布说明写的是:「Switch to indexmap as the implementation of the "preserver_order" feature, as linked-hash-map is no longer maintained」。PR 的说明只有一句「This was surprisingly easy :confused:.」。今天的 map.rs 里,公开的 Map 仍是包着 MapImpl 的一个 struct,换底层的只是那一对 type 别名。本站下面要讲的 serde_yaml,也是因为「no longer maintained」才需要考虑替换。

站点的作者已经注意到了第二处:这个函数的测试里有一句注释「f32 → JSON 经 f64 序列化会引入精度尾噪(0.4 ≠ 0.4000000059604645),按近似比较」(第 1212 行)。这和浮点往返那个坑不是一回事:那里是解析 f64 时差 1 ULP,这里是 f32 拓宽成 f64 后按 f64 打印。

❗IMPORTANT
动脑筋:写回的 0.4000000059604645 会不会让审核阈值变了?

站点下一次 SiteConfig::from_file 读这份文件时,flag_above 还是 Some(0.4f32) 吗?答案见文末。

LLM 客户端:标签只用在请求一侧

crates/llm 同时实现 OpenAI 兼容和 Anthropic 兼容两种协议。和 serde 有关的类型分成两组:发出去的请求体,和收回来的响应。

请求:untagged 和内部标签都只派生了 Serialize

OpenAI 协议的 content 可以是字符串,也可以是内容块数组。站点用一个 untagged enum 表示「二选一」,内容块用内部标签(openai.rs 第 102 行起):

#[derive(Serialize)]
#[serde(untagged)]
enum OpenAiContent<'a> {
  Text(&'a str),
  Blocks(Vec<OpenAiContentBlock<'a>>),
}

#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum OpenAiContentBlock<'a> {
  Text { text: &'a str },
  ImageUrl { image_url: OpenAiImageUrl<'a> },
}

Anthropic 一侧的 AnthropicContentBlock 和它的 source 也是 tag = "type",同样只派生 Serialize。用站点的转换函数 build_openai_content / build_anthropic_content 把一条纯文本消息和一条带图片的消息转出来:

openai 请求: {"model":"deepseek-chat","messages":[{"role":"user","content":"hi"},{"role":"user","content":[{"type":"text","text":"look"},{"type":"image_url","image_url":{"url":"https://img.example/1.png"}},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0K"}}]}]}
anthropic 请求: {"model":"deepseek-chat","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"text","text":"look"},{"type":"image","source":{"type":"url","url":"https://img.example/1.png"}},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBORw0K"}}]}]}

序列化一侧的 untagged 没有缓冲,也不会「不匹配」:值已经是某个变体了,生成的 serialize 只是对内层的值调一次 serialize,外面不包任何东西。内部标签的 struct 变体走 serialize_struct,先写标签再写字段,所以 "type" 总在第一个。两者生成什么见生成的 Serialize 的「另外三种表示调什么」。

untagged 的坑(data did not match any variant of untagged enum)只在反序列化时出现,而站点从来不反序列化这个类型。它也反序列化不了,因为没派生,借用的 &'a str 字段也让它不适合从响应里读。试着 from_str 一个,rustc 直接拒绝:

error[E0277]: the trait bound `OpenAiContent<'_>: serde::Deserialize<'de>` is not satisfied
    --> src/bin/llm_no_de.rs:7:27
     |
   7 |   let _c: OpenAiContent = serde_json::from_str("\"hi\"").unwrap();
     |                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound

如果哪天要反过来读这种消息(比如写一个假的 OpenAI 服务做测试),把它改成拥有所有权的版本再派生 Deserialize,坏输入的报错就成了那一句话。下面四份输入的最后两份,一份是数组里出现了没声明的块类型,一份是 content 为 null:

ok: WireMessage { role: "user", content: Text("hi") }
ok: WireMessage { role: "user", content: Blocks([Text { text: "look" }, ImageUrl { image_url: OpenAiImageUrl { url: "x" } }]) }
Err: data did not match any variant of untagged enum OpenAiContent at line 1 column 97
Err: data did not match any variant of untagged enum OpenAiContent at line 1 column 30

第三行真正的原因是第二个块的 unknown variant \input_audio``,被 untagged 吞掉了。为什么只剩一句话,见 untagged。

lib.rs 里协议中立的 LlmContentBlock 倒是派生了 Deserialize,内部标签,所以它会走缓冲主线(TaggedContentVisitor 先把整个对象读成 Content)。但站点只构造它、序列化它(测试里),没有一处从 JSON 读它。直接调它的 Deserialize 看看:

LlmContentBlock {"url":"https://img.example/1.png","type":"image_url"}: ok ImageUrl { url: "https://img.example/1.png" }
LlmContentBlock {"type":"image","source":{"type":"url","url":"x"}}: Err(unknown variant `image`, expected one of `text`, `image_url`, `image_base64` at line 1 column 15)
LlmContentBlock {"type":"text"}: Err(missing field `text`)

标签不在第一个键也能读(第一行);最后一行的 missing field 没有行列号,因为它是在重放 Content 时报的,那时已经没有输入位置了,见 Content:缓冲的代价。这些只在将来有人用到这个 impl 时才相关。

响应:普通 struct 加 kind: String

读响应的类型一个 enum 都没有。OpenAI 的 ChatResponse 和 Anthropic 的 MessagesResponse 都是每个字段 #[serde(default)] 的 struct,内容块的类型读成字符串:

#[derive(Deserialize)]
struct ContentBlock {
  #[serde(default, rename = "type")]
  kind: String,
  #[serde(default)]
  text: String,
}

然后在代码里 filter(|b| b.kind == "text")。如果写成 #[serde(tag = "type")] enum,服务端多一种块(thinking、tool_use)就会报 unknown variant,整个响应读不出来;读成字符串则只是多一个被过滤掉的块。用站点的类型读几份响应:

openai 响应 正常: ok content=Some("OK")
openai 响应 多出 reasoning_content: ok content=Some("OK")
openai 响应 content 为 null: Err(invalid type: null, expected a string at line 1 column 57)
openai 响应 200 里 error 是字符串: Err(invalid type: string "quota exceeded", expected struct ApiError at line 1 column 25)
anthropic 响应 带 thinking 块: ok answer=["OK"]

多出的字段、多出的块类型都没问题。剩下两种会失败:

  • "content": null。ChoiceMessage 的 content: String 带 default,但 default 只管键缺席,键在、值是 null 时照样交给 String 的 Deserialize,String 不收 null。要同时容忍缺席和 null,类型得是 Option<String>。只要某个兼容服务在 content 里回 null,这条回复就整个读不出来。
  • error 是字符串。ApiError 是 struct,"error": "..." 报 invalid type。这只影响 2xx 的响应:非 2xx 时 chat 在解析那一行之前就按状态码返回了。

两种失败都被 chat 包成「LLM 响应不是合法 JSON: invalid type: ... (body=...)」。JSON 其实是合法的,是形状不对;serde_json 的原文还在后面,看日志时以它为准。审核流水线拿到这个错误后按 on_llm_failure 处理(settings.rs 的注释:「LLM 调用失败 / 回复无法解析时怎么处理这条提交。默认送人工复核」)。

前置元数据:serde_yaml

博客、文档、课程、案例、播客的元数据都是 YAML:Markdown 文件里的放在开头两个 --- 之间,课程、章节、案例、播客还有单独的 .yaml 文件。站点有八处 serde_yaml::from_str,类型和失败时的处理各不相同:

位置类型解析失败时
widgets/src/mdx.rs 的 parse_mdxPostMetadata记 warn,整块元数据用 Default
docs/src/server.rsDocMeta记 warn,用 Default
course/src/server.rs 第 533 行、第 877 行LessonFrontmatter、ChapterMetaunwrap_or_default(),不记日志
course/src/server.rs 第 953 行CourseMeta记 error,课程下线
cases/src/server.rsCaseMeta返回 Err(String)
podcast/src/server.rsEpisodeMeta.ok()?,这一期不出现
board/src/server.rsFrontMatterunwrap_or_default()

除了 CourseMeta、CaseMeta 和 EpisodeMeta,失败都会退到 Default:页面照样出来,只是标题、描述、排序全没了。serde 的报错是整块的,一个字段类型不对,整个 struct 都读不出来。

PostMetadata:容器上的 default 加自定义函数

PostMetadata 是写得最宽容的一个:

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct PostMetadata {
  /// 缺省为空(Z3):之前缺 `title` 会让整块 frontmatter 解析失败。
  #[serde(deserialize_with = "de_text")]
  pub title: String,
  /// `summary` / `excerpt` 是其他博客系统的同义字段(Z3)。
  #[serde(alias = "summary", alias = "excerpt", deserialize_with = "de_opt_text")]
  pub description: Option<String>,
  // ...
  pub image: Option<String>,
  // ...
  #[serde(deserialize_with = "de_list")]
  pub tags: Vec<String>,
  pub draft: bool,
}

/// YAML 标量(字符串 / 数字 / 布尔)转成字符串;`title: 2024` 这类也能读。
fn yaml_scalar(v: serde_yaml::Value) -> Option<String> {
  match v {
    serde_yaml::Value::String(s) => Some(s),
    serde_yaml::Value::Number(n) => Some(n.to_string()),
    serde_yaml::Value::Bool(b) => Some(b.to_string()),
    _ => None,
  }
}

de_text、de_opt_text、de_list 都先读成 serde_yaml::Value,再挑出标量。deserialize_with 绕开了字段类型自己的 Deserialize,所以 title: 2024 这种 YAML 认作整数的值也能当字符串用,invalid type 在这些字段上不会发生。

容器上的 #[serde(default)] 在这里是必需的,不是多余的。带 deserialize_with 的字段缺席时不走 missing_field,即使类型是 Option 也直接报错(生成的 Deserialize 的「default:容器与字段」一节、issue #2878)。把容器上的 default 去掉,只留 title 和 description 两个字段对照:

#[derive(Debug, Deserialize)]
pub struct PostMetadataNoContainerDefault {
  #[serde(deserialize_with = "de_text")]
  pub title: String,
  #[serde(alias = "summary", alias = "excerpt", deserialize_with = "de_opt_text")]
  pub description: Option<String>,
}

读 title: 没有 description:

去掉容器 default: Err(missing field `description`)

有了容器 default,缺的字段从 PostMetadata::default() 里取,就没有这个问题。代价是 Default::default() 每次都会被调用,字段齐全也调。

再看几份真实会写出来的元数据:

  post("标量都能读", "title: 2024\nsummary: 一句话\ntags: rust\nkeywords: [serde, 1.0]\ndraft: true\n");
  post("冒号没加引号", "title: 升级\ndescription: serde 1.0.229: 去掉 ?\n");
  post("image 写成列表", "title: 封面\nimage: [a.png]\n");
PostMetadata 标量都能读: ok PostMetadata { title: "2024", description: Some("一句话"), keywords: Some("serde, 1.0"), image: None, author: None, canonical: None, date: None, updated: None, tags: ["rust"], draft: true }
PostMetadata 冒号没加引号: Err(mapping values are not allowed in this context at line 2 column 27)
PostMetadata image 写成列表: Err(image: invalid type: sequence, expected a string at line 2 column 8)

第二行是 YAML 语法错误,在 serde 之前就失败了:未加引号的值里出现「冒号加空格」。本卷每一页的 description 一旦含有这个组合都要加双引号,原因就在这里。第三行 image 没有 deserialize_with,Option<String> 收到列表就报 invalid type;serde_yaml 的报错前面带字段路径 image:,比 serde_json 的报错多一层定位。

DocMeta:每个字段 default,但没有自定义函数

文档页(包括本卷)的元数据读成 DocMeta。它每个字段都有 default,但没有 deserialize_with,每个字段都按自己的类型读;source 是 DocSource,两个必填字段,crate 是关键字所以 rename:

/// frontmatter `source: { crate: tokio, version: "1.54.0" }`
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DocSource {
  #[serde(rename = "crate")]
  pub krate: String,
  /// 不加引号也行:serde_yaml 读进 String 时保留原文,`1.10` 不会变成 `1.1`
  pub version: String,
}
  doc("keywords 写成字符串", "title: 读法\nkeywords: serde\n");
  doc("sidebar_position 带引号", "title: 读法\nsidebar_position: \"3\"\n");
  doc("version 不加引号", "title: x\nsource: { crate: bytes, version: 1.10 }\n");
  doc("version 1.0.229", "title: x\nsource: { crate: serde, version: 1.0.229 }\n");
  doc("source 缺 version", "title: x\nsource: { crate: serde }\n");
  // 最后一行:读本卷「怎么读这一卷」那一页真实的前置元数据
DocMeta keywords 写成字符串: Err(keywords: invalid type: string "serde", expected a sequence at line 2 column 11)
DocMeta sidebar_position 带引号: Err(sidebar_position: invalid type: string "3", expected i32 at line 2 column 19)
DocMeta version 不加引号: ok title="x" keywords=[] sidebar_position=None source=Some(DocSource { krate: "bytes", version: "1.10" })
DocMeta version 1.0.229: ok title="x" keywords=[] sidebar_position=None source=Some(DocSource { krate: "serde", version: "1.0.229" })
DocMeta source 缺 version: Err(source: missing field `version` at line 2 column 9)
本卷 how-to-read: title="怎么读这一卷" sidebar_position=Some(1) source=Some(DocSource { krate: "serde", version: "1.0.229" }) keywords=8

三个 Err 都会让整页的元数据退成 Default:标题、描述、侧栏位置一起丢失。DocSource 那条注释说的是真的:version: 1.10 在 YAML 里是浮点数,但 String 的 Deserialize 调的是 deserialize_string,serde_yaml 0.9.34 的 deserialize_str(src/de.rs 第 1472 行)遇到标量直接把原文交给 visit_str,不做类型解析,所以得到 "1.10"。这是 serde_yaml 自己的选择,换一个 YAML 库不一定这样。

用户内容也会进 serde_yaml

Markdown 组件对 untrusted: true 的内容(评论、论坛话题,见 comment.rs)同样调 parse_mdx,所以一条以 --- 开头的评论,中间那段会交给 serde_yaml。用三份恶意的 YAML 试 PostMetadata(--release):

  parse("自引用别名", "title: &a [*a]\n");
  // a 是 10 个 x,b 是 10 个 *a,……,j 是 10 个 *i;title 引用 j
  let mut laughs = String::from("a: &a [x,x,x,x,x,x,x,x,x,x]\n");
  for i in 1..10 {
    let p = (b'a' + i - 1) as char;
    let c = (b'a' + i) as char;
    laughs.push_str(&format!("{c}: &{c} [*{p},*{p},*{p},*{p},*{p},*{p},*{p},*{p},*{p},*{p}]\n"));
  }
  laughs.push_str("title: *j\n");
  parse("十层别名展开", &laughs);
  parse("嵌套 200 层", &format!("title: {}{}\n", "[".repeat(200), "]".repeat(200)));
自引用别名: Err(recursion limit exceeded at line 1 column 8) (0 ms)
十层别名展开: Err(repetition limit exceeded) (10 ms)
嵌套 200 层: Err(recursion limit exceeded at line 1 column 135) (0 ms)

serde_yaml 0.9.34 自己有两道限制:嵌套深度从 remaining_depth: 128 往下减(src/de.rs 第 112、133 行),别名展开的跳转次数不超过事件数的 100 倍(src/de.rs 第 479 行)。十层、每层十倍的别名(展开后 10¹⁰ 个元素)在 10 ms 内被拒绝。失败之后 parse_mdx 退到 Default,第二个 --- 之后的内容照常渲染。

「+deprecated」是什么意思

Cargo.lock 里的版本号是 0.9.34+deprecated。+ 后面是 SemVer 的构建元数据,比较版本时被忽略,所以 serde_yaml = "0.9" 照样解析到它,本卷实验 crate 写的 =0.9.34 也一样。serde_yaml 的 README 第 10 行和 src/lib.rs 第 10 行写着:

Rust library for using the [Serde] serialization framework with data in [YAML]
file format. _(This project is no longer maintained.)_

它的源码里没有任何 #[deprecated] 属性,编译不会出警告。「deprecated」只是一个声明:不会再有新版本,bug 和安全问题都不会再修。serde_yaml 依赖的 unsafe-libyaml 0.2.11 是 libyaml 的 C 代码机器翻译成的 unsafe Rust。用本机的 advisory-db 副本(最后提交 2026-10-20)离线跑 cargo audit --no-fetch,和 YAML 有关的只有一条:

Crate:     unsafe-libyaml
Version:   0.2.11
Warning:   unmaintained
Title:     unsafe-libyaml is unmaintained
Date:      2024-03-25
ID:        RUSTSEC-2024-0449
URL:       https://rustsec.org/advisories/RUSTSEC-2024-0449

serde_yaml 自己只有一条 2018 年的 RUSTSEC-2018-0005(别名递归导致 abort,>= 0.8.4 已修),0.9.34 不受影响。

对本站来说,风险是「将来出了问题没人修」,不是「现在有已知漏洞」。替换时要注意站点已经依赖了 serde_yaml 的三样具体行为:mdx.rs 的 yaml_scalar、de_opt_text、de_list 直接用 serde_yaml::Value;DocSource.version 依赖 deserialize_str 不做类型解析;From<serde_yaml::Error> 把错误类型写进了 app-core。那条 advisory 建议的替代品是 saphyr(配 serde-saphyr)和 noyalib;serde_yaml 的分叉 serde_yaml_ng 仍然用 unsafe-libyaml。这几个库本机都没有,本页没有验证它们的行为。

server 函数:两端都是 serde_json

本站 72 个 server 函数全是 #[post("/api/...")],路径里没有参数、没有 query,所有参数都经请求体走。dioxus 怎么把它们注册成路由、请求体超限时为什么是 500,axum 卷的 dioxus 生成的 Router 和 bytes 卷的 server 函数的请求体 讲过。serde 一侧是这样的:

  • dioxus-fullstack-macro 0.7.9 src/lib.rs 第 342 行起,把参数打包成一个泛型结构体,字段名就是参数名:

              #[derive(serde::Serialize, serde::Deserialize)]
              #[serde(crate = "serde")]
              struct ___Body_Serialize___< #(#tys,)* > {
                  #(#names,)*
              }
    
  • 客户端(wasm):dioxus-fullstack 0.7.9 src/client.rs 第 306 行的 send_json 用 serde_json::to_vec;结果是 {} 或 null 时发空 body。

  • 服务端:src/magic.rs 第 523 行起的 ExtractRequest 实现,空 body 换成 "{}",然后 serde_json::from_slice::<In>,其中 In: DeserializeOwned(第 525 行)。

  • 返回值:src/magic.rs 第 630 行 serde_json::to_string(&res).unwrap(),客户端用 serde_json::from_slice::<T> 读,空 body 当成 null。

按 upload_image(name: String, data_base64: String) 的形状手写出那个结构体,模拟两端:

#[derive(Serialize, Deserialize)]
#[serde(crate = "serde")]
#[allow(non_camel_case_types)]
struct ___Body_Serialize___<__Ty0, __Ty1> {
  name: __Ty0,
  data_base64: __Ty1,
}

// 服务端:magic.rs 第 542 行起的那几行
fn server_extract(bytes: &[u8]) -> Result<(String, String), serde_json::Error> {
  let as_str = String::from_utf8_lossy(bytes);
  let bytes = if as_str.is_empty() { "{}".as_bytes() } else { bytes };
  serde_json::from_slice::<___Body_Serialize___<String, String>>(bytes)
    .map(|data| (data.name, data.data_base64))
}
客户端发出: {"name":"a.png","data_base64":"iVBORw0K"}
服务端 正常: ok name="a.png" data_base64="iVBORw0K"
服务端 空 body: Err(missing field `name` at line 1 column 2)
服务端 多一个字段: ok name="a.png" data_base64="x"
服务端 少一个字段: Err(missing field `data_base64` at line 1 column 16)
服务端 类型不对: Err(invalid type: integer `123`, expected a string at line 1 column 33)
服务端 字符串里有非法 UTF-8: Err(invalid unicode code point at line 1 column 11)
返回 (): null
返回 Option::<String>::None: null
客户端把 null 读成 (): ()

几点:

  • 参数结构体没有 deny_unknown_fields,多传的参数被忽略;少传的参数是 missing field,Option 参数缺席时是 None。客户端和服务端从同一份源码、同一个 Cargo.lock 编译,正常情况下字段名不会对不上;可能对不上的是部署切换时,浏览器里还在跑的旧 wasm 调新的服务端。
  • In: DeserializeOwned 让 server 函数的参数不能是 &str 这类借用类型,借用字符串那个坑在这里根本写不出来,编译期就拒绝了。
  • 解析失败时,magic.rs 把错误交给 ServerFnError::from:dioxus-fullstack-core 0.7.9 src/error.rs 第 129 行把它变成 ServerFnError::Deserialization,第 150 行把这个变体映射到 500,Display 是第 66 行的 "error deserializing server function results: {0}"。按这几行读,参数不对时客户端收到的是 500 和一句「results」反序列化失败,而不是 axum 的 Json 提取器那样的 400 / 422(见 body 提取器)。
  • 两端都是 Rust 的 serde_json,数字不经过 JavaScript,i64 / u64 在客户端也是精确的。
  • 返回值 serde_json::to_string(&res).unwrap() 在序列化失败时会 panic。serde_json 序列化失败的常见原因是 map 的键不是字符串(key must be a string);本站 server 函数的返回类型里没有 map,派生类型里仅有的两个 map 字段 HashMap<String, ModuleSettings> 和 BTreeMap<String, u64> 键都是 String。

axum 提取器与支付回调

站点直接写的 axum 提取器只有两个,都收成 HashMap<String, String>:OAuth 回调的 Query、支付宝回调的 Form。两者都经 serde_urlencoded 反序列化,机制在 axum 卷的 Path、Query 与请求元数据和 body 提取器;为什么用 HashMap 而不是带必填字段的 struct,见路由与静态资源。HashMap<String, String> 什么键都收,不会产生 serde 的错误。

微信支付和 Stripe 的回调先按原始字节验签,再 serde_json::from_str 成 Value,用下标取字段,比如 Stripe 的 session["amount_total"].as_i64().unwrap_or(-1)。金额是以分为单位的整数,Value 里存成 u64 / i64,不经过浮点。这条路上没有派生类型,也就没有 missing field:缺的字段变成 Value::Null,as_i64 返回 None,由站点自己决定怎么处理。

常见坑对照

坑本站相关的代码会不会遇到依据
untagged 不匹配OpenAiContent不会只派生 Serialize,rustc 拒绝 from_str
invalid typesite.json、DocMeta、LLM 响应会上面的输出:"0.9"、keywords: serde、content: null
missing fieldAuthSettings、DocSource、server 函数参数会auth 只写 enabled;PostMetadata 靠容器 default 避开了 deserialize_with 那一种
借用字符串请求类型里的 &'a str不会只序列化;server 函数参数要求 DeserializeOwned
flatten 与 deny_unknown_fields无不会crates/ 里没有 flatten,也没有 deny_unknown_fields
u128无不会没有 u128 / i128 字段
非自描述格式无不会只用 JSON 和 YAML;dioxus 的 CborEncoding 站点没有选
key must be a string两个 map 字段不会键都是 String
递归深度用户评论的前置元数据会被拒绝,不会崩serde_yaml 的两道限制,上面的输出
Rc 没实现 Serialize无不会派生类型里没有 Rc / Arc 字段
from_reader 慢无不会全部 from_str / from_slice,没有 from_reader
浮点往返审核阈值相关但不同写回时 f32 拓宽成 f64,读回仍相等
声明的长度不对无不会没有手写的 Serialize
两个 serde 版本无不会Cargo.lock 里只有一个 serde

可以改的地方(未实施)

下面是按上面的实验能得出的建议,站点在 89a61b1 上都没有做:

  • AuthSettings 加容器级 #[serde(default)],让 "auth": {"enabled": true} 也能读。
  • ChoiceMessage.content 改成 Option<String>(或用 deserialize_with 把 null 当空串),ApiError 容忍字符串形式。
  • DocMeta 的 keywords 学 PostMetadata 用 de_list,单个字符串不再让整页元数据失效。
  • 课程的 unwrap_or_default() 至少记一条日志,和 docs、mdx.rs 一致。
  • 写回 site.json 时想保持键的顺序,可以给 serde_json 开 preserve_order。它是全图合并的 feature,会让所有 Value 的 Map 都变成 IndexMap(Value 从 32 字节变成 72 字节,见 Value),要先评估。
  • 换掉 serde_yaml 之前,先给上面那三样行为(Value 的用法、1.10 保留原文、错误类型)写测试。

动脑筋答案

写回的文件再读成 SiteConfig(同一个程序的最后几行):

  let back: SiteConfig = serde_json::from_str(&pretty).unwrap();
  let t = back.moderation.thresholds.unwrap();
  println!("flag_above == Some(0.4f32): {}", t.flag_above == Some(0.4f32));
  println!("block_above == Some(0.85f32): {}", t.block_above == Some(0.85f32));
--- 写回的文件再读成 SiteConfig:
flag_above == Some(0.4f32): true
block_above == Some(0.85f32): true

都是 true。0.4000000059604645 是 0.4f32 转成 f64 之后的最短十进制表示,把它解析成 f32 时舍入回同一个数。阈值没有变,变的只是文件里的文本;所以站点测试用近似比较而不是字符串比较,也是对的。

小结

  • 本站的 serde 用法全是 derive,格式只有 JSON 和 YAML,没有 flatten、deny_unknown_fields、borrow,也没有手写 impl。
  • site.json 靠字段级 default 兼容旧配置、靠「未知键丢弃」兼容多余字段;外层的 default 不传到内层,AuthSettings 缺 providers 仍报 missing field。
  • 经 Value 写回 site.json 会按字母重排所有键,f32 阈值多出 f64 尾数;读回后数值不变。
  • untagged 和内部标签只用在 LLM 请求一侧(只有 Serialize),untagged 不匹配和 Content 的坑碰不到;响应用 kind: String 而不是标签 enum,容忍新块类型,但 content: null 会报 invalid type。
  • PostMetadata 用容器 default 加 serde_yaml::Value 的自定义函数,绕开了 invalid type 和 deserialize_with 的 missing field;DocMeta 没有,一个字段写错整页元数据就退回 Default。
  • serde_yaml 0.9.34 的「+deprecated」是构建元数据,表示不再维护,不产生编译警告;cargo audit 只报出 unsafe-libyaml 无人维护。用户评论的前置元数据也进 serde_yaml,深度和别名展开都有上限。
  • server 函数两端都是 serde_json:参数打包成以参数名为字段的结构体,要求 DeserializeOwned;解析失败按 dioxus 的代码是 500。

💬 关联讨论

发起讨论
加载中...