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
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "QRGen 默认权限 — 限制前端 IPC 和平台 API 访问",
"windows": ["main"],
"permissions": [
"core:default",
"store:default",
"dialog:default",
"clipboard-manager:default"
]
}
@@ -8,6 +8,12 @@ export default class ErrorBoundary extends Component<Props, State> {
static getDerivedStateFromError(error: Error) { return { hasError: true, error }; }
componentDidCatch(error: Error, info: React.ErrorInfo) {
// 生产环境错误日志记录入口
// TODO: 集成遥测服务后将错误上报
console.error('QRGen ErrorBoundary 捕获错误:', error.message, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
@@ -4,27 +4,18 @@ import { invoke } from '@tauri-apps/api/core';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { save } from '@tauri-apps/plugin-dialog';
import { writeFile } from '@tauri-apps/plugin-fs';
function getCurrentText(state: any): string {
switch (state.mode) {
case 'url': return state.formData.url || '';
case 'wifi': return `WIFI:T:${state.formData.encryption || 'WPA'};S:${state.formData.ssid || ''};P:${state.formData.password || ''};;`;
case 'vcard': return `BEGIN:VCARD\nVERSION:3.0\nFN:${state.formData.name || ''}\nTEL:${state.formData.phone || ''}\nEMAIL:${state.formData.email || ''}\nORG:${state.formData.company || ''}\nADR:${state.formData.address || ''}\nEND:VCARD`;
case 'email': return `mailto:${state.formData.to || ''}?subject=${encodeURIComponent(state.formData.subject || '')}&body=${encodeURIComponent(state.formData.body || '')}`;
case 'phone': return `tel:${state.formData.number || ''}`;
case 'sms': return `smsto:${state.formData.number || ''}:${state.formData.message || ''}`;
default: return state.formData.text || '';
}
}
import type { QrConfig } from '../types';
import { buildEncodedText } from '../utils/qrText';
export default function ExportPanel() {
const { state, dispatch } = useQrState();
const [exporting, setExporting] = useState(false);
const handleCopySvg = async () => {
if (state.preview?.svg) {
if (!state.preview?.svg) return;
try {
await writeText(state.preview.svg);
}
} catch { /* 剪贴板不可用时静默忽略 */ }
};
const handleExportPng = async () => {
@@ -38,15 +29,13 @@ export default function ExportPanel() {
if (!filePath) { setExporting(false); return; }
const bytes: number[] = await invoke('export_png', {
text: getCurrentText(state),
text: buildEncodedText(state.mode, state.formData),
level: state.config.level,
margin: state.config.margin,
moduleSize: state.config.moduleSize,
});
await writeFile(filePath, new Uint8Array(bytes));
} catch (e) {
console.warn('导出 PNG 失败:', e);
}
} catch { /* 导出失败时静默处理,UI 回到就绪状态 */ }
setExporting(false);
};
@@ -59,9 +48,7 @@ export default function ExportPanel() {
});
if (!filePath) return;
await writeFile(filePath, new TextEncoder().encode(state.preview.svg));
} catch (e) {
console.warn('导出 SVG 失败:', e);
}
} catch { /* 导出失败时静默处理 */ }
};
return (
@@ -72,7 +59,7 @@ export default function ExportPanel() {
<select
value={state.config.level}
onChange={e => dispatch({ type: 'SET_CONFIG', payload: { level: e.target.value as any } })}
onChange={e => dispatch({ type: 'SET_CONFIG', payload: { level: e.target.value as QrConfig['level'] } })}
className="w-full mt-1 px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm outline-none focus:ring-2 focus:ring-blue-500/30"
>
<option value="L">L 7%</option>
@@ -1,26 +1,30 @@
import { useQrState } from '../store/qrContext';
import { MODE_LABELS, type HistoryEntry } from '../types';
import { persistHistory } from '../hooks/useQrEncode';
export default function HistoryList() {
const { state, dispatch } = useQrState();
const handleClick = (entry: HistoryEntry) => {
dispatch({ type: 'SET_MODE', payload: entry.mode as any });
try {
const formData = JSON.parse(entry.content);
dispatch({ type: 'SET_FORM_DATA', payload: formData });
} catch {
dispatch({ type: 'SET_MODE', payload: entry.mode });
// 优先使用存储的 formData 恢复表单,否则回退到纯文本
if (entry.formData) {
dispatch({ type: 'SET_FORM_DATA', payload: entry.formData });
} else {
dispatch({ type: 'SET_FORM_DATA', payload: { text: entry.content } });
}
};
const handleDelete = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
dispatch({ type: 'REMOVE_HISTORY', payload: id });
const updated = state.history.filter(h => h.id !== id);
dispatch({ type: 'SET_HISTORY', payload: updated });
persistHistory(updated);
};
const handleClear = () => {
dispatch({ type: 'SET_HISTORY', payload: [] });
persistHistory([]);
};
const formatTime = (ts: number) => {
@@ -53,7 +57,7 @@ export default function HistoryList() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="px-1 py-0.5 rounded text-[10px] font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
{MODE_LABELS[entry.mode as keyof typeof MODE_LABELS] || entry.mode}
{MODE_LABELS[entry.mode] || entry.mode}
</span>
<span className="text-gray-400">{formatTime(entry.timestamp)}</span>
</div>
+26 -9
View File
@@ -1,13 +1,29 @@
import { useMemo } from 'react';
import { useQrState } from '../store/qrContext';
/** 将 SVG 字符串转为安全的 data URL<img> 标签中浏览器会阻止 SVG 内的脚本执行) */
function svgToDataUrl(svg: string): string {
const encoded = btoa(unescape(encodeURIComponent(svg)));
return `data:image/svg+xml;base64,${encoded}`;
}
export default function QrPreview() {
const { state } = useQrState();
if (!state.preview?.svg) {
const svgDataUrl = useMemo(
() => (state.preview?.svg ? svgToDataUrl(state.preview.svg) : null),
[state.preview?.svg],
);
if (!svgDataUrl) {
return (
<div className="flex flex-col items-center justify-center gap-3 text-gray-400">
<div className="w-64 h-64 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-2xl flex items-center justify-center bg-white/50 dark:bg-gray-800/50">
<span className="text-sm"> QR </span>
{state.loading ? (
<span className="text-sm animate-pulse">...</span>
) : (
<span className="text-sm"> QR </span>
)}
</div>
</div>
);
@@ -15,15 +31,16 @@ export default function QrPreview() {
return (
<div className="flex flex-col items-center gap-3">
{/* SVG 由 Rust 端 qr-core 生成,仅含 <rect> 和固定颜色,无用户文本嵌入 */}
<div
className="w-64 h-64 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-2xl p-4 flex items-center justify-center bg-white dark:bg-white qr-preview"
dangerouslySetInnerHTML={{ __html: state.preview.svg }}
{/* SVG 转为 data URL 通过 <img> 渲染,浏览器在 img 上下文中阻止脚本执行 */}
<img
src={svgDataUrl}
alt="QR 码"
className="w-64 h-64 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-2xl p-4 bg-white dark:bg-white qr-preview"
/>
<div className="flex gap-3 text-xs text-gray-500 dark:text-gray-400">
<span>: {state.preview.version}</span>
<span>{state.preview.size}×{state.preview.size}</span>
<span>: {state.preview.mask}</span>
<span>: {state.preview!.version}</span>
<span>{state.preview!.size}×{state.preview!.size}</span>
<span>: {state.preview!.mask}</span>
</div>
</div>
);
+53 -15
View File
@@ -2,9 +2,19 @@ import { useCallback, useRef, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Store } from '@tauri-apps/plugin-store';
import { useQrState } from '../store/qrContext';
import type { HistoryEntry } from '../types';
import type { HistoryEntry, ModeType } from '../types';
const HISTORY_KEY = 'qr-history';
const STORE_FILE = 'history.json';
/** 缓存的 Store 实例,避免每次编码都重新加载 */
let storeCache: Promise<Store> | null = null;
function getStore(): Promise<Store> {
if (!storeCache) {
storeCache = Store.load(STORE_FILE);
}
return storeCache;
}
interface QrResponse {
svg: string;
@@ -13,6 +23,36 @@ interface QrResponse {
mask: number;
}
/** 对 WiFi 密码进行脱敏处理 */
function sanitizeContent(mode: ModeType, content: string): string {
if (mode === 'wifi') {
return content.replace(/P:[^;]*/, 'P:***');
}
return content;
}
/**
* 持久化整个历史列表到 store
* 作为内存状态的唯一持久化出口
*/
export async function persistHistory(history: HistoryEntry[]): Promise<void> {
try {
const store = await getStore();
await store.set(HISTORY_KEY, history);
await store.save();
} catch { /* store 不可用时静默忽略 */ }
}
/** 从 store 加载历史记录(应用启动时调用) */
export async function loadHistory(): Promise<HistoryEntry[]> {
try {
const store = await getStore();
return (await store.get<HistoryEntry[]>(HISTORY_KEY)) || [];
} catch {
return [];
}
}
export function useQrEncode() {
const { state, dispatch } = useQrState();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -46,28 +86,26 @@ export function useQrEncode() {
// 保存到历史(内存 + 持久化)
const entryId = Date.now().toString();
const currentMode = modeRef.current;
const entry: HistoryEntry = {
id: entryId,
mode: modeRef.current,
content: text,
mode: currentMode,
content: sanitizeContent(currentMode, text),
timestamp: Date.now(),
formData: { ...state.formData },
};
dispatch({ type: 'ADD_HISTORY', payload: entry });
// 持久化到 tauri-plugin-store
try {
const store = await Store.load('history.json');
const current = await store.get<HistoryEntry[]>(HISTORY_KEY) || [];
const updated = [entry, ...current].slice(0, 50);
await store.set(HISTORY_KEY, updated);
await store.save();
} catch { /* store 不可用时静默忽略 */ }
} catch (e) {
// 编码失败已在 dispatch SET_PREVIEW(null) 中处理,无需额外日志
// 从内存状态持久化(避免 store 读写竞态)
// 注意: dispatch ADD_HISTORY 是异步的,这里手动计算最新列表
// 确保持久化的数据与内存一致
persistHistory([entry, ...state.history].slice(0, 50));
} catch {
// 编码失败时清空预览
dispatch({ type: 'SET_PREVIEW', payload: null });
}
}, 200);
}, [state.config.level, state.config.margin, dispatch]);
}, [state.config.level, state.config.margin, state.formData, state.history, dispatch]);
return { encode };
return { encode, persistHistory };
}
+2 -2
View File
@@ -1,5 +1,6 @@
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
import { buildEmailText } from '../utils/qrText';
export default function EmailMode() {
const { state, dispatch } = useQrState();
@@ -8,8 +9,7 @@ export default function EmailMode() {
const update = (field: string, value: string) => {
const data = { ...state.formData, [field]: value };
dispatch({ type: 'SET_FORM_DATA', payload: data });
const mailto = `mailto:${data.to || ''}?subject=${encodeURIComponent(data.subject || '')}&body=${encodeURIComponent(data.body || '')}`;
encode(mailto);
encode(buildEmailText(data));
};
return (
+2 -1
View File
@@ -1,5 +1,6 @@
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
import { buildPhoneText } from '../utils/qrText';
export default function PhoneMode() {
const { state, dispatch } = useQrState();
@@ -7,7 +8,7 @@ export default function PhoneMode() {
const update = (number: string) => {
dispatch({ type: 'SET_FORM_DATA', payload: { number } });
encode(`tel:${number}`);
encode(buildPhoneText({ number }));
};
return (
+2 -1
View File
@@ -1,5 +1,6 @@
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
import { buildSmsText } from '../utils/qrText';
export default function SmsMode() {
const { state, dispatch } = useQrState();
@@ -8,7 +9,7 @@ export default function SmsMode() {
const update = (field: string, value: string) => {
const data = { ...state.formData, [field]: value };
dispatch({ type: 'SET_FORM_DATA', payload: data });
encode(`smsto:${data.number || ''}:${data.message || ''}`);
encode(buildSmsText(data));
};
return (
+2 -2
View File
@@ -1,5 +1,6 @@
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
import { buildVCardText } from '../utils/qrText';
const FIELDS = [
{ key: 'name', placeholder: '姓名' },
@@ -16,8 +17,7 @@ export default function VCardMode() {
const update = (field: string, value: string) => {
const data = { ...state.formData, [field]: value };
dispatch({ type: 'SET_FORM_DATA', payload: data });
const vcard = `BEGIN:VCARD\nVERSION:3.0\nFN:${data.name || ''}\nTEL:${data.phone || ''}\nEMAIL:${data.email || ''}\nORG:${data.company || ''}\nADR:${data.address || ''}\nEND:VCARD`;
encode(vcard);
encode(buildVCardText(data));
};
return (
+6 -12
View File
@@ -1,22 +1,16 @@
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
import { buildWifiText } from '../utils/qrText';
export default function WifiMode() {
const { state, dispatch } = useQrState();
const { encode } = useQrEncode();
const buildWifiText = (ssid: string, password: string, encryption: string, hidden: boolean) => {
if (!ssid) return '';
return `WIFI:T:${encryption};S:${ssid};P:${password};${hidden ? 'H:true;' : ''};`;
};
const update = (field: string, value: string | boolean) => {
const data = { ...state.formData, [field]: String(value) };
/** checkbox 的 boolean 值统一转为 'true'/'false' 字符串存入 formData */
const update = (field: string, value: string) => {
const data = { ...state.formData, [field]: value };
dispatch({ type: 'SET_FORM_DATA', payload: data });
const wifiText = buildWifiText(
data.ssid || '', data.password || '', data.encryption || 'WPA', data.hidden === 'true'
);
encode(wifiText);
encode(buildWifiText(data));
};
return (
@@ -36,7 +30,7 @@ export default function WifiMode() {
</select>
<label className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
<input type="checkbox" checked={state.formData.hidden === 'true'}
onChange={e => update('hidden', e.target.checked)} />
onChange={e => update('hidden', e.target.checked ? 'true' : 'false')} />
</label>
</div>
+4 -7
View File
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useReducer, useEffect, type ReactNode } from 'react';
import { Store } from '@tauri-apps/plugin-store';
import type { QrState, QrAction, HistoryEntry } from '../types';
import { loadHistory } from '../hooks/useQrEncode';
import type { QrState, QrAction } from '../types';
const initialState: QrState = {
mode: 'text',
@@ -47,11 +47,8 @@ export function QrProvider({ children }: { children: ReactNode }) {
// 启动时从 store 加载持久化的历史记录
useEffect(() => {
(async () => {
try {
const store = await Store.load('history.json');
const history = await store.get<HistoryEntry[]>('qr-history') || [];
dispatch({ type: 'SET_HISTORY', payload: history });
} catch { /* store 不可用时忽略 */ }
const history = await loadHistory();
dispatch({ type: 'SET_HISTORY', payload: history });
})();
}, []);
+3 -1
View File
@@ -15,9 +15,11 @@ export interface QrPreview {
export interface HistoryEntry {
id: string;
mode: string;
mode: ModeType;
content: string;
timestamp: number;
/** 原始表单数据,用于恢复历史记录时回填各模式字段 */
formData?: Record<string, string>;
}
export interface QrState {
+56
View File
@@ -0,0 +1,56 @@
/**
* QR 编码文本构造工具
* 集中管理各模式的文本格式,避免 ExportPanel 和各 mode 组件间的重复逻辑
*/
/** 构造 WiFi 连接字符串 */
export function buildWifiText(formData: Record<string, string>): string {
const ssid = formData.ssid || '';
if (!ssid) return '';
const encryption = formData.encryption || 'WPA';
const password = formData.password || '';
// hidden 存储为字符串 'true'/'false',保留 boolean 语义
const hidden = formData.hidden === 'true' ? 'H:true;' : '';
return `WIFI:T:${encryption};S:${ssid};P:${password};${hidden};`;
}
/** 构造 vCard 字符串 */
export function buildVCardText(formData: Record<string, string>): string {
const name = formData.name || '';
const phone = formData.phone || '';
const email = formData.email || '';
const company = formData.company || '';
const address = formData.address || '';
return `BEGIN:VCARD\nVERSION:3.0\nFN:${name}\nTEL:${phone}\nEMAIL:${email}\nORG:${company}\nADR:${address}\nEND:VCARD`;
}
/** 构造 mailto 链接 */
export function buildEmailText(formData: Record<string, string>): string {
const to = formData.to || '';
const subject = encodeURIComponent(formData.subject || '');
const body = encodeURIComponent(formData.body || '');
return `mailto:${to}?subject=${subject}&body=${body}`;
}
/** 构造电话链接 */
export function buildPhoneText(formData: Record<string, string>): string {
return `tel:${formData.number || ''}`;
}
/** 构造短信链接 */
export function buildSmsText(formData: Record<string, string>): string {
return `smsto:${formData.number || ''}:${formData.message || ''}`;
}
/** 从完整 formData 构造当前模式的编码文本(供 ExportPanel 使用) */
export function buildEncodedText(mode: string, formData: Record<string, string>): string {
switch (mode) {
case 'url': return formData.url || '';
case 'wifi': return buildWifiText(formData);
case 'vcard': return buildVCardText(formData);
case 'email': return buildEmailText(formData);
case 'phone': return buildPhoneText(formData);
case 'sms': return buildSmsText(formData);
default: return formData.text || '';
}
}
+2 -1
View File
@@ -82,7 +82,8 @@ fn export_png(text: String, level: String, margin: u8, module_size: u8) -> Resul
let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?;
qr.to_png_bytes(module_size).map_err(|e| format!("PNG 导出失败: {}", e))
qr.to_png_bytes(module_size)
.map_err(|e| format!("PNG 导出失败: {}", e))
}
/// 保存历史记录条目
+1 -1
View File
@@ -23,7 +23,7 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:"
}
},
"plugins": {},