Unlucky Launcher v0.0.1-alpha
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
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
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
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -0,0 +1,197 @@
|
||||
//! 离线轨:**HMCL / Minecraft 服务端同款离线 UUID** + 启动占位字段。
|
||||
//!
|
||||
//! UUID 算法(`analyse/04` §7.1):`UUID.nameUUIDFromBytes("OfflinePlayer:" + name)`
|
||||
//! = 对 UTF-8 字节做 MD5,再按 RFC 4122 改写成 **v3** 版本位与 **IETF 变体**位。
|
||||
//! 必须逐字节一致:离线服务器的 `ops.json` / 白名单 / 领地数据全按这个 UUID 存,
|
||||
//! 算错就对不上(皮肤、权限、存档全乱)。因此有与 JDK `UUID.nameUUIDFromBytes`
|
||||
//! 对拍的已知向量测试(见 `crates/core/tests/auth/offline_uuid.rs`)。
|
||||
//!
|
||||
//! 启动占位(`analyse/04` §7.2,承 `OfflineAccount.logInWithoutSkin`):
|
||||
//! - `accessToken` = **每次登录随机生成的 32 位十六进制**(游戏客户端不接受空 token);
|
||||
//! - `userType` = `msa`(HMCL 现版本如此;历史注释提过 `legacy`,但那是旧结论);
|
||||
//! - `userProperties` = `{}`;
|
||||
//! - `uuid` 用**无连字符** 32 位形式(`--uuid` 参数要求)。
|
||||
|
||||
use md5::{Digest, Md5};
|
||||
|
||||
use crate::auth::account::{AccountKind, AccountMeta};
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::secret::{random_hex32, Secret};
|
||||
use crate::launch::request::AuthPlaceholders;
|
||||
|
||||
/// 离线 UUID 的固定前缀(改一个字节就会与所有服务端对不上)。
|
||||
pub const OFFLINE_PREFIX: &str = "OfflinePlayer:";
|
||||
/// 离线账户的 launch 用户类型(`AuthInfo.USER_TYPE_MSA`)。
|
||||
pub const OFFLINE_USER_TYPE: &str = "msa";
|
||||
/// 玩家名长度上限(MC 客户端 `--username` 的实际上限)。
|
||||
pub const MAX_NAME_CHARS: usize = 16;
|
||||
|
||||
/// 算离线 UUID(**32 位无连字符小写十六进制**)。
|
||||
#[must_use]
|
||||
pub fn offline_uuid(name: &str) -> String {
|
||||
let digest = Md5::digest(format!("{OFFLINE_PREFIX}{name}").as_bytes());
|
||||
let mut bytes = [0_u8; 16];
|
||||
bytes.copy_from_slice(&digest);
|
||||
|
||||
// RFC 4122 §4.3:版本位 3(MD5 based)、变体位 IETF(10xx)。
|
||||
bytes[6] = (bytes[6] & 0x0F) | 0x30;
|
||||
bytes[8] = (bytes[8] & 0x3F) | 0x80;
|
||||
|
||||
let mut hex = String::with_capacity(32);
|
||||
for byte in bytes {
|
||||
hex.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
/// 带连字符的离线 UUID(展示/诊断用;启动参数要的是无连字符版)。
|
||||
#[must_use]
|
||||
pub fn offline_uuid_dashed(name: &str) -> String {
|
||||
let raw = offline_uuid(name);
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&raw[0..8],
|
||||
&raw[8..12],
|
||||
&raw[12..16],
|
||||
&raw[16..20],
|
||||
&raw[20..32]
|
||||
)
|
||||
}
|
||||
|
||||
/// 校验并规范化离线玩家名。
|
||||
///
|
||||
/// 规则:非空、去掉首尾空白后 ≤16 字符、不含控制字符。
|
||||
/// **允许非 ASCII**(中文名在离线服里很常见,UUID 按 UTF-8 字节算即可)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 空名/过长/含控制字符时返回 [`AuthError::Malformed`]。
|
||||
pub fn normalize_name(name: &str) -> AuthResult<String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AuthError::Malformed("玩家名不能为空".to_string()));
|
||||
}
|
||||
if trimmed.chars().count() > MAX_NAME_CHARS {
|
||||
return Err(AuthError::Malformed(format!(
|
||||
"玩家名最长 {MAX_NAME_CHARS} 个字符,收到 {} 个",
|
||||
trimmed.chars().count()
|
||||
)));
|
||||
}
|
||||
if trimmed.chars().any(char::is_control) {
|
||||
return Err(AuthError::Malformed("玩家名不能含控制字符".to_string()));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
/// 建一条离线账户元数据(名字已规范化)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 名字不合法时返回 [`AuthError::Malformed`]。
|
||||
pub fn offline_meta(name: &str, created_at_ms: u64) -> AuthResult<AccountMeta> {
|
||||
let name = normalize_name(name)?;
|
||||
Ok(AccountMeta::new(
|
||||
AccountKind::Offline,
|
||||
offline_uuid(&name),
|
||||
name,
|
||||
created_at_ms,
|
||||
))
|
||||
}
|
||||
|
||||
/// 生成一次「登录」的启动占位(不联网、不落盘)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 元数据不是离线轨或名字为空时返回 [`AuthError::Malformed`] / [`AuthError::CredentialExpired`]。
|
||||
pub fn offline_login(meta: &AccountMeta) -> AuthResult<AuthPlaceholders> {
|
||||
if meta.kind != AccountKind::Offline {
|
||||
return Err(AuthError::Malformed(format!(
|
||||
"账户 {} 不是离线轨({})",
|
||||
meta.id,
|
||||
meta.kind.as_str()
|
||||
)));
|
||||
}
|
||||
if meta.profile_name.trim().is_empty() {
|
||||
return Err(AuthError::CredentialExpired);
|
||||
}
|
||||
Ok(AuthPlaceholders {
|
||||
player_name: meta.profile_name.clone(),
|
||||
uuid: meta.profile_id.clone(),
|
||||
// 随机占位:每次登录都变(与 HMCL 一致),不落盘、不进日志。
|
||||
access_token: Secret::new(random_hex32()).expose().to_string(),
|
||||
user_type: OFFLINE_USER_TYPE.to_string(),
|
||||
user_properties: "{}".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
normalize_name, offline_login, offline_meta, offline_uuid, offline_uuid_dashed,
|
||||
OFFLINE_USER_TYPE,
|
||||
};
|
||||
use crate::auth::account::AccountKind;
|
||||
|
||||
#[test]
|
||||
fn uuid_matches_jdk_name_uuid_from_bytes() {
|
||||
// 向量由 JDK 25 的 UUID.nameUUIDFromBytes 生成(见 tests/auth/offline_uuid.rs 的说明)。
|
||||
assert_eq!(
|
||||
offline_uuid("Notch"),
|
||||
"b50ad385829d3141a2167e7d7539ba7f".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
offline_uuid_dashed("Notch"),
|
||||
"b50ad385-829d-3141-a216-7e7d7539ba7f"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuid_has_v3_and_ietf_bits() {
|
||||
let raw = offline_uuid("AnyPlayer");
|
||||
let version = u8::from_str_radix(&raw[12..13], 16).expect("版本位");
|
||||
assert_eq!(version, 3, "必须标记为 v3(MD5)");
|
||||
let variant = u8::from_str_radix(&raw[16..17], 16).expect("变体位");
|
||||
assert!(
|
||||
(0x8..=0xb).contains(&variant),
|
||||
"变体位必须是 IETF(10xx),实际 {variant:x}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuid_is_stable_and_case_sensitive() {
|
||||
assert_eq!(offline_uuid("Steve"), offline_uuid("Steve"));
|
||||
assert_ne!(offline_uuid("Steve"), offline_uuid("steve"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_validation() {
|
||||
assert_eq!(normalize_name(" Steve ").expect("合法"), "Steve");
|
||||
assert_eq!(normalize_name("小明").expect("中文合法"), "小明");
|
||||
assert!(normalize_name(" ").is_err());
|
||||
assert!(normalize_name("0123456789abcdefg").is_err());
|
||||
assert!(normalize_name("bad\u{7}name").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_meta_and_login_shape() {
|
||||
let meta = offline_meta("小明", 1_000).expect("建元数据");
|
||||
assert_eq!(meta.kind, AccountKind::Offline);
|
||||
assert_eq!(meta.profile_id, offline_uuid("小明"));
|
||||
let placeholders = offline_login(&meta).expect("占位符");
|
||||
assert_eq!(placeholders.player_name, "小明");
|
||||
assert_eq!(placeholders.uuid, offline_uuid("小明"));
|
||||
assert_eq!(placeholders.user_type, OFFLINE_USER_TYPE);
|
||||
assert_eq!(placeholders.user_properties, "{}");
|
||||
assert_eq!(placeholders.access_token.len(), 32);
|
||||
// 每次登录的占位 token 都不同(HMCL 行为)。
|
||||
let again = offline_login(&meta).expect("占位符");
|
||||
assert_ne!(placeholders.access_token, again.access_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_login_rejects_other_tracks() {
|
||||
let mut meta = offline_meta("Steve", 1).expect("建元数据");
|
||||
meta.kind = AccountKind::Microsoft;
|
||||
assert!(offline_login(&meta).is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user