fix(m198): JAVAGUARD——首屏自动扫 / 子进程回收 / 参数长度三条守卫 + 设置页正文结构断言

- 首屏自动扫:删掉 `cfg!(test)` 分叉,生产入口在测试里真跑;新增
  `the_first_hydrate_starts_a_real_scan_by_itself`(删掉那一行、或把实参翻成 `true`
  都红)。顺手删掉恒为假的 `seeded && !live`:截图种子档在上方就 return 了,编译器也
  报 "assigned but never used"。
- proc:`kill()` + `wait()` 收进 `terminate()`(三个分支共用);用例先用同一条命令
  真跑一次证明子进程起得来(`spawn().ok()?` 的空跑假绿),并新增 Linux/Android 的
  僵尸回收用例(只 kill 不 wait ⇒ `/proc/<pid>` 留成僵尸)。
- java.extra_args:`MAX_JVM_ARGS = 1024` 镜像 schema,`Refusal::JvmTooLong` + 中英
  文案 + JVM 输入框变红;App 级用例断言回执是具名的「太长」而不是通用的「写不进去」。
- 设置页正文:判据①从「数 `stack = stack.push(` 的出现次数」升级成按角色断言
  (唯一来源 section_columns、`push(` 恰一次且实参是循环变量、尾表达式、禁
  extend/chain/once/concat/append),单行折叠变异体现在会红。
This commit is contained in:
AskaEth committed 2026-11-09 06:46:29 +08:00
1 parent 7f697f5762
commit aae9a233ff
9 files changed
+385 -81

No files matched your search

