Compare commits

...

15 Commits

Author SHA1 Message Date
xfy
76ad4ec8fb ci: 修正 Tailwind CLI 包名并收敛重复的环境准备步骤
Some checks failed
CI / check (push) Failing after 2s
CI / build (push) Has been skipped
Tailwind v4 将 CLI 拆为独立包 @tailwindcss/cli,核心包 tailwindcss
不再注册 bin,导致 npx tailwindcss 报 "could not determine executable
to run"。CI 改为安装 @tailwindcss/cli。

同时将 check 与 build 两个 job 重复的 checkout、镜像、Rust/Node/dx/
tailwind 安装步骤抽到 .gitea/actions/setup 复合 action,顶层 env
统一管理 Rust 镜像配置。AGENTS.md 同步说明 v4 拆包这一坑。
2026-06-15 11:47:54 +08:00
xfy
a2ccc3da1a Merge branch 'fix-dos-and-toc-xss'
安全修复:
- 钳制公开分页接口 per_page 与 page 上限,消除 DoS 与缓存键扇出
- 修正 TOC 标题在属性上下文的转义,防止属性注入(XSS)

经 code review 后追加:
- 复用既有 escape_html,删除重复转义实现
- 增加 page 上限作为纵深防御
2026-06-15 11:40:01 +08:00
xfy
a3d6c2f19e refactor(markdown): 复用既有 escape_html,删除重复的转义实现
hooks::comment_storage::escape_html 已实现完整 OWASP 转义(& < > " '),
且无 server feature 门控,全平台可用。escape_heading_text 改为委托给它,
避免在仓库内维护第二份行为略有差异(缺 >)的转义函数。补充 < 转义测试。
2026-06-15 11:38:24 +08:00
xfy
eaa7118e09 fix(posts): 钳制 page 上限,防止无界 OFFSET 与缓存键扇出
clamp_pagination 原先仅限制 per_page,page 无上限。攻击者可用海量不同
page 值撑大缓存键空间(缓存污染)并触发无意义的超大 OFFSET 扫描。新增
MAX_PAGE=10_000,对任何实际博客都足够宽裕(配合 MAX_PER_PAGE 最多覆盖
50 万篇文章),同时把缓存键空间限制在有限范围。
2026-06-15 11:38:24 +08:00
xfy
928fc1a0de Merge branch 'improve-tests-and-comments'
完善单元测试与代码注释。测试 254 → 290(净增 36 个有价值测试),
顺带修复 highlight.rs 中大写语言标识无法走别名映射的 bug。

- theme.rs: 2 → 8(主题切换对合性、预加载脚本契约)
- highlight.rs: 7 → 15(别名映射、大小写回退,修复 'RUST' 回退纯文本 bug)
- webp.rs: 6 → 16(编解码错误处理、像素格式转换路径、Display 格式)
- sanitizer.rs: 8 → 17(is_safe_url 安全敏感分支直接测试)
- error.rs: 5 → 8(AppError 各变体消息转换)
2026-06-15 11:27:33 +08:00
xfy
6d32664020 fix(markdown): 转义 TOC 标题文本,防止属性上下文注入
generate_toc_html 原先用 clean_html 处理标题后拼进 aria-label="..." 与
<a> 正文,但 clean_html 只做正文 HTML 消毒、不转义双引号。标题形如
" onmouseover="alert(1) 会越出属性边界。新增 escape_html_attr(),对
标题文本统一做属性上下文转义(& " <),正文与属性两处一致使用。

内容由 admin 写入、严重度中低,但属真实消毒逻辑缺口,TOC 会展示给所有读者。
2026-06-15 11:27:32 +08:00
xfy
cfa4975813 fix(posts): 钳制分页参数 per_page 上限,消除公开接口 DoS
list_published_posts 为无需认证的公开接口,原先 per_page 直接透传给
SQL LIMIT,攻击者可传入巨大值迫使数据库扫描并实例化超大 Vec,造成
内存放大与拒绝服务。新增 MAX_PER_PAGE=50 与 clamp_pagination(),在
list_published_posts 与 list_posts 入口钳制 page/per_page;钳制值同时
用于缓存键与查询,避免同一逻辑页落入不同缓存条目。
2026-06-15 11:27:25 +08:00
xfy
19ffcada4a test: 移除脆弱断言与凑数测试(review 修正)
对本次新增测试自审后的清理:

1. webp: encode_lower_quality_produces_smaller_or_equal_bytes
   原断言“低质量体积 <= 高质量体积”是 WebP 的统计趋势而非
   确定性不变量,依赖底层 libwebp 量化策略,纯色图上高低差异
   接近 0,未来库升级易变为 flaky。改为验证两个质量档位都能
   成功编码并产生可被本模块解码的合法 WebP。

2. theme: theme_is_copy
   Copy 语义由编译器在编译期保证,运行期断言永真,不测任何
   运行逻辑,属凑数。

3. webp: webp_config_default_quality_and_method
   构造 struct 再断言字段等于构造时填入的值,是同义反复。

净减 2 个测试(292 → 290),全部通过。
2026-06-15 11:26:24 +08:00
xfy
263208b930 style: 修正新测试的格式以符合 rustfmt 宽度限制
cargo fmt 自动调整了 highlight.rs 与 error.rs 中新增测试的
assert! / assert_eq! 长行折行。
2026-06-15 11:22:35 +08:00
xfy
9fc626232c test(error): 补全 AppError 各变体的消息转换测试
原仅覆盖 Unauthorized/Forbidden/NotFound/DbConn/Query 五个变体,
补充:
- Internal 变体的消息透传
- Transaction 变体的 SQL 细节隐藏(与 Query 行为一致)
- 三类数据库错误统一返回中文通用提示的聚合断言

共 8 个测试,全部通过。
2026-06-15 11:20:33 +08:00
xfy
28f0117f06 test(sanitizer): 补充 is_safe_url 分支测试
is_safe_url 是 HTML 消毒器的安全核心,原仅通过 clean_html 间接覆盖。
新增 9 个直接单元测试,锁定各分支契约:

- https/http 白名单通过
- javascript / vbscript scheme 拒绝(含大小写混淆)
- data URI 按 allow_data_uri 标志决定(文章正文 vs 评论)
- 相对路径与锚点片段通过
- 空字符串/纯空白视为安全
- mailto / tel / ftp 等其它白名单 scheme 通过
- 含空白的 scheme 名(混淆手法)被拒绝

共 17 个测试,全部通过。
2026-06-15 11:19:46 +08:00
xfy
a9cde41de8 test(webp): 扩充 WebP 编解码测试覆盖
原仅 6 个测试,补充后覆盖:
- WebpError 的 Display 格式与 std::error::Error trait 约束
- encode 对非快速路径像素格式(Luma8 / LumaA8)的 RGBA 转换
- encode 质量参数对体积的单调性(低质量 <= 高质量)
- decode 对非法字节流与空输入的错误处理(不 panic)
- decode 错误信息带 'WebP decode error' 前缀便于排查
- 编解码往返保持图像尺寸不变

共 17 个测试,全部通过。
2026-06-15 11:18:55 +08:00
xfy
e9aac7d6b4 fix(highlight): 修复大写语言标识无法走别名映射的问题
别名表此前用 lang == from 做严格相等比较,导致 'RUST' 这类
大写标识无法命中 ('rust' → 'rs') 这类别名,最终回退到纯文本。

改用 eq_ignore_ascii_case 比较,使别名解析与扩展名/名称的小写
回退路径保持一致。

test(highlight): 补充 find_syntax 分支覆盖

新增 8 个测试覆盖此前未测的路径:
- 大写语言标识的小写回退(同时验证了上述修复)
- 别名表:golang/bash/yml 等映射
- 未知语言与空字符串的纯文本回退
- 代码首尾空白被 trim
- 多行代码逐行高亮

通过等价性断言(而非脆弱的 span class 字面量)校验别名解析,
避免 syntect 内部 class 命名变化导致测试漂移。

共 15 个测试,全部通过。
2026-06-15 11:17:53 +08:00
xfy
4e89412cde test(theme): 扩充主题模块测试覆盖
原仅 2 个 toggle 测试,补充后覆盖:
- toggle 的对合性(连续切换两次回到原值)
- Theme 的 PartialEq / Copy trait 行为
- 首屏预加载脚本的关键行为契约:读取 localStorage、
  回退 prefers-color-scheme、添加 dark class、
  包裹 try/catch 防止禁用 localStorage 时抛错

共 9 个测试,全部通过。
2026-06-15 10:57:51 +08:00
xfy
81ee60a77d chore: ignore .worktrees directory
Add .worktrees/ to .gitignore so isolated git worktrees created for
parallel development work are not accidentally tracked.
2026-06-15 10:50:48 +08:00
11 changed files with 495 additions and 75 deletions

View File

@ -0,0 +1,49 @@
name: Setup toolchain
description: Checkout + configure Cargo mirror + install Rust, Node, wasm target, Dioxus CLI and Tailwind CSS CLI
runs:
using: composite
steps:
- name: Checkout
shell: bash
run: |
git init
git remote add origin https://git.rua.plus/${{ github.repository }}.git
git fetch --depth 1 origin ${{ github.sha }}
git checkout ${{ github.sha }}
- name: Configure Cargo mirror
shell: bash
run: |
mkdir -p "$HOME/.cargo"
printf '%s\n' '[source.crates-io]' "replace-with = 'rsproxy'" '' "[source.rsproxy]" 'registry = "sparse+https://rsproxy.cn/index/"' > "$HOME/.cargo/config.toml"
# Rust mirror env (RUSTUP_UPDATE_ROOT / RUSTUP_DIST_SERVER) is inherited
# from the top-level workflow env.
- name: Install Rust toolchain
shell: bash
run: |
for i in 1 2 3; do
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable && break
echo "rustup install attempt $i failed, retrying..."
sleep 15
done
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install Node.js
shell: bash
run: |
apt-get update
apt-get install -y nodejs npm
- name: Install Rust target
shell: bash
run: rustup target add wasm32-unknown-unknown
- name: Install Dioxus CLI
shell: bash
run: cargo install dioxus-cli --locked
- name: Install Tailwind CSS CLI
shell: bash
run: npm install -g @tailwindcss/cli

View File

@ -14,40 +14,7 @@ jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
run: |
git init
git remote add origin https://git.rua.plus/${{ github.repository }}.git
git fetch --depth 1 origin ${{ github.sha }}
git checkout ${{ github.sha }}
- name: Configure Cargo mirror
run: |
mkdir -p "$HOME/.cargo"
printf '%s\n' '[source.crates-io]' "replace-with = 'rsproxy'" '' "[source.rsproxy]" 'registry = "sparse+https://rsproxy.cn/index/"' > "$HOME/.cargo/config.toml"
- name: Install Rust toolchain
run: |
for i in 1 2 3; do
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable && break
echo "rustup install attempt $i failed, retrying..."
sleep 15
done
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install Node.js
run: |
apt-get update
apt-get install -y nodejs npm
- name: Install Rust target
run: rustup target add wasm32-unknown-unknown
- name: Install Dioxus CLI
run: cargo install dioxus-cli --locked
- name: Install Tailwind CSS CLI
run: npm install -g tailwindcss
- uses: ./.gitea/actions/setup
- name: Check formatting
run: cargo fmt -- --check
@ -64,44 +31,8 @@ jobs:
build:
runs-on: ubuntu-latest
needs: check
env:
RUSTUP_UPDATE_ROOT: https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup
RUSTUP_DIST_SERVER: https://mirrors.tuna.tsinghua.edu.cn/rustup
steps:
- name: Checkout
run: |
git init
git remote add origin https://git.rua.plus/${{ github.repository }}.git
git fetch --depth 1 origin ${{ github.sha }}
git checkout ${{ github.sha }}
- name: Configure Cargo mirror
run: |
mkdir -p "$HOME/.cargo"
printf '%s\n' '[source.crates-io]' "replace-with = 'rsproxy'" '' "[source.rsproxy]" 'registry = "sparse+https://rsproxy.cn/index/"' > "$HOME/.cargo/config.toml"
- name: Install Rust toolchain
run: |
for i in 1 2 3; do
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable && break
echo "rustup install attempt $i failed, retrying..."
sleep 15
done
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install Node.js
run: |
apt-get update
apt-get install -y nodejs npm
- name: Install Rust target
run: rustup target add wasm32-unknown-unknown
- name: Install Dioxus CLI
run: cargo install dioxus-cli --locked
- name: Install Tailwind CSS CLI
run: npm install -g tailwindcss
- uses: ./.gitea/actions/setup
- name: Build release
run: make build

3
.gitignore vendored
View File

@ -19,3 +19,6 @@ uploads/*
uploads/.cache/
profile.json.gz
# Git worktrees
.worktrees/

View File

@ -18,7 +18,7 @@ make clean # cargo clean + rm public/style.css
- Rust 1.95+ with `wasm32-unknown-unknown` target
- `dx` CLI (`cargo install dioxus-cli`)
- `tailwindcss` CLI v4 (standalone binary)
- `tailwindcss` CLI v4 — install via `npm install -g @tailwindcss/cli` (v4 splits the CLI into its own package; the `tailwindcss` core package has no `bin`), or use the standalone binary
- PostgreSQL running locally
## Environment

View File

@ -106,4 +106,37 @@ mod tests {
let msg = err.to_string();
assert!(msg.contains("文章不存在"), "expected passthrough: {msg}");
}
#[test]
fn internal_message_passthrough() {
// Internal 错误的消息原样透传,便于向用户展示可读的内部错误描述。
let err: ServerFnError = AppError::Internal("内部错误").into();
let msg = err.to_string();
assert!(msg.contains("内部错误"), "expected passthrough: {msg}");
}
#[test]
fn transaction_hides_sql_details() {
// 事务错误同样返回通用提示,不泄露 SQL 细节。
let err: ServerFnError = AppError::tx("deadlock detected on UPDATE posts").into();
let msg = err.to_string();
assert!(!msg.contains("UPDATE"), "should not leak SQL: {msg}");
assert!(
!msg.contains("deadlock"),
"should not leak error detail: {msg}"
);
assert!(msg.contains("操作失败"), "expected generic message: {msg}");
}
#[test]
fn db_conn_query_transaction_all_return_generic_message() {
// 三类数据库错误对外均返回固定中文提示,避免泄露实现细节。
let db_conn: ServerFnError = AppError::DbConn("x".into()).into();
let query: ServerFnError = AppError::Query("x".into()).into();
let tx: ServerFnError = AppError::Transaction("x".into()).into();
assert!(db_conn.to_string().contains("服务暂时不可用"));
assert!(query.to_string().contains("操作失败"));
assert!(tx.to_string().contains("操作失败"));
}
}

View File

@ -12,6 +12,16 @@ pub fn clean_html(input: &str) -> String {
crate::api::sanitizer::clean_html(input)
}
#[cfg(feature = "server")]
/// 将标题纯文本转义,用于安全地拼进 TOC 的 `aria-label="..."` 与 `<a>` 正文。
///
/// 复用 `hooks::comment_storage::escape_html`(转义 `& < > " '`),避免在仓库内
/// 维护第二份转义实现。原先用 `clean_html` 处理属性上下文会漏掉 `"`,标题形如
/// `" onmouseover="alert(1)` 会越出属性边界。
fn escape_heading_text(s: &str) -> String {
crate::hooks::comment_storage::escape_html(s)
}
#[derive(Debug, Clone)]
#[cfg(feature = "server")]
/// Markdown 渲染结果。
@ -222,10 +232,13 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
}
}
let clean_text = clean_html(text);
// 标题 text 是 pulldown-cmark 收集的纯文本Text/Code 字面字符),不是 HTML 片段,
// 因此正文与属性两处都走 escape_heading_text转义 & < > " ')。原先用 clean_html
// 处理属性上下文会漏掉 `"`,标题中的双引号会越出 aria-label 边界。
let escaped_text = escape_heading_text(text);
html.push_str(&format!(
"<li><a href=\"#{}\" aria-label=\"{}\">{}</a>",
id, clean_text, clean_text
id, escaped_text, escaped_text
));
}
@ -357,6 +370,54 @@ mod tests {
assert_eq!(ul_count, 2);
}
#[test]
fn generate_toc_html_escapes_quote_in_attr() {
// 标题中的双引号不得越出 aria-label 属性边界。
let headings = vec![(
2u8,
"\" onmouseover=\"alert(1)".to_string(),
"heading".to_string(),
)];
let html = generate_toc_html(&headings);
// aria-label 中的双引号被转义为 &quot;,无法越出属性边界注入新属性。
assert!(
html.contains("aria-label=\"&quot; onmouseover=&quot;alert(1)\""),
"aria-label 应转义内部双引号got: {html}"
);
// 关键:不得出现「未被引号包裹、可被解析为真实属性」的 onmouseover= 片段。
// 正文中作为纯文本出现 "onmouseover" 字符串是安全的(无 < 或属性结构)。
let attr_injection = "\" onmouseover=\"";
let injected = html.matches(attr_injection).count();
// 原始输入里有 1 个裸双引号起头;转义后该模式不应再作为属性边界出现。
// 注意 aria-label 内部的双引号已变成 &quot;,因此裸的 `" onmouseover="` 不应存在。
assert_eq!(
injected, 0,
"不应存在未转义的属性边界 `\" onmouseover=\"`got: {html}"
);
}
#[test]
fn generate_toc_html_escapes_ampersand_in_attr() {
let headings = vec![(2u8, "A & B".to_string(), "heading".to_string())];
let html = generate_toc_html(&headings);
assert!(
html.contains("aria-label=\"A &amp; B\""),
"& 应在属性中转义got: {html}"
);
}
#[test]
fn generate_toc_html_escapes_less_than_in_attr() {
// `<` 在属性与正文中都应被转义,避免被误解析为标签起始。
let headings = vec![(2u8, "a < b".to_string(), "heading".to_string())];
let html = generate_toc_html(&headings);
assert!(
html.contains("aria-label=\"a &lt; b\""),
"< 应在属性中转义got: {html}"
);
assert!(!html.contains("a < b"));
}
#[test]
fn render_markdown_simple_paragraph() {
let result = render_markdown_enhanced("Hello **world**");

View File

@ -14,6 +14,29 @@ use super::types::PostListResponse;
use crate::api::error::AppError;
use crate::db::pool::get_conn;
/// 单页允许的最大文章数。
///
/// 公开的 `list_published_posts` 接口无需认证,若不对 `per_page` 设上限,
/// 攻击者可传入巨大值迫使数据库扫描并实例化超大 Vec造成内存放大与拒绝服务。
const MAX_PER_PAGE: i32 = 50;
/// 允许的最大页码。
///
/// `page` 无上限时,攻击者可用海量不同 `page` 值撑大缓存键空间(缓存污染),
/// 并触发无意义的超大 `OFFSET` 扫描。10_000 对任何实际博客都足够宽裕
/// (配合 `MAX_PER_PAGE` 最多覆盖 50 万篇文章),同时把缓存键空间限制在有限范围。
const MAX_PAGE: i32 = 10_000;
/// 将分页参数钳制到安全范围:页码 1`MAX_PAGE`,每页 1`MAX_PER_PAGE`。
///
/// 注意:返回值必须同时用于缓存键与 SQL 查询,避免同一逻辑页落入不同缓存条目。
fn clamp_pagination(page: i32, per_page: i32) -> (i32, i32) {
(
page.clamp(1, MAX_PAGE),
per_page.clamp(1, MAX_PER_PAGE),
)
}
/// 获取已发布文章分页列表。
///
/// 优先命中缓存;未命中时查询总数与分页记录,并按 published_at 降序排列。
@ -22,6 +45,9 @@ pub async fn list_published_posts(
page: i32,
per_page: i32,
) -> Result<PostListResponse, ServerFnError> {
// 钳制分页参数,防止无认证调用方请求超大每页数量导致内存放大 / DoS。
let (page, per_page) = clamp_pagination(page, per_page);
#[cfg(feature = "server")]
{
let cache_key = crate::cache::CacheKey::PublishedPosts { page, per_page };
@ -93,6 +119,8 @@ pub async fn list_published_posts(
/// 需要 admin 权限;结果按创建时间降序,不走缓存。
#[server(ListPosts, "/api")]
pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, ServerFnError> {
// 与公开接口保持一致的分页钳制,避免单次请求拉取过多记录。
let (page, per_page) = clamp_pagination(page, per_page);
let _user = get_current_admin_user().await?;
#[cfg(feature = "server")]
@ -199,3 +227,51 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clamp_pagination_keeps_valid_values() {
assert_eq!(clamp_pagination(1, 10), (1, 10));
assert_eq!(clamp_pagination(3, 20), (3, 20));
}
#[test]
fn clamp_pagination_clamps_oversized_per_page() {
// 攻击者传入超大 per_page 必须被压回上限,避免内存放大 / DoS。
assert_eq!(clamp_pagination(1, 1_000_000_000), (1, MAX_PER_PAGE));
assert_eq!(clamp_pagination(2, 51), (2, MAX_PER_PAGE));
}
#[test]
fn clamp_pagination_clamps_non_positive() {
assert_eq!(clamp_pagination(0, 10), (1, 10));
assert_eq!(clamp_pagination(-5, 10), (1, 10));
assert_eq!(clamp_pagination(1, 0), (1, 1));
assert_eq!(clamp_pagination(1, -100), (1, 1));
}
#[test]
fn clamp_pagination_clamps_oversized_page() {
// 巨大 page 必须被压回上限,避免无界 OFFSET 扫描与缓存键扇出。
assert_eq!(clamp_pagination(i32::MAX, 10), (MAX_PAGE, 10));
assert_eq!(clamp_pagination(MAX_PAGE + 1, 10), (MAX_PAGE, 10));
}
#[test]
fn clamp_pagination_max_page_boundary() {
assert_eq!(clamp_pagination(MAX_PAGE, 10), (MAX_PAGE, 10));
assert_eq!(clamp_pagination(MAX_PAGE - 1, 10), (MAX_PAGE - 1, 10));
}
#[test]
fn clamp_pagination_max_per_page_boundary() {
assert_eq!(clamp_pagination(1, MAX_PER_PAGE), (1, MAX_PER_PAGE));
assert_eq!(
clamp_pagination(1, MAX_PER_PAGE - 1),
(1, MAX_PER_PAGE - 1)
);
}
}

View File

@ -435,4 +435,76 @@ mod tests {
"<a rel=\"nofollow noopener\">x</a>"
);
}
// ---- is_safe_url 直接分支测试 ----
// is_safe_url 是安全敏感的内部函数,以下测试锁定其各分支的行为契约。
#[test]
fn is_safe_url_allows_https() {
let schemes = default_allowed_schemes();
assert!(is_safe_url("https://example.com", &schemes, false));
assert!(is_safe_url("http://example.com", &schemes, false));
}
#[test]
fn is_safe_url_rejects_javascript() {
let schemes = default_allowed_schemes();
assert!(!is_safe_url("javascript:alert(1)", &schemes, false));
}
#[test]
fn is_safe_url_rejects_vbscript() {
let schemes = default_allowed_schemes();
assert!(!is_safe_url("vbscript:msgbox", &schemes, false));
}
#[test]
fn is_safe_url_data_uri_respects_flag() {
let schemes = default_allowed_schemes();
// 文章正文允许 data URI
assert!(is_safe_url("data:image/png;base64,iVBOR", &schemes, true));
// 评论禁用 data URI
assert!(!is_safe_url("data:image/png;base64,iVBOR", &schemes, false));
}
#[test]
fn is_safe_url_allows_relative_and_fragment() {
let schemes = default_allowed_schemes();
// 绝对路径
assert!(is_safe_url("/path/to/page", &schemes, false));
// 锚点
assert!(is_safe_url("#section", &schemes, false));
}
#[test]
fn is_safe_url_empty_is_safe() {
let schemes = default_allowed_schemes();
// 空 URL如 img 无 src视为安全。
assert!(is_safe_url("", &schemes, false));
assert!(is_safe_url(" ", &schemes, false));
}
#[test]
fn is_safe_url_allows_other_whitelisted_schemes() {
let schemes = default_allowed_schemes();
// mailto / tel / ftp 等均在默认白名单中。
assert!(is_safe_url("mailto:user@example.com", &schemes, false));
assert!(is_safe_url("tel:+8613800138000", &schemes, false));
assert!(is_safe_url("ftp://example.com/file", &schemes, false));
}
#[test]
fn is_safe_url_rejects_scheme_with_whitespace() {
let schemes = default_allowed_schemes();
// 含空格的 scheme 名是已知的混淆手法,应被拒绝。
assert!(!is_safe_url("java\tscript:alert(1)", &schemes, false));
}
#[test]
fn is_safe_url_scheme_matching_is_case_insensitive() {
let schemes = default_allowed_schemes();
// scheme 大小写不敏感HTTPS 与 https 等价。
assert!(is_safe_url("HTTPS://example.com", &schemes, false));
assert!(!is_safe_url("JAVASCRIPT:alert(1)", &schemes, false));
}
}

View File

@ -69,7 +69,8 @@ pub mod server {
("golang", "go"),
];
for &(from, to) in aliases {
if lang == from {
// 别名比较同样不区分大小写,保证 "RUST" 与 "rust" 等价。
if lang.eq_ignore_ascii_case(from) {
if let Some(s) = ss.find_syntax_by_extension(to) {
return s;
}
@ -151,4 +152,73 @@ mod tests {
assert!(result.contains(r#"<span class="storage type rust">let</span>"#));
assert!(result.contains(r#"<span class="constant numeric integer decimal rust">1</span>"#));
}
#[test]
fn highlight_code_uppercase_language_falls_back_via_lowercase() {
// 大写语言标识应通过小写回退路径匹配到对应语法。
let lower = highlight_code("fn main() {}", Some("rust"));
let upper = highlight_code("fn main() {}", Some("RUST"));
// 大写标识的输出必须与小写标识完全一致,证明回退路径生效。
assert_eq!(lower, upper);
assert!(lower.contains(r#"<span class="storage type function rust">fn</span>"#));
}
#[test]
fn highlight_code_resolves_golang_alias() {
// 别名表中 "golang" 映射到 "go" 扩展名,输出应与直接用 "go" 一致。
let by_alias = highlight_code("package main", Some("golang"));
let by_ext = highlight_code("package main", Some("go"));
assert_eq!(by_alias, by_ext);
// 别名解析必须产出带 span 的高亮输出,而非纯文本。
assert!(by_alias.contains("span"));
}
#[test]
fn highlight_code_resolves_bash_alias() {
// 别名表中 "bash" 映射到 "sh" 扩展名。
let result = highlight_code("echo hello", Some("bash"));
assert!(result.contains("span"));
}
#[test]
fn highlight_code_resolves_yml_alias() {
// 别名表中 "yml" 映射到 "yaml" 扩展名。
let result = highlight_code("key: value", Some("yml"));
assert!(!result.is_empty());
}
#[test]
fn highlight_code_unknown_language_falls_back_to_plain_text() {
// 无法识别的语言应回退到纯文本语法,仍能输出内容。
let result = highlight_code("hello world", Some("totally-not-a-language-xyz"));
assert!(result.contains("hello world"));
}
#[test]
fn highlight_code_empty_language_string_falls_back_to_plain_text() {
// 空字符串语言标识应走纯文本回退路径。
let result = highlight_code("just text", Some(""));
assert!(result.contains("just text"));
}
#[test]
fn highlight_code_trims_surrounding_whitespace() {
// 代码首尾的空白会被 trim 掉再高亮。
let result = highlight_code(" \nfn main() {}\n ", Some("rust"));
assert!(result.contains(r#"<span class="storage type function rust">fn</span>"#));
}
#[test]
fn highlight_code_multiline_output_spans_all_lines() {
// 多行代码每一行都应被解析为带 span 的输出。
let code = "fn a() {}\nfn b() {}";
let result = highlight_code(code, Some("rust"));
// 两处 fn 关键字都应出现
assert_eq!(
result
.matches(r#"<span class="storage type function rust">fn</span>"#)
.count(),
2
);
}
}

View File

@ -210,4 +210,45 @@ mod tests {
fn toggle_switches_dark_to_light() {
assert_eq!(Theme::Dark.toggle(), Theme::Light);
}
#[test]
fn toggle_is_an_involution() {
// 连续切换两次应当回到原始主题。
assert_eq!(Theme::Light.toggle().toggle(), Theme::Light);
assert_eq!(Theme::Dark.toggle().toggle(), Theme::Dark);
}
#[test]
fn theme_derives_equality() {
// Theme 派生了 PartialEq相同变体必须相等。
assert_eq!(Theme::Light, Theme::Light);
assert_eq!(Theme::Dark, Theme::Dark);
assert_ne!(Theme::Light, Theme::Dark);
}
#[test]
fn theme_preload_script_adds_dark_class() {
// 预加载脚本必须包含给 documentElement 添加 dark class 的逻辑。
assert!(THEME_PRELOAD_SCRIPT.contains("classList.add('dark')"));
}
#[test]
fn theme_preload_script_reads_local_storage() {
// 预加载脚本必须读取 yggdrasil-theme 键,与 THEME_KEY 保持一致。
assert!(THEME_PRELOAD_SCRIPT.contains("localStorage.getItem('yggdrasil-theme')"));
assert_eq!(THEME_KEY, "yggdrasil-theme");
}
#[test]
fn theme_preload_script_falls_back_to_prefers_color_scheme() {
// 当 localStorage 中无主题时,脚本应回退到系统颜色偏好。
assert!(THEME_PRELOAD_SCRIPT.contains("prefers-color-scheme: dark"));
}
#[test]
fn theme_preload_script_swallows_errors() {
// 预加载脚本必须包裹在 try/catch 中,避免禁用 localStorage 时抛错。
assert!(THEME_PRELOAD_SCRIPT.contains("try"));
assert!(THEME_PRELOAD_SCRIPT.contains("catch"));
}
}

View File

@ -237,4 +237,88 @@ mod tests {
assert_eq!(0u8.clamp(0, 6), 0);
assert_eq!(6u8.clamp(0, 6), 6);
}
#[test]
fn webp_error_encode_display() {
let err = WebpError::Encode("boom".to_string());
assert_eq!(err.to_string(), "WebP encode error: boom");
}
#[test]
fn webp_error_decode_display() {
let err = WebpError::Decode("busted".to_string());
assert_eq!(err.to_string(), "WebP decode error: busted");
}
#[test]
fn webp_error_implements_std_error() {
// WebpError 必须实现 std::error::Error才能在 ? 传播链中使用。
fn assert_error<T: std::error::Error>() {}
assert_error::<WebpError>();
}
#[test]
fn encode_converts_luma8_to_rgba() {
// Luma8灰度图像不在 encode 的快速路径中,应被转换为 RGBA8 后编码。
let img = image::DynamicImage::new_luma8(4, 4);
let result = encode(&img, 80.0, 2);
assert!(result.is_ok());
assert!(!result.unwrap().is_empty());
}
#[test]
fn encode_converts_luma_a8_to_rgba() {
// LumaA8带 alpha 的灰度)同样走转换路径。
let img = image::DynamicImage::new_luma_a8(4, 4);
let result = encode(&img, 80.0, 2);
assert!(result.is_ok());
assert!(!result.unwrap().is_empty());
}
#[test]
fn encode_lower_quality_does_not_explode_on_solid_color() {
// 纯色图是 WebP 的极端情况(信息熵接近 0确保高低质量都能编码成功
// 而非 panic且产物是合法非空字节流。不假设低质量体积一定更小
// 因为这依赖底层 libwebp 的量化策略,非确定性不变量。
let img = image::DynamicImage::new_rgb8(64, 64);
let high = encode(&img, 95.0, 4).unwrap();
let low = encode(&img, 10.0, 4).unwrap();
assert!(!high.is_empty());
assert!(!low.is_empty());
// 两者都应是合法的 WebP能被本模块解码回来
assert!(decode(&high).is_ok());
assert!(decode(&low).is_ok());
}
#[test]
fn decode_invalid_bytes_returns_error() {
// 非 WebP 字节流应返回解码错误而非 panic。
let junk = b"this is definitely not a webp image";
let result = decode(junk);
assert!(result.is_err());
}
#[test]
fn decode_empty_bytes_returns_error() {
// 空字节流应返回解码错误而非 panic。
let result = decode(&[]);
assert!(result.is_err());
}
#[test]
fn decode_error_message_is_descriptive() {
// 解码错误的 Display 应包含 'WebP decode error' 前缀,便于日志排查。
let err = decode(b"not webp").unwrap_err();
assert!(err.to_string().starts_with("WebP decode error"));
}
#[test]
fn encode_decode_preserves_dimensions() {
// 编码再解码后,图像宽高应保持一致。
let original = image::DynamicImage::new_rgb8(16, 9);
let encoded = encode(&original, 85.0, 4).unwrap();
let decoded = decode(&encoded).unwrap();
assert_eq!(decoded.width(), 16);
assert_eq!(decoded.height(), 9);
}
}