Skip to content

Commit 17adf14

Browse files
committed
fix(review): CSS 解码内联/收件体量上限/裁决会话限定写 + 回归测试
1 parent cb28f22 commit 17adf14

7 files changed

Lines changed: 94 additions & 17 deletions

File tree

‎app/src-tauri/src/commands_after_tests.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,30 @@ fn weekly_batch_tolerates_stale_rows() {
6767
assert!(db.get_note(nid).unwrap().unwrap().content.contains("[x] 任务一"));
6868
}
6969

70+
#[test]
71+
fn weekly_stale_line_flagged_without_history_pollution() {
72+
// 审查回归(高-3):队列加载后行内容被改 → 批决议不得静默照记史
73+
let db = Db::open(":memory:").unwrap();
74+
let nid = seed(&db);
75+
let rows = db.list_task_queue(Some(nid)).unwrap();
76+
let r1 = rows.iter().find(|r| r.task_text == "任务一").unwrap();
77+
// 模拟后台改文:任务一被改写(行号不变、载荷不符)
78+
let note = db.get_note(nid).unwrap().unwrap();
79+
db.update_note(nid, &note.title, note.content.replace("- [ ] 任务一", "- [ ] 任务一改").as_str())
80+
.unwrap();
81+
let view = weekly_resolve_core(
82+
&db,
83+
&[WeeklyDecision { row_id: r1.id, action: "done".into(), reason: None }],
84+
)
85+
.unwrap();
86+
assert_eq!(view.done, 0, "失效行不得计完成");
87+
assert_eq!(view.failed.len(), 1, "显式报失效");
88+
let note2 = db.get_note(nid).unwrap().unwrap();
89+
assert!(note2.content.contains("- [ ] 任务一改"), "正文未被错误改写");
90+
let hist = db.list_completion_events(Some("done"), 10).unwrap();
91+
assert!(hist.is_empty(), "不得污染完成史");
92+
}
93+
7094
#[test]
7195
fn manual_fill_export_done_records_history() {
7296
let db = Db::open(":memory:").unwrap();

‎app/src-tauri/src/commands_asr_pass2.rs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,10 +279,13 @@ pub fn second_pass_decide(
279279
return Err("草稿不存在于该会话(id 越界或已随会话删除)".to_string());
280280
}
281281
let status = if adopt { STATUS_ADOPTED } else { STATUS_REJECTED };
282-
state
282+
let affected = state
283283
.db
284-
.decide_refine_draft(draft_id, status)
284+
.decide_refine_draft_in_session(draft_id, Some(session_id), status)
285285
.map_err(|e| format!("裁决失败: {e}"))?;
286+
if !affected {
287+
return Err("草稿不存在于该会话(并发下已变更)".to_string());
288+
}
286289
// REQ-269 采集:采纳第二遍草稿时把(旧文→新文)词级差异记入混淆画像
287290
// (仅 second_pass origin——校对源不采集;失败仅日志,不影响裁决主链路)
288291
if adopt {

‎app/src-tauri/src/commands_web_inbox.rs‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,18 +194,21 @@ fn read_request(stream: &mut TcpStream) -> Option<(String, String, HashMap<Strin
194194
.and_then(|(_, v)| v.trim().parse().ok())
195195
})
196196
.unwrap_or(0);
197+
// 审查 H1:声明体量预检——超上限立即断(超大流不再读入,
198+
// 与协议 8MB 契约一致;头部已定的积压阶段同样有界)
199+
if content_len > BODY_MAX {
200+
return None;
201+
}
197202
if pos + 4 + content_len <= buf.len() {
198203
let body = buf[pos + 4..pos + 4 + content_len].to_vec();
199204
let head = String::from_utf8_lossy(&buf[..pos]).into_owned();
200205
let (method, path, headers) = parse_headers(&head)?;
201206
return Some((method, path, headers, body));
202207
}
203-
if buf.len() > BODY_MAX {
204-
return None;
205-
}
206208
}
207209
}
208-
if buf.len() > BODY_MAX && head_end.is_none() {
210+
// 未含头的首段与头部已定的积压阶段统一有界(防谎报/流式放大)
211+
if buf.len() > BODY_MAX {
209212
return None;
210213
}
211214
}