+59 -17
View File
@@ -230,8 +230,8 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me
/// 正文由两半拼成:`mod.rs` 的 [`section_columns`](super::section_columns) **生产**「每组一件」的列,
/// `form` 只把它们堆起来再滚出去。于是:
///
/// - [`the_form_stops_at_its_last_group`] 守**收尾**:`form()` 里 `stack = stack.push(` 恰好一处、
/// 且以 `frame::scroll(ui, stack)` 收尾 —— 尾部多一件、或整段换成别的拼装(push 数变 0)都红;
/// - [`the_form_stacks_nothing_but_its_sections`] 守**收尾**:正文的唯一来源是 `section_columns`
/// (`push(` 恰好一次、实参必须是循环变量、尾表达式是 `frame::scroll`)—— 多一件就红;
/// - [`the_form_holds_exactly_one_column_per_group`] 守**生产**:真调 `section_columns` 数它交出的
/// 列数(窄窗 == `GROUPS.len()`,单选各组 == 1)—— 多塞一件就红,**不管那件叫什么名字**。
///
@@ -244,9 +244,11 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me
///
/// ## 抓得住 / 抓不住(据实写)
///
/// 抓得住(三种变异都实测过):①按原样把卡加回循环之后(`stack = stack.push(...)`)⇒ 收尾那条红;
/// ②改成「塞进列清单」(`section_columns` 里多 push 一件)⇒ 列数那条红,换任何名字都躲不掉;
/// ③把拼装整段重写成别的形状(不再用上面那个惯用法)⇒ 收尾那条数的 1 变成 0,同样红。
/// 抓得住(四种变异都实测过):①把卡折叠回循环
/// (`stack = stack.push(section).push(frame::demo_card(…))`)⇒ 收尾那条数到 2 处 `push(`,红
/// (与 `cargo fmt` 是否拆行无关);②换成别的来源(`stack = stack.push(frame::text_role(…))`)⇒
/// 实参不是 `section`,红;③塞进列清单(`section_columns` 里多 push 一件)⇒ 列数那条红;
/// ④整段改写成 `extend` / `chain` / 尾表达式再包一层 ⇒ 收尾那条红。
/// 抓不住:**某一组自己的列里**换内容(比如某个分组真的需要自己的空态)。那不是本缺陷——本页允许
/// 组内有空态,前提是它长在该组的列里、用本页语义的文案。`DevDocs/artifacts/m196/` 那组图是**当时**
/// 的证据,不是将来的防线(静态图只能证明「现在没有」)。
@@ -287,6 +289,15 @@ mod no_stray_empty_state {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
/// `form()` 的函数体,**剥掉每行 `//` 之后的部分**:判据数的是代码,注释里的 `push(` 不算。
fn form_code() -> String {
form_body()
.lines()
.map(|line| line.split("//").next().unwrap_or_default())
.collect::<Vec<_>>()
.join("\n")
}
/// 演示 / harness 路径那份只读状态(与 `pages::page` 同源,不伪造第二份)。
fn states() -> States<'static> {
States {
@@ -295,24 +306,55 @@ mod no_stray_empty_state {
}
}
/// 判据 ①(**收尾**):循环之后没有任何额外的 `push`,且函数以滚出去收尾。
/// 判据 ①(**收尾**):正文的柱子只有一个来源——`section_columns` 交出的那一列。
///
/// 循环体里只有一件(一组一列),所以「`stack = stack.push(` 恰好一次」等于「尾部空着」;
/// 拼装被整段重写成别的形状时计数变 0,同样红。
/// M195 r2 复审实测的盲区:原来数的是 `stack = stack.push(` 的出现**次数**,于是
/// `stack = stack.push(section).push(frame::demo_card(ui, &[]));` 一句折叠里塞第二件时计数仍是 1
/// (第二个 `.push(` 前面没有 `stack = `)⇒ 判据 ① 与 ② 同时绿。那是在数**写法**。现在按**角色**
/// 断言:循环头直接迭代 `section_columns`、`push(` 恰好一次且实参是那个循环变量、尾表达式恰好是
/// `frame::scroll(ui, stack)`、没有 `extend`/`chain`/`once`/`concat`/`append`。`push(` 按**次数**
/// 数(拆行不影响),注释里的不算(先剥掉每行 `//`)。变异证明见模块文档的「抓得住」。
#[test]
fn the_form_stops_at_its_last_group() {
let body = form_body();
let pushes = body.matches("stack = stack.push(").count();
fn the_form_stacks_nothing_but_its_sections() {
let body = form_code();
assert!(
body.contains("for section in section_columns("),
"`form()` 的循环头必须直接迭代 `section_columns(...)`:正文节点的唯一来源就是它"
);
let pushes = body.matches("push(").count();
assert_eq!(
pushes, 1,
"`form()` 里出现了 {pushes} 处 `stack = stack.push(`:分组循环里只该有一处。\
尾部又被叠了一件东西的话,设置页每一组下面都会多出同一件(M196 那张\
「没有逐实例覆盖」的卡就是这么来的)"
"`form()` 里出现了 {pushes} 处 `push(`:分组循环里只该有一处。多出来的那一处就是\
「每组下面多一件」(M196 那张卡)——同一句里折叠成 `.push(a).push(b)` 也算两处"
);
assert!(
body.contains(" frame::scroll(ui, stack)\n}"),
let at = body.find("push(").expect("上面刚数过一次") + "push(".len();
let argument = body[at..].split(')').next().unwrap_or_default().trim();
assert_eq!(
argument, "section",
"被压进正文的必须是 `section_columns` 交出的那一列(循环变量 `section`),实际是 \
`{argument}`:别的来源(演示卡 / 空态 / 任何控件)都不许进正文"
);
for append in ["extend(", "chain(", "once(", "concat(", "append("] {
assert!(
!body.contains(append),
"`form()` 用了 `{append}`:正文只许从 `section_columns` 逐列 push 进来"
);
}
let tail: Vec<&str> = body
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
assert_eq!(
tail.iter().rev().take(2).copied().collect::<Vec<_>>(),
vec!["}", "frame::scroll(ui, stack)"],
"`form()` 必须以 `frame::scroll(ui, stack)` 收尾(后面紧跟函数右花括号):\
正文到最后一组就结束,别再往后叠东西"
正文到最后一组就结束,别再包一层或往后叠东西"
);
}
@@ -125,7 +125,11 @@ pub(super) fn jvm_field<'a>(
.label(catalog.text(Key::SettingsJavaJvm))
.placeholder(catalog.text(Key::SettingsJavaJvmPlaceholder))
.supporting(catalog.text(Key::SettingsJavaJvmHint));
let field = if matches!(state.validate(), Err(Refusal::JvmQuotes)) {
// 两种拒绝都长在这个框上:引号没配对、或者粘了超过 1024 字符的一整段(M199)。
let field = if matches!(
state.validate(),
Err(Refusal::JvmQuotes | Refusal::JvmTooLong)
) {
field.error()
} else {
field
@@ -39,6 +39,12 @@ pub const MAX_MEMORY_MB: u32 = 65536;
/// `java.default_memory_mb` 的出厂默认值(MB):与 schema 的默认值同源,
/// 一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。
pub const DEFAULT_MEMORY_MB: u32 = 2048;
/// `java.extra_args` 的长度上限(字符):**必须**等于 schema 登记的 `Text{max_len: 1024}`。
///
/// 为什么页面要自己再判一次(M199):schema 那一道是在 `store.set` 里拒的,用户得到的是
/// `Notice::WriteFailed`(「写不进去」)+ 一句带具体长度的中文只进 stderr——粘了一大段参数的
/// 人不知道是长度问题。一致性由 `tests::the_jvm_length_mirrors_the_schema` 守。
pub const MAX_JVM_ARGS: usize = 1024;
/// 扫描进度(`None` = 没在扫)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -105,6 +111,8 @@ pub enum Refusal {
MemoryRange,
/// JVM 参数里的引号没配成对
JvmQuotes,
/// `java.extra_args` 超过 schema 登记的长度上限(1024)
JvmTooLong,
/// 手动路径为空
AddEmpty,
/// 手动路径在盘上找不到
@@ -132,6 +140,7 @@ impl Refusal {
Self::MemoryNotNumber => Key::SettingsJavaReasonMemoryNotNumber,
Self::MemoryRange => Key::SettingsJavaReasonMemoryRange,
Self::JvmQuotes => Key::SettingsJavaReasonJvmQuotes,
Self::JvmTooLong => Key::SettingsJavaReasonJvmTooLong,
Self::AddEmpty => Key::SettingsJavaReasonAddEmpty,
Self::AddMissing => Key::SettingsJavaReasonAddMissing,
Self::AddNotJava => Key::SettingsJavaReasonAddNotJava,
@@ -287,7 +296,8 @@ impl State {
/// 保存前的本地校验(过不了就不写盘)。
///
/// # Errors
/// 内存不是整数 / 越界,或 JVM 参数的引号没配成对时返回原因。
/// 内存不是整数 / 越界,JVM 参数引号没配成对,或 JVM 参数超过 schema 登记的长度上限时
/// 返回原因。
pub fn validate(&self) -> Result<u32, Refusal> {
let text = self.memory.trim();
if text.is_empty() {
@@ -302,6 +312,10 @@ impl State {
if unbalanced_quotes(&self.jvm) {
return Err(Refusal::JvmQuotes);
}
// 与 schema 同一个口径(`spec.rs` 的 `ValueKind::Text` 数 `chars()`,写盘前 `trim()`)。
if self.jvm.trim().chars().count() > MAX_JVM_ARGS {
return Err(Refusal::JvmTooLong);
}
Ok(mb)
}
@@ -18,7 +18,7 @@ mod cmdline;
use std::path::{Path, PathBuf};
use super::state::{Refusal, RuntimeRow, State, MAX_MEMORY_MB, MIN_MEMORY_MB};
use super::state::{Refusal, RuntimeRow, State, MAX_JVM_ARGS, MAX_MEMORY_MB, MIN_MEMORY_MB};
use super::store;
/// 每个用例一个临时数据根(`<dir>/config/launcher.json` 的形状与真实数据根一致)。
@@ -238,6 +238,62 @@ fn the_memory_bounds_mirror_the_schema() {
assert_eq!(*min, i64::from(MIN_MEMORY_MB));
assert_eq!(*max, i64::from(MAX_MEMORY_MB));
}
// --------------------------------------------------------------- 守卫⑤:JVM 参数超长被拒(M199)
/// 粘了一整段参数(>1024 字符)时界面给的是**具体**原因,而不是撞 schema 之后的「写不进去」;
/// 而且一个字节都不写盘。
///
/// 变异证明:删掉 `State::validate` 里那条 `MAX_JVM_ARGS` 判据 ⇒ 长度打回 schema 那一档
/// (`store::save` 返回 `WriteError::Schema`,用户只看到通用失败文案、具体原因只在 stderr),
/// 本用例红。这也是 r3 复审 P3-4 的现场。
#[test]
fn an_overlong_jvm_argument_line_is_refused_before_writing() {
let dir = root("jvm-long");
let file = config(&dir);
// 恰好等于上限:放行(判据是「超过」,不是「达到」)。
let mut state = State {
storage: true,
memory: super::state::DEFAULT_MEMORY_MB.to_string(),
jvm: "x".repeat(MAX_JVM_ARGS),
..State::default()
};
assert!(state.validate().is_ok(), "恰好 {MAX_JVM_ARGS} 字符应当放行");
state.jvm = "x".repeat(MAX_JVM_ARGS + 1);
assert_eq!(state.validate().err(), Some(Refusal::JvmTooLong));
assert_eq!(
Refusal::JvmTooLong.label(),
unlucky_i18n::Key::SettingsJavaReasonJvmTooLong,
"拒绝原因必须有自己的一句文案(不许退回通用的「写不进去」)"
);
let error = store::save(&file, &state).expect_err("超长参数应被本地校验拒掉");
assert_eq!(error, store::WriteError::Refused(Refusal::JvmTooLong));
assert!(!file.exists(), "被本地校验拒掉的保存不许碰磁盘");
std::fs::remove_dir_all(&dir).ok();
}
/// `MAX_JVM_ARGS` 必须等于 schema 登记的 `java.extra_args` 上限:本地放行的值不许被 schema 拒
/// (否则用户又只看到那句通用失败文案)。
///
/// 变异证明:把 `MAX_JVM_ARGS` 改成 2048 ⇒ 本用例红。
#[test]
fn the_jvm_length_mirrors_the_schema() {
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::spec::ValueKind;
let schema = launcher_schema();
let kind = schema
.spec("java.extra_args")
.map(|spec| &spec.kind)
.expect("schema 里必须有 java.extra_args");
let ValueKind::Text { max_len } = kind else {
panic!("java.extra_args 应该是 Text:{kind:?}");
};
assert_eq!(*max_len, MAX_JVM_ARGS);
}
#[path = "tests/runtime.rs"]
mod runtime;