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 开发中。
252 lines
8.1 KiB
Rust
252 lines
8.1 KiB
Rust
//! 账户存储:`data/auth/` 下的**元数据 / 凭据分文件**落盘(内存视图与增删)。
|
||
//!
|
||
//! 契约(`PlanDoc/07-accounts.md` §2、`PlanDoc/04-data-and-config.md`):
|
||
//! 1. **分文件**:`accounts.json`(可公开)+ `credentials.dat`(凭据)。列账户不碰凭据文件;
|
||
//! 2. **先写凭据、再写元数据**(HMCL 安全顺序)——元数据永不指向不存在的凭据;
|
||
//! 3. **原子写**:走 `platform::atomic::write_atomic`(tmp → fsync → rename);
|
||
//! 4. **容错**:单条坏记录只跳过该条并留 warning,绝不整体加载失败;
|
||
//! 5. **孤儿清理**:`save()` 只写「元数据里还存在」的凭据。
|
||
//!
|
||
//! 落盘细节(payload 形状、权限收紧)在同目录的 [`payload`] 子模块,本文件只管模型与不变量。
|
||
//!
|
||
//! 保护级别:本期 `plain-v1`(分文件 + 权限 + 全链脱敏)。OS keychain / AES-256-GCM
|
||
//! 分级是 07 §2 的第 1–2 条,需要 keyring/aes-gcm/argon2 的依赖决策,属后续 mission。
|
||
|
||
mod payload;
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use crate::auth::account::{AccountId, AccountMeta, Credentials};
|
||
use crate::auth::error::{AuthError, AuthResult};
|
||
|
||
/// 元数据文件名(`data/auth/accounts.json`)。
|
||
pub const ACCOUNTS_FILE: &str = "accounts.json";
|
||
/// 凭据文件名(`data/auth/credentials.dat`)。
|
||
pub const CREDENTIALS_FILE: &str = "credentials.dat";
|
||
/// 元数据 schema 版本。
|
||
pub const AUTH_SCHEMA_VERSION: u32 = 1;
|
||
/// 本期凭据保护级别(明文 + 分文件 + 权限 + 脱敏)。
|
||
pub const PROTECTION_PLAIN_V1: &str = "plain-v1";
|
||
|
||
/// `data/auth/` 的两个文件位置。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct AuthPaths {
|
||
root: PathBuf,
|
||
}
|
||
|
||
impl AuthPaths {
|
||
/// 以数据分区为根(`LauncherLayout::auth_data()`)。
|
||
#[must_use]
|
||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||
Self { root: root.into() }
|
||
}
|
||
|
||
/// 根目录。
|
||
#[must_use]
|
||
pub fn root(&self) -> &Path {
|
||
&self.root
|
||
}
|
||
|
||
/// 元数据文件。
|
||
#[must_use]
|
||
pub fn accounts_file(&self) -> PathBuf {
|
||
self.root.join(ACCOUNTS_FILE)
|
||
}
|
||
|
||
/// 凭据文件。
|
||
#[must_use]
|
||
pub fn credentials_file(&self) -> PathBuf {
|
||
self.root.join(CREDENTIALS_FILE)
|
||
}
|
||
}
|
||
|
||
/// 账户库(内存视图 + 落盘)。
|
||
#[derive(Debug, Clone)]
|
||
pub struct AccountStore {
|
||
paths: AuthPaths,
|
||
accounts: Vec<AccountMeta>,
|
||
credentials: BTreeMap<String, Credentials>,
|
||
warnings: Vec<String>,
|
||
}
|
||
|
||
impl AccountStore {
|
||
/// 打开(不存在就是空库;坏记录进 warnings)。
|
||
#[must_use]
|
||
pub fn open(root: impl Into<PathBuf>) -> Self {
|
||
Self::open_paths(AuthPaths::new(root))
|
||
}
|
||
|
||
/// 打开指定文件位置。
|
||
#[must_use]
|
||
pub fn open_paths(paths: AuthPaths) -> Self {
|
||
let mut warnings = Vec::new();
|
||
let mut accounts = payload::read_accounts(&paths.accounts_file(), &mut warnings);
|
||
accounts.sort_by_key(|meta| meta.created_at_ms);
|
||
let known: Vec<String> = accounts
|
||
.iter()
|
||
.map(|meta| meta.id.as_str().to_string())
|
||
.collect();
|
||
let credentials =
|
||
payload::read_credentials(&paths.credentials_file(), &known, &mut warnings);
|
||
Self {
|
||
paths,
|
||
accounts,
|
||
credentials,
|
||
warnings,
|
||
}
|
||
}
|
||
|
||
/// 文件位置。
|
||
#[must_use]
|
||
pub fn paths(&self) -> &AuthPaths {
|
||
&self.paths
|
||
}
|
||
|
||
/// 账户列表(按创建时间排序,展示顺序稳定)。
|
||
#[must_use]
|
||
pub fn accounts(&self) -> &[AccountMeta] {
|
||
&self.accounts
|
||
}
|
||
|
||
/// 加载过程中的告警(坏记录、孤儿凭据等)。
|
||
#[must_use]
|
||
pub fn warnings(&self) -> &[String] {
|
||
&self.warnings
|
||
}
|
||
|
||
/// 是否没有任何账户。
|
||
#[must_use]
|
||
pub fn is_empty(&self) -> bool {
|
||
self.accounts.is_empty()
|
||
}
|
||
|
||
/// 按名字 / 登录名 / 条目 id 查找(大小写不敏感)。
|
||
#[must_use]
|
||
pub fn find(&self, key: &str) -> Option<&AccountMeta> {
|
||
self.accounts.iter().find(|meta| meta.matches(key))
|
||
}
|
||
|
||
/// 取某条目的凭据。
|
||
#[must_use]
|
||
pub fn credentials(&self, id: &AccountId) -> Option<&Credentials> {
|
||
self.credentials.get(id.as_str())
|
||
}
|
||
|
||
/// 取某条目的「元数据 + 凭据」(找不到凭据时报错)。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 条目不存在 → [`AuthError::NotFound`];条目在但凭据缺失 → [`AuthError::Malformed`]
|
||
/// (说明文件被手工删过,别静默当成「没登录」)。
|
||
pub fn entry(&self, id: &AccountId) -> AuthResult<(&AccountMeta, &Credentials)> {
|
||
let meta = self
|
||
.accounts
|
||
.iter()
|
||
.find(|meta| meta.id == *id)
|
||
.ok_or_else(|| AuthError::NotFound {
|
||
account: id.to_string(),
|
||
})?;
|
||
let credentials = self
|
||
.credentials
|
||
.get(id.as_str())
|
||
.ok_or_else(|| AuthError::Malformed(format!("账户 {id} 的凭据缺失")))?;
|
||
Ok((meta, credentials))
|
||
}
|
||
|
||
/// 新增账户(内存)。同名同轨冲突时报错——`--account <名字>` 才有唯一解。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 重复、条目 id 重复、轨道与凭据不符都返回错误。
|
||
pub fn insert(&mut self, meta: AccountMeta, credentials: Credentials) -> AuthResult<()> {
|
||
meta.check_credentials(&credentials)?;
|
||
if self.accounts.iter().any(|existing| existing.id == meta.id) {
|
||
return Err(AuthError::Conflict {
|
||
account: meta.id.to_string(),
|
||
});
|
||
}
|
||
let duplicate = self
|
||
.accounts
|
||
.iter()
|
||
.any(|existing| existing.kind == meta.kind && existing.matches(meta.display_name()));
|
||
if duplicate {
|
||
return Err(AuthError::Conflict {
|
||
account: meta.display_name().to_string(),
|
||
});
|
||
}
|
||
self.credentials
|
||
.insert(meta.id.as_str().to_string(), credentials);
|
||
self.accounts.push(meta);
|
||
self.accounts.sort_by_key(|meta| meta.created_at_ms);
|
||
Ok(())
|
||
}
|
||
|
||
/// 删除账户(连同凭据)。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 找不到时返回 [`AuthError::NotFound`]。
|
||
pub fn remove(&mut self, key: &str) -> AuthResult<AccountMeta> {
|
||
let index = self
|
||
.accounts
|
||
.iter()
|
||
.position(|meta| meta.matches(key))
|
||
.ok_or_else(|| AuthError::NotFound {
|
||
account: key.to_string(),
|
||
})?;
|
||
let meta = self.accounts.remove(index);
|
||
self.credentials.remove(meta.id.as_str());
|
||
Ok(meta)
|
||
}
|
||
|
||
/// 更新某条目的凭据(刷新后轮换 token 用)。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 条目不存在或轨道不符时返回错误。
|
||
pub fn update_credentials(
|
||
&mut self,
|
||
id: &AccountId,
|
||
credentials: Credentials,
|
||
) -> AuthResult<()> {
|
||
let meta = self
|
||
.accounts
|
||
.iter()
|
||
.find(|meta| meta.id == *id)
|
||
.ok_or_else(|| AuthError::NotFound {
|
||
account: id.to_string(),
|
||
})?;
|
||
meta.check_credentials(&credentials)?;
|
||
self.credentials
|
||
.insert(id.as_str().to_string(), credentials);
|
||
Ok(())
|
||
}
|
||
|
||
/// 同步某条目的元数据字段(刷新补齐了玩家名之后写回)。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 条目不存在时返回 [`AuthError::NotFound`]。
|
||
pub fn update_meta(&mut self, meta: AccountMeta) -> AuthResult<()> {
|
||
let slot = self
|
||
.accounts
|
||
.iter_mut()
|
||
.find(|existing| existing.id == meta.id)
|
||
.ok_or_else(|| AuthError::NotFound {
|
||
account: meta.id.to_string(),
|
||
})?;
|
||
*slot = meta;
|
||
Ok(())
|
||
}
|
||
|
||
/// 落盘:**先凭据、后元数据**。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 两个文件任一写失败都返回 [`AuthError::Store`]。
|
||
pub fn save(&self) -> AuthResult<()> {
|
||
payload::write(&self.paths, &self.accounts, &self.credentials)
|
||
}
|
||
}
|