fix: 全面代码审查修复 — 安全/类型/持久化/代码质量 (28项)

🔴 CRITICAL (1):
- tauri.conf.json: CSP 从 null 改为最小权限策略

🟠 HIGH (6):
- 新建 capabilities/default.json: Tauri v2 权限约束(store/dialog/clipboard)
- cli: 路径遍历防护 — 拒绝含 ParentDir 组件的输出路径
- HistoryList: 删除/清空同步持久化到 store,历史点击用 formData 回填
- ExportPanel: 移除 console.warn,getCurrentText any→QrState
- useQrEncode: WiFi 密码在历史中脱敏显示(P:***),Store 实例缓存

🟡 MEDIUM (10):
- mode.rs: Kanji fallback 从 UTF-8 字节改为 13-bit 零值占位(段内模式一致)
- mode.rs: Shift JIS 第二字节跳过 0x7F 空洞,修正行内索引
- mode.rs: encode_numeric/alphanumeric 添加 debug_assert! 前置条件
- mask.rs: best_matrix.unwrap()→expect() 附错误信息
- version.rs: ec_info 仅返回 count>0 的 BlockInfo,EcInfo/BlockInfo 加 Debug+Clone
- types/index.ts: HistoryEntry.mode string→ModeType,新增 formData 字段
- qrContext.tsx: 使用缓存 Store 加载历史

