Files
Unlucky-Launcher/crates/core/src/auth/store.rs
T
AskaEth bc41dfcf7d
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
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

252 lines
8.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 账户存储:`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)
}
}