‎app/src-tauri/src/db_session_refine.rs‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,16 +210,33 @@ impl Db {
210210
/// 裁决单条草稿(adopted/rejected 双向可翻转——回退=rejected 即恢复原料;
211211
/// 重新采纳=adopted。原料表始终不动)。
212212
pub fn decide_refine_draft(&self, draft_id: i64, status: &str) -> Result<bool> {
213+
self.decide_refine_draft_in_session(draft_id, None, status)
214+
}
215+
216+
/// 会话限定裁决(审查 L4:WHERE 带 session_id——TOCTOU 双保险,防
217+
/// 预校验后归属变更的越权写)。
218+
pub fn decide_refine_draft_in_session(
219+
&self,
220+
draft_id: i64,
221+
session_id: Option<i64>,
222+
status: &str,
223+
) -> Result<bool> {
213224
if status != STATUS_ADOPTED && status != STATUS_REJECTED {
214225
return Err(crate::error::AppError::Asr(format!(
215226
"非法草稿裁决状态: {status}(仅 adopted/rejected)"
216227
)));
217228
}
218229
self.with_conn(|conn| {
219-
let affected = conn.execute(
220-
"UPDATE session_refine_drafts SET status = ?1, decided_at = ?2 WHERE id = ?3",
221-
params![status, unix_seconds(), draft_id],
222-
)?;
230+
let affected = match session_id {
231+
Some(sid) => conn.execute(
232+
"UPDATE session_refine_drafts SET status = ?1, decided_at = ?2 WHERE id = ?3 AND session_id = ?4",
233+
params![status, unix_seconds(), draft_id, sid],
234+
)?,
235+
None => conn.execute(
236+
"UPDATE session_refine_drafts SET status = ?1, decided_at = ?2 WHERE id = ?3",
237+
params![status, unix_seconds(), draft_id],
238+
)?,
239+
};
223240
Ok(affected > 0)
224241
})
225242
}

‎app/src-tauri/src/db_task_index_tests.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,21 @@ fn rebuild_is_idempotent_and_delete_cleans() {
7171
assert!(db.list_task_queue(None).unwrap().is_empty());
7272
}
7373

74+
#[test]
75+
fn rebuild_preserves_plan_meta_across_saves() {
76+
// 审查回归(高-1):改期元数据是索引列——任何正文保存重扫都不得抹除
77+
let db = Db::open(":memory:").unwrap();
78+
let id = create_note_with(&db, "- [ ] 待办甲\n");
79+
let rows = db.list_task_queue(Some(id)).unwrap();
80+
let tomorrow = crate::db::unix_seconds() / 86_400 * 86_400 + 86_400;
81+
db.set_task_plan_date(rows[0].id, Some(tomorrow)).unwrap();
82+
// 异内容保存(前插一段——行号漂移 + 重扫)
83+
db.update_note(id, "任务笔记", "新段\n\n- [ ] 待办甲\n").unwrap();
84+
let after = db.list_task_queue(Some(id)).unwrap();
85+
assert_eq!(after.len(), 1, "重扫吸收行漂移");
86+
assert_eq!(after[0].plan_date, Some(tomorrow), "计划日元数据跨重扫保留");
87+
}
88+
7489
#[test]
7590
fn content_without_tasks_indexes_empty() {
7691
let db = Db::open(":memory:").unwrap();

‎app/src-tauri/src/web_snapshot.rs‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,14 @@ pub fn resolve_url(base: &str, href: &str) -> Option<String> {
4141
Some(format!("{}://{}{}", &base[..scheme_split], dir, href))
4242
}
4343

44-
/// 内联单资源占位(style/img 空档——防空串替换破坏结构)。
44+
/// 解码 base64 为文本(CSS 内联用;失败=None 保留原引用降级)。
45+
fn decode_text(b64: &str) -> Option<String> {
46+
use base64::Engine as _;
47+
let bytes = base64::engine::general_purpose::STANDARD.decode(b64.trim()).ok()?;
48+
Some(String::from_utf8_lossy(&bytes).into_owned())
49+
}
50+
51+
/// 内联单资源占位(img 用 data URI;style 走 decode_text 分支)。
4552
fn data_or_keep(
4653
kind: &str,
4754
resolved: Option<String>,
@@ -90,13 +97,19 @@ pub fn inline_html(base: &str, html: &str, resolver: Resolver) -> String {
9097
if lower.starts_with("<link") {
9198
if let Some(href) = extract_attr(tag, "href") {
9299
if lower.contains("stylesheet") {
93-
if let Some(data) = data_or_keep("style", resolve_url(base, &href), &mut *resolver) {
94-
out.push_str(&format!("<style data-inlined=\"{}\">{}</style>", escape_attr(&href), data));
95-
continue;
100+
// CSS 必须解码为纯文本塞入 <style>(data URI 字面量是非法规则——
101+
// 审查 M1:内联产物对外部样式要真实生效)
102+
if let Some(url) = resolve_url(base, &href) {
103+
if let Some(b64) = resolver(&url) {
104+
if let Some(css) = decode_text(b64.as_str()) {
105+
out.push_str(&format!("<style data-inlined=\"{}\">{}</style>", escape_attr(&href), css));
106+
continue;
107+
}
108+
}
96109
}
97110
}
98111
}
99-
out.push_str(tag);
112+
out.push_str(&scrub_tag(tag));
100113
} else if lower.starts_with("<img") {
101114
if let Some(src) = extract_attr(tag, "src") {
102115
let data = data_or_keep("img", resolve_url(base, &src), &mut *resolver);

‎app/src-tauri/src/web_snapshot_tests.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ fn inline_styles_imgs_and_strip_external_scripts() {
1818
<script src="https://evil.example/x.js"></script></head>
1919
<body><img src="pic/logo.png" alt="logo"><script>var ok=1;</script><p>正文</p></body></html>"#;
2020
let mut resolver = |url: &str| match url {
21-
"https://a.com/css/main.css" => Some("aGFzaA==".to_string()), // base64('hash')
21+
"https://a.com/css/main.css" => Some("Ym9keXtjb2xvcjpyZWR9".to_string()), // base64('body{color:red}')
2222
"https://a.com/x/pic/logo.png" => Some("aWNvbg==".to_string()),
2323
_ => None,
2424
};
2525
let out = inline_html("https://a.com/x/y.html", html, &mut resolver);
26-
assert!(out.contains("data:text/css;base64,aGFzaA=="), "{}", out);
26+
// CSS 必须解码为规则文本(data URI 字面量是非法规则——M1 修复断言)
27+
assert!(out.contains("body{color:red}"), "{}", out);
28+
assert!(!out.contains("data:text/css"), "{}", out);
2729
assert!(out.contains("data:image/png;base64,aWNvbg=="), "{}", out);
2830
assert!(!out.contains("evil.example"), "外链脚本剔除");
2931
assert!(!out.contains("var ok=1"), "行内脚本同样剔除(离线打开零执行面)");

0 commit comments

Comments
 (0)