🟢 LOW (11):
- cargo fmt 全部文件
- svg.rs: String::new()→with_capacity() 预分配
- patterns.rs: encode_format_info 拆分为两行提高可读性
- png.rs: 提取 fill_module() 辅助函数降低嵌套
- ErrorBoundary: 添加 componentDidCatch 错误日志入口
- QrPreview: dangerouslySetInnerHTML→<img>+data URL(安全),loading 状态指示
- galois.rs/version.rs: 5 处 #[allow(clippy::indexing_slicing)]+安全文档
- 新建 utils/qrText.ts: 集中管理 6 种模式的文本构造,消除 ExportPanel/mode 间重复

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-17 14:10:13 +08:00
parent 3f1b9901b5
commit 1e9c94eff9
26 changed files with 413 additions and 154 deletions
+14 -9
View File
@@ -1,8 +1,15 @@
/// GF(2⁸) Galois 域运算
/// 本原多项式: x⁸ + x⁴ + x³ + x² + 1 = 0x11D
/// 生成元 α = 0x02
//! GF(2⁸) Galois 域运算
//! 本原多项式: x⁸ + x⁴ + x³ + x² + 1 = 0x11D
//! 生成元 α = 0x02
//!
//! 索引安全性: 本模块内数组索引依赖 GF(2⁸) 数学性质——
//! * LOG_TABLE[0..=255] → 查表键为 u8,始终在界内
//! * EXP_TABLE[0..=511] → 双倍长度避免取模,所有指数和 ≤508<512
//! * 所有索引数学上保证安全,编译器无法静态证明
use std::sync::LazyLock;
#[allow(clippy::indexing_slicing)]
static EXP_TABLE: LazyLock<[u8; 512]> = LazyLock::new(|| {
let mut table = [0u8; 512];
let mut x = 1u8;
@@ -21,6 +28,7 @@ static EXP_TABLE: LazyLock<[u8; 512]> = LazyLock::new(|| {
table
});
#[allow(clippy::indexing_slicing)]
static LOG_TABLE: LazyLock<[u8; 256]> = LazyLock::new(|| {
let mut table = [0u8; 256];
let mut x = 1u8;
@@ -50,6 +58,7 @@ pub fn sub(a: u8, b: u8) -> u8 {
/// GF(2⁸) 乘法:a * b
#[inline]
#[allow(clippy::indexing_slicing)]
pub fn mul(a: u8, b: u8) -> u8 {
if a == 0 || b == 0 {
return 0;
@@ -61,6 +70,7 @@ pub fn mul(a: u8, b: u8) -> u8 {
/// GF(2⁸) 除法:a / bb == 0 时返回 None
#[inline]
#[allow(clippy::indexing_slicing)]
pub fn div(a: u8, b: u8) -> Option<u8> {
if a == 0 {
return Some(0);
@@ -76,6 +86,7 @@ pub fn div(a: u8, b: u8) -> Option<u8> {
/// GF(2⁸) 幂运算:base^exp
#[inline]
#[allow(clippy::indexing_slicing)]
pub fn pow(base: u8, exp: usize) -> u8 {
if exp == 0 {
return 1;
@@ -93,11 +104,8 @@ mod tests {
#[test]
fn test_mul_basic() {
// 2 * 3 = 6 in GF(2^8)
assert_eq!(mul(2, 3), 6);
// 0xFF * 0x01 = 0xFF
assert_eq!(mul(0xFF, 1), 0xFF);
// 任何数乘 0 = 0
assert_eq!(mul(0xA5, 0), 0);
}
@@ -167,11 +175,8 @@ mod tests {
#[test]
fn test_pow() {
// α^0 = 1
assert_eq!(pow(2, 0), 1);
// α^1 = 2
assert_eq!(pow(2, 1), 2);
// α^7 * α^2 = α^9
assert_eq!(mul(pow(2, 7), pow(2, 2)), pow(2, 9));
}
}
+45 -20
View File
@@ -60,7 +60,12 @@ impl Mode {
}
/// 数字模式编码: 每 3 位数字 → 10 bit
/// 调用方应确保 input 仅包含 ASCII 数字字符 (0-9)
pub fn encode_numeric(input: &str) -> Vec<bool> {
debug_assert!(
input.chars().all(is_numeric),
"encode_numeric: 输入含非数字字符"
);
let mut bits = Vec::new();
let chars: Vec<u8> = input
.chars()
@@ -87,7 +92,12 @@ pub fn encode_numeric(input: &str) -> Vec<bool> {
const ALPHANUMERIC_CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
/// 字母数字模式编码: 每 2 个字符 → 11 bit
/// 调用方应确保 input 仅包含字母数字字符集内的字符
pub fn encode_alphanumeric(input: &str) -> Vec<bool> {
debug_assert!(
input.chars().all(is_alphanumeric),
"encode_alphanumeric: 输入含非法字符"
);
let values: Vec<u8> = input
.chars()
.filter_map(|c| {
@@ -127,7 +137,11 @@ pub fn encode_byte(input: &str) -> Vec<bool> {
}
/// 汉字模式编码 (Shift JIS → 13 bit)
/// 对于无法转换为 Shift JIS 的字符,降级为 UTF-8 字节编码
/// 对于无法转换为 Shift JIS 的字符,使用全零占位符(避免段内模式混用)
///
/// 注: segment_text 保证 Kanji 段内字符均通过 is_kanji() 检测,
/// 但 unicode_to_shift_jis 目前仅覆盖 U+4E00-U+9FFF (基本 CJK 统一汉字)。
/// U+3400-U+4DBF (CJK Ext-A) 和 U+3000-U+303F (CJK 符号) 的映射有待补全。
pub fn encode_kanji(input: &str) -> Vec<bool> {
let mut bits = Vec::new();
for c in input.chars() {
@@ -136,14 +150,9 @@ pub fn encode_kanji(input: &str) -> Vec<bool> {
bits.push((sjis_val >> i) & 1 == 1);
}
} else {
// 回退到字节模式
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
for &byte in s.as_bytes() {
for i in (0..8).rev() {
bits.push((byte >> i) & 1 == 1);
}
}
// 无法映射的字符,填充 13-bit 零值占位符
// 避免模式混用(不插入字节模式编码以保证段内一致性)
bits.extend(std::iter::repeat_n(false, 13));
}
}
bits
@@ -151,6 +160,10 @@ pub fn encode_kanji(input: &str) -> Vec<bool> {
/// Unicode → Shift JIS 简化转换
/// 覆盖常用 CJK 统一汉字 (U+4E00 ~ U+9FFF)
///
/// 注意: 此映射采用线性近似公式。实际上 Unicode CJK 与 Shift JIS (JIS X 0208)
/// 并非严格线性对应,对于非线性的字符会产生偏差。
/// 对于需要精确映射的场景,建议使用完整的 CJK→Shift JIS 映射表。
fn unicode_to_shift_jis(c: char) -> Option<u16> {
let code = c as u32;
// CJK 统一汉字 基本区
@@ -163,20 +176,32 @@ fn unicode_to_shift_jis(c: char) -> Option<u16> {
// Shift JIS 汉字有两段区间: 0x81-0x9F 和 0xE0-0xEF
// 中间 0xA0-0xDF 为间隙,需要跳过
let (hi, lo) = if hi_offset < 31 {
(0x81u16 + hi_offset as u16, 0x40u16 + lo_offset as u16)
let hi = if hi_offset < 31 {
0x81u16 + hi_offset as u16
} else {
(
0xE0u16 + (hi_offset - 31) as u16,
0x40u16 + lo_offset as u16,
)
0xE0u16 + (hi_offset - 31) as u16
};
// 第二字节有效范围: 0x40-0x7E 和 0x80-0xFC (跳过 0x7F)
let lo_base = 0x40u16 + lo_offset as u16;
let lo = if lo_base >= 0x7F {
lo_base + 1 // 跳过无效的 0x7F
} else {
lo_base
};
// 行内索引: 0x40..=0x7E → 0..62, 0x80..=0xFC → 63..187
let lo_idx = if lo >= 0x80 {
lo - 0x41 // 跳过 0x7F 后的偏移
} else {
lo - 0x40
};
// 映射到 13-bit 码字
let val = if (0x81..=0x9Fu16).contains(&hi) {
(hi - 0x81) * 0xBC + (lo - 0x40)
(hi - 0x81) * 0xBC + lo_idx
} else {
(hi - 0xC1) * 0xBC + (lo - 0x40)
(hi - 0xC1) * 0xBC + lo_idx
};
return Some(val);
}
@@ -309,10 +334,10 @@ mod tests {
#[test]
fn test_kanji_mode_fallback() {
// 非 CJK 字符会被降级为 UTF-8 字节编码
// 非 CJK 字符无法映射到 Shift JIS,填充 13-bit 零值占位符(保持段内模式一致)
let bits = encode_kanji("A");
// 1 个 ASCII 字符 = 8 bit
assert_eq!(bits.len(), 8);
// 1 个无法映射的字符 = 13 bit 占位符
assert_eq!(bits.len(), 13);
}
fn bits_to_u16(bits: &[bool]) -> u16 {
+4 -1
View File
@@ -197,7 +197,10 @@ pub fn best_mask(matrix: &Matrix) -> (u8, Matrix) {
}
}
(best_idx, best_matrix.unwrap())
(
best_idx,
best_matrix.expect("掩码循环 (0..8) 至少执行一次,best_matrix 必定有值"),
)
}
#[cfg(test)]
+4 -2
View File
@@ -153,8 +153,10 @@ pub fn encode_format_info(ec_bits: u8, mask: u8) -> u16 {
}
}
// XOR 掩码 — 注意 ^ 优先级高于 |,必须加括号
(((data as u16) << 10) | (val & 0x3FF)) ^ 0x5412
// BCH 编码结果: (data << 10) | remainder, 再与掩码 XOR
// 注意: ^ 优先级高于 |,必须用括号保护
let raw = ((data as u16) << 10) | (val & 0x3FF);
raw ^ 0x5412
}
/// 将格式信息写入矩阵(两处镜像放置)
+19 -10
View File
@@ -1,6 +1,24 @@
use crate::qr::QrCode;
use image::{ImageBuffer, Luma};
/// 将单个模块填充到图像缓冲区(module_size × module_size 像素块)
fn fill_module(
img: &mut ImageBuffer<Luma<u8>, Vec<u8>>,
x: u32,
y: u32,
module_size: u32,
is_dark: bool,
) {
let px_val = if is_dark { 0u8 } else { 255u8 };
let x0 = x * module_size;
let y0 = y * module_size;
for dy in 0..module_size {
for dx in 0..module_size {
img.put_pixel(x0 + dx, y0 + dy, Luma([px_val]));
}
}
}
pub fn render_png(qr: &QrCode, module_size: u8) -> Result<Vec<u8>, image::ImageError> {
let matrix_size = qr.size() as u32;
let margin = qr.margin as u32;
@@ -20,16 +38,7 @@ pub fn render_png(qr: &QrCode, module_size: u8) -> Result<Vec<u8>, image::ImageE
false // 白边
};
let px_val = if is_dark { 0u8 } else { 255u8 };
for dy in 0..module_size as u32 {
for dx in 0..module_size as u32 {
img.put_pixel(
x * module_size as u32 + dx,
y * module_size as u32 + dy,
Luma([px_val]),
);
}
}
fill_module(&mut img, x, y, module_size as u32, is_dark);
}
}
+8 -1
View File
@@ -5,7 +5,14 @@ pub fn render_svg(qr: &QrCode) -> String {
let margin = qr.margin as u32;
let total = matrix_size + 2 * margin;
let mut svg = String::new();
// 预估 SVG 大小: 固定头部 + 每个暗模块约 48 字节
let dark_count = qr
.modules()
.iter()
.flat_map(|row| row.iter())
.filter(|&&m| m)
.count();
let mut svg = String::with_capacity(200 + dark_count * 50);
svg.push_str(&format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">"#,
total, total, total, total
+21 -11
View File
@@ -82,29 +82,36 @@ impl Version {
row.h_g2_data,
),
};
let mut blocks = Vec::new();
if g1_blocks > 0 {
blocks.push(BlockInfo {
count: g1_blocks,
data_codewords: g1_data,
});
}
if g2_blocks > 0 {
blocks.push(BlockInfo {
count: g2_blocks,
data_codewords: g2_data,
});
}
EcInfo {
total_codewords: total,
ec_per_block,
blocks: vec![
BlockInfo {
count: g1_blocks,
data_codewords: g1_data,
},
BlockInfo {
count: g2_blocks,
data_codewords: g2_data,
},
],
blocks,
}
}
}
#[derive(Debug, Clone)]
pub struct EcInfo {
pub total_codewords: u16,
pub ec_per_block: u8,
pub blocks: Vec<BlockInfo>,
}
#[derive(Debug, Clone)]
pub struct BlockInfo {
pub count: u16,
pub data_codewords: u16,
@@ -1283,6 +1290,8 @@ fn init_capacity_table() -> [[u16; 4]; 40] {
table
}
// SAFETY: version.0 ∈ [1,40] 由 Version::new() 保证; level 是 4 变体枚举
#[allow(clippy::indexing_slicing)]
pub fn get_data_capacity(version: Version, level: EcLevel) -> u16 {
static CAPACITY: OnceLock<[[u16; 4]; 40]> = OnceLock::new();
let cap = CAPACITY.get_or_init(init_capacity_table);
@@ -1335,9 +1344,10 @@ mod tests {
fn test_ec_info_blocks() {
let info = Version(1).ec_info(EcLevel::M);
// Version 1 M: 1 block × 16 data codewords, 10 ec per block
// g2 组 count=0 已被过滤,仅保留 g1
assert_eq!(info.total_codewords, 26);
assert_eq!(info.ec_per_block, 10);
assert_eq!(info.blocks.len(), 2);
assert_eq!(info.blocks.len(), 1);
assert_eq!(info.blocks[0].count, 1);
assert_eq!(info.blocks[0].data_codewords, 16);
}
+89 -18
View File
@@ -44,18 +44,45 @@ fn test_dump_format_info() {
// 格式信息位置 (按标准顺序 bit14→bit0)
let coords = [
(0, 8), (1, 8), (2, 8), (3, 8), (4, 8), (5, 8), (7, 8), (8, 8),
(8, 7), (8, 5), (8, 4), (8, 3), (8, 2), (8, 1), (8, 0),
(0, 8),
(1, 8),
(2, 8),
(3, 8),
(4, 8),
(5, 8),
(7, 8),
(8, 8),
(8, 7),
(8, 5),
(8, 4),
(8, 3),
(8, 2),
(8, 1),
(8, 0),
];
let mut fmt_bits = 0u16;
for (i, &(x, y)) in coords.iter().enumerate() {
let bit = if m[y as usize][x as usize] { 1u16 } else { 0u16 };
let bit = if m[y as usize][x as usize] {
1u16
} else {
0u16
};
fmt_bits = (fmt_bits << 1) | bit;
print!("{} ", if m[y as usize][x as usize] { '█' } else { '_' });
print!(
"{} ",
if m[y as usize][x as usize] {
'█'
} else {
'_'
}
);
}
println!();
println!("读取的格式信息 (原始, 含 XOR mask 0x5412): 0x{:04X}", fmt_bits);
println!(
"读取的格式信息 (原始, 含 XOR mask 0x5412): 0x{:04X}",
fmt_bits
);
// 去掉 XOR mask
let unmasked = fmt_bits ^ 0x5412;
@@ -63,7 +90,10 @@ fn test_dump_format_info() {
let ec_bits = (unmasked >> 13) & 0x03;
let mask_bits = (unmasked >> 10) & 0x07;
let bch = unmasked & 0x3FF;
println!("EC bits: {:02b} 掩码 bits: {:03b} BCH: 0x{:03X}", ec_bits, mask_bits, bch);
println!(
"EC bits: {:02b} 掩码 bits: {:03b} BCH: 0x{:03X}",
ec_bits, mask_bits, bch
);
// 期望值
let expected = {
@@ -71,7 +101,14 @@ fn test_dump_format_info() {
encode_format_info(qr.level.indicator_bits(), qr.mask)
};
println!("期望的格式信息: 0x{:04X}", expected);
println!("匹配: {}", if fmt_bits == expected { "" } else { "❌ 不匹配!" });
println!(
"匹配: {}",
if fmt_bits == expected {
""
} else {
"❌ 不匹配!"
}
);
assert_eq!(fmt_bits, expected, "格式信息不匹配!");
}
#[test]
@@ -84,11 +121,30 @@ fn test_finder_patterns_present() {
let s = size as u8;
let finders: [(u8, u8); 3] = [(0, 0), (s - 7, 0), (0, s - 7)];
for (fx, fy) in finders {
assert!(m[fy as usize][fx as usize], "定位({},{}): 左上角应为暗", fx, fy);
assert!(m[fy as usize][(fx + 6) as usize], "定位({},{}): 右上角应为暗", fx, fy);
assert!(m[(fy + 6) as usize][fx as usize], "定位({},{}): 左角应为暗", fx, fy);
assert!(
m[fy as usize][fx as usize],
"定位({},{}): 左角应为暗",
fx, fy
);
assert!(
m[fy as usize][(fx + 6) as usize],
"定位({},{}): 右上角应为暗",
fx,
fy
);
assert!(
m[(fy + 6) as usize][fx as usize],
"定位({},{}): 左下角应为暗",
fx,
fy
);
// 内部 3×3 是暗色
assert!(m[(fy + 2) as usize][(fx + 2) as usize], "定位({},{}): 中心3×3应为暗", fx, fy);
assert!(
m[(fy + 2) as usize][(fx + 2) as usize],
"定位({},{}): 中心3×3应为暗",
fx,
fy
);
}
}
@@ -128,8 +184,10 @@ fn test_format_info_written() {
let qr = QrCode::encode("HELLO", QrConfig::default()).unwrap();
let m = qr.modules();
// 格式信息在定位图案旁,检查几个位置不是全亮
assert!(m[8][0] || m[8][1] || m[8][2] || !m[8][0],
"格式信息应已写入");
assert!(
m[8][0] || m[8][1] || m[8][2] || !m[8][0],
"格式信息应已写入"
);
}
#[test]
@@ -140,8 +198,10 @@ fn test_svg_valid_structure() {
assert!(svg.starts_with("<svg"), "SVG 应以 <svg 开头");
assert!(svg.contains("rect"), "SVG 应包含 rect 元素");
assert!(svg.contains("fill=\"black\""), "SVG 暗模块应是黑色");
assert!(svg.ends_with("</svg>\n") || svg.ends_with("</svg>"),
"SVG 应以 </svg> 结尾");
assert!(
svg.ends_with("</svg>\n") || svg.ends_with("</svg>"),
"SVG 应以 </svg> 结尾"
);
}
#[test]
@@ -163,7 +223,10 @@ fn test_qr_structure_dump() {
let size = qr.size() as usize;
let m = qr.modules();
println!("\n=== QR Matrix {}x{} v{} mask{} ===", size, size, qr.version.0, qr.mask);
println!(
"\n=== QR Matrix {}x{} v{} mask{} ===",
size, size, qr.version.0, qr.mask
);
for y in 0..size {
for x in 0..size {
print!("{}", if m[y][x] { "##" } else { " " });
@@ -174,9 +237,17 @@ fn test_qr_structure_dump() {
// 统计
let dark: usize = m.iter().flatten().filter(|&&x| x).count();
let total = size * size;
println!("\n暗/总: {}/{} = {:.1}%", dark, total, dark as f64 / total as f64 * 100.0);
println!(
"\n暗/总: {}/{} = {:.1}%",
dark,
total,
dark as f64 / total as f64 * 100.0
);
println!("尺寸: {}×{}", size, size);
println!("版本: {} 掩码: {} 纠错: {:?}", qr.version.0, qr.mask, qr.level);
println!(
"版本: {} 掩码: {} 纠错: {:?}",
qr.version.0, qr.mask, qr.level
);
}
#[test]