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,247 @@
|
||||
//! 落盘细节:payload 形状、宽容解析、凭据文件权限。
|
||||
//!
|
||||
//! 两个文件的形状(都带 `schemaVersion`,便于以后演进):
|
||||
//!
|
||||
//! ```json
|
||||
//! // accounts.json —— 可公开
|
||||
//! { "schemaVersion": 1, "accounts": [ { "id": "account:…", "kind": "offline",
|
||||
//! "profileId": "…", "profileName": "Steve", "createdAtMs": 1700000000000 } ] }
|
||||
//!
|
||||
//! // credentials.dat —— 凭据
|
||||
//! { "schemaVersion": 1, "protection": "plain-v1",
|
||||
//! "entries": [ { "accountId": "account:…", "data": { "kind": "microsoft", … } } ] }
|
||||
//! ```
|
||||
//!
|
||||
//! `entries[].data` 里是 [`Credentials`] 的序列化结果(由 serde 自己定义形状),
|
||||
//! 本模块只负责「哪条属于哪个账户」。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::auth::account::{AccountMeta, Credentials};
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::platform::atomic::write_atomic;
|
||||
|
||||
use super::{AuthPaths, AUTH_SCHEMA_VERSION, PROTECTION_PLAIN_V1};
|
||||
|
||||
/// 写两处文件:**先凭据、后元数据**。
|
||||
pub(super) fn write(
|
||||
paths: &AuthPaths,
|
||||
accounts: &[AccountMeta],
|
||||
credentials: &BTreeMap<String, Credentials>,
|
||||
) -> AuthResult<()> {
|
||||
let credentials_payload = credentials_payload(accounts, credentials);
|
||||
let path = paths.credentials_file();
|
||||
write_atomic(&path, credentials_payload.as_bytes())
|
||||
.map_err(|error| AuthError::store(path.display(), error.to_string()))?;
|
||||
restrict(&path)?;
|
||||
|
||||
let accounts_payload = accounts_payload(accounts);
|
||||
let path = paths.accounts_file();
|
||||
write_atomic(&path, accounts_payload.as_bytes())
|
||||
.map_err(|error| AuthError::store(path.display(), error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn credentials_payload(
|
||||
accounts: &[AccountMeta],
|
||||
credentials: &BTreeMap<String, Credentials>,
|
||||
) -> String {
|
||||
let mut entries = Vec::new();
|
||||
for meta in accounts {
|
||||
// 孤儿凭据(元数据已删)不写回。
|
||||
let Some(data) = credentials.get(meta.id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
entries.push(json!({
|
||||
"accountId": meta.id.as_str(),
|
||||
"data": data,
|
||||
}));
|
||||
}
|
||||
let payload = json!({
|
||||
"schemaVersion": AUTH_SCHEMA_VERSION,
|
||||
"protection": PROTECTION_PLAIN_V1,
|
||||
"entries": entries,
|
||||
});
|
||||
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn accounts_payload(accounts: &[AccountMeta]) -> String {
|
||||
let payload = json!({
|
||||
"schemaVersion": AUTH_SCHEMA_VERSION,
|
||||
"accounts": accounts,
|
||||
});
|
||||
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
/// 读元数据(坏记录跳过并记 warning)。
|
||||
pub(super) fn read_accounts(path: &Path, warnings: &mut Vec<String>) -> Vec<AccountMeta> {
|
||||
let mut accounts = Vec::new();
|
||||
let Some(text) = read_to_string(path, warnings) else {
|
||||
return accounts;
|
||||
};
|
||||
let Ok(value) = serde_json::from_str::<Value>(&text) else {
|
||||
warnings.push(format!("{} 不是合法 JSON,按空账户库处理", path.display()));
|
||||
return accounts;
|
||||
};
|
||||
let Some(items) = value.get("accounts").and_then(Value::as_array) else {
|
||||
warnings.push(format!("{} 缺 accounts 数组", path.display()));
|
||||
return accounts;
|
||||
};
|
||||
for item in items {
|
||||
match AccountMeta::from_value(item) {
|
||||
Ok(meta) => accounts.push(meta),
|
||||
Err(error) => warnings.push(format!("跳过一条账户元数据:{}", error.message())),
|
||||
}
|
||||
}
|
||||
accounts
|
||||
}
|
||||
|
||||
/// 读凭据(只保留 `known` 里存在的账户,孤儿丢弃并记 warning)。
|
||||
pub(super) fn read_credentials(
|
||||
path: &Path,
|
||||
known: &[String],
|
||||
warnings: &mut Vec<String>,
|
||||
) -> BTreeMap<String, Credentials> {
|
||||
let mut credentials = BTreeMap::new();
|
||||
let Some(text) = read_to_string(path, warnings) else {
|
||||
return credentials;
|
||||
};
|
||||
let Ok(value) = serde_json::from_str::<Value>(&text) else {
|
||||
warnings.push(format!(
|
||||
"{} 不是合法 JSON,按「无凭据」处理(账户列表仍可用)",
|
||||
path.display()
|
||||
));
|
||||
return credentials;
|
||||
};
|
||||
if let Some(protection) = value.get("protection").and_then(Value::as_str) {
|
||||
if protection != PROTECTION_PLAIN_V1 {
|
||||
warnings.push(format!(
|
||||
"{} 的保护级别是 `{protection}`,本期只认 `{PROTECTION_PLAIN_V1}`",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
let Some(entries) = value.get("entries").and_then(Value::as_array) else {
|
||||
return credentials;
|
||||
};
|
||||
for entry in entries {
|
||||
let Some(account_id) = entry.get("accountId").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let account_id = account_id.to_ascii_lowercase();
|
||||
if !known.contains(&account_id) {
|
||||
warnings.push(format!("凭据 {account_id} 没有对应账户,已忽略"));
|
||||
continue;
|
||||
}
|
||||
let Some(data) = entry.get("data") else {
|
||||
continue;
|
||||
};
|
||||
match serde_json::from_value::<Credentials>(data.clone()) {
|
||||
Ok(parsed) => {
|
||||
credentials.insert(account_id, parsed);
|
||||
}
|
||||
Err(error) => warnings.push(format!("跳过一条凭据({account_id}):{error}")),
|
||||
}
|
||||
}
|
||||
credentials
|
||||
}
|
||||
|
||||
fn read_to_string(path: &Path, warnings: &mut Vec<String>) -> Option<String> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => Some(text),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => {
|
||||
warnings.push(format!("读 {} 失败:{error}", path.display()));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把凭据文件收紧到「仅本人可读写」。
|
||||
///
|
||||
/// POSIX 侧显式 `0600`;Windows 侧 `set_permissions` 只能表达只读位,凭据保护依赖
|
||||
/// 用户目录默认 ACL(`data/auth/` 位于用户数据根下)。**这是已知缺口**:
|
||||
/// 补一个 `platform::atomic::write_atomic_private` 比在 auth 里散写 cfg 更对,
|
||||
/// 已作为 finding 报给 tower(见完成报告)。
|
||||
fn restrict(path: &Path) -> AuthResult<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let permissions = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(path, permissions)
|
||||
.map_err(|error| AuthError::store(path.display(), error.to_string()))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = path;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{accounts_payload, credentials_payload};
|
||||
use crate::auth::account::{AccountKind, AccountMeta, Credentials};
|
||||
use crate::auth::microsoft::MicrosoftSession;
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
fn microsoft_meta() -> (AccountMeta, Credentials) {
|
||||
let meta = AccountMeta::new(
|
||||
AccountKind::Microsoft,
|
||||
"0f8fad5bd9cb469fa16570867728950e",
|
||||
"Steve",
|
||||
7,
|
||||
);
|
||||
let session = MicrosoftSession::new(
|
||||
"0f8fad5bd9cb469fa16570867728950e",
|
||||
"Steve",
|
||||
Secret::new("mc-access-token"),
|
||||
Secret::new("live-refresh-token"),
|
||||
3600,
|
||||
1_000,
|
||||
None,
|
||||
);
|
||||
(meta, Credentials::Microsoft(session))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_payload_never_contains_tokens() {
|
||||
let (meta, creds) = microsoft_meta();
|
||||
let payload = accounts_payload(std::slice::from_ref(&meta));
|
||||
assert!(payload.contains("0f8fad5bd9cb469fa16570867728950e"));
|
||||
assert!(payload.contains("\"kind\": \"microsoft\""));
|
||||
assert!(!payload.contains("mc-access-token"));
|
||||
assert!(!payload.contains("live-refresh-token"));
|
||||
// 凭据对象的 Debug 也是脱敏的,别把 token 带进 payload。
|
||||
assert!(!format!("{creds:?}").contains("mc-access-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_payload_carries_protection_and_entries() {
|
||||
let (meta, creds) = microsoft_meta();
|
||||
let mut map = BTreeMap::new();
|
||||
map.insert(meta.id.as_str().to_string(), creds);
|
||||
let payload = credentials_payload(std::slice::from_ref(&meta), &map);
|
||||
assert!(payload.contains("\"protection\": \"plain-v1\""));
|
||||
assert!(payload.contains(meta.id.as_str()));
|
||||
assert!(payload.contains("mc-access-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_credentials_are_not_written() {
|
||||
let (meta, creds) = microsoft_meta();
|
||||
let mut map = BTreeMap::new();
|
||||
map.insert(
|
||||
"account:deadbeefdeadbeefdeadbeefdeadbeef".to_string(),
|
||||
creds,
|
||||
);
|
||||
let payload = credentials_payload(std::slice::from_ref(&meta), &map);
|
||||
assert!(!payload.contains("mc-access-token"));
|
||||
assert!(payload.contains("\"entries\": []"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user