Unlucky Launcher v0.2.0
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

首个 iced + Material 3 Expressive 版本的源码快照。
完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
AskaEth committed 2026-11-05 14:22:11 +08:00
commit 8a8e5ca0c0
1079 files changed
+208035

No files matched your search

+293
View File
@@ -0,0 +1,293 @@
//! `ull accounts` 与 `ull launch --account` 的端到端断言。
//!
//! 职责:证明「账户能建/能删/能选」,并且**凭据红线守住了**——
//! stdout(含 `--json`)、`launch --dry-run` 的 argv 与身份块里都不出现 token 明文。
#[path = "support/mod.rs"]
mod support;
use std::path::{Path, PathBuf};
use support::{parse_json, run_cli, TempDir};
use unlucky_core::auth::offline::offline_uuid;
const VERSION: &str = "1.20.1";
/// 建一个带「版本本体 + 一个实例」的数据根。
fn data_root(tag: &str) -> TempDir {
let dir = TempDir::new(tag);
support::write_version(dir.path(), VERSION);
support::write_profiles(dir.path(), &[("生存服", VERSION)]);
dir
}
/// 覆盖版本 JSON:把 `${auth_access_token}` / `${auth_uuid}` 塞进命令行,
/// 这样「脱敏」才是被真的跑到,而不是「压根没有 token 所以没有明文」。
fn write_version_with_auth(root: &Path) {
let dir = root.join(".minecraft").join("versions").join(VERSION);
std::fs::create_dir_all(&dir).expect("建版本目录");
let version = serde_json::json!({
"id": VERSION,
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": { "id": "5" },
"arguments": {
"game": [
"--username", "${auth_player_name}",
"--uuid", "${auth_uuid}",
"--accessToken", "${auth_access_token}",
"--userType", "${auth_user_type}",
"--version", "${version_name}"
],
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
},
"libraries": [],
"downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } }
});
std::fs::write(
dir.join(format!("{VERSION}.json")),
serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"),
)
.expect("写版本 JSON");
}
fn auth_dir(root: &Path) -> PathBuf {
root.join(".ULL").join("data").join("auth")
}
/// 拼一条 `--json --data-dir <root> ...` 命令。
fn cli(dir: &TempDir, tail: &[&str]) -> Vec<String> {
let mut args = vec!["--json".to_string(), "--data-dir".to_string(), dir.arg()];
args.extend(tail.iter().map(|item| (*item).to_string()));
args
}
/// 拼一条 `launch 生存服 --dry-run ...` 命令。
fn launch(dir: &TempDir, extra: &[&str]) -> Vec<String> {
let mut tail = vec!["launch", "生存服", "--dry-run"];
tail.extend_from_slice(extra);
cli(dir, &tail)
}
/// 跑一条命令并断言退出码。
fn run(args: &[String], expected: i32) -> (String, String) {
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let (code, out, err) = run_cli(&refs);
assert_eq!(code, expected, "args={refs:?}\nstdout={out}\nstderr={err}");
(out, err)
}
#[test]
fn accounts_list_is_empty_at_first() {
let dir = data_root("accounts-empty");
let (out, _) = run(&cli(&dir, &["accounts", "list"]), 0);
let envelope = parse_json(&out);
assert_eq!(envelope["data"]["count"], 0);
assert_eq!(
envelope["data"]["accounts"].as_array().map(Vec::len),
Some(0)
);
assert!(!auth_dir(dir.path()).join("accounts.json").exists());
}
#[test]
fn add_offline_writes_both_files_and_reports_hmcl_uuid() {
let dir = data_root("accounts-add");
let (out, _) = run(&cli(&dir, &["accounts", "add", "offline", "小明"]), 0);
let envelope = parse_json(&out);
let account = &envelope["data"]["account"];
assert_eq!(account["name"], "小明");
assert_eq!(account["kind"], "offline");
assert_eq!(account["profile_id"], offline_uuid("小明"));
let accounts_file = auth_dir(dir.path()).join("accounts.json");
let credentials_file = auth_dir(dir.path()).join("credentials.dat");
assert!(accounts_file.is_file(), "元数据文件必须落盘");
assert!(
credentials_file.is_file(),
"凭据文件必须落盘(哪怕离线轨是空的)"
);
// list 里能看到,且输出里没有任何凭据字段。
let (list_out, _) = run(&cli(&dir, &["accounts", "list"]), 0);
let list = parse_json(&list_out);
assert_eq!(list["data"]["count"], 1);
assert_eq!(list["data"]["accounts"][0]["name"], "小明");
for forbidden in ["access_token", "refresh_token", "client_token", "password"] {
assert!(
!list_out.contains(forbidden),
"accounts list 不该出现 {forbidden}"
);
}
}
#[test]
fn duplicate_offline_account_is_rejected() {
let dir = data_root("accounts-dup");
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
let (out, _) = run(&cli(&dir, &["accounts", "add", "offline", "steve"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "conflict");
}
#[test]
fn add_offline_requires_a_valid_name() {
let dir = data_root("accounts-badname");
let (out, _) = run(&cli(&dir, &["accounts", "add", "offline"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "usage");
let (out, _) = run(
&cli(&dir, &["accounts", "add", "offline", "0123456789abcdefg"]),
1,
);
assert_eq!(parse_json(&out)["error"]["code"], "malformed");
}
#[test]
fn remove_deletes_account_and_reports_missing() {
let dir = data_root("accounts-remove");
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
let (out, _) = run(&cli(&dir, &["accounts", "remove", "Steve"]), 0);
assert_eq!(parse_json(&out)["data"]["removed"]["name"], "Steve");
let (out, _) = run(&cli(&dir, &["accounts", "list"]), 0);
assert_eq!(parse_json(&out)["data"]["count"], 0);
let (out, _) = run(&cli(&dir, &["accounts", "remove", "Steve"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "not_found");
}
#[test]
fn add_msa_without_client_id_is_explained() {
// 没配 ULL_MSA_CLIENT_ID 时不该去联网,而是明确告知要配什么。
let saved = std::env::var("ULL_MSA_CLIENT_ID").ok();
std::env::remove_var("ULL_MSA_CLIENT_ID");
let dir = data_root("accounts-msa");
let (out, _) = run(&cli(&dir, &["accounts", "add", "msa"]), 1);
if let Some(value) = saved {
std::env::set_var("ULL_MSA_CLIENT_ID", value);
}
let envelope = parse_json(&out);
assert_eq!(envelope["error"]["code"], "unsupported");
assert!(envelope["error"]["hint"]
.as_str()
.unwrap_or_default()
.contains("ULL_MSA_CLIENT_ID"));
}
#[test]
fn launch_dry_run_uses_the_only_account() {
let dir = data_root("launch-account");
write_version_with_auth(dir.path());
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
let (out, _) = run(&launch(&dir, &[]), 0);
let data = &parse_json(&out)["data"];
let account = &data["account"];
assert_eq!(account["name"], "Steve");
assert_eq!(account["kind"], "offline");
assert_eq!(account["profile_id"], offline_uuid("Steve"));
assert_eq!(account["registered"], true);
// argv 里玩家名与 UUID 都对上了,token 位置是脱敏占位。
let argv: Vec<String> = data["argv"]
.as_array()
.expect("argv")
.iter()
.map(|item| item.as_str().unwrap_or_default().to_string())
.collect();
let username_at = argv
.iter()
.position(|arg| arg == "--username")
.expect("有 --username");
assert_eq!(argv[username_at + 1], "Steve");
// launch 的脱敏器把 uuid 也当敏感值处理(M18 定案:dry-run 输出是「脱敏 uuid」),
// 真实 uuid 在下面的身份块里以可公开元数据的形式给出。
let uuid_at = argv
.iter()
.position(|arg| arg == "--uuid")
.expect("有 --uuid");
assert_eq!(argv[uuid_at + 1], "<redacted:token>");
// 真凭据位置:离线占位 token 是随机 32 hex,输出里必须只剩脱敏占位。
let token_at = argv
.iter()
.position(|arg| arg == "--accessToken")
.expect("有 --accessToken");
assert_eq!(argv[token_at + 1], "<redacted:token>");
assert!(out.contains("<redacted:token>"));
// 命令行与展示行里不该有任何 32 位十六进制串(= 没漏出 accessToken)。
let display = data["display"].as_str().unwrap_or_default();
assert!(!has_hex32(&argv.join(" ")), "argv 漏了 token:{argv:?}");
assert!(!has_hex32(display), "display 漏了 token:{display}");
assert!(
!out.contains("access_token"),
"输出不该出现 access_token 字段"
);
}
/// 输出里是否有 32 位十六进制串(token 形状)。
fn has_hex32(text: &str) -> bool {
text.split(|ch: char| !ch.is_ascii_hexdigit())
.any(|run| run.len() == 32)
}
#[test]
fn multiple_accounts_require_explicit_choice() {
let dir = data_root("launch-multi");
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
run(&cli(&dir, &["accounts", "add", "offline", "Alex"]), 0);
let (out, _) = run(&launch(&dir, &[]), 1);
let envelope = parse_json(&out);
assert_eq!(envelope["error"]["code"], "account_ambiguous");
assert!(
envelope["error"]["hint"]
.as_str()
.unwrap_or_default()
.contains("Steve"),
"hint 要列候选"
);
// 指定名字就能选出来(大小写不敏感)。
let (out, _) = run(&launch(&dir, &["--account", "alex"]), 0);
let data = &parse_json(&out)["data"];
assert_eq!(data["account"]["name"], "Alex");
assert_eq!(data["account"]["profile_id"], offline_uuid("Alex"));
}
#[test]
fn unknown_account_and_username_conflict_are_usage_errors() {
let dir = data_root("launch-unknown");
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
let (out, _) = run(&launch(&dir, &["--account", "Nobody"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "not_found");
let (out, _) = run(&launch(&dir, &["--username", "Alex"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "usage");
}
#[test]
fn launch_without_accounts_keeps_the_offline_placeholder() {
let dir = data_root("launch-placeholder");
let (out, _) = run(&launch(&dir, &["--username", "Node"]), 0);
let data = &parse_json(&out)["data"];
assert_eq!(data["account"]["registered"], false);
assert_eq!(data["account"]["kind"], "offline-placeholder");
assert_eq!(data["account"]["name"], "Node");
let argv: Vec<&str> = data["argv"]
.as_array()
.expect("argv")
.iter()
.map(|item| item.as_str().unwrap_or_default())
.collect();
let at = argv
.iter()
.position(|arg| *arg == "--username")
.expect("--username");
assert_eq!(argv[at + 1], "Node");
}
#[test]
fn accounts_help_topic_exists() {
let (out, _) = run(&["help".to_string(), "accounts".to_string()], 0);
assert!(out.contains("credentials.dat"), "{out}");
assert!(out.contains("accounts add offline"), "{out}");
}