@@ -32,6 +32,29 @@ const MAX_IMAGES: usize = 20;
3232/// 拼接输入段/块数量上限(防恶意超大列表拖垮拼接)。
3333const MAX_INPUT_ITEMS : usize = 5000 ;
3434
35+ /// 音频文件扩展名白名单(安全 L1 修复:transcribe_audio 不得接受任意非音频路径,
36+ /// 与 commands_import::VIDEO_EXTENSIONS 同口径)。
37+ const AUDIO_EXTENSIONS : [ & str ; 6 ] = [ "mp3" , "wav" , "m4a" , "flac" , "ogg" , "aac" ] ;
38+ /// 图片文件扩展名白名单(安全 L1 修复:recognize_image 同口径)。
39+ const IMAGE_EXTENSIONS : [ & str ; 5 ] = [ "png" , "jpg" , "jpeg" , "webp" , "bmp" ] ;
40+
41+ /// 扩展名白名单校验(安全 L1):返回小写扩展名,不在白名单内返回可诊断错误。
42+ /// Why:旧实现接受任意绝对路径无限制——非媒体文件进推理管线只能得到
43+ /// 模糊的引擎报错,且扩大了 IPC 入参攻击面;白名单前置拒绝可诊断。
44+ fn require_media_extension ( path : & str , allowed : & [ & str ] , kind : & str ) -> Result < String , String > {
45+ let ext = std:: path:: Path :: new ( path)
46+ . extension ( )
47+ . and_then ( |e| e. to_str ( ) )
48+ . map ( |e| e. to_ascii_lowercase ( ) )
49+ . unwrap_or_default ( ) ;
50+ if !allowed. contains ( & ext. as_str ( ) ) {
51+ // 三维复审 #7:无扩展名时显示"(无扩展名)"而非空的 "."(可诊断性)
52+ let shown = if ext. is_empty ( ) { "(无扩展名)" . to_string ( ) } else { format ! ( ".{}" , ext) } ;
53+ return Err ( format ! ( "不支持的{}文件类型: {}(支持: {})" , kind, shown, allowed. join( "/" ) ) ) ;
54+ }
55+ Ok ( ext)
56+ }
57+
3558/// 校验并归一化标题:空串回退默认名,超长截断(TD-005 统一口径,commands_session 复用)。
3659pub ( crate ) fn normalize_title ( raw : String , fallback : & str ) -> String {
3760 let trimmed = raw. trim ( ) . to_string ( ) ;
@@ -150,8 +173,13 @@ pub async fn transcribe_audio(state: State<'_, AppState>, path: String) -> Resul
150173 if path. trim ( ) . is_empty ( ) {
151174 return Err ( "音频路径为空" . to_string ( ) ) ;
152175 }
176+ // 安全 L1 修复:扩展名白名单前置校验(拒绝非音频路径)
177+ require_media_extension ( & path, & AUDIO_EXTENSIONS , "音频" ) ?;
153178 let engines = state. engines . clone ( ) ;
154- tauri:: async_runtime:: spawn_blocking ( move || engines. transcribe ( & path) )
179+ // H2 修复:有界等待变体——引擎卡死时返回可诊断超时错误而非永久阻塞
180+ tauri:: async_runtime:: spawn_blocking ( move || {
181+ engines. transcribe_timeout ( & path, crate :: engine:: ASR_REQUEST_TIMEOUT )
182+ } )
155183 . await
156184 . map_err ( |e| format ! ( "任务调度失败: {}" , e) ) ?
157185 . map_err ( |e| e. to_string ( ) )
@@ -163,8 +191,13 @@ pub async fn recognize_image(state: State<'_, AppState>, path: String) -> Result
163191 if path. trim ( ) . is_empty ( ) {
164192 return Err ( "图片路径为空" . to_string ( ) ) ;
165193 }
194+ // 安全 L1 修复:扩展名白名单前置校验(拒绝非图片路径)
195+ require_media_extension ( & path, & IMAGE_EXTENSIONS , "图片" ) ?;
166196 let engines = state. engines . clone ( ) ;
167- tauri:: async_runtime:: spawn_blocking ( move || engines. recognize ( & path) )
197+ // H2 修复:有界等待变体(同 transcribe_audio)
198+ tauri:: async_runtime:: spawn_blocking ( move || {
199+ engines. recognize_timeout ( & path, crate :: engine:: OCR_REQUEST_TIMEOUT )
200+ } )
168201 . await
169202 . map_err ( |e| format ! ( "任务调度失败: {}" , e) ) ?
170203 . map_err ( |e| e. to_string ( ) )
@@ -224,20 +257,35 @@ pub async fn process_to_note(
224257 if audio_path. as_ref ( ) . is_some_and ( |p| p. trim ( ) . is_empty ( ) ) {
225258 return Err ( "音频路径为空" . to_string ( ) ) ;
226259 }
260+ // 安全 L1 修复(三维复审 #4):直送媒体路径同样套用扩展名白名单,
261+ // 与 transcribe_audio/recognize_image 同口径(此前本 command 是缺口)
262+ if let Some ( p) = audio_path. as_deref ( ) {
263+ require_media_extension ( p, & AUDIO_EXTENSIONS , "音频" ) ?;
264+ }
265+ for p in & image_paths {
266+ require_media_extension ( p, & IMAGE_EXTENSIONS , "图片" ) ?;
267+ }
227268 let title = normalize_title ( title, "未命名笔记" ) ;
228269 let engines = state. engines . clone ( ) ;
229270 let db = state. db . clone ( ) ;
230271 tauri:: async_runtime:: spawn_blocking ( move || {
231272 // 1) 转写(可选音频)
273+ // H2 修复:有界等待变体——Err 携带超时/引擎诊断信息返回前端
274+ // 三维复审 #3:整文件转写用 ASR_FILE_TIMEOUT(30 分钟)——耗时与音频
275+ // 时长线性相关,短请求 60s 预算会误杀长录音(行为契约回归)
232276 let mut segments = Vec :: new ( ) ;
233277 if let Some ( path) = audio_path {
234- segments. push ( engines. transcribe ( & path) . map_err ( |e| e. to_string ( ) ) ?) ;
278+ segments. push (
279+ engines
280+ . transcribe_timeout ( & path, crate :: engine:: ASR_FILE_TIMEOUT )
281+ . map_err ( |e| e. to_string ( ) ) ?,
282+ ) ;
235283 }
236284 // 2) 多图 OCR(单图失败跳过不阻断,但记录警告——TD-001 修复:不再静默吞错)
237285 let mut ocr_blocks = Vec :: new ( ) ;
238286 let mut skipped: Vec < String > = Vec :: new ( ) ;
239287 for path in image_paths {
240- match engines. recognize ( & path) {
288+ match engines. recognize_timeout ( & path, crate :: engine :: OCR_REQUEST_TIMEOUT ) {
241289 Ok ( blocks) => ocr_blocks. extend ( blocks) ,
242290 Err ( e) => skipped. push ( format ! ( "{}: {}" , path, e) ) ,
243291 }
0 commit comments